Skip to Content
issuesChangeset 层级解析机制设计方案

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.serverPathparent_id 的不一致。**

用户在 UI 上移动文档时,parent_idpath 被更新,但 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_docsGit 文件路径作为 server_path不受影响(路径来自 Git)可能落入错误的同名文件夹
crawl_siteURL 派生 server_path不受影响(路径来自 URL)可能落入错误的同名文件夹
amend_changeset通过 serverPath Map 查找条目间接受影响同 serverPath 条目互相覆盖

1.4 changeset 挂起期间 Space 结构变化

changeset 从创建到 apply 之间可能有时间间隔。这段时间内用户可能在 UI 上修改了 Space 结构,changeset 中的路径快照不会随之更新。

场景描述后果
D:目录被重命名changeset 存了 "AAA/x.md",AAA 被重命名为 BBBapply 找不到 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 被重命名为 Xapply 创建全新的 A/B 结构,忽略 C/B
H:文章被重命名文章在挂起期间被 renameapply 可能覆盖 rename 后的最新路径(数据回退)
I:引用的目录不存在上述场景的后果静默创建新目录,用户无感知

1.5 问题根源

changeset 用基于 title 的路径字符串表达层级,有两个固有弱点:

  1. 路径会过时:UI 操作不同步更新 sync.serverPath
  2. 路径不唯一:同名目录的 serverPath 完全相同,无法区分

serverPath 是基于名称的寻址(类似”朝阳区的张三”)。JRN/ID 是基于唯一标识的寻址(类似身份证号)。前者在重名、改名、搬家时失效,后者始终精确。


2. 方案对比

方案 A:维护 sync.serverPath + 禁止同级同名

思路

不做结构性改动,补齐 serverPath 的两个短板:维护一致性(UI 操作时同步更新 sync.serverPath)+ 消除歧义(禁止同级同名)。

需要的修改

  1. moveDoc 补上根文档的 sync.serverPath 更新:子文档的递归更新已有,只是遗漏了根文档自身
  2. renameDoc 同步更新 sync.serverPath 并递归更新子文档:重命名后路径中的 title 部分变了,需要递归替换所有后代的 sync.serverPath 前缀
  3. resolveServerPath 优先级调整:从 parent_id 实时计算优先,sync.serverPath 仅作兜底(防御性措施,应对遗留脏数据)
  4. 禁止同级同名:数据库唯一索引 + 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:

  1. **resolveServerPath 优先级反转**:从 parent_id 实时计算优先,sync.serverPath 仅作兜底
  2. **moveDoc 补上根文档自身的 sync.serverPath 更新**

这两处改动约 20 行代码,不需要 DB 迁移,可以快速合入。

中期(独立 ticket)

实施方案 C(changeset 引入 parent_jrn)。方案 C 复用现有 JRN 体系,是唯一能覆盖全部场景(包括同名歧义和挂起期间 Space 变化)的方案,且对 UI 操作零侵入、apply 性能有提升、不引入新标识符。

方案 B 的定位

方案 B 在性能和唯一性上有优势,但 changeset 全链路从 title-based 改为 slug-based 的改动面最大、向后兼容最差、用户可读性最差。在有方案 C 作为替代的情况下,方案 B 的收益不足以覆盖其成本,不建议采用