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.mjscheckpoints 域 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.mjsworkspaceKeyOf、快照根解析、workspaceKey 目录名(与 rewind 同算法)
lib/checkpoints.mjs时间线提取/寻址(id 前缀或 latest)/命令输出格式(纯函数;含项目时间线格式)
lib/project.mjs跨会话/同项目纯函数:项目合并/寻址偏好/血缘标记/分支表(M-A/M-B)
lib/labels.mjs快照意图命名:tool/call 事件按 (turn,step) 索引 + 匹配优先级 + label 附加(纯函数)
lib/service.mjsDiffService: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.mjsLCS 行级 diff(Uint32Array 全表,4M 单元上限降级全删全加)
lib/diff/git.mjsgit 只读: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.mjscopy 只读: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/resultisError/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.mjsTraceService: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 前缀/latestscope=project 可跨会话寻址 (同工作区键的其它会话节点,即跨对话回滚)。
  • 回滚撤销(0.4.0,进程内单次 undo,无 redo)service.rollbackUndo):
    • apply(非 dryRun)成功后在进程内记录 Map<workspaceKey, {target, time, files:[{rel, before, after}]}>(before = 写入前原内容,不存在 = null;after = 快照内容)——重启即失效(文档 已说明);下一次 apply 替换上一次(只撤销最近一次恢复)。
    • 撤销逐文件:当前内容必须仍等于 after(被后续改动 → 跳过并 note, 全部跳过 → 409);恢复 beforebefore === null(恢复时新建的文件) → 删除——"绝不删除"的唯一例外(删除的是恢复操作自己刚创建的文件), 删除前同样走 resolveInside + assertRealFileTarget + 非受保护路径校验; 成功后删除条目(一次 undo)。与回滚共用每工作区串行锁。
    • 端点 POST /api/rollback-undo(body {session},64 KiB 上限 + JSON 校验);命令 /rollback --undo(并入 handleRollback 的 flag 解析)。
  • 恢复预览 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/resultisError/data.error 过滤, 结果缺失按成功并计数);write 整体替换、edit/str_replace_editor 替换 子串(old_string 未找到 = 重放偏差,计入 drift 并在 notes 诚实报告, 状态降级为 M,绝不静默);bash/pwsh 等任意命令的修改不可见(轨迹不含 fs/*-intent 事件)——这是固有盲区,不是实现问题。
    • 数据源:sessionQuery.readSession 首选(live/cold、replay 校验)→ live session.events → zstd 直读兜底(多帧扫描 + 逐帧解码,Node ≥ 23.5; $DSH_HOME/sessions 目录编码与 harness format.ts 同构)。逐级降级,绝不抛错。
    • 寻址:精确 id 优先于前缀(trace:1 不被 trace:11 歧义化——seq 前缀碰撞 常见);支持 latesttrace:<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 的耦合面)

  1. 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;严格性属于生产者)。
  2. copy 快照目录布局:$snapshotDir/<sha256(key)前16>/<uuid>/ + manifest.json{id, base, files:[{rel,size,mtimeMs,mode,hash?}], bytes})。
  3. git 快照 ref:git stash create/commit-tree 的未引用对象 sha(40/64 hex);未引用对象约 2 周后被 git gc 回收——超旧节点的 blob 可能消失,UI/命令已降级。
  4. /rewind fork 后子会话是新 sessionIdscope=project 沿 SessionHeader.parentSession 血缘(sessionQuery.traceSession)合并展示——v1 记录以 forkSeq 为 fork 衔接点,v2 记录以父会话中不晚于子会话 createdAt 的最后一条记录为衔接点(forkSeq 已移除)。