451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
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,
|
||
};
|
||
}
|