mirror of https://github.com/openclaw/openclaw.git
255 lines
7.3 KiB
TypeScript
255 lines
7.3 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
readConfigFileSnapshot,
|
|
resolveConfigPath,
|
|
resolveOAuthDir,
|
|
resolveStateDir,
|
|
} from "../config/config.js";
|
|
import { formatSessionArchiveTimestamp } from "../config/sessions/artifacts.js";
|
|
import { pathExists, shortenHomePath } from "../utils.js";
|
|
import { buildCleanupPlan, isPathWithin } from "./cleanup-utils.js";
|
|
|
|
export type BackupAssetKind = "state" | "config" | "credentials" | "workspace";
|
|
export type BackupSkipReason = "covered" | "missing";
|
|
|
|
export type BackupAsset = {
|
|
kind: BackupAssetKind;
|
|
sourcePath: string;
|
|
displayPath: string;
|
|
archivePath: string;
|
|
};
|
|
|
|
export type SkippedBackupAsset = {
|
|
kind: BackupAssetKind;
|
|
sourcePath: string;
|
|
displayPath: string;
|
|
reason: BackupSkipReason;
|
|
coveredBy?: string;
|
|
};
|
|
|
|
export type BackupPlan = {
|
|
stateDir: string;
|
|
configPath: string;
|
|
oauthDir: string;
|
|
workspaceDirs: string[];
|
|
included: BackupAsset[];
|
|
skipped: SkippedBackupAsset[];
|
|
};
|
|
|
|
type BackupAssetCandidate = {
|
|
kind: BackupAssetKind;
|
|
sourcePath: string;
|
|
canonicalPath: string;
|
|
exists: boolean;
|
|
};
|
|
|
|
function backupAssetPriority(kind: BackupAssetKind): number {
|
|
switch (kind) {
|
|
case "state":
|
|
return 0;
|
|
case "config":
|
|
return 1;
|
|
case "credentials":
|
|
return 2;
|
|
case "workspace":
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
export function buildBackupArchiveRoot(nowMs = Date.now()): string {
|
|
return `${formatSessionArchiveTimestamp(nowMs)}-openclaw-backup`;
|
|
}
|
|
|
|
export function buildBackupArchiveBasename(nowMs = Date.now()): string {
|
|
return `${buildBackupArchiveRoot(nowMs)}.tar.gz`;
|
|
}
|
|
|
|
export function encodeAbsolutePathForBackupArchive(sourcePath: string): string {
|
|
const normalized = sourcePath.replaceAll("\\", "/");
|
|
const windowsMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
|
if (windowsMatch) {
|
|
const drive = windowsMatch[1]?.toUpperCase() ?? "UNKNOWN";
|
|
const rest = windowsMatch[2] ?? "";
|
|
return path.posix.join("windows", drive, rest);
|
|
}
|
|
if (normalized.startsWith("/")) {
|
|
return path.posix.join("posix", normalized.slice(1));
|
|
}
|
|
return path.posix.join("relative", normalized);
|
|
}
|
|
|
|
export function buildBackupArchivePath(archiveRoot: string, sourcePath: string): string {
|
|
return path.posix.join(archiveRoot, "payload", encodeAbsolutePathForBackupArchive(sourcePath));
|
|
}
|
|
|
|
function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number {
|
|
const depthDelta = left.canonicalPath.length - right.canonicalPath.length;
|
|
if (depthDelta !== 0) {
|
|
return depthDelta;
|
|
}
|
|
const priorityDelta = backupAssetPriority(left.kind) - backupAssetPriority(right.kind);
|
|
if (priorityDelta !== 0) {
|
|
return priorityDelta;
|
|
}
|
|
return left.canonicalPath.localeCompare(right.canonicalPath);
|
|
}
|
|
|
|
async function canonicalizeExistingPath(targetPath: string): Promise<string> {
|
|
try {
|
|
return await fs.realpath(targetPath);
|
|
} catch {
|
|
return path.resolve(targetPath);
|
|
}
|
|
}
|
|
|
|
export async function resolveBackupPlanFromDisk(
|
|
params: {
|
|
includeWorkspace?: boolean;
|
|
onlyConfig?: boolean;
|
|
nowMs?: number;
|
|
} = {},
|
|
): Promise<BackupPlan> {
|
|
const includeWorkspace = params.includeWorkspace ?? true;
|
|
const onlyConfig = params.onlyConfig ?? false;
|
|
const stateDir = resolveStateDir();
|
|
const configPath = resolveConfigPath();
|
|
const oauthDir = resolveOAuthDir();
|
|
const archiveRoot = buildBackupArchiveRoot(params.nowMs);
|
|
|
|
if (onlyConfig) {
|
|
const resolvedConfigPath = path.resolve(configPath);
|
|
if (!(await pathExists(resolvedConfigPath))) {
|
|
return {
|
|
stateDir,
|
|
configPath,
|
|
oauthDir,
|
|
workspaceDirs: [],
|
|
included: [],
|
|
skipped: [
|
|
{
|
|
kind: "config",
|
|
sourcePath: resolvedConfigPath,
|
|
displayPath: shortenHomePath(resolvedConfigPath),
|
|
reason: "missing",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const canonicalConfigPath = await canonicalizeExistingPath(resolvedConfigPath);
|
|
return {
|
|
stateDir,
|
|
configPath,
|
|
oauthDir,
|
|
workspaceDirs: [],
|
|
included: [
|
|
{
|
|
kind: "config",
|
|
sourcePath: canonicalConfigPath,
|
|
displayPath: shortenHomePath(canonicalConfigPath),
|
|
archivePath: buildBackupArchivePath(archiveRoot, canonicalConfigPath),
|
|
},
|
|
],
|
|
skipped: [],
|
|
};
|
|
}
|
|
|
|
const configSnapshot = await readConfigFileSnapshot();
|
|
if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) {
|
|
throw new Error(
|
|
`Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`,
|
|
);
|
|
}
|
|
const cleanupPlan = buildCleanupPlan({
|
|
cfg: configSnapshot.config,
|
|
stateDir,
|
|
configPath,
|
|
oauthDir,
|
|
});
|
|
const workspaceDirs = includeWorkspace ? cleanupPlan.workspaceDirs : [];
|
|
|
|
const rawCandidates: Array<Pick<BackupAssetCandidate, "kind" | "sourcePath">> = [
|
|
{ kind: "state", sourcePath: path.resolve(stateDir) },
|
|
...(cleanupPlan.configInsideState
|
|
? []
|
|
: [{ kind: "config" as const, sourcePath: path.resolve(configPath) }]),
|
|
...(cleanupPlan.oauthInsideState
|
|
? []
|
|
: [{ kind: "credentials" as const, sourcePath: path.resolve(oauthDir) }]),
|
|
...(includeWorkspace
|
|
? workspaceDirs.map((workspaceDir) => ({
|
|
kind: "workspace" as const,
|
|
sourcePath: path.resolve(workspaceDir),
|
|
}))
|
|
: []),
|
|
];
|
|
|
|
const candidates: BackupAssetCandidate[] = await Promise.all(
|
|
rawCandidates.map(async (candidate) => {
|
|
const exists = await pathExists(candidate.sourcePath);
|
|
return {
|
|
...candidate,
|
|
exists,
|
|
canonicalPath: exists
|
|
? await canonicalizeExistingPath(candidate.sourcePath)
|
|
: path.resolve(candidate.sourcePath),
|
|
};
|
|
}),
|
|
);
|
|
|
|
const uniqueCandidates: BackupAssetCandidate[] = [];
|
|
const seenCanonicalPaths = new Set<string>();
|
|
for (const candidate of [...candidates].toSorted(compareCandidates)) {
|
|
if (seenCanonicalPaths.has(candidate.canonicalPath)) {
|
|
continue;
|
|
}
|
|
seenCanonicalPaths.add(candidate.canonicalPath);
|
|
uniqueCandidates.push(candidate);
|
|
}
|
|
const included: BackupAsset[] = [];
|
|
const skipped: SkippedBackupAsset[] = [];
|
|
|
|
for (const candidate of uniqueCandidates) {
|
|
if (!candidate.exists) {
|
|
skipped.push({
|
|
kind: candidate.kind,
|
|
sourcePath: candidate.sourcePath,
|
|
displayPath: shortenHomePath(candidate.sourcePath),
|
|
reason: "missing",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const coveredBy = included.find((asset) =>
|
|
isPathWithin(candidate.canonicalPath, asset.sourcePath),
|
|
);
|
|
if (coveredBy) {
|
|
skipped.push({
|
|
kind: candidate.kind,
|
|
sourcePath: candidate.canonicalPath,
|
|
displayPath: shortenHomePath(candidate.canonicalPath),
|
|
reason: "covered",
|
|
coveredBy: coveredBy.displayPath,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
included.push({
|
|
kind: candidate.kind,
|
|
sourcePath: candidate.canonicalPath,
|
|
displayPath: shortenHomePath(candidate.canonicalPath),
|
|
archivePath: buildBackupArchivePath(archiveRoot, candidate.canonicalPath),
|
|
});
|
|
}
|
|
|
|
return {
|
|
stateDir,
|
|
configPath,
|
|
oauthDir,
|
|
workspaceDirs: workspaceDirs.map((entry) => path.resolve(entry)),
|
|
included,
|
|
skipped,
|
|
};
|
|
}
|