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 { 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 = 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) => void = () => {}, ): { stop: () => Promise; runNow: () => Promise } { let current: Promise | 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 }); }