// Runtime helpers for migration providers that need filesystem side effects. import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { writeTextAtomic } from "@openclaw/fs-safe/atomic"; import { resolveAgentConfig } from "../agents/agent-scope-config.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { ensureAbsoluteDirectory, pathExists, readRegularFile, root as openFsSafeRoot, } from "../infra/fs-safe.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; import type { MigrationApplyResult, MigrationItem, MigrationProviderContext, } from "../plugins/types.js"; import { assertMemoryMigrationSourceRevision, MAX_MEMORY_MIGRATION_FILE_BYTES, } from "./memory-migration-source.js"; import { MIGRATION_REASON_MISSING_SOURCE_OR_TARGET, MIGRATION_REASON_TARGET_EXISTS, markMigrationItemConflict, markMigrationItemError, redactMigrationPlan, } from "./migration.js"; export type { MigrationApplyResult, MigrationItem } from "../plugins/types.js"; /** Directories a migration provider writes imported agent data into. */ export type PlannedMigrationTargets = { workspaceDir: string; stateDir: string; agentDir: string; }; /** * Resolves default agent workspace/state/agent directories. Prefers the runtime resolver, * then configured agentDir (using effective-home resolution), then canonical state layout. */ export function resolvePlannedMigrationTargets( ctx: MigrationProviderContext, ): PlannedMigrationTargets { const cfg = ctx.config; const agentId = ctx.targetAgentId ?? resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const configuredAgentDir = resolveAgentConfig(cfg, agentId)?.agentDir?.trim(); const agentDir = ctx.runtime?.agent?.resolveAgentDir(cfg, agentId) ?? (configuredAgentDir ? resolveHomeRelativePath(configuredAgentDir) : undefined) ?? path.join(ctx.stateDir, "agents", agentId, "agent"); return { workspaceDir, stateDir: ctx.stateDir, agentDir, }; } /** Wrap migration runtime config access with a cached mutable snapshot during apply. */ export function withCachedMigrationConfigRuntime( runtime: MigrationProviderContext["runtime"] | undefined, fallbackConfig: MigrationProviderContext["config"], ): MigrationProviderContext["runtime"] | undefined { if (!runtime) { return undefined; } const configApi = runtime.config; if (!configApi?.current || !configApi.mutateConfigFile) { return runtime; } let cachedConfig: MigrationProviderContext["config"] | undefined; const current = (): ReturnType => { cachedConfig ??= structuredClone( (configApi.current() ?? fallbackConfig) as MigrationProviderContext["config"], ); return cachedConfig; }; return { ...runtime, config: { ...runtime.config, current, mutateConfigFile: async (params) => { const result = await configApi.mutateConfigFile({ ...params, mutate: async (draft, context) => { const mutationResult = await params.mutate(draft, context); cachedConfig = structuredClone(draft); return mutationResult; }, }); cachedConfig = structuredClone(result.nextConfig); return result; }, ...(configApi.replaceConfigFile ? { replaceConfigFile: async (params) => { const result = await configApi.replaceConfigFile(params); cachedConfig = structuredClone(result.nextConfig); return result; }, } : {}), }, }; } async function backupExistingMigrationTarget( target: string, reportDir: string, ): Promise { if (!(await pathExists(target))) { return undefined; } const backupRoot = path.join(reportDir, "item-backups"); await fs.mkdir(backupRoot, { recursive: true }); const targetHash = crypto .createHash("sha256") .update(path.resolve(target)) .digest("hex") .slice(0, 12); const backupDir = await fs.mkdtemp(path.join(backupRoot, `${Date.now()}-${targetHash}-`)); const backupPath = path.join(backupDir, path.basename(target)); await fs.cp(target, backupPath, { recursive: true, force: true }); return backupPath; } async function backupMemoryMigrationTarget( target: string, contents: Buffer, reportDir: string, ): Promise { const backupRoot = path.join(reportDir, "item-backups"); await fs.mkdir(backupRoot, { recursive: true }); const targetHash = crypto .createHash("sha256") .update(path.resolve(target)) .digest("hex") .slice(0, 12); const backupDir = await fs.mkdtemp(path.join(backupRoot, `${Date.now()}-${targetHash}-`)); const backupPath = path.join(backupDir, path.basename(target)); await fs.writeFile(backupPath, contents, { flag: "wx", mode: 0o600 }); return backupPath; } type MemoryMigrationRecoveryStatus = "complete" | "prepared" | "recovery-required" | "safe"; async function persistMemoryMigrationRecoveryRecord( recoveryRecordPath: string, params: { backupPath: string; recoveryPath: string; status: MemoryMigrationRecoveryStatus; target: string; }, ): Promise { await writeTextAtomic(recoveryRecordPath, JSON.stringify({ version: 1, ...params }, null, 2), { mode: 0o600, trailingNewline: true, }); } async function writeMemoryMigrationRecoveryRecord(params: { backupPath: string; recoveryPath: string; target: string; }): Promise { const recoveryRecordPath = path.join( path.dirname(params.backupPath), `recovery-${crypto.randomUUID()}.json`, ); await persistMemoryMigrationRecoveryRecord(recoveryRecordPath, { ...params, status: "prepared", }); return recoveryRecordPath; } function errorCode(error: unknown): string | undefined { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; } async function openMemoryMigrationRoot(workspaceDir: string) { const options = { hardlinks: "reject" as const, maxBytes: MAX_MEMORY_MIGRATION_FILE_BYTES, mkdir: true, symlinks: "reject" as const, }; try { return await openFsSafeRoot(workspaceDir, options); } catch (error) { if (errorCode(error) !== "not-found" && errorCode(error) !== "ENOENT") { throw error; } } const ensured = await ensureAbsoluteDirectory(workspaceDir, { scopeLabel: "memory import workspace", mode: 0o700, }); if (!ensured.ok) { throw ensured.error; } return await openFsSafeRoot(ensured.path, options); } function isFileAlreadyExistsError(err: unknown): boolean { return Boolean( err && typeof err === "object" && "code" in err && ((err as { code?: unknown }).code === "ERR_FS_CP_EEXIST" || (err as { code?: unknown }).code === "EEXIST"), ); } function readArchiveRelativePath(item: MigrationItem): string { const detailPath = item.details?.archiveRelativePath; const raw = typeof detailPath === "string" && detailPath.trim() ? detailPath : undefined; const fallback = item.source ? path.basename(item.source) : item.id; const normalized = path .normalize(raw ?? fallback) .split(path.sep) .filter((part) => part && part !== "." && part !== "..") .join(path.sep); return normalized || "item"; } async function resolveUniqueArchivePath( archiveRoot: string, relativePath: string, ): Promise { const parsed = path.parse(relativePath); let candidate = path.join(archiveRoot, relativePath); let index = 2; while (await pathExists(candidate)) { const filename = `${parsed.name}-${index}${parsed.ext}`; candidate = path.join(archiveRoot, parsed.dir, filename); index += 1; } return candidate; } /** Archive a migration item source into the report directory and mark the item migrated. */ export async function archiveMigrationItem( item: MigrationItem, reportDir: string, ): Promise { if (!item.source) { return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET); } try { const sourceStat = await fs.lstat(item.source); if (sourceStat.isSymbolicLink()) { return markMigrationItemError(item, "archive source is a symlink"); } const archiveRoot = path.join(reportDir, "archive"); const relativePath = readArchiveRelativePath(item); const archivePath = await resolveUniqueArchivePath(archiveRoot, relativePath); await fs.mkdir(path.dirname(archivePath), { recursive: true }); await fs.cp(item.source, archivePath, { recursive: true, force: false, errorOnExist: true, verbatimSymlinks: true, }); return { ...item, status: "migrated", target: archivePath, details: { ...item.details, archivePath, archiveRelativePath: relativePath }, }; } catch (err) { if (isFileAlreadyExistsError(err)) { return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS); } return markMigrationItemError(item, err instanceof Error ? err.message : String(err)); } } /** Copy a migration item source to its target, optionally backing up an overwritten target. */ export async function copyMigrationFileItem( item: MigrationItem, reportDir: string, opts: { overwrite?: boolean } = {}, ): Promise { if (!item.source || !item.target) { return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET); } try { const targetExists = await pathExists(item.target); if (targetExists && !opts.overwrite) { return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS); } const backupPath = opts.overwrite ? await backupExistingMigrationTarget(item.target, reportDir) : undefined; await fs.mkdir(path.dirname(item.target), { recursive: true }); await fs.cp(item.source, item.target, { recursive: true, force: Boolean(opts.overwrite), errorOnExist: !opts.overwrite, }); return { ...item, status: "migrated", details: { ...item.details, ...(backupPath ? { backupPath } : {}) }, }; } catch (err) { if (isFileAlreadyExistsError(err)) { return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS); } return markMigrationItemError(item, err instanceof Error ? err.message : String(err)); } } /** Copy one regular memory file through an fs-safe workspace root. */ export async function copyMemoryMigrationFileItem( item: MigrationItem, reportDir: string, opts: { workspaceDir: string; overwrite?: boolean }, ): Promise { if (!item.source || !item.target) { return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET); } let backupPath: string | undefined; let stagedRelative: string | undefined; let stagingDir: string | undefined; let recoveryPath: string | undefined; let recoveryRecordPath: string | undefined; let journalRecoveryPath: string | undefined; let relativeTarget: string | undefined; let targetCreated = false; let safeRoot: Awaited> | undefined; try { const workspaceDir = path.resolve(opts.workspaceDir); relativeTarget = path.relative(workspaceDir, path.resolve(item.target)); safeRoot = await openMemoryMigrationRoot(workspaceDir); const { buffer: sourceBuffer } = await readRegularFile({ filePath: item.source, maxBytes: MAX_MEMORY_MIGRATION_FILE_BYTES, }); assertMemoryMigrationSourceRevision(item, sourceBuffer); const replaceExisting = opts.overwrite === true && (await safeRoot.exists(relativeTarget)); if (replaceExisting) { const existing = await safeRoot.read(relativeTarget, { hardlinks: "reject", maxBytes: MAX_MEMORY_MIGRATION_FILE_BYTES, symlinks: "reject", }); backupPath = await backupMemoryMigrationTarget(item.target, existing.buffer, reportDir); stagingDir = path.join(".openclaw-memory-import-staging", crypto.randomUUID()); stagedRelative = path.join(stagingDir, path.basename(relativeTarget)); const plannedRecoveryPath = path.join(safeRoot.rootReal, stagedRelative); journalRecoveryPath = plannedRecoveryPath; await safeRoot.mkdir(stagingDir); // Persist the exact staging location before moving the destination. A // crash after the move can then be recovered without filesystem search. recoveryRecordPath = await writeMemoryMigrationRecoveryRecord({ backupPath, recoveryPath: plannedRecoveryPath, target: item.target, }); recoveryPath = plannedRecoveryPath; await safeRoot.move(relativeTarget, stagedRelative, { overwrite: false }); const staged = await safeRoot.read(stagedRelative, { hardlinks: "reject", maxBytes: MAX_MEMORY_MIGRATION_FILE_BYTES, symlinks: "reject", }); if (!staged.buffer.equals(existing.buffer)) { backupPath = await backupMemoryMigrationTarget(item.target, staged.buffer, reportDir); await persistMemoryMigrationRecoveryRecord(recoveryRecordPath, { backupPath, recoveryPath: plannedRecoveryPath, status: "prepared", target: item.target, }); } } // Exclusive create keeps a destination that races the import user-owned. await safeRoot.create(relativeTarget, sourceBuffer, { mkdir: true, mode: 0o600, }); targetCreated = true; if (recoveryRecordPath && backupPath && journalRecoveryPath) { await persistMemoryMigrationRecoveryRecord(recoveryRecordPath, { backupPath, recoveryPath: journalRecoveryPath, status: "complete", target: item.target, }); } if (stagedRelative) { await safeRoot.remove(stagedRelative); stagedRelative = undefined; recoveryPath = undefined; } if (stagingDir) { await safeRoot.remove(stagingDir); await safeRoot.remove(".openclaw-memory-import-staging").catch(() => undefined); } if (recoveryRecordPath) { try { await fs.unlink(recoveryRecordPath); recoveryRecordPath = undefined; } catch { // Retained records are already marked complete and returned below. } } return { ...item, status: "migrated", details: { ...item.details, ...(backupPath ? { backupPath } : {}), ...(recoveryRecordPath ? { recoveryRecordPath } : {}), }, }; } catch (error) { if (safeRoot && stagedRelative && relativeTarget && !targetCreated) { try { if (!(await safeRoot.exists(stagedRelative))) { recoveryPath = undefined; } else if (!(await safeRoot.exists(relativeTarget))) { await safeRoot.move(stagedRelative, relativeTarget, { overwrite: false }); stagedRelative = undefined; recoveryPath = undefined; } } catch { // Keep the journal and staged original for operator recovery. } } if (recoveryRecordPath && backupPath && journalRecoveryPath) { await persistMemoryMigrationRecoveryRecord(recoveryRecordPath, { backupPath, recoveryPath: journalRecoveryPath, status: targetCreated ? "complete" : recoveryPath ? "recovery-required" : "safe", target: item.target, }).catch(() => undefined); } const details = { ...item.details, ...(backupPath ? { backupPath } : {}), ...(recoveryPath ? { recoveryPath } : {}), ...(recoveryRecordPath ? { recoveryRecordPath } : {}), }; if (isFileAlreadyExistsError(error) || errorCode(error) === "already-exists") { return { ...markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS), details, }; } return { ...markMigrationItemError(item, error instanceof Error ? error.message : String(error)), details, }; } } /** Write redacted JSON and Markdown migration reports into the apply report directory. */ export async function writeMigrationReport( result: MigrationApplyResult, opts: { title?: string } = {}, ): Promise { if (!result.reportDir) { return; } await fs.mkdir(result.reportDir, { recursive: true }); await fs.writeFile( path.join(result.reportDir, "report.json"), `${JSON.stringify(redactMigrationPlan(result), null, 2)}\n`, "utf8", ); const lines = [ `# ${opts.title ?? "Migration Report"}`, "", `Source: ${result.source}`, result.target ? `Target: ${result.target}` : undefined, result.backupPath ? `Backup: ${result.backupPath}` : undefined, "", `Migrated: ${result.summary.migrated}`, `Skipped: ${result.summary.skipped}`, `Conflicts: ${result.summary.conflicts}`, `Errors: ${result.summary.errors}`, "", ...result.items.map( (item) => `- ${item.status}: ${item.id}${item.reason ? ` (${item.reason})` : ""}`, ), ].filter((line): line is string => typeof line === "string"); await fs.writeFile(path.join(result.reportDir, "summary.md"), `${lines.join("\n")}\n`, "utf8"); }