/** * dsh-plugin-session-rollback — Host 半(快照 / 恢复 / 撤销) * * 职责(全部在 Host 侧,客户端零 RPC 依赖): * * 1. 检查点采集:监听 `session/event`,每条真人消息(`user/message` 且 * `source.kind === 'user'`)提交时,对会话 cwd 做一次「内容寻址快照」, * 作为该提问发出瞬间的工作区检查点(快照内容 = 本轮之前的全部代码修改)。 * * 2. 原会话回退:在当前 Session 追加 Surface replacement,保留目标问题, * 隐藏其回答及后续活动路径,同时恢复目标问题提交时采集的工作区检查点。 * * 3. 恢复安全网:每次恢复前自动把当前工作区状态备份为 safety 快照; * `/rollback-undo` 恢复最近一次备份(可链式重复使用)。 * * 快照存储:`~/.dsh/plugin-data/session-rollback/` * blobs/ 内容寻址块(sha256,跨检查点去重) * sessions//N.json 检查点清单(N = 触发消息的 seq) * safety//…json 恢复前的备份清单 * * 恢复语义(确定态还原): * - 清单内的文件 ← 从 blob 还原; * - 当前存在、清单不含的文件 → 删除(空目录一并回收); * - 排除目录(node_modules/.git/dist/…,见 EXCLUDED_DIRS)与超限大文件 * 记录在 `present`,恢复时完全不触碰。 */ import { mkdir, readFile, readdir, writeFile, rm, rmdir, rename, copyFile } from 'node:fs/promises' import { createHash } from 'node:crypto' import { join, dirname, relative, resolve, sep, isAbsolute } from 'node:path' import { homedir, tmpdir } from 'node:os' export const name = 'session-rollback' export const inject = ['webServer', 'sessions', 'agents'] const PKG = 'dsh-plugin-session-rollback' const STORE_ROOT = join(homedir(), '.dsh', 'plugin-data', 'session-rollback') const BLOB_DIR = join(STORE_ROOT, 'blobs') const SESSION_DIR = join(STORE_ROOT, 'sessions') const SAFETY_DIR = join(STORE_ROOT, 'safety') const SAFETY_LATEST = join(SAFETY_DIR, 'latest.json') /** 单文件大小上限;超限文件进 `present`,恢复时保持原样。 */ const MAX_FILE_BYTES = 16 * 1024 * 1024 /** 安全起见也永远排除的目录(与快照/恢复两侧共用)。 */ const EXCLUDED_DIRS = new Set([ 'node_modules', '.git', '.hg', '.svn', '.jj', 'dist', 'build', 'out', '.next', '.nuxt', '.output', '.svelte-kit', '.cache', '.parcel-cache', '.turbo', '.vercel', '.wrangler', '.venv', 'venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', 'coverage', '.nyc_output', 'target', 'vendor', '.gradle', '.idea', '.vs', 'Pods', 'DerivedData', '.expo', '.dart_tool', ]) const SKIP_FILES = new Set(['.DS_Store']) /* ------------------------------------------------------------------ */ /* 基础工具 */ /* ------------------------------------------------------------------ */ function sha256(buf) { return createHash('sha256').update(buf).digest('hex') } function sha8(text) { return createHash('sha256').update(String(text)).digest('hex').slice(0, 8) } /** 会话 id 等外部输入 → 安全目录名(保留可读前缀 + 内容指纹后缀)。 */ function safeName(raw) { const clean = String(raw).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80) return `${clean}-${sha8(raw)}` } /** 相对路径白名单校验:拒绝绝对路径与越界路径。 */ function relSafe(rel) { if (typeof rel !== 'string' || rel.length === 0) return false if (isAbsolute(rel)) return false if (rel.split('/').includes('..')) return false return true } async function exists(path) { try { await readFile(path) return true } catch { return false } } async function atomicWriteJson(path, value) { const tmp = join(tmpdir(), `${PKG}-${sha8(`${path}:${Date.now()}:${Math.random()}`).slice(0, 12)}.json`) await writeFile(tmp, JSON.stringify(value), 'utf8') await rename(tmp, path) } async function readJson(path) { try { return JSON.parse(await readFile(path, 'utf8')) } catch { return undefined } } /* ------------------------------------------------------------------ */ /* 工作区遍历与 blob 存储 */ /* ------------------------------------------------------------------ */ /** * 遍历工作区。返回: * files Map —— 纳管文件 * present Set —— 存在但不纳管(超限/符号链接/特殊文件) */ async function walkWorkspace(cwd) { const files = new Map() const present = new Set() const root = resolve(cwd) async function walkDir(relDir) { const absDir = relDir === '' ? root : join(root, relDir) let entries try { entries = await readdir(absDir, { withFileTypes: true }) } catch { return } for (const entry of entries) { const rel = relDir === '' ? entry.name : `${relDir}/${entry.name}` if (entry.isDirectory()) { if (EXCLUDED_DIRS.has(entry.name)) continue await walkDir(rel) continue } if (entry.isSymbolicLink()) { present.add(rel) continue } if (!entry.isFile()) { present.add(rel) continue } if (SKIP_FILES.has(entry.name)) { present.add(rel) continue } files.set(rel, join(root, rel)) } } await walkDir('') return { files, present, root } } /** 内容寻址入库;已存在则跳过写入。返回 blob 名。 */ async function putBlob(buf) { const hash = sha256(buf) const blobPath = join(BLOB_DIR, hash.slice(0, 2), hash) if (!(await exists(blobPath))) { await mkdir(dirname(blobPath), { recursive: true }) try { await writeFile(blobPath, buf) } catch (error) { if (!(await exists(blobPath))) throw error } } return hash } /* ------------------------------------------------------------------ */ /* 检查点 / 备份清单 */ /* ------------------------------------------------------------------ */ function checkpointPath(sessionId, seq) { return join(SESSION_DIR, safeName(sessionId), `${seq}.json`) } async function listCheckpoints(sessionId) { const dir = join(SESSION_DIR, safeName(sessionId)) let names try { names = await readdir(dir) } catch { return [] } const seqs = [] for (const name of names) { const m = /^(\d+)\.json$/.exec(name) if (m) seqs.push(Number(m[1])) } return seqs.sort((a, b) => a - b) } /** * 对 cwd 做一次快照并写清单。 * @returns manifest 或 undefined(cwd 缺失等) */ async function snapshotWorkspace(spec) { const { kind, cwd, sessionId, seq, time } = spec if (!cwd) return undefined const { files, present } = await walkWorkspace(cwd) const fileMap = {} const presentList = [] let skippedCount = 0 for (const [rel, absPath] of files) { let buf try { buf = await readFile(absPath) } catch { skippedCount += 1 continue } if (buf.length > MAX_FILE_BYTES) { presentList.push(rel) skippedCount += 1 continue } const blob = await putBlob(buf) fileMap[rel] = { b: blob, s: buf.length } } for (const rel of present) { presentList.push(rel) skippedCount += 1 } const manifest = { v: 1, kind, time: time ?? Date.now(), cwd: resolve(cwd), files: fileMap, present: presentList, skippedCount, } if (sessionId !== undefined) manifest.sessionId = sessionId if (seq !== undefined) manifest.seq = seq return manifest } /* ------------------------------------------------------------------ */ /* 恢复 */ /* ------------------------------------------------------------------ */ let restoreChain = Promise.resolve() /** 全局串行化恢复动作,避免并发恢复互相踩踏。 */ function serialized(fn) { const next = restoreChain.then(fn, fn) restoreChain = next.catch(() => {}) return next } /** 把快照清单物化到其 cwd。返回统计。 */ async function applyManifest(manifest) { const cwd = resolve(manifest.cwd) const { files: currentFiles } = await walkWorkspace(cwd) const keepFiles = new Set(Object.keys(manifest.files ?? {})) const keepPresent = new Set(Array.isArray(manifest.present) ? manifest.present : []) // 1. 删除:当前有、清单无的文件 const deleted = [] const parentDirs = new Set() for (const [rel] of currentFiles) { if (keepFiles.has(rel) || keepPresent.has(rel)) continue try { await rm(join(cwd, rel), { force: true }) deleted.push(rel) let dir = dirname(rel) while (dir !== '.' && dir !== '' && dir !== '/') { parentDirs.add(dir) dir = dirname(dir) } } catch { /* 单文件失败不阻断整体恢复 */ } } // 空目录回收(自深至浅;非空 rmdir 会失败,静默停止即可) const dirs = [...parentDirs].sort((a, b) => b.length - a.length) for (const dir of dirs) { try { await rmdir(join(cwd, dir)) } catch { /* 非空或被排除目录内仍有内容 —— 保留 */ } } // 2. 还原:清单内全部文件 let restored = 0 for (const [rel, meta] of Object.entries(manifest.files ?? {})) { if (!relSafe(rel) || typeof meta?.b !== 'string') continue const blobPath = join(BLOB_DIR, meta.b.slice(0, 2), meta.b) let buf try { buf = await readFile(blobPath) } catch { continue } const absPath = join(cwd, rel) try { await mkdir(dirname(absPath), { recursive: true }) await writeFile(absPath, buf) restored += 1 } catch { /* 单文件失败不阻断 */ } } return { restored, deleted: deleted.length } } /** 恢复前备份当前状态;记录到 latest 指针。 */ async function takeSafetySnapshot(cwd, label) { const manifest = await snapshotWorkspace({ kind: 'safety', cwd, time: Date.now() }) if (manifest === undefined) return undefined const dir = join(SAFETY_DIR, sha8(resolve(cwd))) const file = `${Date.now()}-${sha8(String(Math.random())).slice(0, 6)}.json` await mkdir(dir, { recursive: true }) await atomicWriteJson(join(dir, file), { ...manifest, label: label ?? '' }) await atomicWriteJson(SAFETY_LATEST, { cwd: resolve(cwd), dir: join(dir, file), time: manifest.time }) return join(dir, file) } /* ------------------------------------------------------------------ */ /* 事件处理 */ /* ------------------------------------------------------------------ */ const sessionChains = new Map() /** 同一会话的检查点按序落盘。 */ function chainCheckpoint(sessionId, task) { const prev = sessionChains.get(sessionId) ?? Promise.resolve() const next = prev.then(task, task) sessionChains.set(sessionId, next.catch(() => {})) return next } async function onUserMessage(session, event) { const cwd = session?.header?.cwd if (!cwd) return const sessionId = session.id const seq = event.seq const time = event.time await chainCheckpoint(sessionId, async () => { const manifest = await snapshotWorkspace({ kind: 'checkpoint', cwd, sessionId, seq, time }) if (manifest === undefined) return const path = checkpointPath(sessionId, seq) await mkdir(dirname(path), { recursive: true }) await atomicWriteJson(path, manifest) }).catch((error) => { console.error(`[${PKG}] checkpoint seq=${seq} failed:`, error?.message ?? error) }) } function lastModelSource(session) { for (let i = session.events.length - 1; i >= 0; i--) { const event = session.events[i] if (event.type === 'request/header') { const config = event.data?.header?.config if (config?.provider && config?.model) return { provider: config.provider, model: config.model } } if (event.type === 'assistant/message') { const source = event.data?.message?.source if (source?.kind === 'model' && source.provider && source.model) return { provider: source.provider, model: source.model } } } return undefined } async function rollbackInPlace(sessions, agents, sessionId, targetSeq) { if (!Number.isSafeInteger(targetSeq) || targetSeq < 0) throw new Error('targetSeq 无效') // session/event 的检查点采集是异步的。连续回退后重新发送消息时,用户可能在 // 对应清单尚未完成落盘前再次点击回退;先等待本会话的写入链,避免把竞态 // 误报成“没有代码检查点”。 const pendingCheckpoint = sessionChains.get(sessionId) if (pendingCheckpoint !== undefined) await pendingCheckpoint const manifest = await readJson(checkpointPath(sessionId, targetSeq)) if (!manifest || !manifest.cwd) throw new Error('该条消息没有代码检查点,无法安全回退') const session = sessions?.get?.(sessionId) if (!session) throw new Error('当前会话不存在或尚未加载') const agent = agents?.get?.(sessionId) if (agent?.status === 'running') throw new Error('会话仍在运行,请先停止回答') const surface = session.surface?.nodes if (!Array.isArray(surface)) throw new Error('当前 DSH 不支持会话 Surface 回退') const index = surface.indexOf(targetSeq) if (index < 0) throw new Error('该条消息已不在当前会话路径中') // 保留目标问题本身,只隐藏它的回答以及后续全部问题/回答。 const shadowedSeqs = surface.slice(index + 1) if (shadowedSeqs.length === 0) throw new Error('该条消息之后没有可回退的会话内容') const model = lastModelSource(session) if (!model) throw new Error('会话缺少模型信息,无法写入回退标记') return serialized(async () => { await takeSafetySnapshot(manifest.cwd, `before in-place rollback of ${sessionId}@${targetSeq}`) const markerId = `session-rollback-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` const data = { turn: null, step: null, message: { id: markerId, role: 'assistant', content: [], source: { kind: 'model', provider: model.provider, model: model.model }, }, sessionRollback: { targetSeq }, } const surfaceOp = { op: 'replace', start: shadowedSeqs[0], end: shadowedSeqs[shadowedSeqs.length - 1] } const marker = await session.append('assistant/message', data, { surfaceOp, sourceEventSeqs: shadowedSeqs }) if (typeof sessions.flush === 'function') await sessions.flush(session) const stats = await applyManifest(manifest) console.log(`[${PKG}] in-place rollback done: session=${sessionId} checkpoint=${targetSeq} marker=${marker.seq} restored=${stats.restored} deleted=${stats.deleted}`) return { sessionId, checkpoint: targetSeq, markerSeq: marker.seq, shadowed: shadowedSeqs.length, ...stats } }) } function jsonResponse(res, status, value) { res.statusCode = status res.setHeader('content-type', 'application/json; charset=utf-8') res.end(JSON.stringify(value)) } async function readRequestJson(req) { let text = '' for await (const chunk of req) { text += chunk if (text.length > 64 * 1024) throw new Error('请求体过大') } return text === '' ? {} : JSON.parse(text) } /* ------------------------------------------------------------------ */ /* 插件入口 */ /* ------------------------------------------------------------------ */ export function apply(ctx) { // 会话事件 → 真人消息检查点 // 必须把 Promise 返回给事件总线。否则 DSH 会在快照仍异步遍历工作区时 // 启动本轮 Agent,导致 checkpoint 混入本轮刚写入的代码(典型表现: // 回退到第 N 条时只撤销 N+1 及之后的修改)。 ctx.on('session/event', async (session, event) => { try { if (!session || !event || event.type !== 'user/message') return const source = event.data?.source if (!source || source.kind !== 'user') return await onUserMessage(session, event) } catch (error) { console.error(`[${PKG}] session/event handler failed:`, error?.message ?? error) } }) // 原会话回退 HTTP 入口:追加 Surface replacement + 恢复代码检查点。 const registerFirstRestoreRoute = (webServer) => { if (!webServer || typeof webServer.register !== 'function') return const dispose = webServer.register({ kind: 'prefix', path: '/api/plugins/session-rollback', async handler(req, res) { if (req.method !== 'POST' || !req.url || !req.url.endsWith('/rollback')) { jsonResponse(res, 404, { ok: false, error: 'not found' }) return } try { const body = await readRequestJson(req) if (typeof body.sessionId !== 'string' || body.sessionId === '') throw new Error('sessionId 无效') const sessions = ctx.sessions ?? ctx.get?.('sessions') const agents = ctx.agents ?? ctx.get?.('agents') const value = await rollbackInPlace(sessions, agents, body.sessionId, body.targetSeq) jsonResponse(res, 200, { ok: true, ...value }) } catch (error) { jsonResponse(res, 409, { ok: false, error: error?.message ?? String(error) }) } }, }) console.log(`[${PKG}] in-place rollback route ready`) return dispose } const webServer = ctx.webServer ?? (typeof ctx.get === 'function' ? ctx.get('webServer') : undefined) if (webServer) registerFirstRestoreRoute(webServer) else ctx.inject(['webServer'], registerFirstRestoreRoute) // /rollback-undo:恢复最近一次自动备份 // commands 是 cordis 服务,apply 时可能尚未挂载 —— 用 inject 等待可用 ctx.inject(['commands'], (commands) => { if (typeof commands?.register !== 'function') return const dispose = commands.register({ name: 'rollback-undo', description: '会话回退插件:撤销最近一次代码回退(恢复回退前的自动备份)', input: { hint: '无需参数' }, async handler() { try { const pointer = await readJson(SAFETY_LATEST) if (!pointer || typeof pointer.dir !== 'string') { return { kind: 'error', text: '没有可撤销的回退备份。' } } const manifest = await readJson(pointer.dir) if (!manifest) { return { kind: 'error', text: '最近的备份清单已丢失,无法撤销。' } } const stats = await serialized(async () => { await takeSafetySnapshot(manifest.cwd, 'before rollback-undo') return applyManifest(manifest) }) return { kind: 'success', text: `已撤销最近一次回退:还原 ${stats.restored} 个文件,移除 ${stats.deleted} 个回退后新建的文件。(工作区:${manifest.cwd})`, } } catch (error) { return { kind: 'error', text: `撤销失败:${error?.message ?? String(error)}` } } }, }) void dispose }) console.log(`[${PKG}] ready — snapshots at ${STORE_ROOT}`) } export default { name, inject, apply }