- backup.ts: log.warn when `git rev-parse HEAD` fails so sandbox/CI runs
with no git show diagnosably instead of silently emitting gitSha:null
(Karpathy review ask).
- backup.ts: expand comment on VACUUM INTO to pin the WAL-mode invariant
— future readers must not "helpfully" add a wal_checkpoint call
(Karpathy review ask).
- C6.2 proposal: add hard coordination gate before wave 3 because
commit 0db6e42 already demonstrated the race hazard when two agents
touch scripts/ / connector-definitions/ in the same window
(Linus review ask).
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { chmodSync, cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { copyFile, cp, rm } from "node:fs/promises";
|
|
import { execFileSync } from "node:child_process";
|
|
import { join } from "node:path";
|
|
import { DatabaseSync, backup as backupDatabase } from "node:sqlite";
|
|
import log from "electron-log/main.js";
|
|
|
|
function readSchemaVersion(db: DatabaseSync): number {
|
|
const row = db
|
|
.prepare("select coalesce(max(version), 0) as v from schema_migrations")
|
|
.get() as { v: number } | undefined;
|
|
return row?.v ?? 0;
|
|
}
|
|
|
|
function readGitSha(): string | null {
|
|
try {
|
|
const out = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
cwd: process.cwd(),
|
|
timeout: 1000,
|
|
});
|
|
const sha = out.toString("utf8").trim();
|
|
return sha.length === 40 ? sha : null;
|
|
} catch (e) {
|
|
log.warn(
|
|
"[database/backup] git rev-parse HEAD failed; manifest.gitSha will be null",
|
|
e instanceof Error ? e.message : String(e),
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function timestampSegment(): string {
|
|
// Filesystem-safe variant of ISO 8601 (YYYYMMDD-HHmmss-SSS).
|
|
const now = new Date();
|
|
const pad = (n: number, w = 2) => n.toString().padStart(w, "0");
|
|
return (
|
|
`${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-` +
|
|
`${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}-` +
|
|
`${pad(now.getUTCMilliseconds(), 3)}`
|
|
);
|
|
}
|
|
|
|
async function copyIfExists(source: string, destination: string) {
|
|
if (!existsSync(source)) {
|
|
return;
|
|
}
|
|
await copyFile(source, destination);
|
|
}
|
|
|
|
export function buildBackupMethods(
|
|
deps: {
|
|
getDb: () => DatabaseSync;
|
|
setDb: (db: DatabaseSync) => void;
|
|
openConnection: () => DatabaseSync;
|
|
init: () => void;
|
|
close: () => void;
|
|
profileDir: string;
|
|
dbPath: string;
|
|
logsDir: string;
|
|
artifactsDir: string;
|
|
backupsDir: string;
|
|
appVersion: string;
|
|
},
|
|
) {
|
|
return {
|
|
/**
|
|
* Take a synchronous snapshot of the profile before a destructive or
|
|
* risky operation (currently: pre-migration). Named so the operator
|
|
* can tell snapshots apart by reason and target schema version.
|
|
*
|
|
* Uses `VACUUM INTO` + `cpSync` rather than the async `exportBackup`
|
|
* path so the caller — `runMigrations`, which is synchronous — can
|
|
* block on the backup without any async plumbing. The returned path
|
|
* is absolute so a failed migration can surface the recovery point
|
|
* in logs verbatim.
|
|
*
|
|
* Failure modes are reported by throwing. There is no fallback: if
|
|
* we cannot take a snapshot we must not touch the schema.
|
|
*/
|
|
createAutomaticBackup(reason: string): string {
|
|
if (!reason || reason.includes("/") || reason.includes("\\")) {
|
|
throw new Error(`createAutomaticBackup: invalid reason "${reason}"`);
|
|
}
|
|
const db = deps.getDb();
|
|
const schemaVersion = readSchemaVersion(db);
|
|
const segment = `${reason}-v${schemaVersion}-${timestampSegment()}`;
|
|
const targetDir = join(deps.backupsDir, segment);
|
|
mkdirSync(deps.backupsDir, { recursive: true });
|
|
mkdirSync(targetDir, { recursive: true });
|
|
|
|
// VACUUM INTO takes a single consistent snapshot of the live DB
|
|
// while the connection is held open — no WAL checkpoint dance, no
|
|
// torn writes, works on sqlite3 ≥ 3.27. The destination must not
|
|
// exist; `mkdirSync` above only created the parent directory.
|
|
//
|
|
// Safety invariant under WAL mode: VACUUM INTO opens an implicit
|
|
// read transaction that sees every committed frame including those
|
|
// still in profile.sqlite-wal. DO NOT add PRAGMA wal_checkpoint
|
|
// here — a TRUNCATE checkpoint would fight concurrent writers and
|
|
// buys nothing for the snapshot; a PASSIVE checkpoint is redundant.
|
|
const dbTarget = join(targetDir, "profile.sqlite").replace(/'/g, "''");
|
|
db.exec(`VACUUM INTO '${dbTarget}'`);
|
|
|
|
// Side-car files (logs + artifacts) ride along so operators
|
|
// restoring from this snapshot see the same run history the
|
|
// migration saw. `cpSync` is recursive and force-overwrites.
|
|
if (existsSync(deps.logsDir)) {
|
|
cpSync(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true });
|
|
}
|
|
if (existsSync(deps.artifactsDir)) {
|
|
cpSync(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true });
|
|
}
|
|
|
|
const manifest = {
|
|
reason,
|
|
schemaVersion,
|
|
appVersion: deps.appVersion,
|
|
createdAt: new Date().toISOString(),
|
|
gitSha: readGitSha(),
|
|
};
|
|
writeFileSync(
|
|
join(targetDir, "backup-manifest.json"),
|
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
{ encoding: "utf8" },
|
|
);
|
|
log.info(`[database/backup] automatic snapshot "${reason}" written to ${targetDir}`);
|
|
return targetDir;
|
|
},
|
|
|
|
exportBackup(targetDir: string) {
|
|
mkdirSync(targetDir, { recursive: true });
|
|
deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
|
const copyJobs = [
|
|
backupDatabase(deps.getDb(), join(targetDir, "profile.sqlite")),
|
|
cp(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true }),
|
|
cp(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }),
|
|
copyIfExists(`${deps.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")),
|
|
copyIfExists(`${deps.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")),
|
|
];
|
|
return Promise.all(copyJobs);
|
|
},
|
|
|
|
async restoreFromBackup(sourceDir: string) {
|
|
const sourceDbPath = join(sourceDir, "profile.sqlite");
|
|
if (!existsSync(sourceDbPath)) {
|
|
throw new Error("Backup directory is missing profile.sqlite.");
|
|
}
|
|
|
|
// Validate
|
|
const currentSchemaVersion =
|
|
(deps.getDb()
|
|
.prepare("select coalesce(max(version), 0) as v from schema_migrations")
|
|
.get() as { v: number } | undefined)?.v ?? 0;
|
|
const validationDb = new DatabaseSync(sourceDbPath);
|
|
try {
|
|
const requiredTables = ["settings", "companies", "connectors"] as const;
|
|
for (const table of requiredTables) {
|
|
const row = validationDb
|
|
.prepare("select name from sqlite_master where type = 'table' and name = ?")
|
|
.get(table) as { name: string } | undefined;
|
|
if (!row) {
|
|
throw new Error(`Backup is missing the required table "${table}".`);
|
|
}
|
|
}
|
|
const hasMigrationsTable = validationDb
|
|
.prepare("select name from sqlite_master where type = 'table' and name = 'schema_migrations'")
|
|
.get() as { name: string } | undefined;
|
|
if (!hasMigrationsTable) {
|
|
throw new Error(
|
|
`Backup is missing schema_migrations. Expected schema version ${currentSchemaVersion}.`,
|
|
);
|
|
}
|
|
const backupVersion =
|
|
(validationDb
|
|
.prepare("select coalesce(max(version), 0) as v from schema_migrations")
|
|
.get() as { v: number } | undefined)?.v ?? 0;
|
|
if (backupVersion < currentSchemaVersion) {
|
|
throw new Error(
|
|
`Backup schema version ${backupVersion} is older than current ${currentSchemaVersion}. Restore would lose migrations.`,
|
|
);
|
|
}
|
|
} finally {
|
|
validationDb.close();
|
|
}
|
|
|
|
const recoveryDir = join(deps.backupsDir, `pre-restore-${Date.now()}`);
|
|
mkdirSync(recoveryDir, { recursive: true });
|
|
deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
|
await Promise.all([
|
|
backupDatabase(deps.getDb(), join(recoveryDir, "profile.sqlite")),
|
|
cp(deps.logsDir, join(recoveryDir, "logs"), { recursive: true, force: true }),
|
|
cp(deps.artifactsDir, join(recoveryDir, "artifacts"), { recursive: true, force: true }),
|
|
copyIfExists(join(deps.profileDir, "vault.key"), join(recoveryDir, "vault.key")),
|
|
copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(recoveryDir, "agent-api-signing.key")),
|
|
copyIfExists(`${deps.dbPath}-wal`, join(recoveryDir, "profile.sqlite-wal")),
|
|
copyIfExists(`${deps.dbPath}-shm`, join(recoveryDir, "profile.sqlite-shm")),
|
|
]);
|
|
|
|
deps.close();
|
|
try {
|
|
await applyBackup(sourceDir);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
try {
|
|
await applyBackup(recoveryDir);
|
|
} catch (recoveryError) {
|
|
const recoveryMessage =
|
|
recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
|
|
try {
|
|
deps.setDb(deps.openConnection());
|
|
deps.init();
|
|
} catch (reopenError) {
|
|
log.error(`[database/backup] Reopen failed after restore/recovery failure:`, reopenError instanceof Error ? reopenError.message : String(reopenError));
|
|
}
|
|
throw new Error(
|
|
`Restore failed: ${message}. Recovery also failed: ${recoveryMessage}. Previous profile is at ${recoveryDir}.`,
|
|
);
|
|
}
|
|
throw new Error(`Restore failed. Previous profile was recovered from ${recoveryDir}. ${message}`);
|
|
}
|
|
},
|
|
};
|
|
|
|
async function applyBackup(sourceDir: string) {
|
|
await rm(deps.dbPath, { force: true });
|
|
await rm(`${deps.dbPath}-wal`, { force: true });
|
|
await rm(`${deps.dbPath}-shm`, { force: true });
|
|
|
|
await copyFile(join(sourceDir, "profile.sqlite"), deps.dbPath);
|
|
await copyIfExists(join(sourceDir, "profile.sqlite-wal"), `${deps.dbPath}-wal`);
|
|
await copyIfExists(join(sourceDir, "profile.sqlite-shm"), `${deps.dbPath}-shm`);
|
|
|
|
await rm(deps.logsDir, { recursive: true, force: true });
|
|
await rm(deps.artifactsDir, { recursive: true, force: true });
|
|
mkdirSync(deps.logsDir, { recursive: true });
|
|
mkdirSync(deps.artifactsDir, { recursive: true });
|
|
|
|
if (existsSync(join(sourceDir, "logs"))) {
|
|
await cp(join(sourceDir, "logs"), deps.logsDir, { recursive: true, force: true });
|
|
}
|
|
if (existsSync(join(sourceDir, "artifacts"))) {
|
|
await cp(join(sourceDir, "artifacts"), deps.artifactsDir, { recursive: true, force: true });
|
|
}
|
|
await copyIfExists(join(sourceDir, "vault.key"), join(deps.profileDir, "vault.key"));
|
|
await copyIfExists(join(sourceDir, "agent-api-signing.key"), join(deps.profileDir, "agent-api-signing.key"));
|
|
if (existsSync(join(deps.profileDir, "vault.key"))) {
|
|
chmodSync(join(deps.profileDir, "vault.key"), 0o600);
|
|
}
|
|
if (existsSync(join(deps.profileDir, "agent-api-signing.key"))) {
|
|
chmodSync(join(deps.profileDir, "agent-api-signing.key"), 0o600);
|
|
}
|
|
|
|
deps.setDb(deps.openConnection());
|
|
deps.init();
|
|
}
|
|
}
|