# OpenViking 多版本管理技术方案 — 基于 Gitoxide 的 in-process Git 集成 > 💡 **一句话摘要**:在现有 OpenViking 的 RAGFS Rust 实现中嵌入一套基于 `gitoxide` 的 in-process Git 服务,以 **账号(account\_id)粒度** 提供 `commit / restore / show` 三个版本管理原语;通过 PyO3 binding 直接被 `VikingFS` Python 层调用,全程零 HTTP、零额外进程,Git 对象/Ref 后端复用现有 `localfs`/`s3fs` 客户端,实现"本地或远程"对称配置。 # 1. 背景与目标 ## 1.1 业务背景 OpenViking 现有存储架构是一套以 `viking://` URI 为入口的双层抽象:上层 `VikingFS`(Python)负责 URI 规范化、L0/L1 摘要、向量同步、租户隔离;下层 RAGFS(Rust + PyO3 binding)提供 `FileSystem` trait 与 `MountableFS` radix-trie 路由,实际数据落到 `localfs`、`s3fs`、`memfs` 等插件后端。 在持续运行过程中,用户/Agent 对 `viking://resources/`、`viking://agent/skills/` 等命名空间的写入是连续且不可逆的——出错后无法回滚,跨多个文件的"逻辑事务"难以原子化捕获,实验性改动需要手动备份。这些场景的本质需求都是一套**面向账号的多版本快照机制**,语义与 Git 的 commit/restore/show 高度同构。 ## 1.2 设计目标 - **显式版本化**:用户/Agent 通过 API 显式触发 commit/restore/show,不引入隐式 hook,避免影响现有写链路的延迟与一致性语义 - **账号粒度仓库**:每个 `account_id` 一个逻辑 Git 仓库,跨 scope (resources/agent/user/session) 共享同一棵 root tree,支持跨 scope 的原子快照 - **多后端对称**:Git objects / refs 的实际存储类型与 resources 目录一致,可在配置中切换本地(local)或远程(s3),运维心智零增量 - **零进程膨胀**:Git 服务以 in-process binding 形式嵌入现有 RAGFS,共享 Tokio runtime 与配置加载链路,不引入新 HTTP server - **对现有代码侵入最小**:不修改 `content_write.py`、`viking_fs.write/rm/mv` 等核心写链路,仅在 `VikingFS` 上增加 3 个新方法 - **定向恢复 (restore)**:支持以 **(project\_dir, commit\_id)** 为输入,将指定 project 目录恢复到目标 commit 的快照状态,并以 HEAD 为父节点*正向生成一个新 commit*。非目标 project 目录保持当前最新状态不动。 ## 1.3 非目标 (Out of Scope) - 不实现自动 commit hook (首版纯主动 API 触发) - 不实现分支 merge / rebase / cherry-pick / push/pull (首版只覆盖快照 + 回滚 + 查看) - 不暴露 Git 数据到 `viking://` 用户命名空间 (避免被用户误删/误改) - 不支持向量索引数据的版本化 (向量索引由 watcher 异步重建, restore 后需触发重建;L0/L1 派生文件已纳入版本管理) - 不支持 ref 回退式 checkout:本方案不提供 "把 main / HEAD 指针直接移动到旧 commit" 的能力。所有恢复操作都通过正向新增 commit 实现,保证 HEAD 单调前进、commit 链完整可审计。如需查看旧版本,使用 **show** 接口的只读路径。 *** # 2. 核心设计决策 | 决策 | 设计含义 | 替代方案被淘汰的原因 | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | **单 Repo per account\_id** | 同一账号下的 `resources/`、`agent/`、`user/`、`session/` 全部在一棵 root tree 之下;一次 commit 可覆盖任意 scope 的子集 | per-resource repo 会产生 N×账号数量的索引数据,跨 resource 的"事务性快照"需要协调多 repo,复杂度高 | | **纯 API 触发,不接 hook** | `content_write.py` / `viking_fs.write/rm/mv` 完全不动;Git 仅通过 `VikingFS.commit/restore/show` 三个新方法被显式调用 | hook 模式会让每次小写入都触发 Git 写入,放大延迟、放大冲突窗口、放大 ref CAS 失败率;首版优先简单 | | **Git 存储后端与 resources 同构** | 定义 `ObjectStore` / `RefStore` trait,提供 local 与 s3 两种实现,直接复用 `plugins::localfs::LocalFileSystem` 和 `plugins::s3fs::S3Client` | 独立实现 Git 存储后端会重复造轮子;走 `MountableFS` 又会让 Git 数据进入用户命名空间 | | **嵌入为 crates/ragfs 子模块** | 新增 `crates/ragfs/src/git/` 模块,与 `core/`、`plugins/`、`server/` 平级;PyO3 binding 在 `RAGFSBindingClient` 上加 3 个方法 | 独立 crate 会引入额外配置、额外 runtime、额外鉴权;`ServicePlugin` 又无法表达 commit 这种非文件操作的语义 | | **暴露方式 = PyO3 binding,非 HTTP** | 三个新方法挂在现有 `RAGFSBindingClient` 上,通过 `AsyncAGFSClient.run` 由 `VikingFS` 调用,与 `ls/read/write` 一致 | HTTP server 路径在 OpenViking 当前架构中已是 legacy,生产路径是 in-process binding | *** # 3. 整体架构 ## 3.1 分层与依赖关系 ```mermaid flowchart TB subgraph Py[Python 层] VFS["VikingFS commit / checkout / show"] Async["AsyncAGFSClient (asyncio.to_thread)"] end subgraph BindCrate["crates/ragfs-python (PyO3 cdylib)"] RBC["RAGFSBindingClient + git_commit / git_checkout / git_show"] end subgraph CoreCrate[crates/ragfs] MFS["core/MountableFS (现有 VFS 路由)"] Plugins["plugins/ localfs · s3fs · memfs ..."] GitMod["git/ (新增) GitService · ObjectStore · RefStore"] end subgraph Backends[底层存储后端] Local["LocalFileSystem (本地磁盘)"] S3["S3Client (TOS / S3 / OSS)"] end VFS --> Async Async --> RBC RBC --> MFS RBC --> GitMod GitMod -- "checkout 写回阶段" --> MFS MFS --> Plugins Plugins --> Local Plugins --> S3 GitMod -- "直接持有 struct (不经过 MountableFS)" --> Local GitMod -- "直接持有 struct (不经过 MountableFS)" --> S3 ``` ## 3.2 数据流(三个核心命令) ```mermaid sequenceDiagram participant U as User/Agent participant V as VikingFS (Python) participant B as RAGFSBindingClient participant G as GitService participant M as MountableFS participant O as ObjectStore participant R as RefStore Note over U,R: --- commit --- U->>V: commit(message, paths) V->>B: git_commit(account, branch, ...) B->>G: GitService::commit(req) G->>M: tree_directory / read (枚举+读 blob) M-->>G: 文件内容流 G->>O: put(blob_oid, zlib) G->>O: put(tree_oid, zlib) G->>O: put(commit_oid, zlib) G->>R: cas_update(refs/heads/main, parent, new) R-->>G: ok / conflict G-->>B: CommitResponse B-->>V: dict V-->>U: commit_oid Note over U,R: --- show (只读) --- U->>V: show(ref, path) V->>B: git_show(account, ref, path) B->>G: GitService::show G->>R: read(refs/heads/main) G->>O: get(commit_oid) → tree_oid G->>O: get(tree_oid) → blob_oid G->>O: get(blob_oid) G-->>B: blob bytes B-->>V: bytes V-->>U: bytes Note over U,R: --- checkout (写回 VFS) --- U->>V: checkout(ref, paths) V->>B: git_checkout(account, ref, paths, dry_run) B->>G: GitService::checkout G->>O: 加载 commit → root tree → 递归列 (path, blob_oid) G->>M: stat / read 对比当前态 G->>M: write / remove (走完整 VFS 语义) G-->>B: 差异报告 B-->>V: dict V-->>U: applied / deleted / skipped ``` ## 3.3 关键设计原则 > 💡 **Git 数据不进 viking 命名空间** > > Git 模块直接持有 `LocalFileSystem`/`S3Client` 实例,**不**通过 `MountableFS` 路由。Git 数据存到 `git/{account}/objects/...`,用户在 `viking://` 下看不到、也改不到。 > 💡 **LocalObjectStore 和 S3ObjectStore 直接调用 tokio::fs 和 Arc\, 不复用 LocalFileSystem/S3Client** > > LocalFileSystem/S3Client 是面向"用户文件树"的抽象,而 Git 后端是面向"内容寻址对象库"的存储,两者的语义需求不重叠。强行复用会导致更复杂的胶水代码。 *** # 4. Repo 边界与 Tree 布局 ## 4.1 Tree 镜像 VikingFS 命名空间 由于 `viking_fs._uri_to_path` 已经定义了 `viking://X → /local/{account_id}/X` 的映射规则,我们让 Git 的 root tree 完全镜像 `/local/{account_id}/` 下的子目录结构。这样 tree path 与 viking URI 后缀一一对应,语义直观、无歧义。 ## 4.2 路径剪枝(自动排除) 剪枝规则集中实现在 `crates/ragfs/src/git/enumerate.rs::prune_path`,在 `commit` 入口对 `paths=Some(...)` 与 `paths=None`(全量枚举)两条路径都生效: | 类别 | 规则 | 理由 | | ------------- | ------------------------------------------------------- | --------------------------------------------------------------- | | 内部 scope / 目录 | 第一段命中 `_system` / `tasks` / `temp` / `queue` / `upload` | 与 `VikingFS._INTERNAL_NAMES` / `INTERNAL_SCOPES` 一致,均为运行时锁/系统状态 | | 运行时锁文件 | 任意段以 `.path.ovlock` 开头 | VFS 内部锁,不应纳入版本 | | 向量缓存目录 | 任意非叶段等于 `embedding_cache` | embedding 缓存为派生数据 | | 向量索引文件 | 叶子文件以 `.faiss` 或 `.index` 结尾 | 纯计算产物,体积大且可重建 | L0/L1 派生文件(`.abstract.md`、`.overview.md`、`.relations.json`)未命中任一剪枝规则,**会**纳入主线 commit。restore 时随源文件一起回滚,无需重新生成;Python 层在 `restore` 完成后按 `(written_paths, deleted_paths)` 精确触发 L0/L1/DETAIL 向量异步重建(`.relations.json` 不触发向量任务)。 此外,版本管理支持账号级 `.ovgitignore` 控制文件,物理路径为 `/local/{account_id}/.ovgitignore`,Git tree path 为 `.ovgitignore`。该文件使用账号根相对的 glob 子集规则,在 `commit` 枚举当前 VFS 文件和扫描上一版 tree 时共同生效;匹配的文件不会进入新的 snapshot commit,即使它们曾存在于历史 commit 中。`.ovgitignore` 文件自身始终进入版本管理,规则无法把它排除。 ## 4.3 单库多命名空间的优势 1. **原子跨 scope 快照**:一次 commit 可同时覆盖 `resources/docs` 和 `agent/skills`,对应"Agent 一次任务的所有产出"这种逻辑事务 2. **定向回滚**:restore 时可指定 `paths=["resources/docs/auth.md"]`,只回滚单个文件 3. **索引数据线性**:objects/refs 数量随账号线性,不随 resource 数量指数膨胀 4. **权限边界清晰**:account\_id 已经是天然的隔离单位,Git 仓库边界与现有权限模型完全对齐 *** # 5. 物理布局 ## 5.1 Crate 目录结构 Git 模块作为 `crates/ragfs` 的子模块,与 `core/`、`plugins/`、`server/` 平级。新增文件全部位于 `crates/ragfs/src/git/` 下,Python binding 仅在 `crates/ragfs-python/src/lib.rs` 上追加方法,无新 crate。 ``` crates/ragfs/src/ ├── core/ # 既有(不动) ├── plugins/ # 既有(不动) ├── server/ # 既有(不动) └── git/ # 新增 ├── mod.rs # 模块入口 + 重导出 ├── service.rs # GitService(commit/restore/show 主流程,均在此文件) ├── object_store.rs # ObjectStore trait ├── ref_store.rs # RefStore trait ├── tree_builder.rs # TreeEditor + flatten/lookup 工具 ├── commit.rs # write_commit / Actor / 时间戳 ├── enumerate.rs # 从 MountableFS 枚举 + prune_path 剪枝 ├── util.rs # zlib 压缩/解压、ref 名校验、loose object 读写 ├── types.rs # 请求/响应 DTO ├── error.rs # GitError / ObjectStoreError / RefStoreError(thiserror) ├── config.rs # GitConfig(serde) └── backends/ ├── mod.rs ├── local.rs # LocalObjectStore / LocalRefStore(直接使用 tokio::fs) └── s3.rs # S3ObjectStore / S3RefStore(直接使用 aws_sdk_s3 + If-Match) crates/ragfs-python/src/ └── lib.rs # 追加 git_commit / git_restore / git_show 方法 openviking/openviking/storage/ └── viking_fs.py # 追加 commit / restore / show / log + URI↔tree-path 工具 ``` ## 5.2 依赖增量 仅引入 gitoxide 中实现 commit/restore/show MVP 所需的最小子 crate 集合,通过 `crates/ragfs/Cargo.toml` 增量声明: ```toml [dependencies] # === Git (gitoxide) === gix-hash = "0.14" # ObjectId / Hash 抽象 gix-object = "0.42" # Blob/Tree/Commit 编解码 + tree::Editor gix-actor = "0.31" # 作者/提交者签名(name ts tz) gix-date = "0.8" # 时间戳格式化 # === Zlib 压缩 === flate2 = "1" # loose object zlib 编解码 # === S3 后端 === aws-sdk-s3 = ... # S3 API client(直接依赖,不复用 plugins/s3fs 内部封装) aws-config = ... [dev-dependencies] tempfile = "3" ``` > 💡 **说明:** 不引入 `gitoxide` 顶层 crate,只挑选 commit/restore/show MVP 必需的子 crate;不引入 `gix-pack`(MVP 只用 loose object 格式)、不引入 `gix-protocol`(无 push/pull 需求)、不引入 `gix-worktree`(restore 通过 VFS 完成)。 > > - 实际实现使用 `flate2` 直接做 zlib 编解码,而非 `gix-features`,以减少 gitoxide 依赖面。 > - ref 名校验由 `crates/ragfs/src/git/util.rs` 中自实现的 `validate_ref_name` 完成,未引入 `gix-validate`。 > - 并发模型测试(`loom`)与 fuzz 测试(`proptest`)在 MVP 阶段未引入,以单测 + 集成测试覆盖。 *** # 6. 核心 Trait 设计 ## 6.1 ObjectStore `ObjectStore` 是 Git 内容寻址存储的抽象,提供 blob/tree/commit 三类对象的存取。所有写入按 SHA-1 内容寻址,天然幂等(同样的字节 → 同样的 oid)。trait 必须 `Send + Sync + 'static`,以便在 Tokio 多线程运行时中跨任务共享。 ```rust // crates/ragfs/src/git/object_store.rs use async_trait::async_trait; use bytes::Bytes; use gix_hash::ObjectId; /// 内容寻址的 Git 对象存储抽象 /// put 必须幂等;get 不存在返回 NotFound;exists 不读取内容 #[async_trait] pub trait ObjectStore: Send + Sync + 'static { /// 写入一个已 zlib 压缩的 loose object /// oid 必须等于 SHA-1(未压缩 header + payload) async fn put( &self, account: &str, oid: &ObjectId, zlib_body: Bytes, ) -> Result<(), ObjectStoreError>; /// 读取并 zlib 解压(返回 header + payload 的原始字节) async fn get( &self, account: &str, oid: &ObjectId, ) -> Result; /// 仅检查存在性(HEAD/stat 优化,跳过内容传输) async fn exists( &self, account: &str, oid: &ObjectId, ) -> Result; } #[derive(Debug, thiserror::Error)] pub enum ObjectStoreError { #[error("object not found: {0}")] NotFound(ObjectId), #[error("backend io: {0}")] Io(#[from] std::io::Error), #[error("zlib decode: {0}")] Zlib(String), #[error("oid mismatch: expected {expected}, got {actual}")] OidMismatch { expected: ObjectId, actual: ObjectId }, #[error("backend error: {0}")] Backend(String), } ``` > ℹ️ **说明:** 物理路径布局由各实现自行决定(local 走 fanout 目录,s3 走 key prefix),trait 层不暴露物理路径,只暴露逻辑寻址。 ## 6.2 RefStore `RefStore` 是分支/标签的命名引用存储,核心是 **CAS(Compare-And-Swap)** 更新原语 — 这是 Git 一致性的基石。CAS 保证"两个并发 commit 先到先得,后到的看到 `Conflict` 并需要重试或 rebase",避免静默覆盖。 ```rust // crates/ragfs/src/git/ref_store.rs use async_trait::async_trait; use gix_hash::ObjectId; #[async_trait] pub trait RefStore: Send + Sync + 'static { /// 读取 ref 的当前值;不存在返回 NotFound async fn read( &self, account: &str, ref_name: &str, ) -> Result; /// Compare-And-Swap 更新:仅当当前值 == expected 时才写入 new /// expected = None 表示"仅当 ref 不存在时创建" async fn cas_update( &self, account: &str, ref_name: &str, expected: Option, new: ObjectId, ) -> Result<(), RefStoreError>; /// 列出 account 下的所有 refs(用于 log / branch 列表) async fn list( &self, account: &str, prefix: &str, ) -> Result, RefStoreError>; } #[derive(Debug, thiserror::Error)] pub enum RefStoreError { #[error("ref not found: {0}")] NotFound(String), #[error("CAS conflict: expected {expected:?}, actual {actual:?}")] Conflict { expected: Option, actual: Option, }, #[error("invalid ref name: {0}")] InvalidName(String), #[error("backend io: {0}")] Io(#[from] std::io::Error), #[error("backend: {0}")] Backend(String), } ``` > ⚠️ **注意:** ref 名必须经 `crate::git::util::validate_ref_name(...)` 校验,拒绝 `..`、空字符、特殊保留字等,避免路径穿越和注入(实现位于 `git/util.rs`,未引入 `gix-validate`)。 ## 6.3 命名约定 | 类别 | 路径模板 | 说明 | | ----------- | --------------------------------------- | -------------------------------------------------------- | | Object | `{root}/{account}/objects/{aa}/{bb...}` | Git 标准 fanout(前 2 hex 为目录,后 38 hex 为文件名),便于分布式存储 list 优化 | | Ref (heads) | `{root}/{account}/refs/heads/{branch}` | 文件内容 = 40 hex 字符 + `\n` | | HEAD | `{root}/{account}/HEAD` | 内容 = `ref: refs/heads/main\n` | | Packed-refs | (不实现) | MVP 全部 loose,后续如 ref 数量爆炸再补 pack | *** # 7. 后端实现 ## 7.1 LocalObjectStore / LocalRefStore **LocalObjectStore** 直接调用 `tokio::fs`(不经 MountableFS、也不复用 `LocalFileSystem`),把 Git 对象写入本地磁盘的 `{base_dir}/{account}/objects/{aa}/{bb...}`。**LocalRefStore** 使用进程内的 `DashMap<(account, ref_name), Arc>>` 串行化同 ref 的 CAS,叠加 `tempfile + rename(2)` 的原子重命名,覆盖同进程并发场景。 > **当前实现限制:** MVP 仅做了进程内 Mutex,**未叠加** `flock` 跨进程锁。生产部署若存在同 host 多进程同时写同一账号的场景,需要在后续版本补 `flock`。 ```rust // crates/ragfs/src/git/backends/local.rs (节选) pub struct LocalObjectStore { base_dir: PathBuf, // e.g. /data/openviking/git } #[async_trait] impl ObjectStore for LocalObjectStore { async fn put(&self, account: &str, oid: &ObjectId, body: Bytes) -> Result<()> { let hex = oid.to_hex().to_string(); let path = self.base_dir .join(account).join("objects") .join(&hex[..2]).join(&hex[2..]); // 内容寻址 → 已存在则跳过(幂等) if tokio::fs::try_exists(&path).await? { return Ok(()); } tokio::fs::create_dir_all(path.parent().unwrap()).await?; // 写临时文件 + rename 保证原子性 let tmp = path.with_extension("tmp"); tokio::fs::write(&tmp, &body).await?; tokio::fs::rename(&tmp, &path).await?; Ok(()) } // get / exists 略 } pub struct LocalRefStore { base_dir: PathBuf, // 进程内串行化 CAS,key = (account, ref_name) locks: dashmap::DashMap<(String, String), Arc>>, } #[async_trait] impl RefStore for LocalRefStore { async fn cas_update( &self, account: &str, name: &str, expected: Option, new: ObjectId, ) -> Result<()> { validate_ref_name(name)?; // util.rs 自实现 let lock = self.locks .entry((account.into(), name.into())) .or_default().clone(); let _guard = lock.lock().await; let path = self.ref_path(account, name); let actual = read_ref_opt(&path).await?; if actual != expected { return Err(RefStoreError::Conflict { expected, actual }); } let tmp = path.with_extension("tmp"); tokio::fs::write(&tmp, format!("{}\n", new.to_hex())).await?; // rename 保证 crash-consistency tokio::fs::rename(&tmp, &path).await?; Ok(()) } } ``` ## 7.2 S3ObjectStore / S3RefStore **S3ObjectStore** 直接持有一个 `Arc`(MVP 不复用 `plugins::s3fs::S3Client`,以解耦 git 模块与 plugin 体系),将 object 存为 `{prefix}/{account}/objects/{aa}/{bb...}`。由于内容寻址,`put` 用 `If-None-Match: *` 头实现幂等"仅首次写入"。**S3RefStore** 用 `If-Match: "{etag}"` 实现 CAS,先 `GET` 拿当前值与 ETag,再用 `PUT` 条件写。 > **CAS 模式:** `CasMode::Native` 已实现并默认启用;`CasMode::RedisLock` 仅作为枚举占位,**实际尚未实现**,调用会直接返回 `RefStoreError::Backend("RedisLock CAS mode not yet implemented")`。 ```rust // crates/ragfs/src/git/backends/s3.rs (节选) pub struct S3RefStore { client: Arc, bucket: String, prefix: String, cas_mode: CasMode, // Native | RedisLock(占位,未实现) } #[async_trait] impl RefStore for S3RefStore { async fn cas_update( &self, account: &str, name: &str, expected: Option, new: ObjectId, ) -> Result<()> { validate_ref_name(name)?; match self.cas_mode { CasMode::Native => { // 1. GET 当前 body 与 ETag let current = self.read_ref_opt(account, name).await?; let (current_oid, current_etag) = match current { Some((oid, etag)) => (Some(oid), etag), None => (None, None), }; if current_oid != expected { return Err(RefStoreError::Conflict { expected, actual: current_oid, }); } // 2. 条件 PUT let body = format!("{}\n", new.to_hex()); let put = self.client.put_object() .bucket(&self.bucket).key(&self.ref_key(account, name)) .body(body.into_bytes().into()); let put = match (current_etag, expected) { (Some(etag), Some(_)) => put.if_match(etag), (None, None) => put.if_none_match("*"), _ => return Err(RefStoreError::Conflict { expected, actual: current_oid, }), }; // 412 → Conflict;其他 → Backend map_precondition_failed(put.send().await, expected, current_oid) } CasMode::RedisLock => Err(RefStoreError::Backend( "RedisLock CAS mode not yet implemented".into(), )), } } } ``` > ⚠️ **S3 CAS 兼容性提示:** AWS S3 自 2024 年起支持 `If-Match` / `If-None-Match` 条件写;TOS / OSS 实现情况需在选型时验证。若某后端不支持原生 CAS,需退化为"分布式锁 + GET-then-PUT"模式;`RedisLock` 模式已在配置/枚举中预留,但实现待补。 *** # 8. GitService 主流程 ## 8.1 commit 完整实现 commit 主流程:**枚举 → 读 blob → 构建 tree → 构建 commit → CAS 更新 ref**。所有 ObjectStore 写入按账号粒度幂等(同 oid 多次 put 安全),tree 写入由 `TreeEditor` 自底向上完成。tree 未变 → 不创建空 commit(no-op 优化)。绝大多数 commit 场景下,被调用方声明为 "改动" 的文件里仍有大量未真正修改,需要通过三级 fast path 层层过滤,保证只有真正变化的字节才进入 streaming hash 与 blob 写入。 | 层级 | 触发条件 | 节省的开销 | 实现位置 | | ------------------------------ | --------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Fast Path 1**: Stat 索引复用 oid | 文件 (size, mtime\_ns) 与 prev\_index 完全一致 | 跳过 `vfs.read` + sha1 hash | **已实现**(`IndexStore` trait + `LocalIndexStore`/`S3IndexStore`,`CommitIndex` 与 `parent_oid` 绑定,索引 miss/decode 错误/parent 不匹配 → 静默回退 slow path;通过 `git.tuning.commit_index_enabled` 关闭) | | **Fast Path 2**: Tree 子树原样保留 | 子树下所有路径都没 upsert/remove | 跳过子树重 hash + 新 tree object 写入 | **已实现**(`TreeEditor::from_tree` 惰性加载 root,未被 upsert/remove/upsert\_subtree 触及的子树连读取+zlib 解压都省掉,由 `write_subtree` 的 `None` 分支原样复用其 OID) | | **Fast Path 3**: Blob CAS 去重 | 算出的 oid 在 object\_store 已存在 | 跳过 zlib 压缩 + put\_blob (本地写盘 / S3 PUT) | **已实现**(slow path 写 blob 前在 service 层调用 `object_store.exists` 预检,命中则跳过 zlib 压缩与 `put`;范围严格限定 blob,tree/commit 不预检;通过 `git.tuning.blob_exists_precheck_enabled` 关闭) | ```rust // crates/ragfs/src/git/service.rs ::commit (节选) pub async fn commit(&self, req: CommitRequest) -> Result { let CommitRequest { account, branch, message, paths, author_name, author_email, } = req; // 1. 解析当前 HEAD,加载 prev_tree(若 ref 不存在则空 tree) let prev_head = self.resolve_ref(&account, &branch).await.ok(); let prev_tree = match prev_head { Some(oid) => self.load_commit(&account, &oid).await?.tree, None => empty_tree_oid(), }; let mut editor = TreeEditor::from_tree( &self.object_store, &account, prev_tree, ).await?; let mut changed = 0usize; // 2. 候选路径:paths=Some → 经 prune_path 过滤后的清单;paths=None → enumerate::collect_all 全量 let candidates = match &paths { Some(ps) => ps.iter().filter_map(prune_path).collect(), None => enumerate::collect_all(&self.vfs, &account).await?, }; for path in candidates { match self.vfs.stat(&account_path(&account, &path)).await { Ok(_) => { // 读全量 + streaming hash + 写 blob(无 Fast Path 1/3) let bytes = self.vfs.read(&account_path(&account, &path)).await?; let oid = sha1_blob_streaming(&bytes); self.write_object(&account, &oid, &bytes).await?; // 幂等 editor.upsert(&path, oid)?; changed += 1; } Err(e) if is_not_found(&e) => { // 文件被删 → 从 tree 中移除 editor.remove(&path)?; changed += 1; } Err(e) => return Err(e.into()), } } // 3. 无任何变化 → noop if changed == 0 { return Ok(CommitResponse::Noop { commit_oid: prev_head.unwrap_or_default(), }); } // 4. 写 tree + commit let new_tree = editor.write(&self.object_store, &account).await?; let commit_oid = write_commit(&self.object_store, &account, CommitObject { tree: new_tree, parents: prev_head.into_iter().collect(), author: Actor::now(&author_name, &author_email), committer: Actor::now(&author_name, &author_email), message: message.into(), }).await?; // 5. CAS 更新 ref;失败 → ConcurrentCommit 直接上抛 // 注意:当前实现中 commit() 内部不做 retry,由调用方决定如何处理冲突。 self.ref_store.cas_update( &account, &format!("refs/heads/{}", branch), prev_head, commit_oid, ).await?; Ok(CommitResponse::Created { commit_oid, changed }) } ``` > **关于 retry:** 当前实现中 `commit()` 内部 **不包含 CAS 重试循环**(代码中明确注释 `// There is intentionally no retry loop inside commit().`)。冲突直接以 `ConcurrentCommit` 上抛,由 Python 层或上游业务决定重试策略;这与 §11.3 旧版描述的"内部最多重试 3 次"不一致,以本节为准。 ## 8.2 restore 完整实现 restore 主流程:**解析目标 commit → 提取该 commit 中 project\_dir 子树 → 与当前 HEAD 中同路径子树 diff → 通过 MountableFS.write/rm 回写 → 删除回写后空目录 → 以当前 HEAD 为 parent 生成新 commit → CAS 更新 ref → 把受影响路径返回给调用方**。`dry_run` 模式只计算差异不写,用于预检。 > **`.ovgitignore` 与 restore:** `.ovgitignore` 只影响 `commit`,不影响 `restore` / `show` / `log`。restore 的输入是 source commit 与当前 HEAD 的 Git tree diff,不能用当前工作区 `.ovgitignore` 过滤,否则会导致历史 commit 中已经被跟踪的文件无法恢复。全账号 restore 时 `.ovgitignore` 作为普通已跟踪文件随 source commit 恢复;子目录 restore 默认不触碰账号根 `.ovgitignore`。 向量索引重建在 service 层**不直接触发**,而是通过把 `written_paths` / `deleted_paths` 放到 `RestoreResponse::Applied` 中返回,Python 层(`VikingFS.restore`)再调度 `ReindexExecutor`。 关键差异:与 git checkout 不同,本接口**不移动分支指针到旧 commit**,而是把"旧内容"作为新 commit 的工作树内容,正向写入。新 commit 的 parent 是当前 HEAD,不是目标 commit,这保证了:(1) HEAD 单调前进;(2) 非 `project_dir` 路径自动保留 HEAD 的最新内容,无需特殊处理;(3) restore 本身可以被再次 restore (因为它就是一个普通 commit)。 ```rust pub async fn restore(&self, req: RestoreRequest) -> Result { let RestoreRequest { account, branch, project_dir, source_commit, dry_run, message, author_name, author_email, } = req; // 0. 校验 project_dir(非空、不含 ..、不命中 prune 规则) validate_project_dir(&project_dir)?; // 1. 解析两端 commit let source_oid = self.resolve_ref(&account, &source_commit).await?; let source = self.load_commit(&account, &source_oid).await?; let head_oid = self.resolve_ref(&account, &branch).await?; // 必须已有 HEAD let head = self.load_commit(&account, &head_oid).await?; // 2. 在两棵 tree 中分别"截取" project_dir 子树 // source 没有该子目录 → 视为空树(等价于把整个目录删掉) let source_subtree = tree_builder::subtree(...).await? .unwrap_or(empty_tree_oid()); let head_subtree = tree_builder::subtree(...).await? .unwrap_or(empty_tree_oid()); // 3. 子树之间 diff,得到三类操作(只限 project_dir 范围内) let target_entries = flatten(&self.object_store, &account, source_subtree).await?; let current_entries = flatten(&self.object_store, &account, head_subtree).await?; let diff = compute_subtree_diff(&target_entries, ¤t_entries); if dry_run { return Ok(RestoreResponse::DryRun { diff, source_oid, head_oid }); } if diff.is_empty() { return Ok(RestoreResponse::Noop { head: head_oid, source: source_oid }); } // 4. 并发回写 VFS:路径要带上 project_dir 前缀 // 走完整 viking_fs.write/rm,触发现有 lock、加密 let prefixed = |p: &str| format!("{}/{}", project_dir.trim_end_matches('/'), p); let written_paths: Vec = stream::iter(diff.to_write) .map(|(path, blob_oid)| async move { let body = self.read_blob(&account, &blob_oid).await?; self.vfs.write(&account_path(&account, &prefixed(&path)), body).await?; Ok::<_, GitError>(prefixed(&path)) }) .buffer_unordered(32) // 当前实现硬编码 32,未读取 git.tuning.restore_concurrency .try_collect().await?; let deleted_paths: Vec = stream::iter(diff.to_delete) .map(|path| { let p = prefixed(&path); async move { // 幂等删除:NotFound 视为成功,允许 restore 在已被并发删除的路径上完成 match self.vfs.rm(&account_path(&account, &p)).await { Ok(()) => Ok(p), Err(e) if is_not_found(&e) => Ok(p), Err(e) => Err(e.into()), } } }) .buffer_unordered(32) .try_collect().await?; // 6b. 删除空目录:沿被删路径的祖先链向上 rmdir,直到第一个非空目录或 project_dir 边界 // (gix tree 不存空目录,而 VFS 写到本地后会留下空 dir,造成 ls 不一致) self.prune_empty_dirs(&account, &project_dir, &deleted_paths).await?; // 5. 在 head.tree 之上做增量编辑: // 把 project_dir 子树整体替换为 source_subtree。 // 非 project_dir 路径原样保留 head 中的 tree_oid。 let mut editor = TreeEditor::from_tree(&self.object_store, &account, head.tree).await?; editor.upsert_subtree(&project_dir, source_subtree)?; let new_tree = editor.write(&self.object_store, &account).await?; // 6. 构造新 commit:parent = 当前 HEAD(不是 source_oid!) let new_commit_oid = write_commit(&self.object_store, &account, CommitObject { tree: new_tree, parents: vec![head_oid], // ← 关键:HEAD 单向前进 author: Actor::now(&author_name, &author_email), committer: Actor::now(&author_name, &author_email), message: message.unwrap_or_else(|| format!( "restore {} from {}", project_dir, &source_oid.to_hex()[..12], )), }).await?; // 7. CAS 更新 ref:expect=head_oid, new=new_commit_oid // 若期间有别的 commit 进入 → ConcurrentCommit,调用方按提示重试 self.ref_store.cas_update( &account, &format!("refs/heads/{}", branch), Some(head_oid), new_commit_oid, ).await?; Ok(RestoreResponse::Applied { new_commit_oid, source_commit: source_oid, parent_commit: head_oid, // 计数 + 受影响路径(供上层精确触发向量重建) written: written_paths.len(), deleted: deleted_paths.len(), unchanged: diff.unchanged.len(), written_paths, deleted_paths, }) } ``` > 当前实现相对早期设计的差异 > > - **空目录清理(步骤 6b)**:删除完文件后会沿祖先链 rmdir 至 `project_dir` 或第一个非空目录,避免 VFS 残留空目录。 > - **幂等删除**:`vfs.rm` 返回 NotFound 视为成功,使 restore 可以在已被并发清理的路径上继续推进。 > - **written\_paths / deleted\_paths**:`Applied` 响应除了 `written/deleted` 计数外,还返回**全量受影响路径(已加 project\_dir 前缀)**;Python 层按 marker / 源文件 / `.relations.json` 分类,精确触发 L0/L1/DETAIL 向量更新,不再依赖广义的 `_trigger_vector_rebuild(paths)`。 > - **没有 commit\_index 刷新**:对应 §8.1 的 Fast Path 1 未实现,restore 末尾也无须刷新 index。 > - **回写并发度**:当前硬编码 `buffer_unordered(32)`,**尚未**读取 `git.tuning.restore_concurrency` 配置项。 > > ✅ **推荐:** 生产环境调用前先以 `dry_run=true` 跑一遍取得差异列表,再让用户确认,避免误覆盖未提交的本地变更。 ## 8.3 show 完整实现 show 是**纯读路径**,无任何 VFS 写入或 ref 变更,易于实现与验证。支持两种模式:`path=None` 返回 commit 元信息(用于 log 列表);`path=Some(p)` 返回该 path 的 blob 字节(零拷贝 `Bytes` 切片)。 ```rust pub async fn show(&self, req: ShowRequest) -> Result { let ShowRequest { account, target_ref, path } = req; // 1. ref 解析:依次尝试 // a. 40-hex commit_oid → 直接解析 // b. 4..=39 hex 的缩写 oid → 沿 HEAD 父链回溯,找到唯一前缀匹配的 commit // (歧义返回 AmbiguousOid;无匹配返回 OidPrefixNotFound) // c. branch 名(如 "main") → 加前缀 refs/heads/{branch} // d. 全路径 refs/heads/xxx → 透传 let commit_oid = self.resolve_ref(&account, &target_ref).await?; let commit = self.load_commit(&account, &commit_oid).await?; match path { // 模式 A:返回 commit 元信息(log 用) None => Ok(ShowResponse::Commit { oid: commit_oid, tree: commit.tree, parents: commit.parents, author: commit.author.into(), committer: commit.committer.into(), message: commit.message.to_string(), }), // 模式 B:返回该 path 的 blob 字节 Some(p) => { // 按 / 拆分,在 tree 上逐层递归; // - path 命中目录 → PathIsDirectory(p) // - path 完全无对应 → PathNotFound(p) let blob_oid = tree_builder::lookup( &self.object_store, &account, commit.tree, &p, ).await?; let blob_full = self.load_blob(&account, &blob_oid).await?; // 去掉 "blob {len}\0" header,使用 Bytes::slice 零拷贝返回 payload let payload = strip_object_header(blob_full)?; Ok(ShowResponse::Blob { oid: blob_oid, size: payload.len() as u64, bytes: payload, }) } } } ``` *** # 9. Python Binding 与 VikingFS 集成 ## 9.1 PyO3 binding 新增方法 在现有 `RAGFSBindingClient`(`crates/ragfs-python/src/lib.rs`)上追加三个 `#[pymethods]`。模式与 `ls/read/write` 一致:用 `py_detach_blocking` 释放 GIL,在 Tokio runtime 内调 `GitService`,返回结果序列化为 `PyDict`。 ```rust // crates/ragfs-python/src/lib.rs (追加) #[pymethods] impl RAGFSBindingClient { /// 提交一次快照 /// kwargs: account, branch, message, paths(Option>), /// author_name, author_email /// returns: {"commit_oid": str, "result": "created" | "noop"} fn git_commit(&self, py: Python<'_>, kwargs: &PyDict) -> PyResult { let req = parse_commit_request(kwargs)?; let svc = self.git_service()?; // FeatureDisabled 时返回 PyErr py_detach_blocking(py, || { self.runtime.block_on(svc.commit(req)) .map_err(map_git_error) }).map(|r| commit_response_to_pydict(py, r)) } /// 定向恢复某个 project 目录,正向生成新 commit /// kwargs: account, branch(默认 "main"), project_dir, source_commit, /// dry_run(bool=false), message(Option), /// author_name, author_email /// returns: /// Applied: {"new_commit_oid": str, "source_commit": str, "parent_commit": str, /// "written": int, "deleted": int, "unchanged": int} /// Noop: {"noop": true, "head": str, "source": str} /// DryRun: {"dry_run": true, "diff": {...}, "head": str, "source": str} fn git_restore(&self, py: Python<'_>, kwargs: &PyDict) -> PyResult { let req = parse_restore_request(kwargs)?; let svc = self.git_service()?; py_detach_blocking(py, || self.runtime.block_on(svc.restore(req)) .map_err(map_git_error)) .map(|r| restore_response_to_pydict(py, r)) } /// 读取 ref / commit / blob /// kwargs: account, target_ref, path(Option) /// returns: /// path=None: {"oid","tree","parents","author","committer","message"} /// path=str: {"oid","size","bytes": PyBytes} fn git_show(&self, py: Python<'_>, kwargs: &PyDict) -> PyResult { let req = parse_show_request(kwargs)?; let svc = self.git_service()?; py_detach_blocking(py, || { self.runtime.block_on(svc.show(req)) .map_err(map_git_error) }).map(|r| show_response_to_pydict(py, r)) } } /// GitError → Python 异常映射(在 openviking 侧定义对应异常类) fn map_git_error(e: GitError) -> PyErr { match e { GitError::FeatureDisabled => PyRuntimeError::new_err("git feature disabled"), GitError::ConcurrentCommit => PyValueError::new_err("concurrent commit conflict"), GitError::PathNotFound(p) => PyFileNotFoundError::new_err(p), GitError::RefNotFound(r) => PyFileNotFoundError::new_err(r), other => PyRuntimeError::new_err(other.to_string()), } } ``` ## 9.2 Python 侧 VikingFS 新增方法 在 `openviking/openviking/storage/viking_fs.py` 的 `VikingFS` 类上追加 4 个公开方法。Python 调用方使用 `viking://` URI,内部经 `_uri_to_tree_path` 转换为账号内 tree 路径后再传给 binding。 ```python # openviking/storage/viking_fs.py (追加) class VikingFS: # 已有: read / write / rm / ls / mv / mkdir ... async def commit( self, *, message: str, paths: list[str] | None = None, # viking://... URIs branch: str = "main", author_name: str | None = None, author_email: str | None = None, ) -> dict: """提交一次跨 scope 快照。返回 {commit_oid, result}.""" account = self._current_account() tree_paths = [self._uri_to_tree_path(p) for p in (paths or [])] return await self._async_client.run( "git_commit", account=account, branch=branch, message=message, paths=tree_paths or None, author_name=author_name or self._default_author_name(), author_email=author_email or self._default_author_email(), ) async def get_gitignore(self, ctx: RequestContext | None = None) -> str: """读取账号级 .ovgitignore;不存在时返回空字符串。""" async def set_gitignore( self, content: str, ctx: RequestContext | None = None, ) -> None: """写入账号级 .ovgitignore,不触发语义索引。""" async def delete_gitignore(self, ctx: RequestContext | None = None) -> None: """删除账号级 .ovgitignore;不存在视为成功。""" async def restore( self, *, project_dir: str, # viking://resources/proj_a/ 或 "resources/proj_a" source_commit: str, # 40-hex / branch / tag branch: str = "main", dry_run: bool = False, message: str | None = None, author_name: str | None = None, author_email: str | None = None, ) -> dict: """将 project_dir 恢复到 source_commit 状态,生成一个新 commit。 语义等价于 git restore --source= --worktree --staged / && git commit。HEAD 单调前进,不会回退。 """ account = self._current_account() tree_dir = self._uri_to_tree_path(project_dir).rstrip("/") result = await self._async_client.run( "git_restore", account=account, branch=branch, project_dir=tree_dir, source_commit=source_commit, dry_run=dry_run, message=message, author_name=author_name or self._default_author_name(), author_email=author_email or self._default_author_email(), ) if dry_run or result.get("noop"): return result # 增量向量更新:只对受影响的源文件,逐个 vectors_only 重算。 # L0/L1 派生文件已随源文件一起从 git 回写到 VFS,不需要重新生成。 from openviking.service.reindex_executor import ReindexExecutor executor = ReindexExecutor() ctx = self._current_request_context() for affected_path in result.get("affected_files", []): affected_uri = self._tree_path_to_uri(affected_path) if self._is_derived_file(affected_uri): continue asyncio.create_task(executor.execute( uri=affected_uri, mode="vectors_only", wait=False, ctx=ctx, )) return result async def show( self, target_ref: str, *, path: str | None = None, ) -> dict | bytes: """path=None → commit 元信息;path=str → blob 字节。""" account = self._current_account() tree_path = self._uri_to_tree_path(path) if path else None resp = await self._async_client.run( "git_show", account=account, target_ref=target_ref, path=tree_path, ) if "bytes" in resp: return resp["bytes"] return resp async def log( self, *, branch: str = "main", limit: int = 20, ) -> list[dict]: """便捷封装:沿 parent 链反向遍历 commit。""" account = self._current_account() head = await self._async_client.run( "git_show", account=account, target_ref=branch, path=None, ) result, current = [head], head.get("parents", []) while current and len(result) < limit: parent_oid = current[0] commit = await self._async_client.run( "git_show", account=account, target_ref=parent_oid, path=None, ) result.append(commit) current = commit.get("parents", []) return result # --- 工具方法 --- def _uri_to_tree_path(self, uri: str) -> str: """viking://resources/a.md → 'resources/a.md' (去掉 viking:// 前缀,保留 scope 段作为 tree 一级目录)""" parsed = VikingURI.parse(uri) if parsed.scope in INTERNAL_SCOPES: raise ValueError(f"internal scope not versioned: {parsed.scope}") return f"{parsed.scope}/{parsed.relative_path}" async def _trigger_vector_rebuild( self, account: str, paths: list[str] ) -> None: """restore 后异步触发向量索引重建。 实现可对接现有的 watcher / 任务队列;失败不影响 restore 结果。""" try: await self._vector_service.rebuild(account, paths) except Exception: logger.exception("vector rebuild failed for %s", account) ``` *** # 10. 配置规范 ## 10.1 与 resources 对称的配置布局 配置位于现有 RAGFS 配置文件的 `[git]` 段,布局与 `[plugins.localfs_resources]` / `[plugins.s3fs_resources]` 完全对称,便于运维心智复用。`enabled = false` 时 binding 方法返回 `FeatureDisabled`,不影响现有 VFS。 ```toml # ragfs.toml 新增 [git] 段 [git] enabled = true backend = "local" # "local" | "s3" default_branch = "main" author_name = "openviking-bot" # commit 默认作者 author_email = "openviking-bot@system.local" # 本地后端 [git.local] base_dir = "/data/openviking/git" # objects/refs 存储根 # 远程后端(与 plugins.s3fs_resources 配置同构) [git.s3] bucket = "openviking-prod" prefix = ".ovgit" # 全部 key = {prefix}/{account}/... region = "us-east-1" endpoint = "https://s3.amazonaws.com" access_key_env = "OV_S3_AK" # 从环境变量读 secret_key_env = "OV_S3_SK" cas_mode = "native" # "native"(If-Match):当前唯一支持的模式 use_path_style = true # path-style addressing(MinIO/LocalStack/TOS 默认开) # 高级调优(字段已在 config 中定义,但 MVP 部分尚未生效) [git.tuning] upload_concurrency = 64 # ⚠️ 当前未读取:commit blob 上传现为串行 restore_concurrency = 32 # ⚠️ 当前未读取:restore 回写并发度硬编码 32 ref_cas_max_retry = 3 # ⚠️ 当前未读取:commit 内部不做重试(见 §11.3) ref_cas_backoff_ms = 50 # ⚠️ 当前未读取 commit_index_enabled = true # Fast Path 1 总开关(默认 true);关闭后强制走 slow path,适合测试 / mtime 不可靠环境 ``` ## 10.2 切换本地↔远程 | 维度 | local → s3 改动 | | ---------- | ----------------------------------------------------- | | 配置文件 | `backend = "local"` → `backend = "s3"`;填 `[git.s3]` 块 | | Service 代码 | 无 | | Python 调用方 | 无 | | 数据迁移 | 一次性脚本:本地 `{base_dir}` 全量上传至 S3 key prefix(保持目录结构) | > 💡 从本地切到远程的全部成本 = 修改 `backend = "local"` → `backend = "s3"` + 填 `[git.s3]` 块。Service 代码、Python 调用方完全无感。这与 resources 目录"`plugins.localfs_resources` ↔ `plugins.s3fs_resources`"的切换体验完全对称。 *** # 11. 并发与一致性 ## 11.1 写并发模型 | 层次 | 并发原语 | 说明 | | --------- | ------------------------ | -------------------------------- | | Blob 上传 | `buffer_unordered(64)` | 内容寻址,天然幂等;同 oid 多次 put 安全 | | Tree 写入 | 串行(`Editor::write` 自底向上) | 同 oid 幂等,但顺序必须自底向上 | | Commit 写入 | 串行,最后一步 | 同 oid 幂等 | | Ref 更新 | CAS | 本地: 进程锁 + rename(2);S3: If-Match | ## 11.2 并发冲突处理 ```mermaid flowchart LR A[Client A: commit] --> A1[Read ref = parent_a] B[Client B: commit] --> B1[Read ref = parent_b] A1 --> A2[Build tree A] B1 --> B2[Build tree B] A2 --> A3[CAS update expected=parent_a] B2 --> B3[CAS update expected=parent_b] A3 --> OK1[OK new=commit_a] B3 --> FAIL[CasConflict current=commit_a] FAIL --> RETRY{重试?} RETRY -- yes --> B1 RETRY -- no --> ERR[返回 409 给上层] ``` ## 11.3 重试策略 - **幂等部分(blob/tree/commit 写)**: 同 oid 多次 put 安全;后端层面通过 `If-None-Match: *`(S3)与 `try_exists`(local)短路重复写;service 层不额外做 retry。 - **CAS 冲突**: **当前实现** GitService::commit/restore **内部不做自动重试,直接以 GitError::ConcurrentCommit 上抛给 Python 层,由调用方决定是否 re-read parent 重建 tree 重新提交。git.tuning.ref\_cas\_max\_retry / ref\_cas\_backoff\_ms 配置项已在 GitTuningConfig 中定义但**尚未在代码中读取\*\*,后续接入。 - **跨账号**: 不同 account\_id 的 ref 路径不同,天然无冲突,可完全并行 *** # 12. 安全与隔离 ## 12.1 账号隔离 - Git 数据路径全部以 `{account_id}` 为顶层前缀,与现有 `/local/{account_id}/` 隔离模型完全一致 - `GitService` 所有方法的第一个参数都是 `account_id`,binding 层从 `RequestContext.account_id` 注入,不允许跨账号访问 - Path 解析时必须经过 `validate_account_id`(白名单字符集 + 长度),防止 `../` 注入 ## 12.2 加密 > 💡 **重要:** 现有 `viking_fs.write` 在写入前会调 `_encrypt_content`。**commit 时不应再次加密**——blob 内容 = 当前 VFS 已加密内容,Git 是对密文做版本管理。restore 写回时走 `viking_fs.write`,会再次"加密"——这里需要绕过(或保持密文不变):restore 路径走 `MountableFS.write` 而非 `viking_fs.write`,避免双重加密;或为 `viking_fs.write` 增加 `raw=True` 参数,restore 调用时传入。 ## 12.3 资源限制 | 维度 | 限制 | 措施 | 当前状态 | | ------------ | -------------------------------- | ------------------------------------ | ------------------------------------------------ | | 单 blob 大小 | ≤ 100MB | commit 前 stat 检查,超限报错 | **未实现**(`GitError::BlobTooLarge` 已定义,但无运行时检查) | | 单 commit 文件数 | ≤ 50000 | enumerate 阶段提前拒绝 | **未实现**(`GitError::TooManyFiles` 已定义,但无运行时检查) | | 账号 Git 容量 | 由 quota 系统单独管控 | 放在 `[git.quota]`,首版默认 10GB | **未实现**(配置块未引入) | | restore 并发 | 同子树串行,同一 account\_id 全量 restore 互斥 | `VikingFS.restore` 用 `LockContext` 树锁包裹 writeback:scoped restore 锁 `project_dir`,全量 restore 锁账号根;防止 VFS 写竞态 | **已实现**(写回阶段加锁;后台 reindex 在锁释放后调度,冲突映射为 `ResourceBusyError`) | | 账号 ID 校验 | 白名单字符集 + 长度,防 `../` 注入 | `validate_account_id` 在 binding 入口拒绝 | **未实现**(`GitError::InvalidAccountId` 已定义,但无校验代码) | *** # 13. 错误处理 ## 13.1 错误分类 顶层错误类型为 `GitError`(`crates/ragfs/src/git/error.rs`),按可恢复性与归属分组: | 类别 | Variant | 说明 | | --------- | ------------------------------------------------------------------------------------------- | ------------------------------------------- | | 后端透传 | `ObjectStore(ObjectStoreError)`、`RefStore(RefStoreError)`、`Vfs(...)` | 来自存储后端 / VFS | | 路径校验 | `PathNotFound(String)`、`PathIsDirectory(String)`、`InvalidProjectDir(String)` | show / restore 入参校验 | | Tree 内容缺失 | `SubtreeNotFoundInCommit { commit, path }` | restore 时 source 中缺失目标子树(已通过空树语义包装,正常路径不抛出) | | Ref 解析 | `OidPrefixNotFound(String)`、`AmbiguousOid { prefix, candidates }` | 缩写 OID 解析失败 | | 并发 | `ConcurrentCommit` | CAS 失败(由 ref\_store conflict 上抛) | | 资源限制 | `BlobTooLarge { path, size, max }`、`TooManyFiles { count, max }`、`InvalidAccountId(String)` | 已定义但**当前无运行时检查**,详见 §12.3 | | 其他 | `FeatureDisabled`、`CorruptedObject(...)`、`Other(String)` | binding 关闭、对象腐烂、兜底 | ## 13.2 Python 异常映射 | Rust Error | Python Exception | 语义 | | -------------------------------------------------------------------- | --------------------------- | ----------- | | `FeatureDisabled` | `AGFSNotSupportedError` | git 模块未启用 | | `ConcurrentCommit` | `GitConcurrentCommitError` | 需要上层重试或人工介入 | | `PathNotFound` / `OidPrefixNotFound` / 后端 NotFound | `AGFSNotFoundError` | 404 语义 | | `PathIsDirectory` / `BlobTooLarge` / `TooManyFiles` / `AmbiguousOid` | `AGFSInvalidOperationError` | 入参或资源错误 | | `InvalidAccountId` / `InvalidProjectDir` | `AGFSInvalidPathError` | 路径/账号 ID 非法 | | `CorruptedObject` / `Other` / `Vfs` | `AGFSInternalError` | 底层异常 | *** # 14. 可观测性 > **当前状态:** §14.1/§14.2 中描述的 tracing span / metrics **MVP 尚未接入**;§14.3 健康检查仅暴露 `git_enabled` / `git_backend` 两个字段。下面保留为目标态,未带状态标注的均为待实现项。 ## 14.1 Tracing/日志关键字段(目标态) - **span 名**: `git.commit`, `git.restore`, `git.show` - **tag**: `account_id`, `branch`, `parent_oid`, `commit_oid`, `backend` - **event**: `git.blob.put`(`oid`, `size`), `git.tree.write`, `git.ref.cas`(`expected`, `new`, `result`), `git.cas.conflict` ## 14.2 Metrics(目标态) | 指标 | 类型 | 维度 | | ---------------------------------- | --------- | ---------------------------- | | `git_commit_total` | counter | account\_id, branch, result | | `git_commit_duration_seconds` | histogram | backend | | `git_commit_files` | histogram | — | | `git_commit_bytes` | histogram | backend | | `git_cas_conflict_total` | counter | account\_id, branch | | `git_object_store_latency_seconds` | histogram | op (put/get/exists), backend | | `git_ref_store_latency_seconds` | histogram | op (read/cas), backend | ## 14.3 健康检查 - **当前实现**: `RAGFSBindingClient.health()` 在原有字段上追加 `git_enabled: bool` 和 `git_backend: Option`(关闭时为 None)。 - **目标态(未实现)**: 增加 `git` 子结构,返回 `{"backend", "writable", "last_commit_age_sec"}`;每分钟后台心跳对 `refs/heads/main` 做一次 read,失败则标记 degraded。 *** # 15. 测试策略 ## 15.1 测试层次 | 层级 | 范围 | | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | | **单元测试 (Rust)** | ObjectStore 各操作的幂等性;RefStore CAS 在并发下的正确性(MVP 用普通并发测,未引入 loom);tree\_builder 的 upsert/remove/write;错误映射 | | **集成测试 (Rust)** | LocalObjectStore 跑全套场景(MVP 暂未引入 MemObjectStore);commit → show 路径 → bytes 一致;commit → restore → 文件一致;并发 commit 的 CAS 冲突处理 | | **端到端 (Python)** | VikingFS.commit → restore 全流程;跨 scope 原子快照;派生文件被正确纳入 commit 并随 restore 回滚;向量索引在 restore 后被精确重建;多账号并发隔离 | ## 15.2 关键测试用例清单 1. **幂等性**: 同一 commit\_req 调用两次,第二次应快速返回(blob exists 跳过 + ref 未变 → no-op 或 same oid) 2. **跨 scope 原子性**: 一次 commit 同时改 `resources/a.md` 和 `agent/skills/b.py`,restore 父 commit 后两者都应回滚 3. **派生文件纳入**: 创建 `resources/x.md` 与 `resources/x.md.abstract.md`,commit 后 `show` 两者均可见;restore 父 commit 后两者都应回滚;向量索引文件不被 commit 4. **CAS 冲突**: 两个并发 commit,后到的必须看到 `ConcurrentCommit` 错误而非默默覆盖 5. **dry\_run 不写**: restore dry\_run 后再 ls,VFS 状态不变 6. **账号隔离**: A 账号的 commit\_oid 在 B 账号下 show 必须返回 not found 7. **后端等价性**: LocalObjectStore 与 S3ObjectStore (LocalStack/MinIO) 跑同一组用例输出一致 8. **大文件**: 单 blob 80MB 可正确 commit / show / restore 9. **双重加密**: restore 写回后 VFS read 内容与原始明文一致 *** # 16. 实施计划 (MVP) | 阶段 | 工作内容 | 交付物 | 预估 | | ----- | --------------------------------------------------------------------------------------------------------------------- | ---------------------- | ---- | | D1-D2 | 新建 `crates/ragfs/src/git/`,定义 trait + LocalObjectStore/LocalRefStore + S3ObjectStore/S3RefStore (含 If-Match CAS) + 单测 | 裸 Git 存储跑通 put/get/CAS | 2d | | D3 | 接入 `gix_object::tree::Editor`,实现 `GitService::commit` | commit 流程单测绿 | 1d | | D4 | 实现 `GitService::show` (纯读路径,易验证) | commit + show 闭环 | 1d | | D5 | 实现 `GitService::restore`,dry\_run 优先,验证幂等 | commit + restore 闭环 | 1d | | D6 | PyO3 binding: `RAGFSBindingClient` 三个新方法 + 错误映射 | Python 端可调 | 1d | | D7 | `VikingFS.commit/restore/show/log` + URI ↔ tree path 转换 | Python 端到端 | 1d | | D9 | tracing/metrics 接入 + health check | 可观测性完备 | 0.5d | | D10 | 文档 + 灰度发布 | 上线 Phase 1 | 0.5d | > 💡 **总工期**: \~10 人日 (MVP, 单人); 双后端等价测试与 S3 CAS 兼容性验证可能引入额外 2-3 天。 *** # 17. 当前实现进度与未实现项 下面汇总文档中已写出但当前**尚未实现**的部分,供后续阶段补齐: ### Rust 侧 - **commit / restore 内部 CAS 重试循环** —— 文档 §11.3 旧版描述。当前 `commit()` 明确不做 retry,`ConcurrentCommit` 直接上抛。 - *git.tuning.* 配置项接入\* —— 文档 §10.1。`upload_concurrency` / `restore_concurrency` / `ref_cas_max_retry` / `ref_cas_backoff_ms` 已在 `GitTuningConfig` 中定义并解析,但代码尚未读取(restore 回写并发度硬编码 32,commit blob 上传为串行)。 - **S3 RedisLock CAS 模式** —— 文档 §7.2。`CasMode::RedisLock` 仅作为枚举占位,实际调用返回 "not yet implemented" 错误。 - **资源限制实际生效** —— 文档 §12.3。`BlobTooLarge` / `TooManyFiles` / `[git.quota]` 配置块均未实现(仅错误 variant 已定义)。同账号 restore 写竞态防护已通过 `VikingFS.restore` 的 `LockContext` 树锁实现(见 §12.3)。 - **账号 ID 校验** —— 文档 §12.1。`validate_account_id` 未实现,`GitError::InvalidAccountId` 仅占位。 - **本地 ref 跨进程锁** —— 文档 §7.1。`LocalRefStore` 仅有进程内 `DashMap`,未叠加 `flock`。 - **观测性 (tracing / metrics)** —— 文档 §14.1 / §14.2。span / event / 各类 counter / histogram 均未接入。 - **健康检查增强** —— 文档 §14.3。当前仅 `git_enabled` / `git_backend`,未接入 `writable` / `last_commit_age_sec` / 心跳。 - **GC / pack file / branch & tag 管理 / diff API** —— 文档 §19。属后续 Phase。 - **loom 并发模型测试 + proptest fuzz** —— 文档 §5.2。MVP 未引入。 - **账号级 `.ovgitignore`** —— 已实现。支持账号根 `.ovgitignore` glob 子集规则,规则仅在 `commit` 时生效;`.ovgitignore` 自身进入版本管理,不进入向量索引;`restore` 不应用当前 ignore 过滤。 ### Python 侧 - **VikingFS.commit / restore / show / log 已实现**(`openviking/storage/viking_fs.py`),`_uri_to_tree_path` / `_tree_path_to_uri` / `_classify_restore_path` / `_schedule_vector_rebuild` / `_run_vector_rebuild` 均已实现,精确按 marker / source-file / `.relations.json` 调度 `ReindexExecutor`。 - **VikingFS.\_trigger\_vector\_rebuild(account, paths)(早期设计)** 已被更精确的 `_schedule_vector_rebuild(written, deleted)` 替代,**不会**再实现旧 API。 *** # 18. 风险与缓解 | 风险 | 影响 | 缓解 | | -------------------------------------- | -- | ----------------------------------------------------------------------------------- | | S3/TOS CAS 兼容性差异 | 高 | POC 阶段验证目标后端的 If-Match 条件写支持;不支持时该后端不可用于 git ref 存储 | | 大账号 commit 时 enumerate 慢 | 中 | `paths` 参数限定 scope;后续引入增量 diff(基于 mtime + parent tree) | | 双重加密导致 restore 后内容损坏 | 高 | restore 路径绕过 `viking_fs.write` 加密,直接走 `MountableFS`;集成测试覆盖 | | L0/L1 派生文件纳入版本历史,模型异步重建导致 commit 间差异增加 | 中 | 用户主动控制 commit 时机,不自动触发;L0/L1 文件通常较小(< 10KB),存储成本可控;如需降频可配置 commit 时忽略 mtime-only 变更 | | 同一账号多 Agent 高并发 commit | 中 | CAS 冲突自动重试 3 次;长期可引入"基于队列的串行化提交器" | | Git 数据无 GC,长期膨胀 | 中 | 首版不做 GC,运维侧定期 dump + 压缩;后续接入 reachability-based GC | | loose object 数量爆炸,本地 inode 紧张 | 低 | Phase 4 引入 pack file;Git fanout 已经缓解一半 | *** # 19. 后续演进方向 1. **Pack file 支持**: 引入 `gix-pack`,对历史 commit 做 delta 压缩,降低存储成本 80%+ 2. **Auto-commit hook**: 在 `content_write.ContentWriteCoordinator` 末尾追加可选 hook,实现"每次写自动 commit"模式(Phase 2 重新评估) 3. **Branch / Tag 管理**: 暴露 `branch_create / branch_delete / tag` API 4. **Diff API**: `diff(ref_a, ref_b)` 返回结构化差异,供 UI 渲染 5. **跨账号镜像**: 支持账号间的 commit 分享(类似 GitHub fork) 6. **向量索引版本化(可选)**: 若后续需要向量索引的快照回滚能力,可引入轻量 manifest 记录 index 版本与对应 commit\_oid 的映射,避免全量存储向量数据 7. **外部 Git 工具兼容**: 输出标准 Git 仓库格式,允许通过 `git clone file://...` 检视 *** # 20. 附录 ## 20.1 术语表 | 术语 | 含义 | | ------------ | ------------------------------------------------------------------------------------------------ | | VFS | Virtual File System,本文特指 OpenViking 的 `MountableFS` + plugin 体系 | | Loose Object | Git 的基础存储单元,zlib 压缩,按 SHA 寻址的单文件 | | CAS | Compare-And-Swap,本文特指 ref 更新时"仅当当前值 = 期望值才写入" | | Root Tree | commit 对象指向的最顶层 tree 对象,代表整个仓库快照 | | Tree Editor | `gix_object::tree::Editor`,gitoxide 提供的内存中 tree 构建器,支持 upsert/remove/write | | 派生文件 | `.abstract.md` / `.overview.md` / `.relations.json`,由 OpenViking 模型异步生成的 L0/L1 摘要文件,已纳入 Git 版本管理 | ## 20.2 参考资料 - [GitoxideLabs/gitoxide](https://github.com/GitoxideLabs/gitoxide) - [volcengine/OpenViking](https://github.com/volcengine/OpenViking) - [OpenViking 存储架构文档](../zh/concepts/05-storage.md) - [Git Pack Format (后续 Phase 参考)](https://git-scm.com/docs/gitformat-pack) > 💡 **文档完成**。如需对某一章节细化(如某后端实现细节、某测试用例代码、迁移脚本),请告知具体目标。