Files
boss/src/lib/boss-state-backups.ts

451 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createHash, randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { readState, writeState, type BossState } from "@/lib/boss-data";
export interface BossStateBackupSnapshot {
snapshotId: string;
fileName: string;
absolutePath: string;
bytes: number;
sha256: string;
createdAt: string;
actorAccount?: string;
reason?: string;
schemaVersion?: number;
}
export interface BossStateBackupStatus {
mode: "file";
backupDir: string;
stateFile: string;
restorePointCount: number;
lastBackupAt?: string;
status: "ready" | "empty" | "error";
detail?: string;
}
export type BossBackupScope = "global" | "company" | "project" | "conversation" | "config" | "skill" | "task";
export interface BossStateBackupBusinessSummary {
companyCount: number;
accountCount: number;
deviceCount: number;
projectCount: number;
messageCount: number;
taskCount: number;
skillCount: number;
auditLogCount: number;
}
export interface BossStateBackupVerification {
snapshotId: string;
checkedAt: string;
ok: boolean;
sha256: string;
expectedSha256?: string;
message: string;
}
export interface BossStateBackupProjection extends BossStateBackupSnapshot {
scope: BossBackupScope;
storageKind: "file";
status: "ready" | "error";
verification: BossStateBackupVerification;
businessSummary: BossStateBackupBusinessSummary;
}
export interface BossStateRestoreImpactCounter {
current: number;
after: number;
delta: number;
}
export interface BossStateRestorePreview {
snapshotId: string;
willWriteState: boolean;
generatedAt: string;
summary: string;
impact: {
companies: BossStateRestoreImpactCounter;
accounts: BossStateRestoreImpactCounter;
devices: BossStateRestoreImpactCounter;
projects: BossStateRestoreImpactCounter;
messages: BossStateRestoreImpactCounter;
tasks: BossStateRestoreImpactCounter;
skills: BossStateRestoreImpactCounter;
auditLogs: BossStateRestoreImpactCounter;
};
}
function stateFilePath() {
const configuredStateFile = process.env.BOSS_STATE_FILE?.trim();
if (configuredStateFile) {
return path.resolve(configuredStateFile);
}
const runtimeRoot = process.env.BOSS_RUNTIME_ROOT?.trim();
if (runtimeRoot) {
return path.resolve(runtimeRoot, "data", "boss-state.json");
}
return path.join(process.cwd(), "data", "boss-state.json");
}
function backupDirPath() {
const configuredBackupDir = process.env.BOSS_STATE_BACKUP_DIR?.trim();
if (configuredBackupDir) {
return path.resolve(configuredBackupDir);
}
return path.join(path.dirname(stateFilePath()), "backups");
}
function timestampSegment() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function snapshotIdFor(createdAtSegment: string, text: string) {
const digest = createHash("sha256")
.update(text)
.update(randomBytes(6))
.digest("hex")
.slice(0, 12);
return `state-snapshot-${createdAtSegment}-${digest}`;
}
function snapshotPath(snapshotId: string) {
if (!/^state-snapshot-[0-9TZ-]+-[a-f0-9]{12}$/.test(snapshotId)) {
throw new Error("BACKUP_SNAPSHOT_ID_INVALID");
}
return path.join(backupDirPath(), `${snapshotId}.json`);
}
async function readSnapshotText(snapshotId: string) {
try {
return await fs.readFile(snapshotPath(snapshotId), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error("BACKUP_SNAPSHOT_NOT_FOUND");
}
throw error;
}
}
async function readStateText() {
const state = await readState();
return `${JSON.stringify(state, null, 2)}\n`;
}
function parseStateText(text: string, source: string) {
const parsed = JSON.parse(text) as BossState;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`BACKUP_STATE_INVALID:${source}`);
}
return parsed;
}
async function writeMeta(snapshotId: string, meta: Pick<BossStateBackupSnapshot, "actorAccount" | "reason" | "createdAt" | "sha256" | "bytes" | "schemaVersion">) {
await fs.writeFile(path.join(backupDirPath(), `${snapshotId}.meta.json`), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
}
async function readMeta(snapshotId: string) {
const metaPath = path.join(backupDirPath(), `${snapshotId}.meta.json`);
try {
return JSON.parse(await fs.readFile(metaPath, "utf8")) as Partial<BossStateBackupSnapshot>;
} catch {
return {};
}
}
function emptyBusinessSummary(): BossStateBackupBusinessSummary {
return {
companyCount: 0,
accountCount: 0,
deviceCount: 0,
projectCount: 0,
messageCount: 0,
taskCount: 0,
skillCount: 0,
auditLogCount: 0,
};
}
function summarizeBusinessState(state: BossState): BossStateBackupBusinessSummary {
const skillIds = new Set([
...state.skillCatalog.map((skill) => skill.skillId),
...state.deviceSkills.map((skill) => skill.skillId),
]);
const businessProjects = state.projects.filter((project) => project.id !== "master-agent");
return {
companyCount: state.adminCompanies.length,
accountCount: state.authAccounts.length,
deviceCount: state.devices.length,
projectCount: businessProjects.length,
messageCount: businessProjects.reduce((total, project) => total + project.messages.length, 0),
taskCount: state.masterAgentTasks.length,
skillCount: skillIds.size,
auditLogCount: state.permissionAuditLogs.length,
};
}
function impactCounter(current: number, after: number): BossStateRestoreImpactCounter {
return {
current,
after,
delta: after - current,
};
}
function buildRestorePreviewSummary(impact: BossStateRestorePreview["impact"]) {
const labels: Array<[keyof BossStateRestorePreview["impact"], string]> = [
["companies", "公司"],
["accounts", "账号"],
["devices", "设备"],
["projects", "项目"],
["messages", "消息"],
["tasks", "任务"],
["skills", "Skill"],
["auditLogs", "审计日志"],
];
const changed = labels
.map(([key, label]) => ({ label, counter: impact[key] }))
.filter(({ counter }) => counter.delta !== 0)
.map(({ label, counter }) => `${label} ${counter.current} -> ${counter.after}`);
return changed.length > 0 ? `恢复预览:${changed.join("")}` : "恢复预览:业务计数不变";
}
function verifySnapshotText(snapshot: BossStateBackupSnapshot, text: string): BossStateBackupVerification {
const sha256 = createHash("sha256").update(text).digest("hex");
const checksumOk = snapshot.sha256 === sha256;
let stateOk = true;
try {
parseStateText(text, snapshot.absolutePath);
} catch {
stateOk = false;
}
return {
snapshotId: snapshot.snapshotId,
checkedAt: new Date().toISOString(),
ok: checksumOk && stateOk,
sha256,
expectedSha256: snapshot.sha256,
message: checksumOk && stateOk
? "checksum verified"
: stateOk
? "checksum mismatch"
: "snapshot state is invalid",
};
}
async function loadBossStateBackupSnapshot(snapshotId: string): Promise<BossStateBackupSnapshot> {
const absolutePath = snapshotPath(snapshotId);
const text = await readSnapshotText(snapshotId);
const stat = await fs.stat(absolutePath);
const meta = await readMeta(snapshotId);
let schemaVersion: number | undefined;
try {
const parsed = JSON.parse(text) as Partial<BossState>;
schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : undefined;
} catch {
schemaVersion = undefined;
}
return {
snapshotId,
fileName: `${snapshotId}.json`,
absolutePath,
bytes: typeof meta.bytes === "number" ? meta.bytes : stat.size,
sha256: typeof meta.sha256 === "string" ? meta.sha256 : createHash("sha256").update(text).digest("hex"),
createdAt: typeof meta.createdAt === "string" ? meta.createdAt : stat.mtime.toISOString(),
actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : undefined,
reason: typeof meta.reason === "string" ? meta.reason : undefined,
schemaVersion: typeof meta.schemaVersion === "number" ? meta.schemaVersion : schemaVersion,
};
}
export async function createBossStateBackup(input: {
actorAccount: string;
reason?: string;
prefix?: string;
}): Promise<BossStateBackupSnapshot> {
const text = await readStateText();
const parsed = parseStateText(text, stateFilePath());
const createdAtSegment = timestampSegment();
const snapshotId = snapshotIdFor(createdAtSegment, text);
const dir = backupDirPath();
const absolutePath = path.join(dir, `${snapshotId}.json`);
const sha256 = createHash("sha256").update(text).digest("hex");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(absolutePath, text.endsWith("\n") ? text : `${text}\n`, "utf8");
const stat = await fs.stat(absolutePath);
const createdAt = new Date().toISOString();
await writeMeta(snapshotId, {
actorAccount: input.actorAccount,
reason: input.reason,
createdAt,
sha256,
bytes: stat.size,
schemaVersion: parsed.schemaVersion,
});
return {
snapshotId,
fileName: `${snapshotId}.json`,
absolutePath,
bytes: stat.size,
sha256,
createdAt,
actorAccount: input.actorAccount,
reason: input.reason,
schemaVersion: parsed.schemaVersion,
};
}
export async function verifyBossStateBackup(snapshotId: string): Promise<BossStateBackupVerification> {
const snapshot = await loadBossStateBackupSnapshot(snapshotId);
const text = await readSnapshotText(snapshotId);
return verifySnapshotText(snapshot, text);
}
export async function projectBossStateBackupSnapshot(snapshot: BossStateBackupSnapshot): Promise<BossStateBackupProjection> {
const text = await readSnapshotText(snapshot.snapshotId);
const verification = verifySnapshotText(snapshot, text);
let businessSummary = emptyBusinessSummary();
try {
businessSummary = summarizeBusinessState(parseStateText(text, snapshot.absolutePath));
} catch {
businessSummary = emptyBusinessSummary();
}
return {
...snapshot,
scope: "global",
storageKind: "file",
status: verification.ok ? "ready" : "error",
verification,
businessSummary,
};
}
export async function listBossStateBackups(limit = 20): Promise<BossStateBackupSnapshot[]> {
const dir = backupDirPath();
const entries = await fs.readdir(dir).catch(() => []);
const snapshots = await Promise.all(
entries
.filter((fileName) => /^state-snapshot-.*\.json$/.test(fileName) && !fileName.endsWith(".meta.json"))
.map(async (fileName) => {
const absolutePath = path.join(dir, fileName);
const text = await fs.readFile(absolutePath, "utf8");
const stat = await fs.stat(absolutePath);
const snapshotId = fileName.replace(/\.json$/, "");
const meta = await readMeta(snapshotId);
let schemaVersion: number | undefined;
try {
const parsed = JSON.parse(text) as Partial<BossState>;
schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : undefined;
} catch {
schemaVersion = undefined;
}
return {
snapshotId,
fileName,
absolutePath,
bytes: typeof meta.bytes === "number" ? meta.bytes : stat.size,
sha256: typeof meta.sha256 === "string" ? meta.sha256 : createHash("sha256").update(text).digest("hex"),
createdAt: typeof meta.createdAt === "string" ? meta.createdAt : stat.mtime.toISOString(),
actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : undefined,
reason: typeof meta.reason === "string" ? meta.reason : undefined,
schemaVersion: typeof meta.schemaVersion === "number" ? meta.schemaVersion : schemaVersion,
};
}),
);
return snapshots
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.slice(0, Math.max(1, Math.min(100, limit)));
}
export async function listBossStateBackupProjections(limit = 20): Promise<BossStateBackupProjection[]> {
const snapshots = await listBossStateBackups(limit);
return Promise.all(snapshots.map((snapshot) => projectBossStateBackupSnapshot(snapshot)));
}
export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus> {
try {
const snapshots = await listBossStateBackups(100);
return {
mode: "file",
backupDir: backupDirPath(),
stateFile: stateFilePath(),
restorePointCount: snapshots.length,
lastBackupAt: snapshots[0]?.createdAt,
status: snapshots.length > 0 ? "ready" : "empty",
detail: snapshots.length > 0 ? `最近快照:${snapshots[0]?.snapshotId}` : "暂无可用快照",
};
} catch (error) {
return {
mode: "file",
backupDir: backupDirPath(),
stateFile: stateFilePath(),
restorePointCount: 0,
status: "error",
detail: error instanceof Error ? error.message : String(error),
};
}
}
export async function previewBossStateRestore(input: {
snapshotId: string;
willWriteState?: boolean;
}): Promise<BossStateRestorePreview> {
const snapshot = await loadBossStateBackupSnapshot(input.snapshotId);
const snapshotState = parseStateText(await readSnapshotText(input.snapshotId), snapshot.absolutePath);
const currentSummary = summarizeBusinessState(await readState());
const afterSummary = summarizeBusinessState(snapshotState);
const impact = {
companies: impactCounter(currentSummary.companyCount, afterSummary.companyCount),
accounts: impactCounter(currentSummary.accountCount, afterSummary.accountCount),
devices: impactCounter(currentSummary.deviceCount, afterSummary.deviceCount),
projects: impactCounter(currentSummary.projectCount, afterSummary.projectCount),
messages: impactCounter(currentSummary.messageCount, afterSummary.messageCount),
tasks: impactCounter(currentSummary.taskCount, afterSummary.taskCount),
skills: impactCounter(currentSummary.skillCount, afterSummary.skillCount),
auditLogs: impactCounter(currentSummary.auditLogCount, afterSummary.auditLogCount),
};
return {
snapshotId: input.snapshotId,
willWriteState: Boolean(input.willWriteState),
generatedAt: new Date().toISOString(),
summary: buildRestorePreviewSummary(impact),
impact,
};
}
export async function restoreBossStateBackup(input: {
snapshotId: string;
actorAccount: string;
}): Promise<{
restored: BossStateBackupSnapshot;
preRestoreSnapshot: BossStateBackupSnapshot;
}> {
const absolutePath = snapshotPath(input.snapshotId);
const text = await fs.readFile(absolutePath, "utf8");
const parsed = parseStateText(text, absolutePath);
const restored = (await listBossStateBackups(100)).find((snapshot) => snapshot.snapshotId === input.snapshotId);
if (!restored) {
throw new Error("BACKUP_SNAPSHOT_NOT_FOUND");
}
const preRestoreSnapshot = await createBossStateBackup({
actorAccount: input.actorAccount,
reason: `pre-restore:${input.snapshotId}`,
});
await writeState(parsed);
return {
restored,
preRestoreSnapshot,
};
}