Files
pikasTech-HWLAB/internal/tasktree/backup-contracts.ts
T

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 });
}