Last Updated: 4/8/2026
Changeset 层级解析机制设计方案
1. 问题描述
1.1 背景
Jolli 的文档系统中,文章和目录在 docs 表中通过 parent_id 维护层级关系,前端 UI 的目录树据此构建。
changeset 系统(类似 git 变更集)用于 agent 工具的文档操作。changeset 文件条目存储在 sync_commit_files 表中,使用 server_path 字段(基于 title 拼接的路径字符串,如 Guide/intro.md)来表达文件位置。apply 时系统从 server_path 中提取文件夹名,通过 findFolderByName 按 title 查找目录,拿到 docs.id 作为 parent_id。
此外,docs 表的 content_metadata.sync.serverPath 字段记录了文档在 changeset 系统中的逻辑路径,在 changeset apply 后写入,后续 agent 操作通过它确定文档的”当前位置”。
1.2 核心问题
**sync.serverPath 与 parent_id 的不一致。**
用户在 UI 上移动文档时,parent_id 和 path 被更新,但 sync.serverPath 没有被更新。重命名目录时,contentMetadata.title 被更新,但 sync.serverPath 也没有被更新。
经过 UI 操作后,sync.serverPath 存储的是过时的旧位置信息。
1.3 受影响的工具和场景
场景 A:移动后 agent 读错位置
用户通过 changeset 在 “AAA” 目录下创建了文章 X(sync.serverPath = "AAA/x.md"),然后在 UI 上拖拽到 “BBB” 目录下(parent_id 更新,但 sync.serverPath 仍是 "AAA/x.md")。用户要求 agent 移动文章 X 时,agent 通过 resolveServerPath 读到旧路径,以为文章在 AAA 下——与用户看到的不一致。
场景 B:重命名后 changeset 路径错误
用户通过 changeset 创建了 “AAA” 目录(sync.serverPath = "AAA/"),然后在 UI 上重命名为 “BBB”。用户要求 agent “在 BBB 下创建文章 C”时,agent 找到了 title 为 “BBB” 的目录,但读取其 sync.serverPath 得到 "AAA/",changeset 条目的路径被设为 "AAA/c.md"。apply 时按 title 找 “AAA”——找不到——自动创建新的 “AAA” 目录,文章落入其中而非用户期望的 “BBB”。
场景 C:同名目录 apply 落入错误目录
Space 允许同名目录存在。两个都叫 “Guide” 的文件夹,changeset 中 server_path = "Guide/intro.md" 无法区分应落入哪个。apply 时 findFolderByName 返回第一个匹配的,不一定是用户选择的那个。用户的选择(docId)在转化为 serverPath 时被丢弃了。
受影响工具总结
| 工具 | serverPath 用途 | 过时 serverPath 的影响 | 同名歧义的影响 |
|---|---|---|---|
| move_doc | 确定当前位置、计算新路径 | agent 读错位置;changeset 路径错误 | apply 时解析到错误文件夹 |
| create_article_draft | 拼接文章路径 | 路径前缀错误,apply 落入错误文件夹 | 落入第一个匹配的同名文件夹 |
| create_folder | 构建文件夹路径 | 嵌套创建时路径前缀错误 | 子文件夹落入错误的同名父文件夹 |
| delete_doc | 记录被删文件路径 | 变更历史路径不准确 | 不影响功能(删除靠 JRN) |
| import_repo_docs | Git 文件路径作为 server_path | 不受影响(路径来自 Git) | 可能落入错误的同名文件夹 |
| crawl_site | URL 派生 server_path | 不受影响(路径来自 URL) | 可能落入错误的同名文件夹 |
| amend_changeset | 通过 serverPath Map 查找条目 | 间接受影响 | 同 serverPath 条目互相覆盖 |
1.4 changeset 挂起期间 Space 结构变化
changeset 从创建到 apply 之间可能有时间间隔。这段时间内用户可能在 UI 上修改了 Space 结构,changeset 中的路径快照不会随之更新。
| 场景 | 描述 | 后果 |
|---|---|---|
| D:目录被重命名 | changeset 存了 "AAA/x.md",AAA 被重命名为 BBB | apply 找不到 AAA,创建新 AAA 目录 |
| E:目录被移动 | changeset 存了 "Guide/intro.md",Guide 被移到 Docs 下 | apply 在根目录创建新 Guide,与 Docs/Guide 并存 |
| F:目录被删除 | changeset 引用了 Archive 目录,Archive 被删除 | apply 重新创建 Archive,“死而复生” |
| G:多步变化 | A/B 中 B 被移到 C 下,A 被重命名为 X | apply 创建全新的 A/B 结构,忽略 C/B |
| H:文章被重命名 | 文章在挂起期间被 rename | apply 可能覆盖 rename 后的最新路径(数据回退) |
| I:引用的目录不存在 | 上述场景的后果 | 静默创建新目录,用户无感知 |
1.5 问题根源
changeset 用基于 title 的路径字符串表达层级,有两个固有弱点:
- 路径会过时:UI 操作不同步更新
sync.serverPath - 路径不唯一:同名目录的 serverPath 完全相同,无法区分
serverPath 是基于名称的寻址(类似”朝阳区的张三”)。JRN/ID 是基于唯一标识的寻址(类似身份证号)。前者在重名、改名、搬家时失效,后者始终精确。
2. 方案对比
方案 A:维护 sync.serverPath + 禁止同级同名
思路
不做结构性改动,补齐 serverPath 的两个短板:维护一致性(UI 操作时同步更新 sync.serverPath)+ 消除歧义(禁止同级同名)。
需要的修改
- moveDoc 补上根文档的 sync.serverPath 更新:子文档的递归更新已有,只是遗漏了根文档自身
- renameDoc 同步更新 sync.serverPath 并递归更新子文档:重命名后路径中的 title 部分变了,需要递归替换所有后代的 sync.serverPath 前缀
- resolveServerPath 优先级调整:从
parent_id实时计算优先,sync.serverPath仅作兜底(防御性措施,应对遗留脏数据) - 禁止同级同名:数据库唯一索引 + UI 创建/重命名/changeset apply/导入工具各入口加校验 + 存量数据去重迁移
changeset 挂起期间的应对能力
方案 A 维护的是 docs 表中的 sync.serverPath,但 changeset 创建后其条目中的 server_path 是快照,不会被后续的 docs 表更新所影响。
- 场景 D(rename):目录 sync.serverPath 已更新,但 changeset 快照仍是旧路径。apply 按旧 title 找——找不到——创建新目录。无法解决。
- 场景 E(move):同理。无法解决。
- 场景 F(delete):静默创建已删目录的副本。无法解决。
- 场景 H(文章 rename):apply 会把 changeset 中的旧路径覆盖到已 rename 的文章上,产生数据回退。
优缺点
优点:改动最小(集中在 DocDao 两个函数 + 各入口校验);不改 changeset 数据模型;概念简单;渐进式实施。
缺点:rename 递归更新 JSONB 性能开销(N 次读写,无法批量化);无法应对 changeset 挂起期间的 Space 变化;禁止同名是业务约束变更(存量迁移、用户能力受限、外部导入需冲突处理);维护负担(未来新增 UI 操作都需记得维护 serverPath)。
方案 B:使用 docs.path 替代 sync.serverPath + 禁止同级同名
思路
用 docs 表的 path 字段(slug-based,如 /backend-hevg5l3/folder33-qk0su3d)替代 sync.serverPath 作为 changeset 位置标识。path 是表的直接列,在 moveDoc 时已被正确维护,且基于 slug(创建后不变),rename 天然不影响。配合禁止同级同名消除展示层歧义。
changeset 挂起期间的应对能力
- 场景 D(rename):slug 不因 rename 变化,changeset 中的 slug-based path 仍然有效。天然解决。
- 场景 E(move):path 在 moveDoc 时被更新,changeset 中存的是旧 path。按旧 slug + parentId 查找——目录已被移到新 parent 下,匹配不到。无法解决。
- 场景 F(delete):与方案 A 一致,静默创建新目录。无法解决。
- 场景 H(文章 rename):slug 不变,不存在路径覆盖问题。天然解决。
相比方案 A 的优劣
优势:rename 零成本(slug 不变);path 是表直接列,批量更新可优化为单条 SQL;无维护遗漏风险。
劣势:changeset 全链路改动大(路径体系从 title-based 改为 slug-based,涉及 AgentChangesetService、FolderResolutionService、所有工具);用户可读性差(slug 对人无意义,需额外转换);changeset-only 项目没有 slug 需要预生成;向后兼容成本高(旧记录需迁移)。
方案 C:在 changeset 中通过 JRN 维护层级关系
思路
在 sync_commit_files 表新增 parent_jrn 字段,直接引用目标父目录的 JRN。JRN 是 docs 表已有的唯一标识(如 jrn:/global:docs:folder/backend-hevg5l3),不可变,创建时即可预生成,rename/move 不影响。
核心设计
引用已存在的目录:parent_jrn 填该目录在 docs 表中的 JRN。无论目录后续被 rename/move/delete,JRN 不变,引用始终有效。
引用 changeset 内新建的目录:新目录在 changeset 创建时通过 generateAgentFolderJrn 生成 JRN(虽然 docs 表中还不存在)。子条目的 parent_jrn 引用这个预生成的 JRN。apply 时先处理 folder(按深度排序),存入 folderByJrn 映射(JRN → docs.id),后处理的 document 从映射中解析 parent_id。
为什么用 JRN 而非新增 UUID:JRN 体系已存在(docs.jrn + sync_commit_files.doc_jrn),满足唯一性、不可变、可预生成的全部要求,无需引入冗余标识符。
changeset 挂起期间的应对能力
- 场景 D(rename):JRN 不因 rename 变化,apply 通过 JRN 直接找到重命名后的目录。天然解决。
- 场景 E(move):JRN 不因 move 变化,apply 通过 JRN 找到移动后的目录。文章正确落入目录的当前位置。天然解决。
- 场景 F(delete):通过 JRN 查找时可检测到目录已被软删除,明确报错(“目标目录已删除”)而非静默创建重复目录。可检测冲突。
- 场景 H(文章 rename):
parent_jrn和文章 title 无关,层级关系不受影响。sync.serverPath已降级为展示用途,即使被覆盖也不影响 parent_id 正确性。天然解决。
对同名目录的支持
方案 C 是唯一完整支持同名目录共存的方案。parent_jrn 精确指向特定目录的 JRN,即使有多个同名目录也不会混淆。不需要禁止同名约束,不需要存量数据迁移,不限制用户灵活性,外部导入无需特殊处理。
优缺点
优点:完整覆盖所有场景(A-I);保留同名能力;复用 JRN 体系;UI 操作零侵入;apply 性能提升(跳过逐级路径解析);向后兼容(parent_jrn 为空回退到 serverPath)。
缺点:改动面较大(DB 迁移 + DAO/Service/工具适配);parent_jrn 的 NULL 语义需明确(“根目录” vs “旧记录”);需确保 folder 先于 document 处理(可复用现有按深度排序机制)。
3. 综合对比
| 维度 | 方案 A:维护 serverPath + 禁止同名 | 方案 B:使用 docs.path | 方案 C:changeset 引入 parent_jrn |
|---|---|---|---|
| 改动范围 | 小(DocDao + 各入口加校验) | 大(changeset 全链路重构) | 中(新增字段 + 工具层适配) |
| DB 迁移 | 需要(唯一索引 + 存量去重) | 不需要(但可能需要索引调整) | 需要(新增列,可空,向后兼容) |
| 场景 A(移动后读错) | 解决 | 解决 | 解决 |
| 场景 B(重命名后路径错) | 解决(但需递归更新) | 解决(天然免疫) | 解决(天然免疫) |
| 场景 C(同名歧义) | 解决(禁止同名) | 解决 | 解决 |
| 同名目录/文档支持 | 不支持(需禁止同名) | 部分(apply 正确,展示有歧义) | 完整支持(JRN 天然唯一) |
| 挂起期间 rename | 无法解决 | 解决(slug 不变) | 解决(JRN 不变) |
| 挂起期间 move | 无法解决 | 无法解决 | 解决(JRN 不变) |
| 挂起期间 delete | 无法解决(静默创建重复) | 无法解决(静默创建重复) | 可检测冲突并报错 |
| 挂起期间文章 rename | 有副作用(覆盖最新路径) | 解决(slug 不变) | 解决(parent_jrn 不受影响) |
| 引用的目录不存在 | 静默创建新目录 | 静默创建新目录 | 明确报错 |
| 移动操作性能 | 低开销 | 低开销 | 零开销 |
| 重命名操作性能 | 中等(递归 JSONB 读写) | 零(slug 不变) | 零(JRN 不变) |
| apply 性能 | 无变化 | 无变化 | 提升(跳过逐级路径解析) |
| 向后兼容 | 需处理存量同名数据 | 不兼容(需迁移旧数据) | 兼容(回退到 serverPath) |
| 业务规则变更 | 有(禁止同名) | 无 | 无 |
| 用户可读性 | 好(title-based) | 差(slug 不可读) | 好(serverPath 保留展示用) |
| 未来维护成本 | 中(需持续维护 serverPath) | 低(slug 天然稳定) | 低(JRN 不可变) |
4. 建议的实施路径
短期(当前 PR)
采用方案 A 的最小修复,解决最紧迫的场景 A 和 B:
**resolveServerPath优先级反转**:从parent_id实时计算优先,sync.serverPath仅作兜底**moveDoc补上根文档自身的 sync.serverPath 更新**
这两处改动约 20 行代码,不需要 DB 迁移,可以快速合入。
中期(独立 ticket)
实施方案 C(changeset 引入 parent_jrn)。方案 C 复用现有 JRN 体系,是唯一能覆盖全部场景(包括同名歧义和挂起期间 Space 变化)的方案,且对 UI 操作零侵入、apply 性能有提升、不引入新标识符。
方案 B 的定位
方案 B 在性能和唯一性上有优势,但 changeset 全链路从 title-based 改为 slug-based 的改动面最大、向后兼容最差、用户可读性最差。在有方案 C 作为替代的情况下,方案 B 的收益不足以覆盖其成本,不建议采用。