feat(tasktree): add GitHub disaster recovery backups
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
restoreDiff,
|
||||
snapshotManifest,
|
||||
snapshotSha256,
|
||||
type TaskTreeBackupState,
|
||||
} from "./backup-contracts.ts";
|
||||
import { TaskTreeGitBackup, type TaskTreeGitBackupConfig } from "./backup-git.ts";
|
||||
import { TaskTreeStore } from "./store.ts";
|
||||
|
||||
export type TaskTreeBackupConfig = TaskTreeGitBackupConfig & {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
};
|
||||
|
||||
export class TaskTreeBackupService {
|
||||
private readonly git: TaskTreeGitBackup;
|
||||
|
||||
constructor(
|
||||
private readonly store: TaskTreeStore,
|
||||
readonly config: TaskTreeBackupConfig,
|
||||
) {
|
||||
this.git = new TaskTreeGitBackup(config);
|
||||
}
|
||||
|
||||
async request(reason: string): Promise<void> {
|
||||
await this.store.requestBackup(this.config.target, reason);
|
||||
}
|
||||
|
||||
async create(options: { dryRun?: boolean; reason?: string } = {}) {
|
||||
const snapshot = await this.store.exportSnapshot();
|
||||
const manifest = snapshotManifest(snapshot, { target: this.config.target });
|
||||
if (options.dryRun) return { dryRun: true, changed: false, manifest };
|
||||
this.requireEnabled();
|
||||
await this.store.beginBackupAttempt(this.config.target);
|
||||
try {
|
||||
const published = await this.git.publish(snapshot, options.reason ?? "manual backup");
|
||||
await this.store.finishBackupSuccess(this.config.target, published.contentSha256, published.gitCommit);
|
||||
return { dryRun: false, ...published };
|
||||
} catch (error: any) {
|
||||
await this.store.finishBackupFailure(
|
||||
this.config.target,
|
||||
error?.code ?? "backup_failed",
|
||||
error?.message ?? String(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async status(): Promise<{ enabled: boolean; configured: boolean; state: TaskTreeBackupState }> {
|
||||
return {
|
||||
enabled: this.config.enabled,
|
||||
configured: Boolean(this.config.repoSshUrl),
|
||||
state: await this.store.backupState(this.config.target),
|
||||
};
|
||||
}
|
||||
|
||||
async verify() {
|
||||
this.requireEnabled();
|
||||
const current = await this.store.exportSnapshot();
|
||||
const stored = this.git.readCurrent();
|
||||
if (stored === null) throw codedError("backup_snapshot_missing", "TaskTree backup snapshot does not exist");
|
||||
const databaseSha256 = snapshotSha256(current);
|
||||
return {
|
||||
verified: databaseSha256 === stored.manifest.contentSha256,
|
||||
target: this.config.target,
|
||||
databaseSha256,
|
||||
backupSha256: stored.manifest.contentSha256,
|
||||
gitCommit: stored.gitCommit,
|
||||
counts: stored.manifest.counts,
|
||||
};
|
||||
}
|
||||
|
||||
async restore(options: { confirm: boolean; expectedSha256?: string }) {
|
||||
this.requireEnabled();
|
||||
const stored = this.git.readCurrent();
|
||||
if (stored === null) throw codedError("backup_snapshot_missing", "TaskTree backup snapshot does not exist");
|
||||
const current = await this.store.exportSnapshot();
|
||||
const diff = restoreDiff(current, stored.snapshot);
|
||||
if (!options.confirm) {
|
||||
return {
|
||||
dryRun: true,
|
||||
restored: false,
|
||||
target: this.config.target,
|
||||
contentSha256: stored.manifest.contentSha256,
|
||||
gitCommit: stored.gitCommit,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
if (!options.expectedSha256 || options.expectedSha256 !== stored.manifest.contentSha256) {
|
||||
throw codedError("backup_restore_sha_required", "confirmed restore requires the exact current backup SHA");
|
||||
}
|
||||
await this.store.restoreSnapshot(stored.snapshot);
|
||||
await this.request("post-restore verification backup");
|
||||
return {
|
||||
dryRun: false,
|
||||
restored: true,
|
||||
target: this.config.target,
|
||||
contentSha256: stored.manifest.contentSha256,
|
||||
gitCommit: stored.gitCommit,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
|
||||
private requireEnabled(): void {
|
||||
if (!this.config.enabled) throw codedError("backup_disabled", "TaskTree GitHub backup is disabled");
|
||||
if (!this.config.repoSshUrl) throw codedError("backup_repo_missing", "TaskTree backup Git repository is not configured");
|
||||
}
|
||||
}
|
||||
|
||||
export function taskTreeBackupConfigFromEnv(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): TaskTreeBackupConfig {
|
||||
return {
|
||||
enabled: env.TASKTREE_BACKUP_ENABLED === "true",
|
||||
repoSshUrl: env.TASKTREE_BACKUP_REPO_SSH_URL ?? "",
|
||||
branch: env.TASKTREE_BACKUP_BRANCH ?? "main",
|
||||
basePath: env.TASKTREE_BACKUP_BASE_PATH ?? "tasktree/development",
|
||||
worktreePath: env.TASKTREE_BACKUP_WORKTREE ?? ".state/tasktree/backup-repo",
|
||||
target: env.TASKTREE_BACKUP_TARGET ?? "development",
|
||||
intervalMs: positiveInteger(env.TASKTREE_BACKUP_INTERVAL_MS, 300_000),
|
||||
sshCommand: env.TASKTREE_BACKUP_GIT_SSH_COMMAND ?? "",
|
||||
authorName: env.TASKTREE_BACKUP_GIT_AUTHOR_NAME ?? "HWLAB TaskTree",
|
||||
authorEmail: env.TASKTREE_BACKUP_GIT_AUTHOR_EMAIL ?? "tasktree@hwlab.local",
|
||||
commitMessagePrefix: env.TASKTREE_BACKUP_GIT_COMMIT_PREFIX ?? "tasktree:",
|
||||
};
|
||||
}
|
||||
|
||||
export function startTaskTreeBackupScheduler(
|
||||
service: TaskTreeBackupService,
|
||||
onResult: (result: Record<string, unknown>) => void = () => {},
|
||||
): { stop: () => Promise<void>; runNow: () => Promise<void> } {
|
||||
let current: Promise<void> | null = null;
|
||||
const runNow = () => {
|
||||
if (!service.config.enabled) return Promise.resolve();
|
||||
if (current) return current;
|
||||
current = (async () => {
|
||||
try {
|
||||
const result = await service.create({ reason: "automatic worker snapshot" });
|
||||
onResult({ ok: true, event: "tasktree_backup_succeeded", ...result });
|
||||
} catch (error: any) {
|
||||
onResult({
|
||||
ok: false,
|
||||
event: "tasktree_backup_failed",
|
||||
code: error?.code ?? "backup_failed",
|
||||
message: error?.message ?? String(error),
|
||||
});
|
||||
}
|
||||
})().finally(() => {
|
||||
current = null;
|
||||
});
|
||||
return current;
|
||||
};
|
||||
const timer = setInterval(() => void runNow(), service.config.intervalMs);
|
||||
timer.unref();
|
||||
void runNow();
|
||||
return {
|
||||
async stop() {
|
||||
clearInterval(timer);
|
||||
await current;
|
||||
},
|
||||
runNow,
|
||||
};
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function codedError(code: string, message: string): Error {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
Reference in New Issue
Block a user