Add rollback snapshot controls
This commit is contained in:
1 parent
6768d19239
commit
89763592de
19 files changed
+551
-7
No files matched your search
@@ -19,6 +19,7 @@ import { IdentityAccessService } from "../services/identity-access-service";
|
|||||||
import { OperationsService } from "../services/operations-service";
|
import { OperationsService } from "../services/operations-service";
|
||||||
import { SdlcService } from "../services/sdlc-service";
|
import { SdlcService } from "../services/sdlc-service";
|
||||||
import { AuditService } from "../services/audit-service";
|
import { AuditService } from "../services/audit-service";
|
||||||
|
import { RollbackService } from "../services/rollback-service";
|
||||||
|
|
||||||
export interface IpcDeps {
|
export interface IpcDeps {
|
||||||
connection: DatabaseConnection;
|
connection: DatabaseConnection;
|
||||||
@@ -42,6 +43,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
|||||||
const operations = new OperationsService(deps.connection.db);
|
const operations = new OperationsService(deps.connection.db);
|
||||||
const sdlc = new SdlcService(deps.connection.db);
|
const sdlc = new SdlcService(deps.connection.db);
|
||||||
const audit = new AuditService(deps.connection.db);
|
const audit = new AuditService(deps.connection.db);
|
||||||
|
const rollback = new RollbackService(deps.connection.db);
|
||||||
const onboarding = new OnboardingService({
|
const onboarding = new OnboardingService({
|
||||||
db: deps.connection.db,
|
db: deps.connection.db,
|
||||||
saveProviderCredential: (input) => providers.saveCredential(input),
|
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.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.setFeatureFlag, (_event, input) => mutate(() => operations.setFeatureFlag(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.runChaosDrill, (_event, input) => mutate(() => operations.runChaosDrill(input)));
|
ipcMain.handle(IPC_CHANNELS.runChaosDrill, (_event, input) => mutate(() => operations.runChaosDrill(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.runSdlcReview, (_event, input) => mutate(() => sdlc.runReview(input)));
|
ipcMain.handle(IPC_CHANNELS.runSdlcReview, (_event, input) => mutate(() => sdlc.runReview(input)));
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
`;
|
||||||
@@ -18,6 +18,7 @@ import { orgGovernanceSchema } from "./migrations/016-org-governance";
|
|||||||
import { operationsControlsSchema } from "./migrations/017-operations-controls";
|
import { operationsControlsSchema } from "./migrations/017-operations-controls";
|
||||||
import { sdlcControlsSchema } from "./migrations/018-sdlc-controls";
|
import { sdlcControlsSchema } from "./migrations/018-sdlc-controls";
|
||||||
import { auditReplaySchema } from "./migrations/019-audit-replay";
|
import { auditReplaySchema } from "./migrations/019-audit-replay";
|
||||||
|
import { rollbackSnapshotsSchema } from "./migrations/020-rollback-snapshots";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -45,6 +46,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 17, name: "operations_controls", sql: operationsControlsSchema },
|
{ version: 17, name: "operations_controls", sql: operationsControlsSchema },
|
||||||
{ version: 18, name: "sdlc_controls", sql: sdlcControlsSchema },
|
{ version: 18, name: "sdlc_controls", sql: sdlcControlsSchema },
|
||||||
{ version: 19, name: "audit_replay", sql: auditReplaySchema },
|
{ version: 19, name: "audit_replay", sql: auditReplaySchema },
|
||||||
|
{ version: 20, name: "rollback_snapshots", sql: rollbackSnapshotsSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const LEGACY_TABLES = [
|
|||||||
"retention_freezes",
|
"retention_freezes",
|
||||||
"search_documents",
|
"search_documents",
|
||||||
"audit_replay_runs",
|
"audit_replay_runs",
|
||||||
|
"rollback_snapshots",
|
||||||
"audit_events",
|
"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: "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: "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: "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" },
|
{ table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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<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>;
|
||||||
|
}
|
||||||
@@ -245,7 +245,7 @@ export class SdlcService {
|
|||||||
const schemaVersion = count(this.db, "select coalesce(max(version), 0) as count from schema_migrations");
|
const schemaVersion = count(this.db, "select coalesce(max(version), 0) as count from schema_migrations");
|
||||||
return [
|
return [
|
||||||
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
|
{ 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 },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export class SnapshotService {
|
|||||||
.map((agent) => ({ ...agent, enabled: toBool(agent.enabled) }));
|
.map((agent) => ({ ...agent, enabled: toBool(agent.enabled) }));
|
||||||
const prBotGates = list<AppSnapshot["prBotGates"][number]>(this.db, "pr_bot_gates", "key asc")
|
const prBotGates = list<AppSnapshot["prBotGates"][number]>(this.db, "pr_bot_gates", "key asc")
|
||||||
.map((gate) => ({ ...gate, enabled: toBool(gate.enabled) }));
|
.map((gate) => ({ ...gate, enabled: toBool(gate.enabled) }));
|
||||||
|
const rollbackSnapshots = list<AppSnapshot["rollbackSnapshots"][number]>(this.db, "rollback_snapshots", "created_at desc")
|
||||||
|
.map((snapshot) => ({ ...snapshot, active: toBool(snapshot.active) }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeOrgId,
|
activeOrgId,
|
||||||
@@ -99,6 +101,7 @@ export class SnapshotService {
|
|||||||
browserActions: list(this.db, "browser_actions", "created_at desc"),
|
browserActions: list(this.db, "browser_actions", "created_at desc"),
|
||||||
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
||||||
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
||||||
|
rollbackSnapshots,
|
||||||
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
|
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
|
||||||
legalHolds,
|
legalHolds,
|
||||||
retentionFreezes,
|
retentionFreezes,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const api: DesktopApi = {
|
|||||||
importFile: (input) => invoke(IPC_CHANNELS.importFile, input),
|
importFile: (input) => invoke(IPC_CHANNELS.importFile, input),
|
||||||
setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input),
|
setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input),
|
||||||
createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, 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),
|
setFeatureFlag: (input) => invoke(IPC_CHANNELS.setFeatureFlag, input),
|
||||||
runChaosDrill: (input) => invoke(IPC_CHANNELS.runChaosDrill, input),
|
runChaosDrill: (input) => invoke(IPC_CHANNELS.runChaosDrill, input),
|
||||||
runSdlcReview: (input) => invoke(IPC_CHANNELS.runSdlcReview, input),
|
runSdlcReview: (input) => invoke(IPC_CHANNELS.runSdlcReview, input),
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
|||||||
const evalRuns = useMemo(() => snapshot.evalRuns.filter((item) => item.orgId === orgId), [snapshot.evalRuns, orgId]);
|
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 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 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 (
|
return (
|
||||||
<div className="split-main" style={{ height: "calc(100vh - 44px)", margin: "-28px -32px -40px" }}>
|
<div className="split-main" style={{ height: "calc(100vh - 44px)", margin: "-28px -32px -40px" }}>
|
||||||
@@ -198,7 +199,15 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
|||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "migration" && <MigrationPanel orgId={orgId} reports={migrationReports} actions={actions} />}
|
{tab === "migration" && (
|
||||||
|
<MigrationPanel
|
||||||
|
orgId={orgId}
|
||||||
|
employeeId={employee.id}
|
||||||
|
reports={migrationReports}
|
||||||
|
rollbackSnapshots={rollbackSnapshots}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{tab === "profile" && <ProfilePanel employee={employee} />}
|
{tab === "profile" && <ProfilePanel employee={employee} />}
|
||||||
{tab === "notifications" && <NotificationsPanel events={auditEvents} employee={employee} />}
|
{tab === "notifications" && <NotificationsPanel events={auditEvents} employee={employee} />}
|
||||||
{tab === "security" && <SecurityPanel events={auditEvents} />}
|
{tab === "security" && <SecurityPanel events={auditEvents} />}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { Button, Field, Icon, Pill } from "../../shared/ui";
|
import { Button, Field, Icon, Pill } from "../../shared/ui";
|
||||||
import { Dialog } from "../../shared/Dialog";
|
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 type { SnapshotActions } from "../../app/useSnapshot";
|
||||||
import { formatRelative } from "../types";
|
import { formatRelative } from "../types";
|
||||||
|
|
||||||
@@ -15,11 +15,15 @@ interface ParsedReport {
|
|||||||
|
|
||||||
export function MigrationPanel({
|
export function MigrationPanel({
|
||||||
orgId,
|
orgId,
|
||||||
|
employeeId,
|
||||||
reports,
|
reports,
|
||||||
|
rollbackSnapshots,
|
||||||
actions,
|
actions,
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
employeeId: string;
|
||||||
reports: MigrationReportRecord[];
|
reports: MigrationReportRecord[];
|
||||||
|
rollbackSnapshots: RollbackSnapshotRecord[];
|
||||||
actions: SnapshotActions;
|
actions: SnapshotActions;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -47,10 +51,33 @@ export function MigrationPanel({
|
|||||||
{reports.length > 0 && (
|
{reports.length > 0 && (
|
||||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
{reports.map((report, index) => (
|
{reports.map((report, index) => (
|
||||||
<ReportRow key={report.id} report={report} last={index === reports.length - 1} />
|
<ReportRow
|
||||||
|
key={report.id}
|
||||||
|
orgId={orgId}
|
||||||
|
employeeId={employeeId}
|
||||||
|
report={report}
|
||||||
|
actions={actions}
|
||||||
|
last={index === reports.length - 1}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<section className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
|
||||||
|
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||||
|
<Icon name="refresh" size={14} color="var(--text-3)" />
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>Rollback snapshots</div>
|
||||||
|
</div>
|
||||||
|
{rollbackSnapshots.length > 0 ? rollbackSnapshots.map((snapshot, index) => (
|
||||||
|
<RollbackRow
|
||||||
|
key={snapshot.id}
|
||||||
|
orgId={orgId}
|
||||||
|
employeeId={employeeId}
|
||||||
|
snapshot={snapshot}
|
||||||
|
actions={actions}
|
||||||
|
last={index === rollbackSnapshots.length - 1}
|
||||||
|
/>
|
||||||
|
)) : <div style={{ padding: 16 }}><Pill>0 rollback snapshots</Pill></div>}
|
||||||
|
</section>
|
||||||
<Dialog title="Migration report" open={open} submitLabel="Create" onClose={() => setOpen(false)} onSubmit={submit}>
|
<Dialog title="Migration report" open={open} submitLabel="Create" onClose={() => setOpen(false)} onSubmit={submit}>
|
||||||
<Field label="Source database path"><input className="input" name="sourceDatabasePath" required /></Field>
|
<Field label="Source database path"><input className="input" name="sourceDatabasePath" required /></Field>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -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 body = parseReport(report.reportJson);
|
||||||
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
|
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="table-row"
|
className="table-row"
|
||||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
style={{ gridTemplateColumns: "minmax(0, 1.35fr) minmax(0, 1fr) 120px 88px 96px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
|
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
|
||||||
@@ -80,6 +119,50 @@ function ReportRow({ report, last }: { report: MigrationReportRecord; last: bool
|
|||||||
<Pill>{idCount} ids</Pill>
|
<Pill>{idCount} ids</Pill>
|
||||||
{body.failures.length > 0 ? <Pill tone="amber">{body.failures.length} failures</Pill> : null}
|
{body.failures.length > 0 ? <Pill tone="amber">{body.failures.length} failures</Pill> : null}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!body.rollbackPack}
|
||||||
|
onClick={() => void actions.run(() => window.agentCompany.createRollbackSnapshot({ orgId, migrationReportId: report.id, createdByEmployeeId: employeeId }))}
|
||||||
|
>
|
||||||
|
Snapshot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="table-row"
|
||||||
|
style={{ gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, .8fr) 96px 96px 72px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={snapshot.sourceDatabasePath}>{snapshot.sourceDatabasePath}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{formatRelative(snapshot.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
<Pill>{snapshot.sourceDatabaseSha256.slice(0, 12)}</Pill>
|
||||||
|
<Pill>{manifest.workspaces.length} workspaces</Pill>
|
||||||
|
<Pill>{manifest.files.length} files</Pill>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={snapshot.active}
|
||||||
|
onClick={() => void actions.run(() => window.agentCompany.setRollbackSnapshotActive({ orgId, snapshotId: snapshot.id, active: true, employeeId }))}
|
||||||
|
>
|
||||||
|
{snapshot.active ? "active" : "Set"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,19 @@ export interface CreateMigrationReportInput {
|
|||||||
sourceDatabasePath: string;
|
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 {
|
export interface SearchInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
employeeId: string;
|
employeeId: string;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
CreateOrganizationInput,
|
CreateOrganizationInput,
|
||||||
CreateProviderResponseInput,
|
CreateProviderResponseInput,
|
||||||
CreateRetentionFreezeInput,
|
CreateRetentionFreezeInput,
|
||||||
|
CreateRollbackSnapshotInput,
|
||||||
CreateScheduledPostInput,
|
CreateScheduledPostInput,
|
||||||
CreateSocialAccountInput,
|
CreateSocialAccountInput,
|
||||||
CreateTaskInput,
|
CreateTaskInput,
|
||||||
@@ -38,6 +39,7 @@ import type {
|
|||||||
SetFeatureFlagInput,
|
SetFeatureFlagInput,
|
||||||
SetLegalHoldStateInput,
|
SetLegalHoldStateInput,
|
||||||
SetPrBotGateInput,
|
SetPrBotGateInput,
|
||||||
|
SetRollbackSnapshotActiveInput,
|
||||||
SetRetentionFreezeStateInput,
|
SetRetentionFreezeStateInput,
|
||||||
SetScheduledPostStateInput,
|
SetScheduledPostStateInput,
|
||||||
SyncEmployeeIdentityInput,
|
SyncEmployeeIdentityInput,
|
||||||
@@ -81,6 +83,8 @@ export interface DesktopApi {
|
|||||||
importFile(input: ImportFileInput): Promise<DesktopResult<AppSnapshot>>;
|
importFile(input: ImportFileInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>;
|
setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>;
|
createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
createRollbackSnapshot(input: CreateRollbackSnapshotInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
setRollbackSnapshotActive(input: SetRollbackSnapshotActiveInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
setFeatureFlag(input: SetFeatureFlagInput): Promise<DesktopResult<AppSnapshot>>;
|
setFeatureFlag(input: SetFeatureFlagInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
runChaosDrill(input: RunChaosDrillInput): Promise<DesktopResult<AppSnapshot>>;
|
runChaosDrill(input: RunChaosDrillInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
runSdlcReview(input: RunSdlcReviewInput): Promise<DesktopResult<AppSnapshot>>;
|
runSdlcReview(input: RunSdlcReviewInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, Ser
|
|||||||
import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain";
|
import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain";
|
||||||
import type { ChaosDrillRecord, ChaosDrillRunRecord, FeatureFlagRecord, OperationalEventRecord, SloTargetRecord } from "./operations-domain";
|
import type { ChaosDrillRecord, ChaosDrillRunRecord, FeatureFlagRecord, OperationalEventRecord, SloTargetRecord } from "./operations-domain";
|
||||||
import type { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-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";
|
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 { 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";
|
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 { 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 { 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 { 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 { 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 { 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;
|
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[];
|
browserActions: BrowserActionRecord[];
|
||||||
policyRules: PolicyRuleRecord[];
|
policyRules: PolicyRuleRecord[];
|
||||||
migrationReports: MigrationReportRecord[];
|
migrationReports: MigrationReportRecord[];
|
||||||
|
rollbackSnapshots: RollbackSnapshotRecord[];
|
||||||
complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[];
|
complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[];
|
||||||
employeeIdentities: EmployeeIdentityRecord[]; serviceIdentities: ServiceIdentityRecord[]; roleGrants: RoleGrantRecord[]; accessDecisions: AccessDecisionRecord[];
|
employeeIdentities: EmployeeIdentityRecord[]; serviceIdentities: ServiceIdentityRecord[]; roleGrants: RoleGrantRecord[]; accessDecisions: AccessDecisionRecord[];
|
||||||
orgDepartments: OrgDepartmentRecord[]; hiringPolicies: HiringPolicyRecord[]; approvalRoutingRules: ApprovalRoutingRuleRecord[]; approvalAssignments: ApprovalAssignmentRecord[];
|
orgDepartments: OrgDepartmentRecord[]; hiringPolicies: HiringPolicyRecord[]; approvalRoutingRules: ApprovalRoutingRuleRecord[]; approvalAssignments: ApprovalAssignmentRecord[];
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const IPC_CHANNELS = {
|
|||||||
importFile: "agentcompany:import-file",
|
importFile: "agentcompany:import-file",
|
||||||
setFilePinned: "agentcompany:set-file-pinned",
|
setFilePinned: "agentcompany:set-file-pinned",
|
||||||
createMigrationReport: "agentcompany:create-migration-report",
|
createMigrationReport: "agentcompany:create-migration-report",
|
||||||
|
createRollbackSnapshot: "agentcompany:create-rollback-snapshot",
|
||||||
|
setRollbackSnapshotActive: "agentcompany:set-rollback-snapshot-active",
|
||||||
setFeatureFlag: "agentcompany:set-feature-flag",
|
setFeatureFlag: "agentcompany:set-feature-flag",
|
||||||
runChaosDrill: "agentcompany:run-chaos-drill",
|
runChaosDrill: "agentcompany:run-chaos-drill",
|
||||||
runSdlcReview: "agentcompany:run-sdlc-review",
|
runSdlcReview: "agentcompany:run-sdlc-review",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { mkdirSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
test("onboards, hires, and creates work in the Electron shell", async () => {
|
test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||||
const profileDir = join(tmpdir(), `agentcompany-new-e2e-${randomUUID()}`);
|
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("Replay runs", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText(/\d+ events ·/).first()).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);
|
await verifyPagesDoNotOverflow(page);
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
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<void> {
|
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
|
||||||
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
|
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
|
||||||
await page.setViewportSize(size);
|
await page.setViewportSize(size);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
|||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
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();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 retention_freezes(id text primary key, org_id text, created_by_employee_id text);
|
||||||
create table search_documents(entity_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 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);
|
create table audit_events(id text primary key, org_id text);
|
||||||
insert into organizations(id) values ('org-1');
|
insert into organizations(id) values ('org-1');
|
||||||
insert into employees(id, org_id) values ('emp-1', '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 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 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 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');
|
insert into audit_events(id, org_id) values ('audit-1', 'org-1');
|
||||||
`);
|
`);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
@@ -244,6 +246,7 @@ describe("migration service", () => {
|
|||||||
expect(body.tableCounts.provider_usage_windows).toBe(1);
|
expect(body.tableCounts.provider_usage_windows).toBe(1);
|
||||||
expect(body.tableCounts.compliance_exports).toBe(1);
|
expect(body.tableCounts.compliance_exports).toBe(1);
|
||||||
expect(body.tableCounts.audit_replay_runs).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.tableCounts.search_documents).toBe(1);
|
||||||
expect(body.tableChecksums.audit_events).toHaveLength(64);
|
expect(body.tableChecksums.audit_events).toHaveLength(64);
|
||||||
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
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.board_interventions).toEqual(["bdi-1"]);
|
||||||
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
||||||
expect(body.idMaps.audit_replay_runs).toEqual(["replay-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"]);
|
expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user