evework / agent /lib /agentfs-debug /checkpoint.ts
hunian
feat: 添加 CORS 支持和 session 文件系统,优化沙箱后端逻辑
67a584e
Raw
History Blame Contribute Delete
6.67 kB
/**
* Session 文件系统存档点:创建 / 列表 / 回退 / 删除。
*
* 存档库落在 `<sessionsDir>/checkpoints/<sessionId>/<name>.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<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);
}
// symlink 和其他类型跳过
}
}
/** 清空虚拟 FS 根下所有条目(单条失败不阻塞)。 */
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 {
// 单个文件/目录删除失败不阻塞整体
}
}
}
/** 删除 SQLite 主库及常见旁路文件(-wal / -shm)。 */
async function removeSqliteFiles(dbPath: string): Promise<void> {
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<T>(
sessionDb: string,
fn: () => Promise<T>,
): Promise<T> {
return hostRegistry.withExclusiveAccess(sessionDb, fn);
}
/**
* 创建存档点:把 session 当前文件状态完整快照到独立 .db(同名则整体替换)。
* 只读 session 侧:复用 live host,不 evict;checkpoint 库独立 open。
*/
export async function createCheckpoint(
sessionId: string,
name: string,
): Promise<CheckpointInfo> {
// 必须先确认 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<CheckpointInfo[]> {
// 校验 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<void> {
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
}
}
}