import { createHash } from "node:crypto"; import type { ExecutionReport, Milestone, TaskGroup, TaskItem } from "./contracts.ts"; export const TASKTREE_BACKUP_SCHEMA_VERSION = 1; export type TaskTreeSnapshot = { schemaVersion: typeof TASKTREE_BACKUP_SCHEMA_VERSION; groups: TaskGroup[]; tasks: TaskItem[]; milestones: Milestone[]; reports: ExecutionReport[]; }; export type TaskTreeBackupManifest = { schemaVersion: typeof TASKTREE_BACKUP_SCHEMA_VERSION; target: string; generatedAt: string; contentSha256: string; writer: string; counts: { groups: number; tasks: number; milestones: number; reports: number; }; }; export type TaskTreeBackupState = { target: string; pending: boolean; requestedAt: string | null; requestReason: string; lastAttemptAt: string | null; lastSuccessAt: string | null; contentSha256: string; gitCommit: string; status: "never" | "pending" | "succeeded" | "failed"; errorCode: string; errorMessage: string; updatedAt: string; }; export type TaskTreeRestoreDiff = Record< "groups" | "tasks" | "milestones" | "reports", { create: number; update: number; delete: number } >; export function canonicalSnapshot(value: TaskTreeSnapshot): string { return `${JSON.stringify(normalizeSnapshot(value), null, 2)}\n`; } export function snapshotSha256(value: TaskTreeSnapshot): string { return createHash("sha256").update(canonicalSnapshot(value)).digest("hex"); } export function snapshotManifest( snapshot: TaskTreeSnapshot, options: { target: string; generatedAt?: string; writer?: string }, ): TaskTreeBackupManifest { return { schemaVersion: TASKTREE_BACKUP_SCHEMA_VERSION, target: requiredText(options.target, "target"), generatedAt: new Date(options.generatedAt ?? Date.now()).toISOString(), contentSha256: snapshotSha256(snapshot), writer: options.writer ?? "hwlab-tasktree-worker", counts: snapshotCounts(snapshot), }; } export function parseSnapshot(text: string): TaskTreeSnapshot { let value: unknown; try { value = JSON.parse(text); } catch { throw codedError("backup_snapshot_invalid_json", "backup snapshot is not valid JSON"); } return validateSnapshot(value); } export function parseManifest(text: string): TaskTreeBackupManifest { let value: unknown; try { value = JSON.parse(text); } catch { throw codedError("backup_manifest_invalid_json", "backup manifest is not valid JSON"); } if (!isRecord(value) || value.schemaVersion !== TASKTREE_BACKUP_SCHEMA_VERSION) { throw codedError("backup_manifest_schema_unsupported", "backup manifest schema version is unsupported"); } const counts = value.counts; if (!isRecord(counts)) throw codedError("backup_manifest_invalid", "backup manifest counts are required"); return { schemaVersion: TASKTREE_BACKUP_SCHEMA_VERSION, target: requiredText(value.target, "manifest.target"), generatedAt: validIso(value.generatedAt, "manifest.generatedAt"), contentSha256: validSha(value.contentSha256, "manifest.contentSha256"), writer: requiredText(value.writer, "manifest.writer"), counts: { groups: validCount(counts.groups, "manifest.counts.groups"), tasks: validCount(counts.tasks, "manifest.counts.tasks"), milestones: validCount(counts.milestones, "manifest.counts.milestones"), reports: validCount(counts.reports, "manifest.counts.reports"), }, }; } export function verifySnapshotManifest(snapshot: TaskTreeSnapshot, manifest: TaskTreeBackupManifest): void { if (snapshotSha256(snapshot) !== manifest.contentSha256) { throw codedError("backup_content_sha_mismatch", "backup snapshot does not match the manifest SHA"); } if (JSON.stringify(snapshotCounts(snapshot)) !== JSON.stringify(manifest.counts)) { throw codedError("backup_count_mismatch", "backup snapshot counts do not match the manifest"); } } export function restoreDiff(current: TaskTreeSnapshot, incoming: TaskTreeSnapshot): TaskTreeRestoreDiff { return { groups: entityDiff(current.groups, incoming.groups), tasks: entityDiff(current.tasks, incoming.tasks), milestones: entityDiff(current.milestones, incoming.milestones), reports: entityDiff(current.reports, incoming.reports), }; } export function validateSnapshot(value: unknown): TaskTreeSnapshot { if (!isRecord(value) || value.schemaVersion !== TASKTREE_BACKUP_SCHEMA_VERSION) { throw codedError("backup_snapshot_schema_unsupported", "backup snapshot schema version is unsupported"); } if (!Array.isArray(value.groups) || !Array.isArray(value.tasks) || !Array.isArray(value.milestones) || !Array.isArray(value.reports)) { throw codedError("backup_snapshot_invalid", "backup snapshot entity arrays are required"); } const snapshot = normalizeSnapshot(value as TaskTreeSnapshot); uniqueIds(snapshot.groups, "group"); uniqueIds(snapshot.tasks, "task"); uniqueIds(snapshot.milestones, "milestone"); uniqueIds(snapshot.reports, "report"); const groupIds = new Set(snapshot.groups.map((item) => item.id)); const taskIds = new Set(snapshot.tasks.map((item) => item.id)); for (const task of snapshot.tasks) { if (!groupIds.has(task.groupId)) throw codedError("backup_reference_invalid", `task ${task.id} references a missing taskgroup`); if (task.parentId !== null && !taskIds.has(task.parentId)) throw codedError("backup_reference_invalid", `task ${task.id} references a missing parent`); } for (const milestone of snapshot.milestones) { if (!groupIds.has(milestone.groupId)) throw codedError("backup_reference_invalid", `milestone ${milestone.id} references a missing taskgroup`); if (milestone.taskId !== null && !taskIds.has(milestone.taskId)) throw codedError("backup_reference_invalid", `milestone ${milestone.id} references a missing task`); } for (const report of snapshot.reports) { if (!taskIds.has(report.taskId)) throw codedError("backup_reference_invalid", `report ${report.id} references a missing task`); } assertAcyclic(snapshot.tasks); return snapshot; } function normalizeSnapshot(value: TaskTreeSnapshot): TaskTreeSnapshot { return { schemaVersion: TASKTREE_BACKUP_SCHEMA_VERSION, groups: [...value.groups].sort(byId), tasks: [...value.tasks].sort(byId), milestones: [...value.milestones].sort(byId), reports: [...value.reports].sort(byId), }; } function snapshotCounts(snapshot: TaskTreeSnapshot) { return { groups: snapshot.groups.length, tasks: snapshot.tasks.length, milestones: snapshot.milestones.length, reports: snapshot.reports.length, }; } function entityDiff(current: T[], incoming: T[]) { const before = new Map(current.map((item) => [item.id, JSON.stringify(item)])); const after = new Map(incoming.map((item) => [item.id, JSON.stringify(item)])); let create = 0; let update = 0; let remove = 0; for (const [id, value] of after) { if (!before.has(id)) create += 1; else if (before.get(id) !== value) update += 1; } for (const id of before.keys()) if (!after.has(id)) remove += 1; return { create, update, delete: remove }; } function uniqueIds(items: Array<{ id: string }>, kind: string): void { const ids = new Set(); for (const item of items) { if (!item.id || ids.has(item.id)) throw codedError("backup_snapshot_invalid", `${kind} IDs must be non-empty and unique`); ids.add(item.id); } } function assertAcyclic(tasks: TaskItem[]): void { const parents = new Map(tasks.map((item) => [item.id, item.parentId])); for (const task of tasks) { const seen = new Set(); let cursor: string | null = task.id; while (cursor !== null) { if (seen.has(cursor)) throw codedError("backup_reference_cycle", `task ${task.id} has a cyclic parent chain`); seen.add(cursor); cursor = parents.get(cursor) ?? null; } } } function byId(left: { id: string }, right: { id: string }): number { return left.id.localeCompare(right.id); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function requiredText(value: unknown, field: string): string { if (typeof value !== "string" || value.trim().length === 0) throw codedError("backup_contract_invalid", `${field} is required`); return value; } function validIso(value: unknown, field: string): string { const date = new Date(String(value ?? "")); if (Number.isNaN(date.valueOf())) throw codedError("backup_contract_invalid", `${field} must be an ISO timestamp`); return date.toISOString(); } function validSha(value: unknown, field: string): string { const text = requiredText(value, field); if (!/^[0-9a-f]{64}$/u.test(text)) throw codedError("backup_contract_invalid", `${field} must be a SHA-256 hex digest`); return text; } function validCount(value: unknown, field: string): number { if (!Number.isInteger(value) || Number(value) < 0) throw codedError("backup_contract_invalid", `${field} must be a non-negative integer`); return Number(value); } function codedError(code: string, message: string): Error { return Object.assign(new Error(message), { code }); }