/** * Session 文件系统存档点:创建 / 列表 / 回退 / 删除。 * * 存档库落在 `/checkpoints//.db`, * 与运行中 session 库分离。 * * - create:只读 session FS 快照 → 写入独立 checkpoint 库; * 经 withSessionFileSystem(复用 live host,不 evict)。 * - restore:改写 session 库 → withExclusiveAccess(evict live host), * 禁止工具继续看回滚前视图。 */ import { readdir, rm, stat } from "node:fs/promises"; import { join } from "node:path"; import type { FileSystem } from "agentfs-sdk"; import { hostRegistry } from "../agentfs-sandbox/host.js"; import { CHECKPOINT_NAME_RE } from "./constants.js"; import { AgentFsDebugError } from "./errors.js"; import { assertSessionDbExists, withAgentFsAtPath, withSessionFileSystem, } from "./open.js"; import { joinVirtualPath, resolveCheckpointDbPath, resolveCheckpointsDir, } from "./paths.js"; import type { CheckpointInfo } from "./types.js"; /** 递归复制 src 的 dirPath 下所有文件/目录到 dest(跳过 symlink 等)。 */ export async function recursiveCopyFs( src: FileSystem, dest: FileSystem, dirPath: string, ): Promise { const entries = await src.readdirPlus(dirPath); for (const entry of entries) { const child = joinVirtualPath(dirPath, entry.name); if (entry.stats.isDirectory()) { try { await dest.mkdir(child); } catch { // 已存在则忽略 } await recursiveCopyFs(src, dest, child); } else if (entry.stats.isFile()) { const content = await src.readFile(child); await dest.writeFile(child, content); } // symlink 和其他类型跳过 } } /** 清空虚拟 FS 根下所有条目(单条失败不阻塞)。 */ async function clearFsRoot(fs: FileSystem): Promise { const rootEntries = await fs.readdirPlus("/"); for (const e of rootEntries) { try { await fs.rm(joinVirtualPath("/", e.name), { recursive: true, force: true, }); } catch { // 单个文件/目录删除失败不阻塞整体 } } } /** 删除 SQLite 主库及常见旁路文件(-wal / -shm)。 */ async function removeSqliteFiles(dbPath: string): Promise { for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { try { await rm(p, { force: true }); } catch { // 不存在或瞬时占用:留给后续 open 报错 } } } /** * 在 session 库上独占执行:先踢 live host,再 withAgentFsAtPath。 * 仅 restore 使用;create 不得走此路径。 */ async function withExclusiveSessionDb( sessionDb: string, fn: () => Promise, ): Promise { return hostRegistry.withExclusiveAccess(sessionDb, fn); } /** * 创建存档点:把 session 当前文件状态完整快照到独立 .db(同名则整体替换)。 * 只读 session 侧:复用 live host,不 evict;checkpoint 库独立 open。 */ export async function createCheckpoint( sessionId: string, name: string, ): Promise { // 必须先确认 session 存在:AgentFS.open 会对缺失 path 创建空库 await assertSessionDbExists(sessionId); const cpDb = resolveCheckpointDbPath(sessionId, name); // 同名全量替换:先清磁盘文件,再 open 新建,避免旧树残留 await removeSqliteFiles(cpDb); // shared + live host:禁止 create 踢掉正在跑的 sandbox await withSessionFileSystem(sessionId, async (sessionFs) => { await withAgentFsAtPath( cpDb, async (cpAgent) => { await recursiveCopyFs(sessionFs, cpAgent.fs, "/"); }, { mustExist: false, label: `checkpoint:${name}` }, ); }); const st = await stat(cpDb); return { name, sizeBytes: st.size, mtimeMs: st.mtimeMs }; } /** 列出 session 的所有存档点。 */ export async function listCheckpoints( sessionId: string, ): Promise { // 校验 id;目录不存在视为无存档 const dir = resolveCheckpointsDir(sessionId); let names: string[]; try { names = await readdir(dir); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") return []; throw err; } const out: CheckpointInfo[] = []; for (const name of names) { if (!name.endsWith(".db")) continue; const label = name.slice(0, -".db".length); if (!CHECKPOINT_NAME_RE.test(label)) continue; const fullPath = join(dir, name); try { const st = await stat(fullPath); if (!st.isFile()) continue; out.push({ name: label, sizeBytes: st.size, mtimeMs: st.mtimeMs }); } catch { // 单文件失败跳过 } } out.sort((a, b) => b.mtimeMs - a.mtimeMs); return out; } /** 回退 session 到某个存档点:清空 session 根后从存档完整复制。 */ export async function restoreCheckpoint( sessionId: string, name: string, ): Promise<{ restored: number }> { const cpDb = resolveCheckpointDbPath(sessionId, name); // session 必须已存在;禁止 open 时静默新建空库再写入 const sessionDb = await assertSessionDbExists(sessionId); // exclusive:evict host → 独占写回滚 → host 失效,避免工具继续看旧盘 await withExclusiveSessionDb(sessionDb, async () => { await withAgentFsAtPath( cpDb, async (cpAgent) => { await withAgentFsAtPath( sessionDb, async (sessionAgent) => { await clearFsRoot(sessionAgent.fs); await recursiveCopyFs(cpAgent.fs, sessionAgent.fs, "/"); }, { mustExist: true, label: sessionId }, ); }, { mustExist: true, label: `checkpoint:${name}`, notFound: { code: "checkpoint_not_found", message: `存档点不存在: ${name}`, }, }, ); }); return { restored: 1 }; } /** 删除单个存档点(含 -wal/-shm)。 */ export async function deleteCheckpoint( sessionId: string, name: string, ): Promise { const cpDb = resolveCheckpointDbPath(sessionId, name); // force:false 才能在缺失时抛 ENOENT → 404 try { await rm(cpDb, { force: false }); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { throw new AgentFsDebugError( 404, "checkpoint_not_found", `存档点不存在: ${name}`, ); } throw err; } // 旁路文件尽量清理;主库已删成功即视为删除完成 for (const p of [`${cpDb}-wal`, `${cpDb}-shm`]) { try { await rm(p, { force: true }); } catch { // ignore } } }