dsh-checkpoint-diff 架构
August 17, 2026 · View on GitHub
只读消费 dsh-checkpoint-rewind 的检查点存储域,把快照当作时间节点做文件差异可视化;唯一写路径是回滚(从时间节点恢复工作区文件,覆盖写、绝不删除;其进程内单次撤销是"绝不删除"的唯一例外)。0.5.0 起新增轨迹重放(Trace):无需快照生产者,从会话日志(session.jsonl.zstd)重放 write/edit 内容,把每个工具调用当作时间节点做同样的区间 diff——纯读、历史会话开箱即用。三层:领域服务(时间线/摘要/逐行 diff/回滚预览/回滚/撤销 + 轨迹时间线/区间 diff,命令与 HTTP 共用)→ 两个消费面(/diff、/rollback 命令 + webServer JSON API)→ 浏览器面板(header 动作 + shell 浮层)。
数据流
dsh-checkpoint-rewind dsh-checkpoint-diff
(快照生产者, host-only) (本插件)
fs/*-intent / tools/pre-execute │
→ git stash create / copy 目录 │
→ storageDomain 域 'checkpoints' ────┼─ inject: storageDomain
(git 未引用对象 sha | copy uuid) │ get() 优先复用域,否则 open()
▼
dsh-session-query (可选服务: traceSession / readSession / readTitle)
│ ctx.get() 缺席 → 扁平合并降级
▼
DiffService (lib/service.mjs)
├─ /diff [/diff --project] 命令 (index.mjs)
├─ /rollback [--project] [--dry-run] [--undo] 命令 (index.mjs)
├─ webServer /checkpoint-diff/api/*
│ ├─ GET timeline/summary/file-diff/preview-diff(只读)
│ └─ POST rollback(写)+ POST rollback-undo(撤销)
│ rollback: 读快照文件集/内容(git 只读原语
│ 或 copy 目录)→ 校验 → 原子写回会话工作区
▼
▲ fetch (同源 JSON)
浏览器半 (src/client/*)
├─ conversation.session.header.actions (DiffTrigger)
└─ shell.overlay (DiffPanel,含 Restore 回滚行、
撤销按钮、恢复预览 diff、块级修改点跳转、
上次查看跳回、降级节点标注与 (head) 位置标记)
模块清单(host)
| 模块 | 职责 |
|---|---|
index.mjs | 插件入口:Config(Schemastery)、域获取(复用优先 + 双版本回退:v2 打开失败按 version-mismatch/malformed-medium 回退 v1)、/diff 与 /rollback 命令、webServer 可选注册 |
lib/constants.mjs | 词汇表:域名/命令名/provider 值/文件状态/默认值/上限/变更型工具名单 |
lib/domain.mjs | checkpoints 域 spec 重声明:v2 主 spec(rewind 0.5.0)+ v1 回退 spec(rewind 0.4.0);schema 从 lib/domain-schema.mjs 导入 |
lib/domain-schema.mjs | 记录 zod schema(纯 zod、零 DSH 依赖,CI 单测可 import):容错超集——核心字段必填,v1 的 stepEndSeq/forkSeq 与 v2 的 kind/config/tree/note/sessionBoundary 全可选(严格性属于生产者 rewind) |
lib/workspace.mjs | workspaceKeyOf、快照根解析、workspaceKey 目录名(与 rewind 同算法) |
lib/checkpoints.mjs | 时间线提取/寻址(id 前缀或 latest)/命令输出格式(纯函数;含项目时间线格式) |
lib/project.mjs | 跨会话/同项目纯函数:项目合并/寻址偏好/血缘标记/分支表(M-A/M-B) |
lib/labels.mjs | 快照意图命名:tool/call 事件按 (turn,step) 索引 + 匹配优先级 + label 附加(纯函数) |
lib/service.mjs | DiffService:timeline/summary/fileDiff/previewDiff/rollback/rollbackUndo(scope=session/project;git 节点降级标注 degraded(cat-file -e 并行检查)+ bad-object 错误归因)+ HTTP 路由处理器(GET 门禁 + POST rollback/rollback-undo) |
lib/rollback.mjs | 回滚纯函数与低层 fs 助手:路径规范化/受保护路径/分类(restore/unchanged/skip)/原子写/真实路径断言/工作区遍历(遗留报告)/每工作区锁/命令格式(含 --undo 格式) |
lib/diff/engine.mjs | LCS 行级 diff(Uint32Array 全表,4M 单元上限降级全删全加) |
lib/diff/git.mjs | git 只读:diff-tree -r -z --name-status + show <ref>:<path> + ls-tree -r -z(回滚文件集)+ diff --name-only(回滚预览判定)+ ls-files --others(遗留报告)+ rev-parse --show-toplevel(仓库根校验)+ cat-file -e(降级标注/错误归因;缺失判定兼容 Windows 静默退出 1 与 Linux "bad object");ref 40/64-hex 校验 |
lib/diff/copy.mjs | copy 只读:manifest 读取 + 内容比较 + changedPaths(回滚预览判定,size/mode 不同或内容字节不同);ref uuid 校验、rel 越界拒绝 |
lib/trace/frames.mjs | 会话日志 zstd 多帧扫描/解码(scanZstdFrames 与 harness 同构;node:zlib zstd 门控 Node ≥ 23.5,缺失时明确降级)+ JSONL 解析(纯函数,零依赖) |
lib/trace/replay.mjs | 轨迹重放核心(纯函数):tool/call → 内容操作序列(只收成功调用,按 tool/result 的 isError/error 过滤)、轨迹节点(trace:<seq>)、任意两点内容重放、区间变更清单、/diff --trace 输出格式 |
lib/trace/read.mjs | 事件源适配:sessionQuery.readSession 首选 → live session.events → zstd/明文直读兜底($DSH_HOME/sessions/<projectKey>/<sessionId>/…,目录编码与 harness format.ts 同构);全部只读、逐级降级 |
lib/trace/service.mjs | TraceService:traceTimeline / traceSummary / traceFileDiff(区间 = (from.seq, to.seq];重放偏差以 notes 报告、状态降级为 M;路径相对化)+ trace HTTP 端点路由 |
模块清单(client,打包产物 lib/client.js)
| 模块 | 职责 |
|---|---|
src/client/index.js | 插件体:样式注入 + 两个 slot 注册(header actions / shell.overlay) |
src/client/store.js | 面板开关模块单例(useSyncExternalStore 订阅) |
src/client/api.js | /checkpoint-diff/api/* fetch 封装(含 preview-diff / rollback-undo)+ 记录标签(意图 label 优先) |
src/client/tree.js | 扁平文件清单 → 可折叠目录树(纯函数:路径折叠/计数聚合/排序) |
src/client/blocks.js | 变更块(hunk)计算(纯函数):连续 del/add 行合成一块(ctx 分隔),↑/↓ 跳转与自动定位首个块的单位 |
src/client/DiffTrigger.js | 会话头部 "Diff" 按钮 |
src/client/DiffPanel.js | 浮层面板:节点选择((HEAD) 全局最新标记 + ⚠ degraded 标注,默认选择跳过降级节点)→ 树形文件清单(A/M/D,可折叠) → 逐行 diff(打开自动定位首个变更块 + 块级 ↑/↓ 跳转,目标=块中心行,边界先提示再回绕)+ 独立可折叠 Restore 卡片(目标选择/预览/应用/每文件 ↩/状态区/撤销按钮,与 diff 区视觉分离)+ 恢复预览 diff(Restore preview 覆盖显示,与 from/to 选择解耦、换目标自动重载)+ 降级提示条 + 上次查看节点对跳回(localStorage) |
src/client/style.js | 主题 token 驱动样式(--dsw-alias-*,data-plugin 注入) |
客户端 bundle 契约(与 harness clientBundle 预设同构):经典 script 调用
window.__ModuleLoader__.load({id, factory});外部依赖只 react(loader 模块表
seed 词);CSS 经 style 标签注入;dsh.client 声明喂给 client-modules 扫描。
关键决策
- 域共享(双版本):storage-domain 的
open()对同名域互斥(already-open)。 本插件get()优先复用(rewind 拥有者,任意版本),否则自开并自管——先按 v2 打开(介质不存在则创建 v2,与 rewind 0.5.0 对齐),后端报version-mismatch/malformed-medium(StorageError,code 稳定)时回退 v1 spec;自开失败时短暂轮询等待对方 open 完成。这让 rewind 0.4.0 / 0.5.0 与 本插件共存于同一 host 而不互相破坏。 - 变更前快照语义:每条记录是"变更前"状态——
/diff <from> <to>呈现的 是 from 快照 → to 快照的差异,to 快照不含 to 之后的变更。 - 跨 provider 拒绝:git 与 copy 记录混用时响亮报错(无统一内容寻址);
配额清理/
git gc//rewind clear造成的缺失节点全部优雅降级为错误文本。 - 降级标注与错误归因(0.4.1):时间线加载时对 git 节点做
cat-file -e存在性检查(并发受限、纯只读),对象丢失的节点标注degraded——GUI 标注 选项、默认选择跳过、显示提示条;copy 节点不标注(快照目录与记录由 rewind 配额一并删除,且 snapshotDir 配置错位时不应误报)。diff-tree/show失败时逐侧cat-file -e归因,错误精确指出缺失节点(410);回滚到缺失 对象节点在写盘前失败。所有检查绝不写任何数据。 - 回滚是唯一的写路径,且严格受限(
lib/rollback.mjs+service.rollback):- 覆盖写、绝不删除:只写目标节点的快照文件集(git =
ls-tree树; copy = manifest);工作区中不在该集合内的文件保留并列入leftovers报告(git 用ls-files --others+diff --diff-filter=A,copy 用遍历)。 - git provider 只用只读 git 原语(ls-tree/show/diff/ls-files/
rev-parse/cat-file),不用
git restore——写入由本插件自管(临时文件 + 原子 rename + 尽力 chmod),配合assertRealFileTarget真实路径断言(拒绝 符号链接祖先/目标,防链接逃逸)与.git/.dsh任意深度路径拒绝。 - 单文件精度:
paths只恢复指定文件;整节点:缺省恢复全部。 - 预览先行:
dryRun只分类不写盘("将恢复"按内容判定——copy 用 size/mode + 字节比对,mtime 不参与,避免回滚自身改写 mtime 造成误报); 显式请求的路径不允许静默 skip(受保护/越界 → 响亮拒绝)。 - git 节点要求会话 cwd 即仓库根(
rev-parse --show-toplevel校验), 否则拒绝——快照树路径是仓库根相对,落点语义不能错位。 - 每工作区串行锁(
withKeyLock)防止并发回滚交错;回滚不写快照存储, 不产生新检查点(下一次变更型工具执行时 rewind 会捕获回滚后的状态)。 - HTTP 面:读端点 GET 门禁;
POST /api/rollback请求体 64 KiB 上限 + JSON 校验;target支持 id 前缀/latest,scope=project可跨会话寻址 (同工作区键的其它会话节点,即跨对话回滚)。
- 覆盖写、绝不删除:只写目标节点的快照文件集(git =
- 回滚撤销(0.4.0,进程内单次 undo,无 redo)(
service.rollbackUndo):- apply(非 dryRun)成功后在进程内记录
Map<workspaceKey, {target, time, files:[{rel, before, after}]}>(before = 写入前原内容,不存在 = null;after = 快照内容)——重启即失效(文档 已说明);下一次 apply 替换上一次(只撤销最近一次恢复)。 - 撤销逐文件:当前内容必须仍等于
after(被后续改动 → 跳过并 note, 全部跳过 → 409);恢复before;before === null(恢复时新建的文件) → 删除——"绝不删除"的唯一例外(删除的是恢复操作自己刚创建的文件), 删除前同样走 resolveInside + assertRealFileTarget + 非受保护路径校验; 成功后删除条目(一次 undo)。与回滚共用每工作区串行锁。 - 端点
POST /api/rollback-undo(body{session},64 KiB 上限 + JSON 校验);命令/rollback --undo(并入 handleRollback 的 flag 解析)。
- apply(非 dryRun)成功后在进程内记录
- 恢复预览 diff(0.4.0)(
service.previewDiff):目标节点快照内容 + 工作区当前文件(resolveInside + lstat 门禁,不存在 → 空)→diffLines, 方向 = 当前 → 快照(del = 当前行会被删,add = 快照行会加回来,直观 展示"回滚会怎样");二进制检测;返回与 fileDiff 同形状 +present('both' | 'workspace-missing')。GET/api/preview-diff(方法门禁), 面板预览计划中的 restore 行点击即覆盖显示右侧 diff 区。 - 轨迹重放(0.5.0,可脱离 rewind 独立工作)(
lib/trace/*):- 节点模型:每个
tool/call边界 = 一个轨迹节点(id = trace:<seq>); 任意两节点 = 选中区间,diff = 重放到两点后的文件内容差(同一 LCS 引擎、 同一面板 from/to UX)。 - 正确性:只重放成功调用(
tool/result的isError/data.error过滤, 结果缺失按成功并计数);write整体替换、edit/str_replace_editor替换 子串(old_string未找到 = 重放偏差,计入 drift 并在notes诚实报告, 状态降级为 M,绝不静默);bash/pwsh 等任意命令的修改不可见(轨迹不含fs/*-intent事件)——这是固有盲区,不是实现问题。 - 数据源:
sessionQuery.readSession首选(live/cold、replay 校验)→ livesession.events→ zstd 直读兜底(多帧扫描 + 逐帧解码,Node ≥ 23.5;$DSH_HOME/sessions目录编码与 harnessformat.ts同构)。逐级降级,绝不抛错。 - 寻址:精确 id 优先于前缀(
trace:1不被trace:11歧义化——seq 前缀碰撞 常见);支持latest、trace:<seq>、裸 seq、前缀。 - 与快照的关系:快照(rewind/自有)粒度 = 变更前状态、含 bash;轨迹粒度 =
工具调用、仅内容型。两者并存(快照优先,轨迹兜底),不互相覆盖。
绝对路径与
..;回滚/撤销/预览目标路径拒绝绝对路径/../反斜杠穿越, 解析必须落在工作区根内;HTTP 除 rollback/rollback-undo 外只读 GET。
- 节点模型:每个
测试
test/diff-engine.test.mjs— LCS 引擎(含 4M 单元降级、可逆性、二进制检测)。test/checkpoints.test.mjs— 时间线过滤/寻址/格式(含项目格式)。test/labels.test.mjs— 快照意图命名(tool/call 索引、目标提取、匹配优先级、回退)。test/project.test.mjs— 跨会话纯函数:项目合并/寻址偏好/血缘标记/分支表/项目格式。test/rollback.test.mjs— 回滚纯函数与 fs 助手:路径规范化/受保护路径/分类/ 串行锁/原子写/真实路径断言/工作区遍历/命令格式(含 --undo 格式)。test/tree.test.mjs— 目录树折叠(路径折叠、计数聚合、排序、重复路径)。test/diff-panel.test.mjs— jsdom 面板冒烟(首帧不崩溃、背板关闭、树折叠/展开/点文件、scope 切换/分支下拉/fork 标记、回滚预览/应用交互、0.4.0 撤销按钮/恢复预览 diff/修改点跳转(scrollIntoView stub)/上次查看节点对跳回;0.4.1 块级跳转与自动定位(多块 ops + 视口模拟)、(head)/⚠ degraded 标注与默认选择跳过、预览工具栏 current → target 联动与 done 阶段去预览入口)。test/integration/diff-headless.mjs— 组装式集成:真 cordis + 真存储 + 真 CommandRuntime + 真 dsh-checkpoint-rewind(本地克隆,经scripts/link-profile-deps.mjs结链)+ 本插件;copy 与 git 双流程 + 命令 + HTTP + 意图 label 端到端 + 降级路径;项目流程:真 dsh-session-query-sqlite(rc.5 部署同源)血缘分支/fork 标记/readTitle +ctx.sessions.create({meta:{parentSession}})造 fork + 冷会话 readSession/readTitle(假服务注入)+ 无 sessionQuery 降级;回滚流程: copy/git/项目三种 scope 的整节点与单文件恢复、dry-run 不写盘、篡改清单 的 .git 防御、穿越路径/缺失文件/非法 scope 拒绝、HTTP POST + 方法门禁; 0.4.0 流程:撤销删除恢复新建文件/恢复后改动全跳过 409/撤销后条目 删除/HTTP 与命令面撤销(应用与撤销走同一 service 实例——HTTP 面与测试 直连实例的进程内 undoData 相互独立,生产只有一个插件实例无此差异)、 previewDiff 方向/workspace-missing/二进制/受保护/缺失文件/跨会话寻址。 沙箱内 git spawn 不可用时注入假 runner 覆盖降级路径。
跨会话/同项目时间线(0.2.0 已实现)
按 workspaceKey 合并同项目检查点(scope=project)+ /rewind fork 血缘
(sessionQuery.traceSession,可选服务)的分支组织;冷会话意图 label 经
sessionQuery.readSession、分支标题经 readTitle(逐条失败软降级)。设计
与降级矩阵见 docs/timeline-design.md。
集成契约(与 rewind 的耦合面)
- 域
checkpoints双版本:rewind 0.4.0 = version 1(可选forkSeq)、0.5.0 = version 2(kind/config必填、移除forkSeq)。本插件以 v2 主 spec + v1 回退 spec 消费(schema 容错超集,见lib/domain.mjs/lib/domain-schema.mjs;严格性属于生产者)。 - copy 快照目录布局:
$snapshotDir/<sha256(key)前16>/<uuid>/+manifest.json({id, base, files:[{rel,size,mtimeMs,mode,hash?}], bytes})。 - git 快照 ref:
git stash create/commit-tree的未引用对象 sha(40/64 hex);未引用对象约 2 周后被git gc回收——超旧节点的 blob 可能消失,UI/命令已降级。 /rewindfork 后子会话是新sessionId;scope=project沿SessionHeader.parentSession血缘(sessionQuery.traceSession)合并展示——v1 记录以forkSeq为 fork 衔接点,v2 记录以父会话中不晚于子会话createdAt的最后一条记录为衔接点(forkSeq已移除)。