# dsh-checkpoint-diff 架构

只读消费 [dsh-checkpoint-rewind](https://github.com/PerryLink/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` 可跨会话寻址
    （同工作区键的其它会话节点，即跨对话回滚）。
- **回滚撤销（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 解析）。
- **恢复预览 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 校验）→ live
    `session.events` → zstd 直读兜底（多帧扫描 + 逐帧解码，Node ≥ 23.5；
    `$DSH_HOME/sessions` 目录编码与 harness `format.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](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 后子会话是新 `sessionId`；`scope=project` 沿 `SessionHeader.parentSession` 血缘（`sessionQuery.traceSession`）合并展示——v1 记录以 `forkSeq` 为 fork 衔接点，v2 记录以父会话中不晚于子会话 `createdAt` 的最后一条记录为衔接点（`forkSeq` 已移除）。
