| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| export async function recursiveCopyFs( |
| src: FileSystem, |
| dest: FileSystem, |
| dirPath: string, |
| ): Promise<void> { |
| 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); |
| } |
| |
| } |
| } |
|
|
| |
| async function clearFsRoot(fs: FileSystem): Promise<void> { |
| const rootEntries = await fs.readdirPlus("/"); |
| for (const e of rootEntries) { |
| try { |
| await fs.rm(joinVirtualPath("/", e.name), { |
| recursive: true, |
| force: true, |
| }); |
| } catch { |
| |
| } |
| } |
| } |
|
|
| |
| async function removeSqliteFiles(dbPath: string): Promise<void> { |
| for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { |
| try { |
| await rm(p, { force: true }); |
| } catch { |
| |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| async function withExclusiveSessionDb<T>( |
| sessionDb: string, |
| fn: () => Promise<T>, |
| ): Promise<T> { |
| return hostRegistry.withExclusiveAccess(sessionDb, fn); |
| } |
|
|
| |
| |
| |
| |
| export async function createCheckpoint( |
| sessionId: string, |
| name: string, |
| ): Promise<CheckpointInfo> { |
| |
| await assertSessionDbExists(sessionId); |
| const cpDb = resolveCheckpointDbPath(sessionId, name); |
|
|
| |
| await removeSqliteFiles(cpDb); |
|
|
| |
| 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 }; |
| } |
|
|
| |
| export async function listCheckpoints( |
| sessionId: string, |
| ): Promise<CheckpointInfo[]> { |
| |
| 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; |
| } |
|
|
| |
| export async function restoreCheckpoint( |
| sessionId: string, |
| name: string, |
| ): Promise<{ restored: number }> { |
| const cpDb = resolveCheckpointDbPath(sessionId, name); |
| |
| const sessionDb = await assertSessionDbExists(sessionId); |
|
|
| |
| 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 }; |
| } |
|
|
| |
| export async function deleteCheckpoint( |
| sessionId: string, |
| name: string, |
| ): Promise<void> { |
| const cpDb = resolveCheckpointDbPath(sessionId, name); |
| |
| 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 { |
| |
| } |
| } |
| } |
|
|