242 lines
9.0 KiB
TypeScript
242 lines
9.0 KiB
TypeScript
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<T extends { id: string }>(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<string>();
|
|
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<string>();
|
|
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<string, any> {
|
|
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 });
|
|
}
|