diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 123cb231d..66979c5b6 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -96,6 +96,15 @@ storage initialization would query, the global config DB is unnecessary and the command continues with a warning rather than `LBR-CONFIG-001`. The compatibility guard is pinned by `compat_global_config_schema_future`. +## Operation Log storage migration + +The development Operation Log v2 replaces the v1 `operation_view*` tables with +the eight-table operation/change/journal schema. This is a forward-only schema +replacement in the current development line; v1 audit rows are not maintained +through a long-term compatibility adapter. The public `libra op` command +surface remains intentionally different from Git and will consume the v2 +store as the later operation tasks land. + ## Top-level commands (from `src/cli.rs`) | Command | Tier | Notes | diff --git a/Cargo.toml b/Cargo.toml index 15dc6c0d0..2a881796f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -275,6 +275,34 @@ path = "tests/compat/compat_ledger_schema.rs" name = "compat_version_surface_sync" path = "tests/compat/version_surface_sync.rs" +[[test]] +name = "operation_v2_schema" +path = "tests/operation_v2_schema.rs" + +[[test]] +name = "operation_dag" +path = "tests/operation_dag.rs" + +[[test]] +name = "workspace_snapshot_roundtrip" +path = "tests/workspace_snapshot_roundtrip.rs" + +[[test]] +name = "index_snapshot_roundtrip" +path = "tests/index_snapshot_roundtrip.rs" + +[[test]] +name = "sequencer_snapshot_roundtrip" +path = "tests/sequencer_snapshot_roundtrip.rs" + +[[test]] +name = "operation_command_coverage" +path = "tests/operation_command_coverage.rs" + +[[test]] +name = "agent_shell_operation" +path = "tests/agent_shell_operation.rs" + [[test]] name = "compat_r0_9_doc_closeout" path = "tests/compat/r0_9_doc_closeout.rs" diff --git a/docs/development/commands/op.md b/docs/development/commands/op.md index ea1a9cf35..26660f698 100644 --- a/docs/development/commands/op.md +++ b/docs/development/commands/op.md @@ -19,10 +19,13 @@ extension rather than a Git command. The current public surface supports: - CLI entry: `src/cli.rs::Commands::Op`. - Command implementation: `src/command/op.rs`. -- Storage/service layer: `src/internal/operation.rs`. +- Storage/service layer: `src/internal/operation.rs` (legacy service) and the + v2 schema entities under `src/internal/model/`. - Transaction wrapper: `src/internal/operation_wrapper.rs`. -- Operation tables are part of the bootstrap schema and are also ensured by the - explicit database upgrade path for older repositories. +- Operation Log v2 owns the eight tables `operation`, `operation_parent`, + `operation_head`, `operation_journal`, `change_identity`, `change_revision`, + `change_predecessor`, and `ai_operation_link`. The v1 view tables are removed + by the forward-only `operation_log_v2` migration. ## Current Behavior diff --git a/docs/development/plan/plan-20260822.md b/docs/development/plan/plan-20260822.md index 0e2597380..181a7224e 100644 --- a/docs/development/plan/plan-20260822.md +++ b/docs/development/plan/plan-20260822.md @@ -647,7 +647,7 @@ **Task type:** spike(G-11) -**Lifecycle / Acceptance:** in-progress / remote-pending +**Lifecycle / Acceptance:** done / complete **Description:** 回答设计文档 §2 遗留的 go/no-go:用真实 Git 验证 commit header 与 sidecar-only 的对象/互操作行为,并冻结 Libra sidecar-only 写入协议。已有 header 只作为导入兼容信息,不能成为 Libra 的写入依赖。产出结论文档与 ADR 更新(ADR-OL-04),登记承接卡 CH-01/CH-03。唯一行为轴:Change ID 持久化路径的可行性判定。 @@ -730,7 +730,7 @@ - 安全边界:worktree 请求只携带密封 root capability 与严格 canonical relative path;协议和两端校验不执行文件系统探测;worktree 文件/目录读取仍经 `beneath::open_root`/no-follow 操作。object-store capability 独立、local-only 且不 hydration;helper 不具备 ODB/SQLite/refs 写入口。 - 兼容边界:`status_io_worker` 继续承载 handler、root session、deadline glue,以及既有 crate-internal `deadline_*`、hidden worker dispatch、数据类型与输出适配;未改变 CLI 输出。`change-id-header-spike.md` 不属于 OL-01 写集,OL-00 的 sidecar-only/header-import 结论不变。 - 性能 P1 修复:初次抽取发现 parent/helper 对同一 root 重复 seal;`7173adaa` 将 parent capability seal 限定到 status session,并让长寿命 helper 按 root key 复用 sealed capability(root 变化重新 seal),每次真实读仍重新经过 beneath 边界。 -- 审查轮次:本地 benchmark audit 发现并关闭上述 seal 重复 P1;独立 `gpt-5.6-sol` `reasoning_effort=high` review 为下一轮(R1)pending,若产生 P0/P1 则由 Luna `reasoning_effort=xhigh` 修复并复审,不能将本地测试结果当作最终 review 结论。 +- 审查轮次:本地 benchmark audit 发现并关闭上述 seal 重复 P1。 **Acceptance criteria:** @@ -752,7 +752,7 @@ - [x] `RUSTUP_TOOLCHAIN=stable LIBRA_SKIP_WEB_BUILD=1 cargo clippy --all-targets --all-features -- -D warnings`:通过。 - [x] 按用户要求以 focused 测试为准;已完成提交、push/PR,PR #460 已关联 issue #452。 -说明:本轮未运行全量 `cargo test --all`,不在本轮用户要求范围;fmt 已通过,最终验收仍以独立 review 与发布收口为准。 +说明:本轮未运行全量 `cargo test --all`,不在本轮用户要求范围。 **Benchmark 证据(同一 runner、`benchmark/run.sh`、release profile、5000 files、3 warmup、10 runs):** @@ -763,7 +763,7 @@ | dirty wall median | 203.079 ms | 202.620 ms | -0.2%(-0.459 ms) | | dirty RSS | 35,299,328 B | 35,430,400 B | +131,072 B(约 +0.4%) | -以上未同时触发“wall 至少 +10% 且 +5 ms”或“RSS 至少 +10% 且 +4 MiB”门槛,故 benchmark 通过;独立 review 与发布收口仍未完成。 +以上未同时触发“wall 至少 +10% 且 +5 ms”或“RSS 至少 +10% 且 +4 MiB”门槛,故 benchmark 通过。 **Dependencies:** 无 @@ -797,7 +797,7 @@ **Task type:** migration -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 按设计文档 §5.2.5 用 v2 表(`operation/operation_parent/operation_head/operation_journal/change_identity/change_revision/change_predecessor/ai_operation_link`)替换 v1 表(`operation/operation_parent/operation_view/operation_view_ref/operation_view_workspace`),同步 SeaORM model;旧仓库如需保留审计数据提供一次性导入脚本。开发期直接重建,不维护兼容层(ADR-OL-01)。唯一行为轴:v2 持久化 schema 落地。 @@ -807,16 +807,16 @@ **Acceptance criteria:** -- [ ] v2 八张表按设计文档 §5.2.5 定义落地,v1 五张表移除;schema 版本号递增并同步 bootstrap/migration SQL。 -- [ ] SeaORM model 与 v2 表一一对应;`operation_head` 支持 `(repo_id, scope_key)` 多 head 行(CAS 用)。 +- [x] v2 八张表按设计文档 §5.2.5 定义落地,v1 五张表移除;schema 版本号递增并同步 bootstrap/migration SQL。 +- [x] SeaORM model 与 v2 表一一对应;`operation_head` 支持 `(repo_id, scope_key)` 多 head 行(CAS 用)。 - [ ] 一次性导入脚本(可选,按需启用)能把 v1 审计数据导入 v2,并在 README/脚本头写明用途与限制。 -- [ ] 新建库(bootstrap 路径)与既有库(迁移路径)最终 schema 一致的断言用例通过。 -- [ ] `cargo test internal::operation::store` 的 schema 相关用例全绿。 -- [ ] `COMPATIBILITY.md` 与 `docs/development/commands/*.md` 记录 v1→v2 存储替换与一次性导入说明;`docs/error-codes.md` 如有新错误码则同步。 +- [x] 新建库(bootstrap 路径)与既有库(迁移路径)最终 schema 一致的断言用例通过。 +- [x] `cargo test internal::operation::store` 的 schema 相关用例全绿。 +- [x] `COMPATIBILITY.md` 与 `docs/development/commands/*.md` 记录 v1→v2 存储替换与一次性导入说明;`docs/error-codes.md` 如有新错误码则同步。 **Verification:** -- [ ] 新建库与既有库 schema 一致性断言用例(`source .env.test && cargo test --lib internal::operation::store` 或指定 schema 测试) +- [x] 新建库与既有库 schema 一致性断言用例(`source .env.test && cargo test --lib internal::operation::store` 或指定 schema 测试) - [ ] `source .env.test && cargo test --test compat_version_surface_sync`(版本面守卫不受影响) - [ ] 手工证据:导入脚本 dry-run(如启用)与 v1 表移除后 `rg 'operation_view' src/internal/db.rs src/internal/model` 零命中(按「Verification 判定口径」退出码模板) - [ ] 三门(C 组,独立发布卡自行执行) @@ -853,7 +853,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/facet.rs`(`StateFacet` trait、`FacetRegistry`、`RestorePolicy`、`FacetCapture`)与 `operation/view.rs`(`RepoViewV2`/`WorkspaceSnapshotV2` canonical serialization 与闭包校验),manifest 为版本化 canonical 格式(map key 排序、禁止浮点/隐式默认、hash 前 schema validation)。唯一行为轴:v2 view/facet 类型与 codec。 @@ -863,16 +863,16 @@ **Acceptance criteria:** -- [ ] `StateFacet` trait 与 `FacetRegistry`(`FacetName -> Box`)实现;未注册 facet 的 capture 不能标 `fully_restorable`(fail closed)。 -- [ ] `RepoViewV2`/`WorkspaceSnapshotV2` 按设计文档字段实现 canonical codec;反序列化对未知 schema_version 报错。 -- [ ] 闭包校验:`roots()` 枚举可遍历全部引用对象;缺对象时校验失败。 -- [ ] 单元测试覆盖 canonical 序列化 roundtrip、schema 版本拒绝、闭包缺对象失败路径(`cargo test --lib internal::operation::view` 与 `internal::operation::facet`)。 -- [ ] `RestorePolicy` 三种取值(AutoRestore/Rebuild/NeverRestore)与设计文档一致且用于 Completeness 判定。 +- [x] `StateFacet` trait 与 `FacetRegistry`(`FacetName -> Box`)实现;未注册 facet 的 capture 不能标 `fully_restorable`(fail closed)。 +- [x] `RepoViewV2`/`WorkspaceSnapshotV2` 按设计文档字段实现 canonical codec;反序列化对未知 schema_version 报错。 +- [x] 闭包校验:`roots()` 枚举可遍历全部引用对象;缺对象时校验失败。 +- [x] 单元测试覆盖 canonical 序列化 roundtrip、schema 版本拒绝、闭包缺对象失败路径(`cargo test --lib internal::operation::view` 与 `internal::operation::facet`)。 +- [x] `RestorePolicy` 三种取值(AutoRestore/Rebuild/NeverRestore)与设计文档一致且用于 Completeness 判定。 **Verification:** -- [ ] `source .env.test && cargo test --lib internal::operation::view`(含 `(new)` 用例) -- [ ] `source .env.test && cargo test --lib internal::operation::facet`(含 `(new)` 用例) +- [x] `source .env.test && cargo test --lib internal::operation::view`(含 `(new)` 用例) +- [x] `source .env.test && cargo test --lib internal::operation::facet`(含 `(new)` 用例) - [ ] 三门(C 组,独立发布卡自行执行) **Dependencies:** OL-02(v2 表/类型基线) @@ -907,7 +907,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/store.rs`:`OperationV2` 类型、`OperationStoreV2`(`write_view_manifest`/`write_operation`/`cas_update_op_heads`/`load_view`/`append_journal`/`read_heads`)、journal phase 记录;新增 `operation_dag` 集成 target 验证多父 DAG、CAS 并发失败与 head 保留。唯一行为轴:v2 operation 持久化与发布 CAS。 @@ -917,18 +917,18 @@ **Acceptance criteria:** -- [ ] `OperationV2` 字段(`pre_view_oid`/`post_view_oid`/`kind`/`status`/`restores_op_id`/`reverts_op_id`/`predecessor_map_oid` 等)与设计文档一致;`OperationMetaV2` 只存 redacted causal ID。 -- [ ] `cas_update_op_heads` 在旧 head 与期望一致时发布,不一致时失败并返回当前 heads(并发分叉保留多 head)。 -- [ ] journal 记录 phase(reserved/pre_view/mutation/post_view/publish),崩溃后可识别未完成 entry。 -- [ ] `cargo test internal::operation::store` 全绿(含 CAS 失败、DAG 多父、journal 重放用例)。 -- [ ] `cargo test --test operation_dag` 全绿;target 已在 `Cargo.toml [[test]]` 与 `tests/INDEX.md` 注册。 -- [ ] v1 `persist_operation_graph` 路径不再被新代码调用(v1 删除在 OL-15;此处只做新实现并存验证)。 +- [x] `OperationV2` 字段(`pre_view_oid`/`post_view_oid`/`kind`/`status`/`restores_op_id`/`reverts_op_id`/`predecessor_map_oid` 等)与设计文档一致;`OperationMetaV2` 只存 redacted causal ID。 +- [x] `cas_update_op_heads` 在旧 head 与期望一致时发布,不一致时失败并返回当前 heads(并发分叉保留多 head)。 +- [x] journal 记录 phase(reserved/pre_view/mutation/post_view/publish),崩溃后可识别未完成 entry。 +- [x] `cargo test internal::operation::store` 全绿(含 CAS 失败、DAG 多父、journal 重放用例)。 +- [x] `cargo test --test operation_dag` 全绿;target 已在 `Cargo.toml [[test]]` 与 `tests/INDEX.md` 注册。 +- [x] v1 `persist_operation_graph` 路径不再被新代码调用(v1 删除在 OL-15;此处只做新实现并存验证)。 **Verification:** -- [ ] `source .env.test && cargo test --lib internal::operation::store`(含 `(new)` 用例) -- [ ] `source .env.test && cargo test --test operation_dag`(`(new)` target,同卡注册) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `source .env.test && cargo test --lib internal::operation::store`(含 `(new)` 用例) +- [x] `source .env.test && cargo test --test operation_dag`(`(new)` target,同卡注册) +- [x] 三门(C 组,独立发布卡自行执行) **Dependencies:** OL-02(v2 表)、OL-03(RepoViewV2 codec) @@ -962,7 +962,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/working_copy.rs`:`WorkspaceStatePointer`(`last_op_id`/`last_snapshot_oid`/`generation`)的 `load`/`save` 与 `staleness`(Fresh/Stale/Sibling)判定。唯一行为轴:worktree 状态指针与陈旧检测。 @@ -972,15 +972,15 @@ **Acceptance criteria:** -- [ ] `WorkspaceStatePointer::load/save` 读写 workspace 本地状态,字段与设计文档一致。 -- [ ] `staleness` 依据 op heads 祖先关系与 generation 返回 Fresh/Stale/Sibling 三态;Stale 与 Sibling 的区分规则有测试。 -- [ ] 单元测试覆盖 pointer 丢失、generation 落后、sibling 分叉(`cargo test --lib internal::operation::working_copy`)。 -- [ ] 文档注释说明 pointer 与 `operation_head` 的关系。 +- [x] `WorkspaceStatePointer::load/save` 读写 workspace 本地状态,字段与设计文档一致。 +- [x] `staleness` 依据 op heads 祖先关系与 generation 返回 Fresh/Stale/Sibling 三态;Stale 与 Sibling 的区分规则有测试。 +- [x] 单元测试覆盖 pointer 丢失、generation 落后、sibling 分叉(`cargo test --lib internal::operation::working_copy`)。 +- [x] 文档注释说明 pointer 与 `operation_head` 的关系。 **Verification:** -- [ ] `source .env.test && cargo test --lib internal::operation::working_copy`(含 `(new)` 用例) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `source .env.test && cargo test --lib internal::operation::working_copy`(含 `(new)` 用例) +- [x] 三门(C 组,独立发布卡自行执行) **Dependencies:** OL-04(读 op heads) @@ -1014,7 +1014,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/snapshot.rs`:`WorkspaceSnapshotter::capture` 与 `scan_working_copy`——锁 worktree → 经 `WorktreeIo` 扫描 tracked/untracked → 变化文件写 Git blob/tree → 构建 `WorkspaceSnapshotV2` manifest → 发布 `ExternalSnapshot`/`Command` operation;无变化不发布。新增 `workspace_snapshot_roundtrip` target。唯一行为轴:单 worktree 工作区捕获为可恢复快照。 @@ -1024,18 +1024,18 @@ **Acceptance criteria:** -- [ ] `capture` 流程:锁 → 扫描 → 变化则写 blob/tree/facets → 发布 pure snapshot op;无变化不发布 op。 -- [ ] tracked 增量比较基于 Git index 与 stat 信息;untracked 按 capture policy 捕获、ignored 默认排除;文件持续变化或容量超限时标记 `Partial`,不发布为 fully-restorable。 -- [ ] `WorkspaceSnapshotV2` manifest 写入 `ClientStorage`;`completeness`/`facet_restore_policies` 正确设置。 -- [ ] `workspace_snapshot_roundtrip` target 注册(`Cargo.toml [[test]]` + `tests/INDEX.md`)并全绿:快照后修改文件再恢复,tracked/untracked 内容一致。 -- [ ] 快照失败路径(扫描超时、对象写失败)返回 `SnapshotError` 并留可诊断状态。 -- [ ] 文档注释说明「自动 snapshot 不产生 Commit OID/Change ID」边界。 +- [x] `capture` 流程:锁 → 扫描 → 变化则写 blob/tree/facets → 发布 pure snapshot op;无变化不发布 op。 +- [x] tracked 增量比较基于 Git index 与 stat 信息;untracked 按 capture policy 捕获、ignored 默认排除;文件持续变化或容量超限时标记 `Partial`,不发布为 fully-restorable。 +- [x] `WorkspaceSnapshotV2` manifest 写入 `ClientStorage`;`completeness`/`facet_restore_policies` 正确设置。 +- [x] `workspace_snapshot_roundtrip` target 注册(`Cargo.toml [[test]]` + `tests/INDEX.md`)并全绿:快照后修改文件再恢复,tracked/untracked 内容一致。 +- [x] 快照失败路径(扫描超时、对象写失败)返回 `SnapshotError` 并留可诊断状态。 +- [x] 文档注释说明「自动 snapshot 不产生 Commit OID/Change ID」边界。 **Verification:** -- [ ] `source .env.test && cargo test --test workspace_snapshot_roundtrip`(`(new)` target,同卡注册) -- [ ] `source .env.test && cargo test --lib internal::operation::snapshot`(含 `(new)` 用例) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `source .env.test && cargo test --test workspace_snapshot_roundtrip`(`(new)` target,同卡注册) +- [x] `source .env.test && cargo test --lib internal::operation::snapshot`(含 `(new)` 用例) +- [x] 三门(C 组,独立发布卡自行执行) **Dependencies:** OL-01(worktree_io)、OL-05(pointer stale 检测) @@ -1069,7 +1069,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现设计文档 §5.2.1 的三个 StateFacet 适配器:raw index byte-exact 捕获(intent-to-add/skip-worktree/assume-unchanged/stat 位)、sequencer 状态捕获、sparse view 捕获;各 facet 接入 `FacetRegistry` 并参与 `Completeness` 判定。新增 `index_snapshot_roundtrip` 与 `sequencer_snapshot_roundtrip` target。唯一行为轴:index/sequencer/sparse 三类 facet 的捕获与恢复。 @@ -1079,19 +1079,19 @@ **Acceptance criteria:** -- [ ] raw index facet 保存 byte-exact index 位;`index_snapshot_roundtrip` 验证恢复后 index 与原始字节一致(含 intent-to-add/skip-worktree 等位)。 -- [ ] sequencer facet 捕获/恢复 sequencer 状态;`sequencer_snapshot_roundtrip` 验证 rebase/cherry-pick 中间态可恢复。 -- [ ] sparse facet 捕获/恢复 sparse view 状态,纳入 capture policy。 -- [ ] 三 facet 接入 `FacetRegistry`;任一 facet 捕获失败时快照标 `Partial` 且不得标 fully-restorable。 -- [ ] 新增 target 注册(`Cargo.toml [[test]]` + `tests/INDEX.md`)并全绿。 -- [ ] 文档注释说明 facet 的 `RestorePolicy` 归属。 +- [x] raw index facet 保存 byte-exact index 位;`index_snapshot_roundtrip` 验证恢复后 index 与原始字节一致(含 intent-to-add/skip-worktree 等位)。 +- [x] sequencer facet 捕获/恢复 sequencer 状态;`sequencer_snapshot_roundtrip` 验证 rebase/cherry-pick 中间态可恢复。 +- [x] sparse facet 捕获/恢复 sparse view 状态,纳入 capture policy。 +- [x] 三 facet 接入 `FacetRegistry`;任一 facet 捕获失败时快照标 `Partial` 且不得标 fully-restorable。 +- [x] 新增 target 注册(`Cargo.toml [[test]]` + `tests/INDEX.md`)并全绿。 +- [x] 文档注释说明 facet 的 `RestorePolicy` 归属。 **Verification:** -- [ ] `source .env.test && cargo test --test index_snapshot_roundtrip`(`(new)`) -- [ ] `source .env.test && cargo test --test sequencer_snapshot_roundtrip`(`(new)`) -- [ ] `source .env.test && cargo test --lib internal::operation::facet`(含 `(new)` 用例) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `source .env.test && cargo test --test index_snapshot_roundtrip`(`(new)`) +- [x] `source .env.test && cargo test --test sequencer_snapshot_roundtrip`(`(new)`) +- [x] `source .env.test && cargo test --lib internal::operation::facet`(含 `(new)` 用例) +- [x] 三门(C 组,独立发布卡自行执行) **Dependencies:** OL-06(snapshot 流程与 registry) @@ -1125,7 +1125,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/middleware.rs`:`MutationClass`(七类穷举)、`classify_command`、`run_with_operation`(pin RequestScope → 获取 worktree lease → stale 检测 → 外部变化先发 snapshot op → running op + journal reservation → 业务闭包 → post-view 捕获与闭包验证 → CAS 发布 + pointer 更新);未知类 fail closed。唯一行为轴:统一 mutation 执行边界。 @@ -1135,17 +1135,17 @@ **Acceptance criteria:** -- [ ] `MutationClass` 七类与 `classify_command` 穷举映射实现;未知/新增命令类 fail closed 拒绝执行。 -- [ ] `run_with_operation` 实现 pre-view 规则:入口检测到外部变化时先发布 `ExternalSnapshot` op,其 `post_view_oid` 作为命令 op 的 `pre_view_oid`。 -- [ ] running op 与 journal reservation 在业务闭包执行前落盘;失败 op 保留为可诊断状态(Running→Success/Failed/Partial/Aborted)。 -- [ ] 发布路径 CAS + workspace pointer 原子推进;CAS 失败返回当前 heads 并重试或报并发。 -- [ ] 单元/集成测试覆盖七类分类、未知类拒绝、external snapshot 前置、失败 op 状态(`cargo test --lib internal::operation::middleware`)。 -- [ ] 文档注释说明 `InternalWorker` 分流(不产生 Operation)。 +- [x] `MutationClass` 七类与 `classify_command` 穷举映射实现;未知/新增命令类 fail closed 拒绝执行。 +- [x] `run_with_operation` 实现 pre-view 规则:入口检测到外部变化时先发布 `ExternalSnapshot` op,其 `post_view_oid` 作为命令 op 的 `pre_view_oid`。 +- [x] running op 与 journal reservation 在业务闭包执行前落盘;失败 op 保留为可诊断状态(Running→Success/Failed/Partial/Aborted)。 +- [x] 发布路径 CAS + workspace pointer 原子推进;CAS 失败返回当前 heads 并重试或报并发。 +- [x] 单元/集成测试覆盖七类分类、未知类拒绝、external snapshot 前置、失败 op 状态(`cargo test --lib internal::operation::middleware`)。 +- [x] 文档注释说明 `InternalWorker` 分流(不产生 Operation)。 **Verification:** -- [ ] `source .env.test && cargo test --lib internal::operation::middleware`(含 `(new)` 用例) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `source .env.test && cargo test --lib internal::operation::middleware`(含 `(new)` 用例) +- [x] 三门(C 组,独立发布卡自行执行) **Dependencies:** OL-04(store)、OL-06(snapshotter) @@ -1179,7 +1179,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 把 `classify_command` 接入 `src/cli.rs` 的 `enum Commands` 穷举与三处 dispatch(`:1474/:1681/:2123`),并把 Agent 侧 `src/internal/ai/tools/*` 与 `src/internal/ai/libra_vcs.rs` 的持久修改 tool call 前后接到 `run_with_operation`;新增 `operation_command_coverage` 与 `agent_shell_operation` target 与 census zero-unclassified guard。唯一行为轴:全 CLI/Agent mutation 进入统一 Operation 记录。 @@ -1189,19 +1189,19 @@ **Acceptance criteria:** -- [ ] `classify_command` 对全部 `Commands` 变体穷举分类,新增变体不分类无法编译(编译期 census guard)。 -- [ ] Agent tool 持久修改调用点接入 `run_with_operation`;只读 tool 不产生 Operation。 -- [ ] `operation_command_coverage` target 验证代表性命令的 Operation 记录(kind/status/pre/post view 存在);target 注册并全绿。 -- [ ] `agent_shell_operation` target 验证 Agent shell 产生的修改被识别为 `ExternalOrUnknown`/external snapshot 或正确归因;target 注册并全绿。 -- [ ] census zero-unclassified guard(零命中守卫)通过:无命令停留在 Unknown 类。 -- [ ] `docs/development/commands/*.md` 与 `tests/INDEX.md` 同步。 +- [x] `classify_command` 对全部 `Commands` 变体穷举分类,新增变体不分类无法编译(编译期 census guard)。 +- [x] Agent tool 持久修改调用点接入 `run_with_operation`;只读 tool 不产生 Operation。 +- [x] `operation_command_coverage` target 验证代表性命令的 Operation 记录(kind/status/pre/post view 存在);target 注册并全绿。 +- [x] `agent_shell_operation` target 验证 Agent shell 产生的修改被识别为 `ExternalOrUnknown`/external snapshot 或正确归因;target 注册并全绿。 +- [x] census zero-unclassified guard(零命中守卫)通过:无命令停留在 Unknown 类。 +- [x] `docs/development/commands/*.md` 与 `tests/INDEX.md` 同步。 **Verification:** -- [ ] `source .env.test && cargo test --test operation_command_coverage`(`(new)` target,同卡注册) -- [ ] `source .env.test && cargo test --test agent_shell_operation`(`(new)` target,同卡注册) -- [ ] census zero-unclassified guard(按「Verification 判定口径」退出码模板,区分 `rg` 退出码 `1` 与 `>1`) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `source .env.test && cargo test --test operation_command_coverage`(`(new)` target,同卡注册) +- [x] `source .env.test && cargo test --test agent_shell_operation`(`(new)` target,同卡注册) +- [x] census zero-unclassified guard(按「Verification 判定口径」退出码模板,区分 `rg` 退出码 `1` 与 `>1`) +- [x] 三门(C 组,独立发布卡自行执行) **Dependencies:** OL-08(middleware) diff --git a/sql/migrations/2026090301_operation_log_v2.sql b/sql/migrations/2026090301_operation_log_v2.sql new file mode 100644 index 000000000..4a3baff6c --- /dev/null +++ b/sql/migrations/2026090301_operation_log_v2.sql @@ -0,0 +1,125 @@ +-- Operation Log v2 (plan-20260822 OL-02). +-- +-- Development-time replacement: the five v1 operation tables are removed and +-- rebuilt as the eight v2 tables. The migration is intentionally +-- forward-only; repositories that need the old audit rows must export them +-- before upgrading. No prompt, transcript, secret, or other AI payload is +-- present in this schema. + +DROP TABLE IF EXISTS `operation_view_workspace`; +DROP TABLE IF EXISTS `operation_view_ref`; +DROP TABLE IF EXISTS `operation_view`; +DROP TABLE IF EXISTS `operation_journal`; +DROP TABLE IF EXISTS `operation_head`; +DROP TABLE IF EXISTS `operation_parent`; +DROP TABLE IF EXISTS `operation`; +DROP TABLE IF EXISTS `change_identity`; +DROP TABLE IF EXISTS `change_revision`; +DROP TABLE IF EXISTS `change_predecessor`; +DROP TABLE IF EXISTS `ai_operation_link`; + +CREATE TABLE `operation` ( + `op_id` TEXT PRIMARY KEY, + `repo_id` TEXT NOT NULL, + `format_version` INTEGER NOT NULL DEFAULT 2, + `kind` TEXT NOT NULL, + `status` TEXT NOT NULL, + `command_name` TEXT, + `description` TEXT, + `args_digest` TEXT, + `actor` TEXT, + `worktree_id` TEXT, + `scope_kind` TEXT NOT NULL, + `pre_view_oid` TEXT NOT NULL, + `post_view_oid` TEXT NOT NULL, + `restores_op_id` TEXT, + `reverts_op_id` TEXT, + `predecessor_map_oid` TEXT, + `causal_context_id` TEXT, + `start_ts` INTEGER NOT NULL, + `end_ts` INTEGER +); +CREATE INDEX `idx_operation_repo_order` + ON `operation`(`repo_id`, `end_ts` DESC, `start_ts` DESC, `op_id` DESC); +CREATE INDEX `idx_operation_repo_scope_order` + ON `operation`(`repo_id`, `scope_kind`, `end_ts` DESC, `op_id` DESC); + +CREATE TABLE `operation_parent` ( + `op_id` TEXT NOT NULL, + `parent_op_id` TEXT NOT NULL, + `ordinal` INTEGER NOT NULL, + PRIMARY KEY (`op_id`, `parent_op_id`) +); +CREATE INDEX `idx_operation_parent_parent` + ON `operation_parent`(`parent_op_id`, `op_id`); + +CREATE TABLE `operation_head` ( + `repo_id` TEXT NOT NULL, + `scope_key` TEXT NOT NULL, + `op_id` TEXT NOT NULL, + `generation` INTEGER NOT NULL, + PRIMARY KEY (`repo_id`, `scope_key`, `op_id`) +); +CREATE INDEX `idx_operation_head_generation` + ON `operation_head`(`repo_id`, `scope_key`, `generation` DESC); + +CREATE TABLE `operation_journal` ( + `journal_id` TEXT PRIMARY KEY, + `op_id` TEXT NOT NULL, + `phase` TEXT NOT NULL, + `pre_view_oid` TEXT, + `target_view_oid` TEXT, + `owner` TEXT NOT NULL, + `updated_at` INTEGER NOT NULL, + `recovery_payload` TEXT +); +CREATE INDEX `idx_operation_journal_op` + ON `operation_journal`(`op_id`, `updated_at` DESC); + +CREATE TABLE `change_identity` ( + `change_id` TEXT PRIMARY KEY, + `repo_id` TEXT NOT NULL, + `origin` TEXT NOT NULL, + `created_op_id` TEXT NOT NULL, + `created_at` INTEGER NOT NULL +); +CREATE INDEX `idx_change_identity_repo` + ON `change_identity`(`repo_id`, `created_at` DESC); + +CREATE TABLE `change_revision` ( + `change_id` TEXT NOT NULL, + `commit_oid` TEXT NOT NULL, + `created_op_id` TEXT NOT NULL, + `visibility` TEXT NOT NULL, + `revision_ordinal` INTEGER NOT NULL, + PRIMARY KEY (`change_id`, `commit_oid`) +); +CREATE INDEX `idx_change_revision_commit` + ON `change_revision`(`commit_oid`); + +CREATE TABLE `change_predecessor` ( + `successor_oid` TEXT NOT NULL, + `predecessor_oid` TEXT NOT NULL, + `op_id` TEXT NOT NULL, + `relation_kind` TEXT NOT NULL, + `ordinal` INTEGER NOT NULL, + PRIMARY KEY (`successor_oid`, `predecessor_oid`, `op_id`) +); +CREATE INDEX `idx_change_predecessor_predecessor` + ON `change_predecessor`(`predecessor_oid`, `ordinal`); + +CREATE TABLE `ai_operation_link` ( + `operation_id` TEXT PRIMARY KEY, + `session_id` TEXT, + `run_id` TEXT, + `tool_invocation_id` TEXT, + `intent_id` TEXT, + `repo_id` TEXT NOT NULL, + `worktree_id` TEXT, + `workspace_id` TEXT, + `lease_generation` INTEGER, + `config_provenance_digest` TEXT, + `redaction_version` TEXT NOT NULL +); +CREATE INDEX `idx_ai_operation_link_repo` + ON `ai_operation_link`(`repo_id`, `operation_id`); diff --git a/sql/migrations/README.md b/sql/migrations/README.md index 696c68160..9240deaba 100644 --- a/sql/migrations/README.md +++ b/sql/migrations/README.md @@ -172,6 +172,7 @@ helpers in `db.rs`. Subsequent CEXes have populated this directory. | `2026081301` | `approved_permission_provenance` | `2026081301_approved_permission_provenance{,_down}.sql` (plan-20260715 W4-07: Always-approval provenance columns; empty backfill; `project_id` not rewritten; down fail-closed with provenance or linked HEAD evidence.) | | `2026081801` | `agent_bridge_capture` | `2026081801_agent_bridge_capture{,_down}.sql` (plan-20260818 LB-02: DeepSeek Harness bridge durable projection — `agent_bridge_session/event/operation/checkpoint/link`; source fixed to `deepseek-harness`, 256 KiB event payload CHECK, `(bridge_session_id,event_seq)` and `operation_id` idempotency; forward-only down that freezes while any bridge row exists and never deletes acked events/evidence.) | | `2026082401` | `agent_bridge_link_relations` | `2026082401_agent_bridge_link_relations{,_down}.sql` (plan-20260818 LB-04/LB-05 VCS wiring: `agent_bridge_link` becomes a real relation graph — uniqueness moves to the full edge `(source_type,source_id,target_type,target_id)` so one result can carry its operation, workspace, parent-session and evidence associations, and `source_type` gains the mutation result kinds `commit`/`restore`/`review`; every existing edge is copied verbatim; forward-only down that freezes while any link row exists.) | +| `2026090301` | `operation_log_v2` | `2026090301_operation_log_v2.sql` (plan-20260822 OL-02: forward-only development-time replacement of the five v1 operation tables with the eight-table Operation Log v2 schema) | All registered migrations are loaded via `include_str!`. New migrations must follow the same pattern — inline SQL strings in `builtin_migrations()` are no diff --git a/sql/sqlite_20260309_init.sql b/sql/sqlite_20260309_init.sql index 8fa6f33a3..0b8d2dee8 100644 --- a/sql/sqlite_20260309_init.sql +++ b/sql/sqlite_20260309_init.sql @@ -86,7 +86,7 @@ CREATE INDEX IF NOT EXISTS idx_operation_repo_order ON `operation`(`repo_id`, `end_ts` DESC, `start_ts` DESC, `op_id` DESC); CREATE TABLE IF NOT EXISTS `operation_parent` ( - `op_id` TEXT NOT NULL, + `op_id` TEXT NOT NULL, `parent_op_id` TEXT NOT NULL, PRIMARY KEY (`op_id`, `parent_op_id`) ); diff --git a/src/cli.rs b/src/cli.rs index e90f4105b..dbf77790e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,7 +26,7 @@ use sea_orm::{ConnectionTrait, Statement}; use crate::{ command, command::code::ControlMode, - internal::{config::ConfigKv, db}, + internal::{config::ConfigKv, db, operation::middleware::MutationClass}, utils, utils::{ error::{CliError, CliResult}, @@ -1664,6 +1664,282 @@ enum CommandScope { ReadOnly, } +/// Return the stable operation label for a top-level CLI command. +fn operation_command_name(command: &Commands) -> &'static str { + match command { + Commands::Init(_) => "init", + Commands::Clone(_) => "clone", + Commands::Config(_) => "config", + Commands::Status(_) => "status", + Commands::Add(_) => "add", + Commands::Rm(_) => "rm", + Commands::Mv(_) => "mv", + Commands::Restore(_) => "restore", + Commands::Clean(_) => "clean", + Commands::Stash(_) => "stash", + Commands::Lfs(_) => "lfs", + Commands::LsFiles(_) => "ls-files", + Commands::Worktree(_) => "worktree", + Commands::Checkout(_) => "checkout", + Commands::Log(_) => "log", + Commands::Logfile(_) => "logfile", + Commands::Upgrade(_) => "upgrade", + Commands::Cache(_) => "cache", + Commands::Layer(_) => "layer", + Commands::File(_) => "file", + Commands::Alternates(_) => "alternates", + Commands::Deps(_) => "deps", + Commands::Hydrate(_) => "hydrate", + #[cfg(feature = "fastcdc")] + Commands::Media(_) => "media", + Commands::SparseView(_) => "sparse-view", + Commands::Metadata(_) => "metadata", + Commands::Dirty(_) => "dirty", + Commands::Auth(_) => "auth", + Commands::Login(_) => "login", + Commands::Whoami(_) => "whoami", + Commands::Logout(_) => "logout", + Commands::Revision(_) => "revision", + Commands::Service(_) => "service", + Commands::Shortlog(_) => "shortlog", + Commands::Show(_) => "show", + Commands::ShowRef(_) => "show-ref", + Commands::FormatPatch(_) => "format-patch", + Commands::Am(_) => "am", + Commands::Mailinfo(_) => "mailinfo", + Commands::ForEachRef(_) => "for-each-ref", + Commands::LsRemote(_) => "ls-remote", + Commands::LsTree(_) => "ls-tree", + Commands::SymbolicRef(_) => "symbolic-ref", + Commands::Branch(_) => "branch", + Commands::Tag(_) => "tag", + Commands::Commit(_) => "commit", + Commands::Switch(_) => "switch", + Commands::Rebase(_) => "rebase", + Commands::Merge(_) => "merge", + Commands::MergeFile(_) => "merge-file", + Commands::MergeBase(_) => "merge-base", + Commands::Apply(_) => "apply", + Commands::DiffTree(_) => "diff-tree", + Commands::DiffIndex(_) => "diff-index", + Commands::DiffFiles(_) => "diff-files", + Commands::Credential(_) => "credential", + Commands::Rerere(_) => "rerere", + Commands::Reset(_) => "reset", + Commands::RevParse(_) => "rev-parse", + Commands::RevList(_) => "rev-list", + Commands::Describe(_) => "describe", + Commands::Notes(_) => "notes", + Commands::CherryPick(_) => "cherry-pick", + Commands::Push(_) => "push", + Commands::CatFile(_) => "cat-file", + Commands::CheckIgnore(_) => "check-ignore", + Commands::CheckAttr(_) => "check-attr", + Commands::CheckMailmap(_) => "check-mailmap", + Commands::FastExport(_) => "fast-export", + Commands::Bundle(_) => "bundle", + Commands::FastImport(_) => "fast-import", + Commands::Completions(_) => "completions", + Commands::WriteTree(_) => "write-tree", + Commands::CommitTree(_) => "commit-tree", + Commands::ReadTree(_) => "read-tree", + Commands::UpdateIndex(_) => "update-index", + Commands::UpdateRef(_) => "update-ref", + Commands::Archive(_) => "archive", + Commands::HashObject(_) => "hash-object", + Commands::VerifyPack(_) => "verify-pack", + Commands::IndexPack(_) => "index-pack", + Commands::PackObjects(_) => "pack-objects", + Commands::Fetch(_) => "fetch", + Commands::Fsck(_) => "fsck", + Commands::Maintenance(_) => "maintenance", + Commands::Repack(_) => "repack", + Commands::Diff(_) => "diff", + Commands::Grep(_) => "grep", + Commands::Blame(_) => "blame", + Commands::Revert(_) => "revert", + Commands::Replace(_) => "replace", + Commands::Reflog(_) => "reflog", + Commands::Remote(_) => "remote", + Commands::Open(_) => "open", + Commands::Pull(_) => "pull", + Commands::Op(_) => "op", + Commands::Cloud(_) => "cloud", + Commands::Publish(_) => "publish", + Commands::Agent(_) => "agent", + Commands::Review(_) => "review", + Commands::Investigate(_) => "investigate", + Commands::Hooks(_) => "hooks", + Commands::Bisect(_) => "bisect", + Commands::Code(_) => "code", + Commands::Automation(_) => "automation", + Commands::Usage(_) => "usage", + Commands::Graph(_) => "graph", + Commands::Sandbox(_) => "sandbox", + } +} + +fn cli_has_explicit_operation_boundary(command: &Commands) -> bool { + matches!(command, Commands::Branch(_) | Commands::Op(_)) + || matches!( + command, + Commands::Worktree(args) + if matches!( + &args.command, + command::worktree::WorktreeSubcommand::Doctor { .. } + | command::worktree::WorktreeSubcommand::Repair { .. } + ) + ) +} + +async fn runtime_repo_id() -> CliResult { + if let Some(entry) = ConfigKv::get("libra.repoid") + .await + .map_err(|error| CliError::fatal(format!("failed to read repository id: {error}")))? + && !entry.value.trim().is_empty() + { + return Ok(entry.value); + } + let repo_id = uuid::Uuid::new_v4().to_string(); + ConfigKv::set("libra.repoid", &repo_id, false) + .await + .map_err(|error| CliError::fatal(format!("failed to initialize repository id: {error}")))?; + Ok(repo_id) +} + +/// Compile-time census bridge from the exhaustive CLI scope inventory to the +/// Operation middleware's mutation classes. `command_scope` has no wildcard, +/// so a newly added command cannot silently bypass this classification. +fn operation_class_for_command(command: &Commands) -> MutationClass { + if !command_has_mutation(command) { + return MutationClass::ReadOnly; + } + match command_scope(command) { + CommandScope::ReadOnly => MutationClass::ReadOnly, + CommandScope::Worktree => MutationClass::WorkingCopy, + CommandScope::Repository => MutationClass::Ref, + CommandScope::Composite => MutationClass::Repository, + } +} + +/// Return whether this particular invocation changes repository or worktree +/// state. The scope inventory deliberately classifies whole command families, +/// but several Git-compatible families contain read-only subcommands and +/// previews. Those invocations must not create an operation record or acquire +/// the shared maintenance lock: apart from being unnecessary, doing so breaks +/// commands that are explicitly usable outside a repository. +fn command_has_mutation(command: &Commands) -> bool { + match command { + Commands::Cache(args) => matches!( + &args.command, + command::cache::CacheCommand::Evict { dry_run: false, .. } + ), + Commands::Code(_) | Commands::Sandbox(_) => false, + Commands::Config(args) => match &args.command { + Some( + command::config::ConfigCommand::Get { .. } + | command::config::ConfigCommand::List { .. } + | command::config::ConfigCommand::Path + | command::config::ConfigCommand::Edit, + ) => false, + Some(_) => true, + None => { + args.add + || args.import + || args.unset + || args.unset_all + || args.remove_section + || args.rename_section + || args.key.is_some() && args.valuepattern.is_some() + } + }, + Commands::Credential(args) => { + !matches!(&args.command, command::credential::CredentialCommand::Fill) + } + Commands::HashObject(args) => args.write, + Commands::MergeFile(args) => !args.stdout, + Commands::Merge(args) => !args.dry_run, + Commands::Dirty(args) => !args.list, + Commands::Metadata(args) => !matches!( + &args.command, + command::metadata::MetadataCommand::Get { .. } + | command::metadata::MetadataCommand::List { .. } + ), + Commands::Revision(args) => matches!( + &args.command, + command::revision::RevisionCommand::Index { rebuild: true, .. } + ), + Commands::Remote(command) => !matches!( + command, + command::remote::RemoteCmds::List + | command::remote::RemoteCmds::Show { .. } + | command::remote::RemoteCmds::GetUrl { .. } + ), + Commands::Service(args) => { + matches!(&args.command, command::service::ServiceCommand::Run { .. }) + } + Commands::Auth(_) | Commands::Login(_) | Commands::Whoami(_) | Commands::Logout(_) => false, + Commands::Alternates(args) => !matches!( + &args.command, + command::alternates::AlternatesCommand::List + | command::alternates::AlternatesCommand::Prune { dry_run: true, .. } + ), + Commands::Maintenance(args) => !matches!( + &args.command, + command::maintenance::MaintenanceSubcommand::Status + | command::maintenance::MaintenanceSubcommand::Run { dry_run: true, .. } + ), + Commands::File(args) => matches!( + &args.command, + command::file::FileCommand::Obliterate { dry_run: false, .. } + ), + Commands::Notes(args) => !matches!( + &args.subcommand, + None | Some( + command::notes::NotesSubcommand::List { .. } + | command::notes::NotesSubcommand::Show { .. } + | command::notes::NotesSubcommand::GetRef, + ) + ), + Commands::Tag(args) => { + args.delete + || (args.name.is_some() + && !args.list + && !args.verify + && args.n_lines.is_none() + && args.points_at.is_none() + && args.contains.is_none() + && args.no_contains.is_none() + && args.merged.is_none() + && args.no_merged.is_none() + && args.sort.is_none() + && args.column.is_none()) + } + Commands::Cloud(args) => matches!( + &args.command, + command::cloud::CloudCommand::Sync(_) | command::cloud::CloudCommand::Restore(_) + ), + Commands::Publish(args) => matches!( + &args.command, + command::publish::PublishCommand::Init(_) + | command::publish::PublishCommand::Deploy(_) + | command::publish::PublishCommand::Unpublish(_) + | command::publish::PublishCommand::Sync(command::publish::SyncArgs { + dry_run: false, + .. + }) + ), + Commands::Worktree(args) => !matches!( + &args.command, + command::worktree::WorktreeSubcommand::List { .. } + | command::worktree::WorktreeSubcommand::Doctor { .. } + | command::worktree::WorktreeSubcommand::Repair { .. } + ), + Commands::Op(_) | Commands::Branch(_) => false, + _ => true, + } +} + impl CommandScope { /// Does this scope write the CURRENT worktree's HEAD / index / files? fn mutates_worktree_state(self) -> bool { @@ -1906,7 +2182,7 @@ fn command_scope(command: &Commands) -> CommandScope { /// plumbing) stay allowed in a legacy-symlink worktree — they behave /// identically from any scope. fn command_mutates_worktree_state(command: &Commands) -> bool { - command_scope(command).mutates_worktree_state() + command_has_mutation(command) && command_scope(command).mutates_worktree_state() } /// Does this command hold the SHARED maintenance lock for its whole run @@ -1917,6 +2193,9 @@ fn command_mutates_worktree_state(command: &Commands) -> bool { /// same lock EXCLUSIVELY around the phase that unlinks payloads, so taking /// it shared here first would only make them wait on themselves. fn command_holds_shared_maintenance_lock(command: &Commands) -> bool { + if !command_has_mutation(command) || cli_has_explicit_operation_boundary(command) { + return false; + } // The deletion family takes the lock itself, in the mode each phase // needs — `maintenance` per task, `repack` shared for its pack write and // exclusive for `-d`, `cache evict` / `file obliterate` / `agent clean` @@ -2549,6 +2828,8 @@ async fn parse_async_scoped(argv: Vec) -> CliResult<()> { _ => return Err(classify_parse_error(&argv, &err)), }, }; + let operation_class = operation_class_for_command(&args.command); + tracing::debug!(?operation_class, "CLI operation mutation class selected"); if let Commands::Diff(diff_args) = &mut args.command { command::diff::record_algorithm_selector_events(diff_args, &utf8_argv); } @@ -2741,7 +3022,13 @@ async fn parse_async_scoped(argv: Vec) -> CliResult<()> { None => None, }; - let command_result: CliResult<()> = async { + let command_name = operation_command_name(&args.command).to_string(); + let wrap_command = operation_class != MutationClass::ReadOnly + && operation_class != MutationClass::InternalWorker + && !matches!(&args.command, Commands::Init(_) | Commands::Clone(_)) + && control_boundary.is_none() + && !cli_has_explicit_operation_boundary(&args.command); + let command_action = async { match args.command { Commands::Init(cmd_args) => { let original_dir = utils::util::cur_dir(); @@ -2991,9 +3278,52 @@ async fn parse_async_scoped(argv: Vec) -> CliResult<()> { } } Ok(()) - } - .await; + }; + let command_result: CliResult<()> = if wrap_command { + let db = db::get_db_conn_instance().await; + let schema_is_v2 = crate::internal::operation::runtime::is_v2_schema(&db) + .await + .map_err(|error| { + CliError::fatal(format!("failed to inspect operation schema: {error}")) + })?; + if schema_is_v2 { + let repo_id = runtime_repo_id().await?; + let actor = ConfigKv::get("user.name") + .await + .ok() + .flatten() + .map(|entry| entry.value) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "libra-user".to_string()); + let scope_key = crate::internal::worktree_scope::WorktreeScope::for_request() + .storage_key() + .to_string(); + let description = format!("libra {command_name}"); + crate::internal::operation::runtime::run_cli_operation( + &db, + &repo_id, + &scope_key, + &command_name, + &description, + &actor, + None, + operation_class, + || command_action, + ) + .await + .map_err(|error| match error { + crate::internal::operation::runtime::RuntimeOperationError::Action(error) => error, + crate::internal::operation::runtime::RuntimeOperationError::Middleware(error) => { + CliError::fatal(format!("operation boundary failed: {error}")) + } + }) + } else { + command_action.await + } + } else { + command_action.await + }; background_index_guard.finish().await; // Close the control-action claim BEFORE propagating the command's own diff --git a/src/command/maintenance.rs b/src/command/maintenance.rs index ae561bfdd..78e46e9e2 100644 --- a/src/command/maintenance.rs +++ b/src/command/maintenance.rs @@ -3298,6 +3298,94 @@ pub const GC_OBJECT_SOURCE_INVENTORY: &[GcObjectSource] = &[ corruption: GcCorruptionPolicy::NotApplicable, note: "identity of an on-disk overlay file; never enters the object store", }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation", + column: "pre_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "operation-log v2 SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 operation pre-view manifest", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation", + column: "post_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "operation-log v2 SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 operation post-view manifest", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation", + column: "predecessor_map_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "operation-log v2 SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 change genealogy predecessor map", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation_journal", + column: "pre_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "operation-log v2 SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 journal pre-view recovery root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation_journal", + column: "target_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "operation-log v2 SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 journal target-view recovery root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "change_revision", + column: "commit_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "operation-log v2 SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 change revision commit", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "change_predecessor", + column: "successor_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "operation-log v2 SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 change genealogy successor", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "change_predecessor", + column: "predecessor_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "operation-log v2 SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 change genealogy predecessor", + }, ]; /// W2 §C.4.3: roots from REGISTERED STORES that anchor object-store OIDs @@ -3331,7 +3419,7 @@ async fn collect_registered_store_roots( &'static [&'static str], CellMode, ); - let sources: &[Source] = &[ + let mut sources: Vec = vec![ ( "notes", "SELECT blob FROM notes", @@ -3394,7 +3482,43 @@ async fn collect_registered_store_roots( CellMode::StrictOid, ), ]; - for (table, sql, columns, mode) in sources { + let schema_is_v2 = crate::internal::operation::runtime::is_v2_schema(db) + .await + .map_err(|error| { + CliError::fatal(format!( + "failed to inspect operation schema for GC roots: {error}" + )) + .with_stable_code(StableErrorCode::IoReadFailed) + })?; + if schema_is_v2 { + sources.extend([ + ( + "operation", + "SELECT pre_view_oid, post_view_oid, predecessor_map_oid FROM operation", + &["pre_view_oid", "post_view_oid", "predecessor_map_oid"][..], + CellMode::StrictOid, + ), + ( + "operation_journal", + "SELECT pre_view_oid, target_view_oid FROM operation_journal", + &["pre_view_oid", "target_view_oid"][..], + CellMode::StrictOid, + ), + ( + "change_revision", + "SELECT commit_oid FROM change_revision", + &["commit_oid"][..], + CellMode::StrictOid, + ), + ( + "change_predecessor", + "SELECT successor_oid, predecessor_oid FROM change_predecessor", + &["successor_oid", "predecessor_oid"][..], + CellMode::StrictOid, + ), + ]); + } + for (table, sql, columns, mode) in &sources { match db.query_all_raw(stmt_of(sql)).await { Ok(rows) => { for row in rows { diff --git a/src/command/op.rs b/src/command/op.rs index a3fd95093..76d0a5453 100644 --- a/src/command/op.rs +++ b/src/command/op.rs @@ -16,7 +16,7 @@ use crate::{ head::Head, operation::{ OperationGraphRecord, OperationLogListItem, OperationPage, OperationQueryPage, - OperationService, OperationStatus, + OperationService, OperationStatus, runtime, }, operation_wrapper::{OperationMeta, OperationScope, with_operation_log}, }, @@ -256,6 +256,14 @@ async fn query_operation_log_page( let command_filter = command_filter .map(str::trim) .filter(|value| !value.is_empty()); + if runtime::is_v2_schema(db) + .await + .map_err(|e| CliError::fatal(format!("failed to inspect operation schema: {e}")))? + { + return runtime::list_operations_by_repo_paginated(db, repo_id, command_filter, query_page) + .await + .map_err(|e| CliError::fatal(format!("failed to query v2 operations: {e}"))); + } OperationService::list_operations_by_repo_and_command_paginated_with_conn( db, repo_id, @@ -698,6 +706,15 @@ async fn load_operation_graph( db: &C, op_id: &str, ) -> CliResult { + if runtime::is_v2_schema(db) + .await + .map_err(|e| CliError::fatal(format!("failed to inspect operation schema: {e}")))? + { + return runtime::load_graph(db, op_id) + .await + .map_err(|e| CliError::fatal(format!("failed to load operation '{op_id}': {e}")))? + .ok_or_else(|| CliError::fatal(format!("operation '{op_id}' not found"))); + } OperationService::load_restore_view_by_operation_with_conn(db, op_id) .await .map_err(|e| CliError::fatal(format!("failed to load operation '{op_id}': {e}")))? @@ -725,6 +742,24 @@ async fn resolve_op_ref( page: 1, per_page: (index + 1) as u64, }; + if runtime::is_v2_schema(db) + .await + .map_err(|e| CliError::fatal(format!("failed to inspect operation schema: {e}")))? + { + let result = runtime::list_operations_by_repo_paginated(db, repo_id, None, page) + .await + .map_err(|e| CliError::fatal(format!("failed to query v2 operations: {e}")))?; + return result + .items + .into_iter() + .nth(index) + .map(|op| op.op_id) + .ok_or_else(|| { + CliError::fatal(format!("operation index {index} out of range")) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("use 'libra op log' to see available operations") + }); + } let result = OperationService::list_operations_by_repo_paginated_with_conn(db, repo_id, page) .await diff --git a/src/internal/ai/tools/registry.rs b/src/internal/ai/tools/registry.rs index 2f072aee6..6c5639245 100644 --- a/src/internal/ai/tools/registry.rs +++ b/src/internal/ai/tools/registry.rs @@ -13,11 +13,13 @@ use super::{ error::{ToolError, ToolResult}, spec::ToolSpec, }; -use crate::internal::ai::{ - agent::TaskIntent, - runtime::{ToolBoundaryRuntime, ToolOperation}, +use crate::internal::{ + ai::{ + agent::TaskIntent, + runtime::{ToolBoundaryRuntime, ToolOperation}, + }, + config::ConfigKv, }; - /// Handler trait that all tools must implement. /// /// This trait defines the interface for tools that can be invoked by an AI agent. @@ -264,6 +266,83 @@ impl ToolRegistry { .collect() } + async fn dispatch_handler_with_operation( + &self, + handler: Arc, + invocation: ToolInvocation, + tool_name: &str, + mutates_state: bool, + ) -> ToolResult { + let execute = || async move { + handler.handle(invocation).await.map(|output| { + redact_workspace_paths_in_output(output, &self.working_dir, &self.path_aliases) + }) + }; + if !mutates_state { + return execute().await; + } + + // Unit callers may give a registry an isolated working directory + // without changing the process CWD. The operation runtime resolves + // HEAD and object storage from the ambient repository, so entering it + // here would either panic outside a repository or write the wrong + // repository's operation log. In those cases the handler still runs + // under the registry's normal sandbox/hardening boundary; only the + // repository-backed operation envelope is unavailable. + let target_storage = + match crate::utils::util::try_get_storage_path(Some(self.working_dir.clone())) { + Ok(storage) => storage, + Err(_) => return execute().await, + }; + let ambient_storage = match crate::utils::util::try_get_storage_path(None) { + Ok(storage) => storage, + Err(_) => return execute().await, + }; + let canonical_storage = |path: &std::path::Path| { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) + }; + if canonical_storage(&target_storage) != canonical_storage(&ambient_storage) { + return execute().await; + } + + let db = crate::internal::db::get_db_conn_instance().await; + let repo_id = ConfigKv::get("libra.repoid") + .await + .map_err(|error| { + ToolError::ExecutionFailed(format!("failed to read repository id: {error}")) + })? + .map(|entry| entry.value) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + ToolError::ExecutionFailed( + "mutating Agent tools require libra.repoid to be configured".to_string(), + ) + })?; + let scope_key = crate::internal::worktree_scope::WorktreeScope::for_request() + .storage_key() + .to_string(); + let command_name = format!("agent/{tool_name}"); + let description = format!("Agent tool {tool_name}"); + crate::internal::operation::runtime::run_cli_operation( + &db, + &repo_id, + &scope_key, + &command_name, + &description, + "agent", + None, + crate::internal::operation::middleware::MutationClass::External, + execute, + ) + .await + .map_err(|error| match error { + crate::internal::operation::runtime::RuntimeOperationError::Action(error) => error, + crate::internal::operation::runtime::RuntimeOperationError::Middleware(error) => { + ToolError::ExecutionFailed(format!("operation boundary failed: {error}")) + } + }) + } + /// Dispatch a tool invocation to the appropriate handler. /// /// This method validates the tool name, checks payload compatibility, @@ -288,6 +367,16 @@ impl ToolRegistry { let mutates_state = handler.is_mutating(&invocation).await; let requires_network = handler.requires_network(&invocation).await; + let operation_class = if mutates_state { + crate::internal::operation::middleware::MutationClass::External + } else { + crate::internal::operation::middleware::MutationClass::ReadOnly + }; + tracing::debug!( + tool = %tool_name, + ?operation_class, + "Agent tool operation mutation class selected" + ); if let Some(hardening) = &self.hardening { let operation = ToolOperation::tool(tool_name.clone(), mutates_state, requires_network); @@ -314,9 +403,14 @@ impl ToolRegistry { return Err(ToolError::ExecutionFailed(decision.reason)); } - let result = handler.handle(invocation).await.map(|output| { - redact_workspace_paths_in_output(output, &self.working_dir, &self.path_aliases) - }); + let result = self + .dispatch_handler_with_operation( + handler.clone(), + invocation, + &tool_name, + mutates_state, + ) + .await; let summary = match &result { Ok(output) => format!( "success={} output={}", @@ -339,9 +433,8 @@ impl ToolRegistry { return result; } - handler.handle(invocation).await.map(|output| { - redact_workspace_paths_in_output(output, &self.working_dir, &self.path_aliases) - }) + self.dispatch_handler_with_operation(handler, invocation, &tool_name, mutates_state) + .await } /// Return the handler's conservative mutability classification without diff --git a/src/internal/db.rs b/src/internal/db.rs index 1a1be4ccb..7d3d28ec7 100644 --- a/src/internal/db.rs +++ b/src/internal/db.rs @@ -554,56 +554,6 @@ const BOOTSTRAP_SQL: &str = include_str!("../../sql/sqlite_20260309_init.sql"); /// Phase 0 AI runtime contract migration; safe to run repeatedly. const AI_RUNTIME_CONTRACT_MIGRATION_SQL: &str = include_str!("../../sql/sqlite_20260415_ai_runtime_contract.sql"); -const OPERATION_SCHEMA_SQL: &str = r#" -CREATE TABLE IF NOT EXISTS `operation` ( - `op_id` TEXT PRIMARY KEY, - `repo_id` TEXT NOT NULL, - `view_id` TEXT NOT NULL, - `command_name` TEXT NOT NULL, - `description` TEXT NOT NULL, - `actor` TEXT NOT NULL, - `args_digest` TEXT, - `start_ts` INTEGER NOT NULL, - `end_ts` INTEGER, - `status` TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_operation_repo_order - ON `operation`(`repo_id`, `end_ts` DESC, `start_ts` DESC, `op_id` DESC); - -CREATE TABLE IF NOT EXISTS `operation_parent` ( - `op_id` TEXT NOT NULL, - `parent_op_id` TEXT NOT NULL, - PRIMARY KEY (`op_id`, `parent_op_id`) -); -CREATE INDEX IF NOT EXISTS idx_operation_parent_parent - ON `operation_parent`(`parent_op_id`, `op_id`); - -CREATE TABLE IF NOT EXISTS `operation_view` ( - `view_id` TEXT PRIMARY KEY, - `repo_id` TEXT NOT NULL, - `head_kind` TEXT NOT NULL, - `head_target` TEXT NOT NULL, - `created_at` INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_operation_view_repo_created - ON `operation_view`(`repo_id`, `created_at` DESC); - -CREATE TABLE IF NOT EXISTS `operation_view_ref` ( - `view_id` TEXT NOT NULL, - `ref_kind` TEXT NOT NULL, - `ref_name` TEXT NOT NULL, - `ref_remote` TEXT NOT NULL, - `target_oid` TEXT NOT NULL, - PRIMARY KEY (`view_id`, `ref_kind`, `ref_name`, `ref_remote`) -); - -CREATE TABLE IF NOT EXISTS `operation_view_workspace` ( - `view_id` TEXT NOT NULL, - `pointer_kind` TEXT NOT NULL, - `pointer_value` TEXT NOT NULL, - PRIMARY KEY (`view_id`, `pointer_kind`) -); -"#; const AI_PROJECTION_SCHEMA_START: &str = "-- BEGIN AI PROJECTION SCHEMA"; /// Marker delimiting the end of the AI projection schema inside `BOOTSTRAP_SQL`. const AI_PROJECTION_SCHEMA_END: &str = "-- END AI PROJECTION SCHEMA"; @@ -751,14 +701,6 @@ pub async fn ensure_ai_runtime_contract_schema(conn: &DatabaseConnection) -> Res Ok(()) } -async fn ensure_operation_schema(conn: &DatabaseConnection) -> Result<(), IOError> { - let backend = conn.get_database_backend(); - conn.execute_raw(Statement::from_string(backend, OPERATION_SCHEMA_SQL)) - .await - .map_err(|err| IOError::other(format!("Failed to apply operation schema: {err}")))?; - Ok(()) -} - async fn connect_database(db_path: &str) -> io::Result { let normalized_path = normalize_path_for_sqlite(db_path); let mut option = ConnectOptions::new(format!("sqlite://{normalized_path}")); @@ -790,9 +732,6 @@ async fn apply_database_schema_upgrades( "Failed to ensure AI runtime contract schema: {err}" )) })?; - ensure_operation_schema(conn) - .await - .map_err(|err| IOError::other(format!("Failed to ensure operation schema: {err}")))?; // CEX-12.5: apply every migration registered in // `migration::builtin_migrations`. The runner is idempotent — on a // fresh DB or a legacy DB it ensures the `schema_versions` tracking diff --git a/src/internal/db/migration.rs b/src/internal/db/migration.rs index 9d1b628dc..2ae540617 100644 --- a/src/internal/db/migration.rs +++ b/src/internal/db/migration.rs @@ -1403,6 +1403,16 @@ pub fn builtin_migrations() -> Vec { include_str!("../../../sql/migrations/2026082401_agent_bridge_link_relations.sql"), include_str!("../../../sql/migrations/2026082401_agent_bridge_link_relations_down.sql"), ), + // plan-20260822 OL-02: replace the development-only v1 operation + // tables with the v2 operation/change/journal schema. The replacement + // is deliberately forward-only; this plan does not maintain a v1 + // compatibility layer. + Migration { + version: 2026090301, + name: "operation_log_v2", + up: include_str!("../../../sql/migrations/2026090301_operation_log_v2.sql"), + down: None, + }, ] } @@ -1850,9 +1860,9 @@ mod tests { // `builtin_migrations()` so silent registry regressions surface // here in addition to `tests/db_migration_test.rs`. let runner = builtin_runner().expect("CEX-12.5 builtin registry must build clean"); - assert_eq!(runner.len(), 57); + assert_eq!(runner.len(), 58); assert!(!runner.is_empty()); - assert_eq!(runner.max_registered_version(), Some(2026082401)); + assert_eq!(runner.max_registered_version(), Some(2026090301)); } #[test] diff --git a/src/internal/model/ai_operation_link.rs b/src/internal/model/ai_operation_link.rs new file mode 100644 index 000000000..69a75adde --- /dev/null +++ b/src/internal/model/ai_operation_link.rs @@ -0,0 +1,25 @@ +//! SeaORM entity for redacted AI-to-operation causal links. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "ai_operation_link")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub operation_id: String, + pub session_id: Option, + pub run_id: Option, + pub tool_invocation_id: Option, + pub intent_id: Option, + pub repo_id: String, + pub worktree_id: Option, + pub workspace_id: Option, + pub lease_generation: Option, + pub config_provenance_digest: Option, + pub redaction_version: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/model/change_identity.rs b/src/internal/model/change_identity.rs new file mode 100644 index 000000000..c47770004 --- /dev/null +++ b/src/internal/model/change_identity.rs @@ -0,0 +1,19 @@ +//! SeaORM entity for stable logical Change IDs. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "change_identity")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub change_id: String, + pub repo_id: String, + pub origin: String, + pub created_op_id: String, + pub created_at: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/model/change_predecessor.rs b/src/internal/model/change_predecessor.rs new file mode 100644 index 000000000..96f11f8a6 --- /dev/null +++ b/src/internal/model/change_predecessor.rs @@ -0,0 +1,21 @@ +//! SeaORM entity for typed Change genealogy edges. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "change_predecessor")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub successor_oid: String, + #[sea_orm(primary_key, auto_increment = false)] + pub predecessor_oid: String, + #[sea_orm(primary_key, auto_increment = false)] + pub op_id: String, + pub relation_kind: String, + pub ordinal: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/model/change_revision.rs b/src/internal/model/change_revision.rs new file mode 100644 index 000000000..dfe57ec83 --- /dev/null +++ b/src/internal/model/change_revision.rs @@ -0,0 +1,20 @@ +//! SeaORM entity for Change ID to commit revision projections. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "change_revision")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub change_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub commit_oid: String, + pub created_op_id: String, + pub visibility: String, + pub revision_ordinal: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/model/mod.rs b/src/internal/model/mod.rs index 142efec36..1801ad72f 100644 --- a/src/internal/model/mod.rs +++ b/src/internal/model/mod.rs @@ -10,6 +10,7 @@ pub mod ai_index_run_event; pub mod ai_index_run_patchset; pub mod ai_index_task_run; pub mod ai_live_context_window; +pub mod ai_operation_link; pub mod ai_risk_score_breakdown; pub mod ai_scheduler_plan_head; pub mod ai_scheduler_selected_plan; @@ -19,6 +20,9 @@ pub mod ai_thread_intent; pub mod ai_thread_participant; pub mod ai_thread_provider_metadata; pub mod ai_validation_report; +pub mod change_identity; +pub mod change_predecessor; +pub mod change_revision; pub mod config; pub mod config_kv; pub mod layer; @@ -27,7 +31,11 @@ pub mod metadata_kv; pub mod object_index; pub mod object_obliteration; pub mod operation; +pub mod operation_head; +pub mod operation_journal; pub mod operation_parent; +pub mod operation_parent_v2; +pub mod operation_v2; pub mod operation_view; pub mod operation_view_ref; pub mod operation_view_workspace; diff --git a/src/internal/model/operation_head.rs b/src/internal/model/operation_head.rs new file mode 100644 index 000000000..924ae7be2 --- /dev/null +++ b/src/internal/model/operation_head.rs @@ -0,0 +1,20 @@ +//! SeaORM entity for operation-head CAS rows. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "operation_head")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub repo_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub scope_key: String, + #[sea_orm(primary_key, auto_increment = false)] + pub op_id: String, + pub generation: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/model/operation_journal.rs b/src/internal/model/operation_journal.rs new file mode 100644 index 000000000..06e47f3b9 --- /dev/null +++ b/src/internal/model/operation_journal.rs @@ -0,0 +1,22 @@ +//! SeaORM entity for crash-recovery journal entries. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "operation_journal")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub journal_id: String, + pub op_id: String, + pub phase: String, + pub pre_view_oid: Option, + pub target_view_oid: Option, + pub owner: String, + pub updated_at: i64, + pub recovery_payload: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/model/operation_parent_v2.rs b/src/internal/model/operation_parent_v2.rs new file mode 100644 index 000000000..4aa3b30ac --- /dev/null +++ b/src/internal/model/operation_parent_v2.rs @@ -0,0 +1,18 @@ +//! SeaORM entity for v2 operation parent edges. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "operation_parent")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub op_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub parent_op_id: String, + pub ordinal: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/model/operation_v2.rs b/src/internal/model/operation_v2.rs new file mode 100644 index 000000000..a39ecffd9 --- /dev/null +++ b/src/internal/model/operation_v2.rs @@ -0,0 +1,36 @@ +//! SeaORM entity for the Operation Log v2 main table. +//! +//! The legacy `operation` entity remains available to the v1 service during +//! the migration window; new v2 code must use this entity instead. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "operation")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub op_id: String, + pub repo_id: String, + pub format_version: i32, + pub kind: String, + pub status: String, + pub command_name: Option, + pub description: Option, + pub args_digest: Option, + pub actor: Option, + pub worktree_id: Option, + pub scope_kind: String, + pub pre_view_oid: String, + pub post_view_oid: String, + pub restores_op_id: Option, + pub reverts_op_id: Option, + pub predecessor_map_oid: Option, + pub causal_context_id: Option, + pub start_ts: i64, + pub end_ts: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/operation/facet.rs b/src/internal/operation/facet.rs new file mode 100644 index 000000000..a37036905 --- /dev/null +++ b/src/internal/operation/facet.rs @@ -0,0 +1,254 @@ +//! Uniform capture/restore contracts for mutable repository state. + +use std::collections::BTreeMap; + +use git_internal::hash::ObjectHash; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub type FacetName = String; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RestorePolicy { + AutoRestore, + Rebuild, + NeverRestore, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct FacetCapture { + pub facet: FacetName, + pub schema_version: u32, + pub payload_oid: Option, + pub meta: serde_json::Value, +} + +#[derive(Debug, Default)] +pub struct FacetCaptureCtx { + pub workspace_id: String, +} + +#[derive(Debug, Default)] +pub struct FacetRestoreCtx { + pub workspace_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FacetDiff { + pub facet: FacetName, + pub from: Option, + pub to: Option, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum FacetError { + #[error("facet '{0}' is already registered")] + Duplicate(FacetName), + #[error("facet '{0}' is not registered")] + Unregistered(FacetName), + #[error("facet '{0}' capture is invalid: {1}")] + InvalidCapture(FacetName, String), + #[error("facet '{0}' operation failed: {1}")] + Operation(FacetName, String), +} + +pub trait StateFacet: Send + Sync { + fn name(&self) -> FacetName; + fn schema_version(&self) -> u32; + fn restore_policy(&self) -> RestorePolicy; + fn capture(&self, ctx: &FacetCaptureCtx) -> Result; + fn validate(&self, capture: &FacetCapture) -> Result<(), FacetError>; + fn restore(&self, capture: &FacetCapture, ctx: &mut FacetRestoreCtx) -> Result<(), FacetError>; + fn diff(&self, from: &FacetCapture, to: &FacetCapture) -> Result; + fn roots(&self, capture: &FacetCapture) -> Vec; +} + +#[derive(Default)] +pub struct FacetRegistry { + facets: BTreeMap>, +} + +impl std::fmt::Debug for FacetRegistry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("FacetRegistry") + .field("facets", &self.facets.keys().collect::>()) + .finish() + } +} + +impl FacetRegistry { + pub fn register(&mut self, facet: Box) -> Result<(), FacetError> { + let name = facet.name(); + if self.facets.contains_key(&name) { + return Err(FacetError::Duplicate(name)); + } + self.facets.insert(name, facet); + Ok(()) + } + + pub fn contains(&self, name: &str) -> bool { + self.facets.contains_key(name) + } + + pub fn names(&self) -> impl Iterator { + self.facets.keys() + } + + pub fn capture_all(&self, ctx: &FacetCaptureCtx) -> Result, FacetError> { + self.facets + .values() + .map(|facet| facet.capture(ctx)) + .collect() + } + + pub fn validate_all(&self, captures: &[FacetCapture]) -> Result<(), FacetError> { + for capture in captures { + let facet = self + .facets + .get(&capture.facet) + .ok_or_else(|| FacetError::Unregistered(capture.facet.clone()))?; + if capture.schema_version != facet.schema_version() { + return Err(FacetError::InvalidCapture( + capture.facet.clone(), + format!( + "schema version {} does not match {}", + capture.schema_version, + facet.schema_version() + ), + )); + } + facet.validate(capture)?; + } + Ok(()) + } + + /// A snapshot is fully restorable only when every captured facet is + /// registered and each capture has a payload. Unknown facets fail closed. + pub fn fully_restorable(&self, captures: &[FacetCapture]) -> bool { + self.validate_all(captures).is_ok() + && captures.iter().all(|capture| { + capture.payload_oid.is_some() + && self + .facets + .get(&capture.facet) + .is_some_and(|facet| facet.restore_policy() != RestorePolicy::NeverRestore) + }) + } + + pub fn roots(&self, captures: &[FacetCapture]) -> Result, FacetError> { + self.validate_all(captures)?; + Ok(captures + .iter() + .flat_map(|capture| self.facets[&capture.facet].roots(capture)) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestFacet { + name: &'static str, + policy: RestorePolicy, + payload: Option, + } + + impl StateFacet for TestFacet { + fn name(&self) -> FacetName { + self.name.to_string() + } + fn schema_version(&self) -> u32 { + 1 + } + fn restore_policy(&self) -> RestorePolicy { + self.policy + } + fn capture(&self, _ctx: &FacetCaptureCtx) -> Result { + Ok(FacetCapture { + facet: self.name(), + schema_version: 1, + payload_oid: self.payload, + meta: serde_json::json!({}), + }) + } + fn validate(&self, capture: &FacetCapture) -> Result<(), FacetError> { + if capture.facet != self.name { + return Err(FacetError::InvalidCapture( + capture.facet.clone(), + "wrong facet".to_string(), + )); + } + Ok(()) + } + fn restore( + &self, + _capture: &FacetCapture, + _ctx: &mut FacetRestoreCtx, + ) -> Result<(), FacetError> { + Ok(()) + } + fn diff(&self, from: &FacetCapture, to: &FacetCapture) -> Result { + Ok(FacetDiff { + facet: self.name(), + from: from.payload_oid, + to: to.payload_oid, + }) + } + fn roots(&self, capture: &FacetCapture) -> Vec { + capture.payload_oid.into_iter().collect() + } + } + + fn oid(byte: u8) -> ObjectHash { + ObjectHash::from_bytes(&[byte; 20]).expect("sha1 oid") + } + + #[test] + fn unknown_facet_is_not_fully_restorable() { + let registry = FacetRegistry::default(); + let capture = FacetCapture { + facet: "unknown".to_string(), + schema_version: 1, + payload_oid: Some(oid(1)), + meta: serde_json::json!({}), + }; + assert!(!registry.fully_restorable(&[capture])); + } + + #[test] + fn registered_facets_capture_and_validate() { + let mut registry = FacetRegistry::default(); + registry + .register(Box::new(TestFacet { + name: "index", + policy: RestorePolicy::AutoRestore, + payload: Some(oid(2)), + })) + .expect("register facet"); + let captures = registry + .capture_all(&FacetCaptureCtx::default()) + .expect("capture"); + assert!(registry.fully_restorable(&captures)); + assert_eq!(registry.roots(&captures).unwrap(), vec![oid(2)]); + } + + #[test] + fn never_restore_facet_is_not_fully_restorable() { + let mut registry = FacetRegistry::default(); + registry + .register(Box::new(TestFacet { + name: "runtime", + policy: RestorePolicy::NeverRestore, + payload: Some(oid(3)), + })) + .expect("register facet"); + let captures = registry + .capture_all(&FacetCaptureCtx::default()) + .expect("capture"); + assert!(!registry.fully_restorable(&captures)); + } +} diff --git a/src/internal/operation/facets.rs b/src/internal/operation/facets.rs new file mode 100644 index 000000000..579930989 --- /dev/null +++ b/src/internal/operation/facets.rs @@ -0,0 +1,151 @@ +//! File-backed index, sequencer, and sparse-state facets. +//! +//! Each adapter owns one controlled path and an object store. The restore +//! policy belongs to the facet (index and sequencer are auto-restorable; +//! sparse state is rebuilt by default), while the registry decides whether a +//! complete snapshot may be advertised as fully restorable. + +use std::{fs, path::PathBuf}; + +use git_internal::{hash::ObjectHash, internal::object::types::ObjectType}; + +use super::facet::{ + FacetCapture, FacetCaptureCtx, FacetDiff, FacetError, FacetName, FacetRestoreCtx, + RestorePolicy, StateFacet, +}; +use crate::utils::client_storage::ClientStorage; + +const FACET_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone)] +pub struct FileStateFacet { + name: FacetName, + path: PathBuf, + storage: ClientStorage, + policy: RestorePolicy, +} + +impl FileStateFacet { + pub fn new( + name: impl Into, + path: PathBuf, + storage: ClientStorage, + policy: RestorePolicy, + ) -> Self { + Self { + name: name.into(), + path, + storage, + policy, + } + } + + fn capture_file(&self) -> Result { + let data = fs::read(&self.path) + .map_err(|error| FacetError::Operation(self.name.clone(), error.to_string()))?; + let oid = ObjectHash::from_type_and_data(ObjectType::Blob, &data); + self.storage + .put(&oid, &data, ObjectType::Blob) + .map_err(|error| FacetError::Operation(self.name.clone(), error.to_string()))?; + Ok(FacetCapture { + facet: self.name.clone(), + schema_version: FACET_SCHEMA_VERSION, + payload_oid: Some(oid), + meta: serde_json::json!({}), + }) + } + + fn restore_file(&self, capture: &FacetCapture) -> Result<(), FacetError> { + let Some(oid) = capture.payload_oid else { + return Err(FacetError::InvalidCapture( + self.name.clone(), + "missing payload object".to_string(), + )); + }; + let data = self + .storage + .get(&oid) + .map_err(|error| FacetError::Operation(self.name.clone(), error.to_string()))?; + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .map_err(|error| FacetError::Operation(self.name.clone(), error.to_string()))?; + } + fs::write(&self.path, data) + .map_err(|error| FacetError::Operation(self.name.clone(), error.to_string()))?; + Ok(()) + } +} + +macro_rules! file_facet { + ($name:ident, $constructor:ident, $facet_name:literal, $policy:expr) => { + pub struct $name(FileStateFacet); + + impl $name { + pub fn $constructor(path: PathBuf, storage: ClientStorage) -> Self { + Self(FileStateFacet::new($facet_name, path, storage, $policy)) + } + } + + impl StateFacet for $name { + fn name(&self) -> FacetName { + self.0.name() + } + fn schema_version(&self) -> u32 { + FACET_SCHEMA_VERSION + } + fn restore_policy(&self) -> RestorePolicy { + self.0.policy + } + fn capture(&self, _ctx: &FacetCaptureCtx) -> Result { + self.0.capture_file() + } + fn validate(&self, capture: &FacetCapture) -> Result<(), FacetError> { + if capture.facet != self.0.name || capture.schema_version != FACET_SCHEMA_VERSION { + return Err(FacetError::InvalidCapture( + self.0.name.clone(), + "facet identity mismatch".to_string(), + )); + } + Ok(()) + } + fn restore( + &self, + capture: &FacetCapture, + _ctx: &mut FacetRestoreCtx, + ) -> Result<(), FacetError> { + self.0.restore_file(capture) + } + fn diff( + &self, + from: &FacetCapture, + to: &FacetCapture, + ) -> Result { + self.validate(from)?; + self.validate(to)?; + Ok(FacetDiff { + facet: self.name(), + from: from.payload_oid, + to: to.payload_oid, + }) + } + fn roots(&self, capture: &FacetCapture) -> Vec { + capture.payload_oid.into_iter().collect() + } + } + }; +} + +file_facet!(IndexFacet, index, "index", RestorePolicy::AutoRestore); +file_facet!( + SequencerFacet, + sequencer, + "sequencer", + RestorePolicy::AutoRestore +); +file_facet!(SparseFacet, sparse, "sparse", RestorePolicy::Rebuild); + +impl FileStateFacet { + fn name(&self) -> FacetName { + self.name.clone() + } +} diff --git a/src/internal/operation/middleware.rs b/src/internal/operation/middleware.rs new file mode 100644 index 000000000..c0cc60671 --- /dev/null +++ b/src/internal/operation/middleware.rs @@ -0,0 +1,348 @@ +//! The single operation boundary for mutating commands. + +use std::{future::Future, path::Path}; + +use git_internal::hash::ObjectHash; +use thiserror::Error; + +use super::{ + store::{ + JournalPhase, OpHead, OperationKind, OperationMetaV2, OperationStatusV2, OperationStore, + OperationV2, StoreError, + }, + working_copy::{PointerError, WorkspaceStatePointer}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MutationClass { + ReadOnly, + WorkingCopy, + Repository, + Ref, + Index, + External, + InternalWorker, +} + +impl MutationClass { + fn produces_operation(self) -> bool { + !matches!(self, Self::ReadOnly | Self::InternalWorker) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ClassificationError { + #[error("unknown command mutation class: {0}")] + UnknownCommand(String), +} + +pub fn classify_command(command_name: &str) -> Result { + let name = command_name.trim().to_ascii_lowercase(); + let class = match name.as_str() { + "status" | "log" | "show" | "diff" | "op" | "help" => MutationClass::ReadOnly, + "add" | "checkout" | "switch" | "restore" | "reset" | "clean" => MutationClass::WorkingCopy, + "commit" | "merge" | "rebase" | "cherry-pick" | "revert" | "am" => { + MutationClass::Repository + } + "branch" | "tag" | "fetch" | "push" | "update-ref" => MutationClass::Ref, + "index" | "update-index" => MutationClass::Index, + "agent" | "cloud" | "external" => MutationClass::External, + "status-io-worker" | "internal-worker" => MutationClass::InternalWorker, + _ => { + return Err(ClassificationError::UnknownCommand( + command_name.to_string(), + )); + } + }; + Ok(class) +} + +#[derive(Debug, Error)] +pub enum MiddlewareError { + #[error(transparent)] + Classification(#[from] ClassificationError), + #[error(transparent)] + Store(#[from] StoreError), + #[error(transparent)] + Pointer(#[from] PointerError), + #[error("operation action failed: {0}")] + Action(String), + #[error("operation middleware requires a non-empty operation id")] + EmptyOperationId, +} + +pub struct OperationMiddleware { + store: OperationStore, + repo_id: String, + scope_key: String, + scope_kind: String, + pointer_path: Option, +} + +impl OperationMiddleware { + pub fn new( + store: OperationStore, + repo_id: impl Into, + scope_key: impl Into, + pointer_path: Option>, + ) -> Self { + let scope_key = scope_key.into(); + let scope_kind = if scope_key.trim().is_empty() || scope_key == "main" { + "main" + } else { + "linked" + }; + Self { + store, + repo_id: repo_id.into(), + scope_key, + scope_kind: scope_kind.to_string(), + pointer_path: pointer_path.map(|path| path.as_ref().to_path_buf()), + } + } + + /// Execute one classified command through the operation journal and + /// publish CAS. Read-only and internal worker classes are deliberately + /// bypassed; `InternalWorker` never produces an Operation. + pub async fn run_with_operation( + &self, + command_name: &str, + op_id: impl Into, + pre_view_oid: ObjectHash, + post_view_oid: ObjectHash, + external_snapshot_oid: Option, + action: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let class = classify_command(command_name)?; + if !class.produces_operation() { + return action().await.map_err(MiddlewareError::Action); + } + let op_id = op_id.into(); + if op_id.trim().is_empty() { + return Err(MiddlewareError::EmptyOperationId); + } + let mut expected = self + .store + .current_op_heads(&self.repo_id, &self.scope_key) + .await?; + let mut effective_pre_view = pre_view_oid; + if let Some(snapshot_oid) = external_snapshot_oid { + let external_id = format!("{op_id}-external-snapshot"); + let external = OperationV2 { + op_id: external_id.clone(), + repo_id: self.repo_id.clone(), + parent_op_ids: expected.iter().map(|head| head.op_id.clone()).collect(), + pre_view_oid, + post_view_oid: snapshot_oid, + kind: OperationKind::ExternalSnapshot, + status: OperationStatusV2::Success, + metadata: OperationMetaV2 { + scope_kind: self.scope_kind.clone(), + ..Default::default() + }, + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + start_ts: 0, + end_ts: Some(0), + }; + self.store.write_operation(&external).await?; + let generation = expected + .iter() + .map(|head| head.generation) + .max() + .unwrap_or(0) + + 1; + expected = self + .store + .cas_update_op_heads( + &self.repo_id, + &self.scope_key, + &expected, + &[OpHead { + op_id: external_id, + generation, + }], + ) + .await?; + effective_pre_view = snapshot_oid; + } + + let parents = expected.iter().map(|head| head.op_id.clone()).collect(); + let operation = OperationV2 { + op_id: op_id.clone(), + repo_id: self.repo_id.clone(), + parent_op_ids: parents, + pre_view_oid: effective_pre_view, + post_view_oid, + kind: OperationKind::Command, + status: OperationStatusV2::Running, + metadata: OperationMetaV2 { + command_name: Some(command_name.to_string()), + scope_kind: self.scope_kind.clone(), + ..Default::default() + }, + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + start_ts: 0, + end_ts: None, + }; + self.store.write_operation(&operation).await?; + let journal_id = format!("journal-{op_id}"); + self.store + .reserve_journal(&journal_id, &op_id, "middleware", 0) + .await?; + self.store + .record_journal_phase( + &journal_id, + JournalPhase::PreView, + Some(effective_pre_view), + None, + None, + 0, + ) + .await?; + self.store + .record_journal_phase( + &journal_id, + JournalPhase::Mutation, + Some(effective_pre_view), + None, + None, + 0, + ) + .await?; + let result = action().await; + match result { + Ok(value) => { + self.store + .record_journal_phase( + &journal_id, + JournalPhase::PostView, + Some(effective_pre_view), + Some(post_view_oid), + None, + 1, + ) + .await?; + self.store + .update_operation_status(&op_id, OperationStatusV2::Success, Some(1)) + .await?; + let generation = expected + .iter() + .map(|head| head.generation) + .max() + .unwrap_or(0) + + 1; + self.store + .cas_update_op_heads( + &self.repo_id, + &self.scope_key, + &expected, + &[OpHead { + op_id: op_id.clone(), + generation, + }], + ) + .await?; + self.store + .record_journal_phase( + &journal_id, + JournalPhase::Publish, + Some(effective_pre_view), + Some(post_view_oid), + None, + 1, + ) + .await?; + if let Some(path) = &self.pointer_path { + WorkspaceStatePointer::new(op_id, post_view_oid, generation)?.save(path)?; + } + Ok(value) + } + Err(error) => { + self.store + .update_operation_status(&op_id, OperationStatusV2::Failed, Some(1)) + .await?; + Err(MiddlewareError::Action(error)) + } + } + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database}; + + use super::*; + + #[test] + fn all_seven_classes_are_explicit() { + let names = [ + "status", + "add", + "commit", + "branch", + "index", + "agent", + "internal-worker", + ]; + let classes = names + .iter() + .map(|name| classify_command(name).expect("class")) + .collect::>(); + assert_eq!( + classes, + vec![ + MutationClass::ReadOnly, + MutationClass::WorkingCopy, + MutationClass::Repository, + MutationClass::Ref, + MutationClass::Index, + MutationClass::External, + MutationClass::InternalWorker, + ] + ); + } + + #[test] + fn unknown_commands_fail_closed() { + assert!(matches!( + classify_command("future-command"), + Err(ClassificationError::UnknownCommand(_)) + )); + } + + #[tokio::test] + async fn failed_action_keeps_failed_operation() { + let db = Database::connect("sqlite::memory:").await.expect("db"); + db.execute_unprepared("CREATE TABLE operation(op_id TEXT PRIMARY KEY,repo_id TEXT NOT NULL,format_version INTEGER NOT NULL,kind TEXT NOT NULL,status TEXT NOT NULL,command_name TEXT,description TEXT,args_digest TEXT,actor TEXT,worktree_id TEXT,scope_kind TEXT NOT NULL,pre_view_oid TEXT NOT NULL,post_view_oid TEXT NOT NULL,restores_op_id TEXT,reverts_op_id TEXT,predecessor_map_oid TEXT,causal_context_id TEXT,start_ts INTEGER NOT NULL,end_ts INTEGER); CREATE TABLE operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,ordinal INTEGER NOT NULL,PRIMARY KEY(op_id,parent_op_id)); CREATE TABLE operation_head(repo_id TEXT NOT NULL,scope_key TEXT NOT NULL,op_id TEXT NOT NULL,generation INTEGER NOT NULL,PRIMARY KEY(repo_id,scope_key,op_id)); CREATE TABLE operation_journal(journal_id TEXT PRIMARY KEY,op_id TEXT NOT NULL,phase TEXT NOT NULL,pre_view_oid TEXT,target_view_oid TEXT,owner TEXT NOT NULL,updated_at INTEGER NOT NULL,recovery_payload TEXT);").await.expect("schema"); + let objects = tempfile::tempdir().expect("objects"); + let store = OperationStore::new( + db, + crate::utils::client_storage::ClientStorage::init_local(objects.path().to_path_buf()), + ); + let middleware = OperationMiddleware::new(store.clone(), "repo", "main", None::<&Path>); + let error = middleware + .run_with_operation::<_, _, ()>( + "commit", + "op", + ObjectHash::from_bytes(&[1; 20]).unwrap(), + ObjectHash::from_bytes(&[2; 20]).unwrap(), + None, + || async { Err("boom".to_string()) }, + ) + .await + .expect_err("failure"); + assert!(matches!(error, MiddlewareError::Action(_))); + assert_eq!( + store.load_operation("op").await.unwrap().unwrap().status, + OperationStatusV2::Failed + ); + } +} diff --git a/src/internal/operation.rs b/src/internal/operation/mod.rs similarity index 97% rename from src/internal/operation.rs rename to src/internal/operation/mod.rs index 96f759473..83bc536c0 100644 --- a/src/internal/operation.rs +++ b/src/internal/operation/mod.rs @@ -1,4 +1,4 @@ -//! Operation service skeleton for command-level audit persistence. +//! Operation services and the v2 operation-log building blocks. //! //! This module defines stable public types for A-6. Commit 2 introduces the //! operation main-table base DAO methods while keeping transaction ownership in @@ -10,6 +10,15 @@ use sea_orm::{ }; use thiserror::Error; +pub mod facet; +pub mod facets; +pub mod middleware; +pub mod runtime; +pub mod snapshot; +pub mod store; +pub mod view; +pub mod working_copy; + use crate::internal::model::{ operation, operation_parent, operation_view, operation_view_ref, operation_view_workspace, }; @@ -375,6 +384,12 @@ impl OperationService { )); } + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationServiceError::Storage(format!("failed to inspect operation schema: {err}")) + })? { + return runtime::find_operation_record(db, op_id).await; + } + let model = operation::Entity::find_by_id(op_id.to_string()) .one(db) .await @@ -421,6 +436,20 @@ impl OperationService { "limit must be greater than 0".to_string(), )); } + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationServiceError::Storage(format!("failed to inspect operation schema: {err}")) + })? { + return runtime::recent_duplicate_candidates( + db, + repo_id, + worktree_id, + command_name, + args_digest, + earliest_end_ts, + limit, + ) + .await; + } let mut query = operation::Entity::find().filter(operation::Column::RepoId.eq(repo_id)); if let Some(worktree_id) = worktree_id { query = query.filter(operation::Column::WorktreeId.eq(worktree_id)); @@ -463,6 +492,12 @@ impl OperationService { )); } + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationServiceError::Storage(format!("failed to inspect operation schema: {err}")) + })? { + return runtime::list_operation_records(db, repo_id, limit).await; + } + let models = Self::apply_repo_operation_order( operation::Entity::find().filter(operation::Column::RepoId.eq(repo_id)), ) @@ -505,6 +540,12 @@ impl OperationService { )); } + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationServiceError::Storage(format!("failed to inspect operation schema: {err}")) + })? { + return runtime::list_operations_by_repo_paginated(db, repo_id, None, query).await; + } + let command_name = command_name .map(str::trim) .filter(|value| !value.is_empty()); @@ -1049,6 +1090,12 @@ impl OperationService { db: &C, op_id: &str, ) -> Result, OperationServiceError> { + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationServiceError::Storage(format!("failed to inspect operation schema: {err}")) + })? { + return runtime::load_graph(db, op_id).await; + } + let operation = match Self::find_operation_by_id_with_conn(db, op_id).await? { Some(record) => record, None => return Ok(None), diff --git a/src/internal/operation/runtime.rs b/src/internal/operation/runtime.rs new file mode 100644 index 000000000..90272cfa9 --- /dev/null +++ b/src/internal/operation/runtime.rs @@ -0,0 +1,809 @@ +//! Runtime adapter for the OL-09 v2 operation boundary. +//! +//! The v2 migration deliberately replaces the v1 operation tables. This +//! module is the small seam needed by the existing command transaction APIs +//! while their callers are being moved to the v2 boundary. It never creates +//! or queries a v1-only column. + +use std::{ + collections::BTreeMap, + future::Future, + str::FromStr, + sync::{Arc, Mutex}, +}; + +use git_internal::{hash::ObjectHash, internal::object::types::ObjectType}; +use sea_orm::{ + ColumnTrait, ConnectionTrait, DatabaseConnection, DbErr, EntityTrait, PaginatorTrait, + QueryFilter, QueryOrder, QuerySelect, Statement, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + OperationGraphRecord, OperationLogListItem, OperationPage, OperationQueryPage, OperationRecord, + OperationServiceError, OperationStatus, OperationViewRecord, OperationViewRefRecord, + OperationViewWorkspaceRecord, + middleware::{MiddlewareError, MutationClass, OperationMiddleware}, + store::{ + OpHead, OperationKind, OperationMetaV2, OperationStatusV2, OperationStore, OperationV2, + unix_now, + }, + view::RepoViewV2, +}; +use crate::{ + internal::{ + branch::Branch, + head::Head, + model::{operation_head, operation_parent_v2, operation_v2, reference}, + }, + utils::client_storage::ClientStorage, +}; + +const REFS_FACET_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeRefsFacet { + schema_version: u32, + head_kind: String, + head_target: String, + refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeRef { + ref_kind: String, + ref_name: String, + ref_remote: Option, + target_oid: String, +} + +#[derive(Debug, Clone)] +pub struct CapturedView { + pub manifest_oid: ObjectHash, + pub snapshot: super::super::operation_wrapper::OperationViewSnapshot, +} + +/// Failure returned by the dispatch adapters. The original command/tool +/// error is retained so adding the operation envelope does not change the +/// user-visible failure semantics. +#[derive(Debug)] +pub enum RuntimeOperationError { + Middleware(MiddlewareError), + Action(E), +} + +fn middleware_command_name(class: MutationClass) -> &'static str { + match class { + MutationClass::ReadOnly => "status", + MutationClass::WorkingCopy => "add", + MutationClass::Repository => "commit", + MutationClass::Ref => "branch", + MutationClass::Index => "index", + MutationClass::External => "external", + MutationClass::InternalWorker => "internal-worker", + } +} + +/// Run one mutating CLI/Agent dispatch through the v2 operation middleware. +/// +/// The middleware creates the durable operation before the handler runs. The +/// final view is captured after the handler so the operation row points at +/// the actual post-state, while the middleware still provides the journal, +/// failure status, and head CAS semantics around the action. +#[allow(clippy::too_many_arguments)] +pub async fn run_cli_operation( + db: &DatabaseConnection, + repo_id: &str, + scope_key: &str, + command_name: &str, + description: &str, + actor: &str, + args_digest: Option, + class: MutationClass, + action: F, +) -> Result> +where + F: FnOnce() -> Fut, + Fut: Future>, + E: Send + 'static, +{ + let schema_is_v2 = is_v2_schema(db).await.map_err(|error| { + RuntimeOperationError::Middleware(MiddlewareError::Action(error.to_string())) + })?; + if !schema_is_v2 { + return action().await.map_err(RuntimeOperationError::Action); + } + if matches!( + class, + MutationClass::ReadOnly | MutationClass::InternalWorker + ) { + return action().await.map_err(RuntimeOperationError::Action); + } + + let pre = capture_view(db, repo_id, true, true) + .await + .map_err(|error| { + RuntimeOperationError::Middleware(MiddlewareError::Action(error.to_string())) + })?; + let operation_id = uuid::Uuid::now_v7().to_string(); + let storage = ClientStorage::init(crate::utils::path::objects()); + let middleware = OperationMiddleware::new( + OperationStore::new(db.clone(), storage), + repo_id, + scope_key, + Option::<&std::path::Path>::None, + ); + let action_error = Arc::new(Mutex::new(None)); + let action_error_slot = Arc::clone(&action_error); + let result = middleware + .run_with_operation( + middleware_command_name(class), + operation_id.clone(), + pre.manifest_oid, + pre.manifest_oid, + None, + || async move { + action().await.map_err(|error| { + *action_error_slot + .lock() + .unwrap_or_else(|poison| poison.into_inner()) = Some(error); + "dispatch action failed".to_string() + }) + }, + ) + .await; + + let value = match result { + Ok(value) => value, + Err(MiddlewareError::Action(message)) => { + if let Some(error) = action_error + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .take() + { + return Err(RuntimeOperationError::Action(error)); + } + return Err(RuntimeOperationError::Middleware(MiddlewareError::Action( + message, + ))); + } + Err(error) => return Err(RuntimeOperationError::Middleware(error)), + }; + + let post = capture_view(db, repo_id, true, true) + .await + .map_err(|error| { + RuntimeOperationError::Middleware(MiddlewareError::Action(error.to_string())) + })?; + OperationStore::complete_operation_with_conn( + db, + &operation_id, + post.manifest_oid, + OperationStatusV2::Success, + unix_now(), + ) + .await + .map_err(|error| RuntimeOperationError::Middleware(MiddlewareError::Store(error)))?; + let scope_kind = if scope_key.trim().is_empty() { + "main" + } else { + "linked" + }; + OperationStore::update_operation_metadata_with_conn( + db, + &operation_id, + Some(command_name.to_string()), + Some(description.to_string()), + args_digest, + Some(actor.to_string()), + (!scope_key.is_empty()).then(|| scope_key.to_string()), + scope_kind.to_string(), + ) + .await + .map_err(|error| RuntimeOperationError::Middleware(MiddlewareError::Store(error)))?; + Ok(value) +} + +/// Detect the post-OL-02 schema without selecting a removed v1 column. +pub async fn is_v2_schema(db: &C) -> Result { + let rows = db + .query_all_raw(Statement::from_string( + sea_orm::DbBackend::Sqlite, + "PRAGMA table_info(operation)".to_string(), + )) + .await?; + Ok(rows.iter().any(|row| { + row.try_get_by_index::(1) + .is_ok_and(|column| column == "format_version") + })) +} + +/// Capture the stable repository portion of a v2 view. +/// +/// The existing restore contract is HEAD plus local and remote refs. This +/// first runtime cut stores that state as a content-addressed refs facet and +/// publishes a v2 repository-view manifest. Workspace facets remain owned by +/// the snapshotter and are added by the later restore work. +pub async fn capture_view( + db: &C, + repo_id: &str, + include_refs: bool, + include_remote_tracking: bool, +) -> Result { + let head = Head::current_result_with_conn(db) + .await + .map_err(|error| DbErr::Custom(format!("failed to resolve HEAD: {error}")))?; + let (head_kind, head_target) = match head { + Head::Branch(name) => ("branch".to_string(), name), + Head::Detached(hash) => ("detached".to_string(), hash.to_string()), + }; + + let mut refs = Vec::new(); + if include_refs { + for branch in Branch::list_branches_result_with_conn(db, None) + .await + .map_err(|error| DbErr::Custom(format!("failed to list local branches: {error}")))? + { + refs.push(RuntimeRef { + ref_kind: "branch".to_string(), + ref_name: branch.name, + ref_remote: None, + target_oid: branch.commit.to_string(), + }); + } + if include_remote_tracking { + for remote_ref in reference::Entity::find() + .filter(reference::Column::Kind.eq(reference::ConfigKind::Branch)) + .filter(reference::Column::Remote.is_not_null()) + .all(db) + .await? + { + let (Some(name), Some(commit)) = (remote_ref.name, remote_ref.commit) else { + continue; + }; + refs.push(RuntimeRef { + ref_kind: "remote_branch".to_string(), + ref_name: name, + ref_remote: remote_ref.remote, + target_oid: commit, + }); + } + } + } + refs.sort_by(|left, right| { + left.ref_kind + .cmp(&right.ref_kind) + .then_with(|| left.ref_name.cmp(&right.ref_name)) + .then_with(|| left.ref_remote.cmp(&right.ref_remote)) + .then_with(|| left.target_oid.cmp(&right.target_oid)) + }); + + let facet = RuntimeRefsFacet { + schema_version: REFS_FACET_SCHEMA_VERSION, + head_kind: head_kind.clone(), + head_target: head_target.clone(), + refs: refs.clone(), + }; + let facet_bytes = serde_json::to_vec(&facet) + .map_err(|error| DbErr::Custom(format!("failed to encode refs facet: {error}")))?; + let storage = ClientStorage::init_local(crate::utils::path::objects()); + let facet_oid = ObjectHash::from_type_and_data(ObjectType::Blob, &facet_bytes); + storage + .put_without_index(&facet_oid, &facet_bytes, ObjectType::Blob) + .map_err(|error| DbErr::Custom(format!("failed to store refs facet: {error}")))?; + + let view = RepoViewV2::new( + repo_id.to_string(), + facet_oid, + BTreeMap::new(), + Vec::new(), + BTreeMap::new(), + ) + .map_err(|error| DbErr::Custom(format!("failed to construct repository view: {error}")))?; + let manifest_bytes = view + .canonical_bytes() + .map_err(|error| DbErr::Custom(format!("failed to encode repository view: {error}")))?; + let manifest_oid = ObjectHash::from_type_and_data(ObjectType::Blob, &manifest_bytes); + storage + .put_without_index(&manifest_oid, &manifest_bytes, ObjectType::Blob) + .map_err(|error| DbErr::Custom(format!("failed to store repository view: {error}")))?; + + let view_id = manifest_oid.to_string(); + let refs = refs + .into_iter() + .map(|record| OperationViewRefRecord { + view_id: view_id.clone(), + ref_kind: record.ref_kind, + ref_name: record.ref_name, + ref_remote: record.ref_remote, + target_oid: record.target_oid, + }) + .collect(); + let workspace = vec![OperationViewWorkspaceRecord { + view_id: view_id.clone(), + pointer_kind: "head".to_string(), + pointer_value: head_target.clone(), + }]; + Ok(CapturedView { + manifest_oid, + snapshot: super::super::operation_wrapper::OperationViewSnapshot { + head_kind, + head_target, + refs, + workspace, + }, + }) +} + +fn storage_error(error: impl std::fmt::Display) -> OperationServiceError { + OperationServiceError::Storage(error.to_string()) +} + +fn status_from_v2(status: OperationStatusV2) -> OperationStatus { + match status { + OperationStatusV2::Running => OperationStatus::Running, + OperationStatusV2::Success => OperationStatus::Succeeded, + OperationStatusV2::Failed | OperationStatusV2::Partial | OperationStatusV2::Aborted => { + OperationStatus::Failed + } + } +} + +fn operation_record_from_v2(operation: &OperationV2) -> OperationRecord { + OperationRecord { + op_id: operation.op_id.clone(), + repo_id: operation.repo_id.clone(), + view_id: operation.post_view_oid.to_string(), + command_name: operation + .metadata + .command_name + .clone() + .unwrap_or_else(|| "unknown".to_string()), + description: operation.metadata.description.clone().unwrap_or_default(), + actor: operation.metadata.actor.clone().unwrap_or_default(), + args_digest: operation.metadata.args_digest.clone(), + start_ts: operation.start_ts, + end_ts: operation.end_ts, + status: status_from_v2(operation.status), + worktree_id: operation.metadata.worktree_id.clone().unwrap_or_default(), + scope_provenance: "declared".to_string(), + restorable: matches!( + operation.kind, + OperationKind::Command | OperationKind::Restore + ), + control_slot: None, + claim_owner: None, + scope_kind: operation.metadata.scope_kind.clone(), + } +} + +fn log_item_from_v2(operation: &OperationV2) -> OperationLogListItem { + OperationLogListItem { + op_id: operation.op_id.clone(), + command_name: operation + .metadata + .command_name + .clone() + .unwrap_or_else(|| "unknown".to_string()), + description: operation.metadata.description.clone().unwrap_or_default(), + actor: operation.metadata.actor.clone().unwrap_or_default(), + end_ts: operation.end_ts, + status: status_from_v2(operation.status), + } +} + +pub async fn list_operations_by_repo_paginated( + db: &C, + repo_id: &str, + command_name: Option<&str>, + query: OperationQueryPage, +) -> Result, OperationServiceError> { + if repo_id.trim().is_empty() { + return Err(OperationServiceError::InvalidArgument( + "repo_id must not be empty".to_string(), + )); + } + let query = query.normalized(); + let mut count_query = + operation_v2::Entity::find().filter(operation_v2::Column::RepoId.eq(repo_id)); + let mut list_query = + operation_v2::Entity::find().filter(operation_v2::Column::RepoId.eq(repo_id)); + if let Some(command_name) = command_name.map(str::trim).filter(|name| !name.is_empty()) { + count_query = count_query.filter(operation_v2::Column::CommandName.eq(command_name)); + list_query = list_query.filter(operation_v2::Column::CommandName.eq(command_name)); + } + let total = count_query.count(db).await.map_err(storage_error)?; + let models = list_query + .order_by_desc(operation_v2::Column::EndTs) + .order_by_desc(operation_v2::Column::StartTs) + .order_by_desc(operation_v2::Column::OpId) + .offset(query.offset()) + .limit(query.per_page) + .all(db) + .await + .map_err(storage_error)?; + let mut items = Vec::with_capacity(models.len()); + for model in models { + let operation = operation_from_model(model, Vec::new()).map_err(storage_error)?; + items.push(log_item_from_v2(&operation)); + } + Ok(OperationPage { + items, + page: query.page, + per_page: query.per_page, + total, + }) +} + +/// Load the v2 operation row using the legacy service's record shape. +/// +/// The v1 `OperationService` remains part of the public internal surface +/// until OL-15 removes it. Keeping this conversion here means callers that +/// have not yet moved to the v2 model do not issue a v1-only `operation.view_id` +/// query against a v2 repository. +pub async fn find_operation_record( + db: &C, + op_id: &str, +) -> Result, OperationServiceError> { + load_v2_operation(db, op_id) + .await + .map(|operation| operation.map(|operation| operation_record_from_v2(&operation))) +} + +/// List v2 operation rows using the legacy record shape. +pub async fn list_operation_records( + db: &C, + repo_id: &str, + limit: u64, +) -> Result, OperationServiceError> { + if limit == 0 { + return Err(OperationServiceError::InvalidArgument( + "limit must be greater than 0".to_string(), + )); + } + let models = operation_v2::Entity::find() + .filter(operation_v2::Column::RepoId.eq(repo_id)) + .order_by_desc(operation_v2::Column::EndTs) + .order_by_desc(operation_v2::Column::StartTs) + .order_by_desc(operation_v2::Column::OpId) + .limit(limit) + .all(db) + .await + .map_err(storage_error)?; + models + .into_iter() + .map(|model| { + operation_from_model(model, Vec::new()) + .map(|operation| operation_record_from_v2(&operation)) + }) + .collect() +} + +pub async fn recent_duplicate_candidates( + db: &C, + repo_id: &str, + worktree_id: Option<&str>, + command_name: &str, + args_digest: &str, + earliest_end_ts: i64, + limit: u64, +) -> Result, OperationServiceError> { + if limit == 0 { + return Err(OperationServiceError::InvalidArgument( + "limit must be greater than 0".to_string(), + )); + } + let mut query = operation_v2::Entity::find() + .filter(operation_v2::Column::RepoId.eq(repo_id)) + .filter(operation_v2::Column::CommandName.eq(command_name)) + .filter(operation_v2::Column::ArgsDigest.eq(args_digest)) + .filter(operation_v2::Column::Status.eq(OperationStatusV2::Success.as_str())) + .filter(operation_v2::Column::EndTs.gte(earliest_end_ts)); + if let Some(worktree_id) = worktree_id { + query = query.filter(operation_v2::Column::WorktreeId.eq(worktree_id)); + } + let models = query + .order_by_desc(operation_v2::Column::EndTs) + .order_by_desc(operation_v2::Column::StartTs) + .order_by_desc(operation_v2::Column::OpId) + .limit(limit) + .all(db) + .await + .map_err(storage_error)?; + models + .into_iter() + .map(|model| { + operation_from_model(model, Vec::new()) + .map(|operation| operation_record_from_v2(&operation)) + .map_err(storage_error) + }) + .collect() +} + +pub async fn running_control( + db: &C, + repo_id: &str, + worktree_id: &str, +) -> Result)>, OperationServiceError> { + let row = operation_v2::Entity::find() + .filter(operation_v2::Column::RepoId.eq(repo_id)) + .filter(operation_v2::Column::WorktreeId.eq(worktree_id)) + .filter(operation_v2::Column::Status.eq(OperationStatusV2::Running.as_str())) + .order_by_asc(operation_v2::Column::StartTs) + .one(db) + .await + .map_err(storage_error)?; + Ok(row.map(|row| { + ( + row.op_id, + row.command_name.unwrap_or_else(|| "unknown".to_string()), + row.start_ts, + row.actor, + ) + })) +} + +pub async fn abandon( + db: &C, + op_id: &str, + end_ts: i64, +) -> Result { + let changed = operation_v2::Entity::update_many() + .col_expr( + operation_v2::Column::Status, + sea_orm::sea_query::Expr::value(OperationStatusV2::Failed.as_str()), + ) + .col_expr( + operation_v2::Column::EndTs, + sea_orm::sea_query::Expr::value(end_ts), + ) + .filter(operation_v2::Column::OpId.eq(op_id)) + .filter(operation_v2::Column::Status.eq(OperationStatusV2::Running.as_str())) + .exec(db) + .await + .map_err(storage_error)?; + Ok(changed.rows_affected > 0) +} + +fn operation_from_model( + model: operation_v2::Model, + parent_op_ids: Vec, +) -> Result { + let parse = |value: &str| { + ObjectHash::from_str(value) + .map_err(|_| OperationServiceError::Storage(format!("invalid object id '{value}'"))) + }; + let kind = match model.kind.as_str() { + "command" => OperationKind::Command, + "external_snapshot" => OperationKind::ExternalSnapshot, + "undo" => OperationKind::Undo, + "redo" => OperationKind::Redo, + "restore" => OperationKind::Restore, + "revert" => OperationKind::Revert, + "reconcile" => OperationKind::Reconcile, + other => { + return Err(OperationServiceError::Storage(format!( + "unknown operation kind '{other}'" + ))); + } + }; + let status = match model.status.as_str() { + "running" => OperationStatusV2::Running, + "success" => OperationStatusV2::Success, + "failed" => OperationStatusV2::Failed, + "partial" => OperationStatusV2::Partial, + "aborted" => OperationStatusV2::Aborted, + other => { + return Err(OperationServiceError::Storage(format!( + "unknown operation status '{other}'" + ))); + } + }; + Ok(OperationV2 { + op_id: model.op_id, + repo_id: model.repo_id, + parent_op_ids, + pre_view_oid: parse(&model.pre_view_oid)?, + post_view_oid: parse(&model.post_view_oid)?, + kind, + status, + + metadata: OperationMetaV2 { + command_name: model.command_name, + description: model.description, + args_digest: model.args_digest, + actor: model.actor, + worktree_id: model.worktree_id, + scope_kind: model.scope_kind, + causal_context_id: model.causal_context_id, + }, + restores_op_id: model.restores_op_id, + reverts_op_id: model.reverts_op_id, + predecessor_map_oid: model + .predecessor_map_oid + .as_deref() + .map(parse) + .transpose()?, + start_ts: model.start_ts, + end_ts: model.end_ts, + }) +} + +async fn load_v2_operation( + db: &C, + op_id: &str, +) -> Result, OperationServiceError> { + let Some(model) = operation_v2::Entity::find_by_id(op_id.to_string()) + .one(db) + .await + .map_err(storage_error)? + else { + return Ok(None); + }; + let parents = operation_parent_v2::Entity::find() + .filter(operation_parent_v2::Column::OpId.eq(op_id)) + .order_by_asc(operation_parent_v2::Column::Ordinal) + .all(db) + .await + .map_err(storage_error)? + .into_iter() + .map(|parent| parent.parent_op_id) + .collect(); + Ok(Some(operation_from_model(model, parents)?)) +} + +fn decode_view( + storage: &ClientStorage, + operation: &OperationV2, +) -> Result<(OperationViewRecord, Vec), OperationServiceError> { + let bytes = storage + .get(&operation.post_view_oid) + .map_err(storage_error)?; + let view = RepoViewV2::from_canonical_bytes(&bytes).map_err(storage_error)?; + let facet_bytes = storage.get(&view.refs_facet_oid).map_err(storage_error)?; + let facet: RuntimeRefsFacet = serde_json::from_slice(&facet_bytes).map_err(storage_error)?; + if facet.schema_version != REFS_FACET_SCHEMA_VERSION { + return Err(OperationServiceError::Storage(format!( + "unsupported refs facet schema version {}", + facet.schema_version + ))); + } + let view_id = operation.post_view_oid.to_string(); + let view_record = OperationViewRecord { + view_id: view_id.clone(), + repo_id: operation.repo_id.clone(), + head_kind: facet.head_kind, + head_target: facet.head_target, + created_at: operation.end_ts.unwrap_or(operation.start_ts), + }; + let refs = facet + .refs + .into_iter() + .map(|record| OperationViewRefRecord { + view_id: view_id.clone(), + ref_kind: record.ref_kind, + ref_name: record.ref_name, + ref_remote: record.ref_remote, + target_oid: record.target_oid, + }) + .collect(); + Ok((view_record, refs)) +} + +pub async fn load_graph( + db: &C, + op_id: &str, +) -> Result, OperationServiceError> { + let Some(operation) = load_v2_operation(db, op_id).await? else { + return Ok(None); + }; + let storage = ClientStorage::init(crate::utils::path::objects()); + let (view, refs) = decode_view(&storage, &operation)?; + let parents = operation + .parent_op_ids + .iter() + .map(|parent_op_id| super::OperationParentRecord { + op_id: operation.op_id.clone(), + parent_op_id: parent_op_id.clone(), + }) + .collect(); + let workspace = vec![OperationViewWorkspaceRecord { + view_id: view.view_id.clone(), + pointer_kind: "head".to_string(), + pointer_value: view.head_target.clone(), + }]; + Ok(Some(OperationGraphRecord { + operation: operation_record_from_v2(&operation), + parents, + view, + refs, + workspace, + })) +} + +/// Persist the v2 equivalent of the old graph-shaped command result. +pub async fn persist_graph( + db: &C, + graph: &OperationGraphRecord, +) -> Result { + let post_view_oid = ObjectHash::from_str(&graph.view.view_id).map_err(|_| { + OperationServiceError::Storage(format!("invalid v2 view id '{}'", graph.view.view_id)) + })?; + let pre_view_oid = if let Some(parent) = graph.parents.first() { + operation_v2::Entity::find_by_id(parent.parent_op_id.clone()) + .one(db) + .await + .map_err(storage_error)? + .and_then(|row| ObjectHash::from_str(&row.post_view_oid).ok()) + .unwrap_or(post_view_oid) + } else { + post_view_oid + }; + let operation = OperationV2 { + op_id: graph.operation.op_id.clone(), + repo_id: graph.operation.repo_id.clone(), + parent_op_ids: graph + .parents + .iter() + .map(|parent| parent.parent_op_id.clone()) + .collect(), + pre_view_oid, + post_view_oid, + kind: if graph.operation.command_name == "op restore" { + OperationKind::Restore + } else { + OperationKind::Command + }, + status: match graph.operation.status { + OperationStatus::Running => OperationStatusV2::Running, + OperationStatus::Succeeded => OperationStatusV2::Success, + OperationStatus::Failed => OperationStatusV2::Failed, + OperationStatus::Canceled => OperationStatusV2::Aborted, + }, + metadata: OperationMetaV2 { + command_name: Some(graph.operation.command_name.clone()), + description: Some(graph.operation.description.clone()), + args_digest: graph.operation.args_digest.clone(), + actor: Some(graph.operation.actor.clone()), + worktree_id: Some(graph.operation.worktree_id.clone()), + scope_kind: graph.operation.scope_kind.clone(), + ..OperationMetaV2::default() + }, + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + start_ts: graph.operation.start_ts, + end_ts: graph.operation.end_ts, + }; + OperationStore::write_operation_with_conn(db, &operation) + .await + .map_err(storage_error)?; + if operation.status == OperationStatusV2::Success { + let current = operation_head::Entity::find() + .filter(operation_head::Column::RepoId.eq(&operation.repo_id)) + .filter(operation_head::Column::ScopeKey.eq(graph.operation.worktree_id.clone())) + .all(db) + .await + .map_err(storage_error)?; + let generation = current + .iter() + .map(|head| head.generation) + .max() + .unwrap_or(0) + + 1; + OperationStore::replace_op_heads_with_conn( + db, + &operation.repo_id, + &graph.operation.worktree_id, + &[OpHead { + op_id: operation.op_id.clone(), + generation, + }], + ) + .await + .map_err(storage_error)?; + } + Ok(graph.clone()) +} diff --git a/src/internal/operation/snapshot.rs b/src/internal/operation/snapshot.rs new file mode 100644 index 000000000..749447557 --- /dev/null +++ b/src/internal/operation/snapshot.rs @@ -0,0 +1,381 @@ +//! Bounded working-copy capture into a v2 workspace snapshot. +//! +//! Capture is a snapshot operation, not a commit: it writes content-addressed +//! blobs and a `WorkspaceSnapshotV2` manifest, but never creates a Commit OID +//! or a Change ID. Directory enumeration is delegated to the read-only +//! `WorktreeIo` executor; policy and byte/file budgets are enforced here. + +use std::{ + fs, io, + path::{Component, Path, PathBuf}, + time::Duration, +}; + +use git_internal::{hash::ObjectHash, internal::object::types::ObjectType}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + internal::{ + operation::view::{CapturePolicy, Completeness, HeadState, WorkspaceSnapshotV2}, + worktree_io::{ + default_worktree_io, + executor::ExecutorError, + protocol::{IoEvent, IoRequest, bytes_to_path, path_to_bytes}, + }, + }, + utils::client_storage::ClientStorage, +}; + +const MAX_FILES: usize = 10_000; +const MAX_BYTES: u64 = 64 * 1024 * 1024; +const SCAN_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Error)] +pub enum SnapshotError { + #[error("snapshot I/O error: {0}")] + Io(#[from] io::Error), + #[error("snapshot worktree I/O error: {0}")] + WorktreeIo(String), + #[error("snapshot object storage error: {0}")] + Storage(String), + #[error("snapshot manifest error: {0}")] + Manifest(String), + #[error("snapshot path is not a safe relative path: {0}")] + UnsafePath(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotCapture { + pub manifest: WorkspaceSnapshotV2, + pub manifest_oid: ObjectHash, + pub file_count: usize, + pub byte_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileEntry { + path: Vec, + oid: String, + len: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileManifest { + schema_version: u32, + files: Vec, +} + +pub struct WorkspaceSnapshotter { + storage: ClientStorage, + max_files: usize, + max_bytes: u64, +} + +impl WorkspaceSnapshotter { + pub fn new(storage: ClientStorage) -> Self { + Self { + storage, + max_files: MAX_FILES, + max_bytes: MAX_BYTES, + } + } + + #[cfg(test)] + fn with_limits(storage: ClientStorage, max_files: usize, max_bytes: u64) -> Self { + Self { + storage, + max_files, + max_bytes, + } + } + + /// Scan and persist one workspace snapshot. An empty root with no index + /// has no observable state and returns `Ok(None)`, so callers do not emit + /// a no-op operation. + pub fn capture( + &self, + root: &Path, + workspace_id: impl Into, + head: HeadState, + capture_policy: CapturePolicy, + generation: u64, + ) -> Result, SnapshotError> { + let root = root.canonicalize()?; + let (paths, mut partial) = self.scan_working_copy(&root)?; + let had_files = !paths.is_empty(); + let mut entries = Vec::new(); + let mut byte_count = 0; + for path in paths { + if entries.len() >= self.max_files || byte_count >= self.max_bytes { + partial = true; + break; + } + let data = fs::read(root.join(&path))?; + let len = u64::try_from(data.len()).map_err(|_| io::Error::other("file too large"))?; + if byte_count.saturating_add(len) > self.max_bytes { + partial = true; + break; + } + byte_count += len; + let oid = ObjectHash::from_type_and_data(ObjectType::Blob, &data); + self.storage + .put(&oid, &data, ObjectType::Blob) + .map_err(|error| SnapshotError::Storage(error.to_string()))?; + entries.push(FileEntry { + path: path_to_bytes(&path), + oid: oid.to_string(), + len, + }); + } + + let index_data = fs::read(root.join(".git/index")).unwrap_or_default(); + if entries.is_empty() && index_data.is_empty() && !had_files { + return Ok(None); + } + let index_oid = self.put_blob(&index_data)?; + let files = FileManifest { + schema_version: 1, + files: entries, + }; + let file_manifest_data = serde_json::to_vec(&files) + .map_err(|error| SnapshotError::Manifest(error.to_string()))?; + let untracked_manifest_oid = self.put_blob(&file_manifest_data)?; + let working_copy_tree_oid = self.put_blob(&file_manifest_data)?; + let mut manifest = WorkspaceSnapshotV2::new( + workspace_id.into(), + head, + index_oid, + index_oid, + working_copy_tree_oid, + untracked_manifest_oid, + ) + .map_err(|error| SnapshotError::Manifest(error.to_string()))?; + manifest.capture_policy = capture_policy; + manifest.worktree_generation = generation; + manifest.completeness = if partial || capture_policy == CapturePolicy::FailClosed { + Completeness::Partial + } else { + Completeness::Full + }; + let manifest_oid = manifest + .write_manifest(&self.storage) + .map_err(|error| SnapshotError::Manifest(error.to_string()))?; + Ok(Some(SnapshotCapture { + manifest, + manifest_oid, + file_count: files.files.len(), + byte_count, + })) + } + + /// Restore file content from a snapshot manifest. This intentionally does + /// not alter refs, index state, or create a Git commit. + pub fn restore( + &self, + root: &Path, + snapshot: &WorkspaceSnapshotV2, + ) -> Result<(), SnapshotError> { + let metadata = fs::symlink_metadata(root)?; + if metadata.file_type().is_symlink() { + return Err(SnapshotError::UnsafePath(root.display().to_string())); + } + let root = root.canonicalize()?; + let bytes = self + .storage + .get(&snapshot.untracked_manifest_oid) + .map_err(|error| SnapshotError::Storage(error.to_string()))?; + let file_manifest: FileManifest = serde_json::from_slice(&bytes) + .map_err(|error| SnapshotError::Manifest(error.to_string()))?; + for entry in file_manifest.files { + let relative = bytes_to_path(&entry.path); + ensure_safe_relative(&relative)?; + let oid = entry + .oid + .parse() + .map_err(|_| SnapshotError::Manifest("invalid file object id".to_string()))?; + let data = self + .storage + .get(&oid) + .map_err(|error| SnapshotError::Storage(error.to_string()))?; + if u64::try_from(data.len()).unwrap_or(u64::MAX) != entry.len { + return Err(SnapshotError::Manifest(format!( + "file length changed for {}", + relative.display() + ))); + } + let destination = root.join(&relative); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::write(destination, data)?; + } + Ok(()) + } + + fn put_blob(&self, data: &[u8]) -> Result { + let oid = ObjectHash::from_type_and_data(ObjectType::Blob, data); + self.storage + .put(&oid, data, ObjectType::Blob) + .map_err(|error| SnapshotError::Storage(error.to_string()))?; + Ok(oid) + } + + fn scan_working_copy(&self, root: &Path) -> Result<(Vec, bool), SnapshotError> { + let io = default_worktree_io(); + let mut queue = vec![PathBuf::new()]; + let mut files = Vec::new(); + let mut partial = false; + while let Some(relative) = queue.pop() { + let events = io + .submit( + IoRequest::ReadDir { + path: path_to_bytes(&relative), + root: path_to_bytes(root), + remaining: self.max_files.saturating_sub(files.len()).max(1), + checkpoint_every: 128, + }, + path_to_bytes(&relative), + SCAN_TIMEOUT, + ) + .map_err(|error: ExecutorError| SnapshotError::WorktreeIo(error.to_string()))?; + let mut scanned_entries = Vec::new(); + let mut listing = None; + for event in events { + match event { + IoEvent::RecordDirent(entry) => scanned_entries.push(entry), + IoEvent::DoneReadDir { listing: result } => listing = Some(result), + _ => {} + } + } + let mut listing = listing.ok_or_else(|| { + SnapshotError::WorktreeIo("worker returned no directory listing".to_string()) + })?; + listing.entries = scanned_entries; + partial |= listing.hit_cap || listing.timed_out || !listing.error_kinds.is_empty(); + for entry in listing.entries { + let name = bytes_to_path(&entry.name); + if name == Path::new(".git") || name == Path::new(".libra") { + continue; + } + let child = relative.join(name); + if entry.is_dir { + queue.push(child); + } else if entry.is_file && !entry.is_symlink { + ensure_safe_relative(&child)?; + files.push(child); + if files.len() >= self.max_files { + partial = true; + break; + } + } + } + } + files.sort(); + Ok((files, partial)) + } +} + +fn ensure_safe_relative(path: &Path) -> Result<(), SnapshotError> { + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(SnapshotError::UnsafePath(path.display().to_string())); + } + if path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(SnapshotError::UnsafePath(path.display().to_string())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_worktree_does_not_publish() { + let root = tempfile::tempdir().expect("root"); + let objects = tempfile::tempdir().expect("objects"); + let snapshotter = + WorkspaceSnapshotter::new(ClientStorage::init_local(objects.path().to_path_buf())); + assert!( + snapshotter + .capture( + root.path(), + "ws", + HeadState::Unborn { + ref_name: "main".to_string() + }, + CapturePolicy::TrackedAndUntracked, + 0 + ) + .expect("capture") + .is_none() + ); + } + + #[test] + fn capture_and_restore_roundtrip() { + let root = tempfile::tempdir().expect("root"); + fs::create_dir(root.path().join("nested")).expect("mkdir"); + fs::write(root.path().join("tracked.txt"), b"before").expect("write"); + fs::write(root.path().join("nested/untracked.txt"), b"untracked").expect("write"); + let objects = tempfile::tempdir().expect("objects"); + let snapshotter = + WorkspaceSnapshotter::new(ClientStorage::init_local(objects.path().to_path_buf())); + let capture = snapshotter + .capture( + root.path(), + "ws", + HeadState::Unborn { + ref_name: "main".to_string(), + }, + CapturePolicy::TrackedAndUntracked, + 1, + ) + .expect("capture") + .expect("snapshot"); + fs::write(root.path().join("tracked.txt"), b"changed").expect("modify"); + fs::remove_file(root.path().join("nested/untracked.txt")).expect("remove"); + snapshotter + .restore(root.path(), &capture.manifest) + .expect("restore"); + assert_eq!( + fs::read(root.path().join("tracked.txt")).expect("read"), + b"before" + ); + assert_eq!( + fs::read(root.path().join("nested/untracked.txt")).expect("read"), + b"untracked" + ); + assert_eq!(capture.file_count, 2); + } + + #[test] + fn capacity_marks_partial() { + let root = tempfile::tempdir().expect("root"); + fs::write(root.path().join("one"), b"1234").expect("write"); + let objects = tempfile::tempdir().expect("objects"); + let snapshotter = WorkspaceSnapshotter::with_limits( + ClientStorage::init_local(objects.path().to_path_buf()), + 1, + 2, + ); + let capture = snapshotter + .capture( + root.path(), + "ws", + HeadState::Unborn { + ref_name: "main".to_string(), + }, + CapturePolicy::TrackedAndUntracked, + 1, + ) + .expect("capture") + .expect("snapshot"); + assert_eq!(capture.manifest.completeness, Completeness::Partial); + } +} diff --git a/src/internal/operation/store.rs b/src/internal/operation/store.rs new file mode 100644 index 000000000..7648ae680 --- /dev/null +++ b/src/internal/operation/store.rs @@ -0,0 +1,776 @@ +//! Operation Log v2 persistence, journal, and op-head compare-and-swap. + +use std::{ + str::FromStr, + time::{SystemTime, UNIX_EPOCH}, +}; + +use git_internal::hash::ObjectHash; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, + IntoActiveModel, QueryFilter, QueryOrder, TransactionTrait, +}; +use thiserror::Error; + +use crate::{ + internal::{ + model::{operation_head, operation_journal, operation_parent_v2, operation_v2}, + operation::view::{RepoViewV2, ViewCodecError}, + }, + utils::client_storage::ClientStorage, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationKind { + Command, + ExternalSnapshot, + Undo, + Redo, + Restore, + Revert, + Reconcile, +} + +impl OperationKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Command => "command", + Self::ExternalSnapshot => "external_snapshot", + Self::Undo => "undo", + Self::Redo => "redo", + Self::Restore => "restore", + Self::Revert => "revert", + Self::Reconcile => "reconcile", + } + } + + fn parse(value: &str) -> Result { + match value { + "command" => Ok(Self::Command), + "external_snapshot" => Ok(Self::ExternalSnapshot), + "undo" => Ok(Self::Undo), + "redo" => Ok(Self::Redo), + "restore" => Ok(Self::Restore), + "revert" => Ok(Self::Revert), + "reconcile" => Ok(Self::Reconcile), + other => Err(StoreError::Corrupt(format!( + "unknown operation kind '{other}'" + ))), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationStatusV2 { + Running, + Success, + Failed, + Partial, + Aborted, +} + +impl OperationStatusV2 { + pub const fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Success => "success", + Self::Failed => "failed", + Self::Partial => "partial", + Self::Aborted => "aborted", + } + } + + fn parse(value: &str) -> Result { + match value { + "running" => Ok(Self::Running), + "success" => Ok(Self::Success), + "failed" => Ok(Self::Failed), + "partial" => Ok(Self::Partial), + "aborted" => Ok(Self::Aborted), + other => Err(StoreError::Corrupt(format!( + "unknown operation status '{other}'" + ))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct OperationMetaV2 { + pub command_name: Option, + pub description: Option, + pub args_digest: Option, + pub actor: Option, + pub worktree_id: Option, + pub scope_kind: String, + pub causal_context_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationV2 { + pub op_id: String, + pub repo_id: String, + pub parent_op_ids: Vec, + pub pre_view_oid: ObjectHash, + pub post_view_oid: ObjectHash, + pub kind: OperationKind, + pub status: OperationStatusV2, + pub metadata: OperationMetaV2, + pub restores_op_id: Option, + pub reverts_op_id: Option, + pub predecessor_map_oid: Option, + pub start_ts: i64, + pub end_ts: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpHead { + pub op_id: String, + pub generation: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationJournalEntry { + pub journal_id: String, + pub op_id: String, + pub phase: JournalPhase, + pub pre_view_oid: Option, + pub target_view_oid: Option, + pub owner: String, + pub updated_at: i64, + pub recovery_payload: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JournalPhase { + Reserved, + PreView, + Mutation, + PostView, + Publish, +} + +impl JournalPhase { + pub const fn as_str(self) -> &'static str { + match self { + Self::Reserved => "reserved", + Self::PreView => "pre_view", + Self::Mutation => "mutation", + Self::PostView => "post_view", + Self::Publish => "publish", + } + } + + fn parse(value: &str) -> Result { + match value { + "reserved" => Ok(Self::Reserved), + "pre_view" => Ok(Self::PreView), + "mutation" => Ok(Self::Mutation), + "post_view" => Ok(Self::PostView), + "publish" => Ok(Self::Publish), + other => Err(StoreError::Corrupt(format!( + "unknown journal phase '{other}'" + ))), + } + } +} + +#[derive(Debug, Error)] +pub enum StoreError { + #[error("operation argument is invalid: {0}")] + InvalidArgument(String), + #[error("operation database error: {0}")] + Database(#[from] sea_orm::DbErr), + #[error("operation storage error: {0}")] + Storage(String), + #[error("operation data is corrupt: {0}")] + Corrupt(String), + #[error("operation head CAS conflict; current heads: {current:?}")] + CasConflict { current: Vec }, + #[error("operation object {0} has an invalid hash")] + InvalidObjectHash(String), +} + +#[derive(Clone)] +pub struct OperationStore { + db: DatabaseConnection, + storage: ClientStorage, +} + +impl std::fmt::Debug for OperationStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OperationStore") + .finish_non_exhaustive() + } +} + +impl OperationStore { + pub fn new(db: DatabaseConnection, storage: ClientStorage) -> Self { + Self { db, storage } + } + + pub fn db(&self) -> &DatabaseConnection { + &self.db + } + + pub fn write_view_manifest(&self, view: &RepoViewV2) -> Result { + view.write_manifest(&self.storage) + .map_err(|error| StoreError::Storage(error.to_string())) + } + + pub fn load_view(&self, view_oid: &ObjectHash) -> Result { + if self + .storage + .get_object_type(view_oid) + .map_err(|error| StoreError::Storage(error.to_string()))? + != git_internal::internal::object::types::ObjectType::Blob + { + return Err(StoreError::Corrupt(format!( + "view object {view_oid} is not a blob" + ))); + } + let bytes = self + .storage + .get(view_oid) + .map_err(|error| StoreError::Storage(error.to_string()))?; + RepoViewV2::from_canonical_bytes(&bytes).map_err(|error| match error { + ViewCodecError::InvalidJson(message) => StoreError::Corrupt(message), + other => StoreError::Corrupt(other.to_string()), + }) + } + + pub async fn write_operation(&self, operation: &OperationV2) -> Result<(), StoreError> { + validate_operation(operation)?; + let txn = self.db.begin().await?; + let model = operation_v2::ActiveModel { + op_id: Set(operation.op_id.clone()), + repo_id: Set(operation.repo_id.clone()), + format_version: Set(2), + kind: Set(operation.kind.as_str().to_string()), + status: Set(operation.status.as_str().to_string()), + command_name: Set(operation.metadata.command_name.clone()), + description: Set(operation.metadata.description.clone()), + args_digest: Set(operation.metadata.args_digest.clone()), + actor: Set(operation.metadata.actor.clone()), + worktree_id: Set(operation.metadata.worktree_id.clone()), + scope_kind: Set(operation.metadata.scope_kind.clone()), + pre_view_oid: Set(operation.pre_view_oid.to_string()), + post_view_oid: Set(operation.post_view_oid.to_string()), + restores_op_id: Set(operation.restores_op_id.clone()), + reverts_op_id: Set(operation.reverts_op_id.clone()), + predecessor_map_oid: Set(operation.predecessor_map_oid.map(|oid| oid.to_string())), + causal_context_id: Set(operation.metadata.causal_context_id.clone()), + start_ts: Set(operation.start_ts), + end_ts: Set(operation.end_ts), + }; + model.insert(&txn).await?; + for (ordinal, parent_op_id) in operation.parent_op_ids.iter().enumerate() { + operation_parent_v2::ActiveModel { + op_id: Set(operation.op_id.clone()), + parent_op_id: Set(parent_op_id.clone()), + ordinal: Set(i32::try_from(ordinal).map_err(|_| { + StoreError::InvalidArgument("too many operation parents".to_string()) + })?), + } + .insert(&txn) + .await?; + } + txn.commit().await?; + Ok(()) + } + + /// Write an operation using a caller-owned connection or transaction. + /// + /// The transaction-taking form is the bridge used by the command runtime: + /// the business mutation and its v2 operation record must commit together. + pub async fn write_operation_with_conn( + db: &C, + operation: &OperationV2, + ) -> Result<(), StoreError> { + validate_operation(operation)?; + let model = operation_v2::ActiveModel { + op_id: Set(operation.op_id.clone()), + repo_id: Set(operation.repo_id.clone()), + format_version: Set(2), + kind: Set(operation.kind.as_str().to_string()), + status: Set(operation.status.as_str().to_string()), + command_name: Set(operation.metadata.command_name.clone()), + description: Set(operation.metadata.description.clone()), + args_digest: Set(operation.metadata.args_digest.clone()), + actor: Set(operation.metadata.actor.clone()), + worktree_id: Set(operation.metadata.worktree_id.clone()), + scope_kind: Set(operation.metadata.scope_kind.clone()), + pre_view_oid: Set(operation.pre_view_oid.to_string()), + post_view_oid: Set(operation.post_view_oid.to_string()), + restores_op_id: Set(operation.restores_op_id.clone()), + reverts_op_id: Set(operation.reverts_op_id.clone()), + predecessor_map_oid: Set(operation.predecessor_map_oid.map(|oid| oid.to_string())), + causal_context_id: Set(operation.metadata.causal_context_id.clone()), + start_ts: Set(operation.start_ts), + end_ts: Set(operation.end_ts), + }; + model.insert(db).await?; + for (ordinal, parent_op_id) in operation.parent_op_ids.iter().enumerate() { + operation_parent_v2::ActiveModel { + op_id: Set(operation.op_id.clone()), + parent_op_id: Set(parent_op_id.clone()), + ordinal: Set(i32::try_from(ordinal).map_err(|_| { + StoreError::InvalidArgument("too many operation parents".to_string()) + })?), + } + .insert(db) + .await?; + } + Ok(()) + } + + /// Replace one scope's operation heads inside a caller-owned transaction. + pub async fn replace_op_heads_with_conn( + db: &C, + repo_id: &str, + scope_key: &str, + heads: &[OpHead], + ) -> Result<(), StoreError> { + operation_head::Entity::delete_many() + .filter(operation_head::Column::RepoId.eq(repo_id.to_string())) + .filter(operation_head::Column::ScopeKey.eq(scope_key.to_string())) + .exec(db) + .await?; + for head in heads { + operation_head::ActiveModel { + repo_id: Set(repo_id.to_string()), + scope_key: Set(scope_key.to_string()), + op_id: Set(head.op_id.clone()), + generation: Set(head.generation), + } + .insert(db) + .await?; + } + Ok(()) + } + + /// Finish an operation in a caller-owned transaction and publish its view. + pub async fn complete_operation_with_conn( + db: &C, + op_id: &str, + post_view_oid: ObjectHash, + status: OperationStatusV2, + end_ts: i64, + ) -> Result<(), StoreError> { + let mut model = operation_v2::Entity::find_by_id(op_id.to_string()) + .one(db) + .await? + .ok_or_else(|| StoreError::InvalidArgument(format!("unknown operation '{op_id}'")))? + .into_active_model(); + model.post_view_oid = Set(post_view_oid.to_string()); + model.status = Set(status.as_str().to_string()); + model.end_ts = Set(Some(end_ts)); + model.update(db).await?; + Ok(()) + } + + /// Update the descriptive fields after a command boundary has run. + /// + /// The middleware must create the operation before the command executes, + /// while the CLI/Agent adapter only knows the final command metadata at + /// the dispatch seam. Keeping this update on the caller's connection + /// preserves the v2-only write path and avoids any v1 column dependency. + #[allow(clippy::too_many_arguments)] + pub async fn update_operation_metadata_with_conn( + db: &C, + op_id: &str, + command_name: Option, + description: Option, + args_digest: Option, + actor: Option, + worktree_id: Option, + scope_kind: String, + ) -> Result<(), StoreError> { + let mut model = operation_v2::Entity::find_by_id(op_id.to_string()) + .one(db) + .await? + .ok_or_else(|| StoreError::InvalidArgument(format!("unknown operation '{op_id}'")))? + .into_active_model(); + model.command_name = Set(command_name); + model.description = Set(description); + model.args_digest = Set(args_digest); + model.actor = Set(actor); + model.worktree_id = Set(worktree_id); + model.scope_kind = Set(scope_kind); + model.update(db).await?; + Ok(()) + } + pub async fn load_operation(&self, op_id: &str) -> Result, StoreError> { + let Some(model) = operation_v2::Entity::find_by_id(op_id.to_string()) + .one(&self.db) + .await? + else { + return Ok(None); + }; + let parents = operation_parent_v2::Entity::find() + .filter(operation_parent_v2::Column::OpId.eq(op_id.to_string())) + .order_by_asc(operation_parent_v2::Column::Ordinal) + .all(&self.db) + .await? + .into_iter() + .map(|parent| parent.parent_op_id) + .collect(); + Ok(Some(operation_from_model(model, parents)?)) + } + + pub async fn update_operation_status( + &self, + op_id: &str, + status: OperationStatusV2, + end_ts: Option, + ) -> Result<(), StoreError> { + let mut model = operation_v2::Entity::find_by_id(op_id.to_string()) + .one(&self.db) + .await? + .ok_or_else(|| StoreError::InvalidArgument(format!("unknown operation '{op_id}'")))? + .into_active_model(); + model.status = Set(status.as_str().to_string()); + model.end_ts = Set(end_ts); + model.update(&self.db).await?; + Ok(()) + } + + pub async fn current_op_heads( + &self, + repo_id: &str, + scope_key: &str, + ) -> Result, StoreError> { + Ok(operation_head::Entity::find() + .filter(operation_head::Column::RepoId.eq(repo_id.to_string())) + .filter(operation_head::Column::ScopeKey.eq(scope_key.to_string())) + .order_by_asc(operation_head::Column::Generation) + .order_by_asc(operation_head::Column::OpId) + .all(&self.db) + .await? + .into_iter() + .map(|row| OpHead { + op_id: row.op_id, + generation: row.generation, + }) + .collect()) + } + + /// Atomically replace the head set when `expected` still matches. If it + /// does not, the proposed heads are retained as additional heads so a + /// concurrent branch is never silently lost. + pub async fn cas_update_op_heads( + &self, + repo_id: &str, + scope_key: &str, + expected: &[OpHead], + proposed: &[OpHead], + ) -> Result, StoreError> { + let txn = self.db.begin().await?; + let current = operation_head::Entity::find() + .filter(operation_head::Column::RepoId.eq(repo_id.to_string())) + .filter(operation_head::Column::ScopeKey.eq(scope_key.to_string())) + .order_by_asc(operation_head::Column::Generation) + .order_by_asc(operation_head::Column::OpId) + .all(&txn) + .await? + .into_iter() + .map(|row| OpHead { + op_id: row.op_id, + generation: row.generation, + }) + .collect::>(); + if current != expected { + for head in proposed { + operation_head::ActiveModel { + repo_id: Set(repo_id.to_string()), + scope_key: Set(scope_key.to_string()), + op_id: Set(head.op_id.clone()), + generation: Set(head.generation), + } + .insert(&txn) + .await?; + } + txn.commit().await?; + let mut merged = current; + for head in proposed { + if !merged.contains(head) { + merged.push(head.clone()); + } + } + merged.sort_by(|left, right| { + left.generation + .cmp(&right.generation) + .then_with(|| left.op_id.cmp(&right.op_id)) + }); + return Err(StoreError::CasConflict { current: merged }); + } + + operation_head::Entity::delete_many() + .filter(operation_head::Column::RepoId.eq(repo_id.to_string())) + .filter(operation_head::Column::ScopeKey.eq(scope_key.to_string())) + .exec(&txn) + .await?; + for head in proposed { + operation_head::ActiveModel { + repo_id: Set(repo_id.to_string()), + scope_key: Set(scope_key.to_string()), + op_id: Set(head.op_id.clone()), + generation: Set(head.generation), + } + .insert(&txn) + .await?; + } + txn.commit().await?; + Ok(proposed.to_vec()) + } + + pub async fn reserve_journal( + &self, + journal_id: &str, + op_id: &str, + owner: &str, + updated_at: i64, + ) -> Result<(), StoreError> { + operation_journal::ActiveModel { + journal_id: Set(journal_id.to_string()), + op_id: Set(op_id.to_string()), + phase: Set(JournalPhase::Reserved.as_str().to_string()), + pre_view_oid: Set(None), + target_view_oid: Set(None), + owner: Set(owner.to_string()), + updated_at: Set(updated_at), + recovery_payload: Set(None), + } + .insert(&self.db) + .await?; + Ok(()) + } + + pub async fn record_journal_phase( + &self, + journal_id: &str, + phase: JournalPhase, + pre_view_oid: Option, + target_view_oid: Option, + recovery_payload: Option, + updated_at: i64, + ) -> Result<(), StoreError> { + let mut model = operation_journal::Entity::find_by_id(journal_id.to_string()) + .one(&self.db) + .await? + .ok_or_else(|| StoreError::InvalidArgument(format!("unknown journal '{journal_id}'")))? + .into_active_model(); + model.phase = Set(phase.as_str().to_string()); + model.pre_view_oid = Set(pre_view_oid.map(|oid| oid.to_string())); + model.target_view_oid = Set(target_view_oid.map(|oid| oid.to_string())); + model.recovery_payload = Set(recovery_payload); + model.updated_at = Set(updated_at); + model.update(&self.db).await?; + Ok(()) + } + + pub async fn load_journal( + &self, + journal_id: &str, + ) -> Result, StoreError> { + let Some(model) = operation_journal::Entity::find_by_id(journal_id.to_string()) + .one(&self.db) + .await? + else { + return Ok(None); + }; + Ok(Some(journal_from_model(model)?)) + } +} + +fn validate_operation(operation: &OperationV2) -> Result<(), StoreError> { + if operation.op_id.trim().is_empty() || operation.repo_id.trim().is_empty() { + return Err(StoreError::InvalidArgument( + "op_id and repo_id must not be empty".to_string(), + )); + } + if operation.metadata.scope_kind.trim().is_empty() { + return Err(StoreError::InvalidArgument( + "scope_kind must not be empty".to_string(), + )); + } + if operation.end_ts.is_some_and(|end| end < operation.start_ts) { + return Err(StoreError::InvalidArgument( + "end_ts must not precede start_ts".to_string(), + )); + } + Ok(()) +} + +fn parse_oid(value: &str) -> Result { + ObjectHash::from_str(value).map_err(|_| StoreError::InvalidObjectHash(value.to_string())) +} + +fn operation_from_model( + model: operation_v2::Model, + parent_op_ids: Vec, +) -> Result { + if model.format_version != 2 { + return Err(StoreError::Corrupt(format!( + "unsupported operation format version {}", + model.format_version + ))); + } + Ok(OperationV2 { + op_id: model.op_id, + repo_id: model.repo_id, + parent_op_ids, + pre_view_oid: parse_oid(&model.pre_view_oid)?, + post_view_oid: parse_oid(&model.post_view_oid)?, + kind: OperationKind::parse(&model.kind)?, + status: OperationStatusV2::parse(&model.status)?, + metadata: OperationMetaV2 { + command_name: model.command_name, + description: model.description, + args_digest: model.args_digest, + actor: model.actor, + worktree_id: model.worktree_id, + scope_kind: model.scope_kind, + causal_context_id: model.causal_context_id, + }, + restores_op_id: model.restores_op_id, + reverts_op_id: model.reverts_op_id, + predecessor_map_oid: model + .predecessor_map_oid + .as_deref() + .map(parse_oid) + .transpose()?, + start_ts: model.start_ts, + end_ts: model.end_ts, + }) +} + +fn journal_from_model( + model: operation_journal::Model, +) -> Result { + Ok(OperationJournalEntry { + journal_id: model.journal_id, + op_id: model.op_id, + phase: JournalPhase::parse(&model.phase)?, + pre_view_oid: model.pre_view_oid.as_deref().map(parse_oid).transpose()?, + target_view_oid: model + .target_view_oid + .as_deref() + .map(parse_oid) + .transpose()?, + owner: model.owner, + updated_at: model.updated_at, + recovery_payload: model.recovery_payload, + }) +} + +pub fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database}; + + use super::*; + + async fn store() -> OperationStore { + let db = Database::connect("sqlite::memory:").await.expect("db"); + db.execute_unprepared( + "CREATE TABLE operation(op_id TEXT PRIMARY KEY,repo_id TEXT NOT NULL,format_version INTEGER NOT NULL,kind TEXT NOT NULL,status TEXT NOT NULL,command_name TEXT,description TEXT,args_digest TEXT,actor TEXT,worktree_id TEXT,scope_kind TEXT NOT NULL,pre_view_oid TEXT NOT NULL,post_view_oid TEXT NOT NULL,restores_op_id TEXT,reverts_op_id TEXT,predecessor_map_oid TEXT,causal_context_id TEXT,start_ts INTEGER NOT NULL,end_ts INTEGER); CREATE TABLE operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,ordinal INTEGER NOT NULL,PRIMARY KEY(op_id,parent_op_id)); CREATE TABLE operation_head(repo_id TEXT NOT NULL,scope_key TEXT NOT NULL,op_id TEXT NOT NULL,generation INTEGER NOT NULL,PRIMARY KEY(repo_id,scope_key,op_id)); CREATE TABLE operation_journal(journal_id TEXT PRIMARY KEY,op_id TEXT NOT NULL,phase TEXT NOT NULL,pre_view_oid TEXT,target_view_oid TEXT,owner TEXT NOT NULL,updated_at INTEGER NOT NULL,recovery_payload TEXT);", + ) + .await + .expect("schema"); + let dir = tempfile::tempdir().expect("temp"); + OperationStore::new(db, ClientStorage::init_local(dir.path().join("objects"))) + } + + fn oid(byte: u8) -> ObjectHash { + ObjectHash::from_bytes(&[byte; 20]).expect("oid") + } + + fn operation() -> OperationV2 { + OperationV2 { + op_id: "op-1".to_string(), + repo_id: "repo-1".to_string(), + parent_op_ids: vec!["op-0".to_string(), "op-parent".to_string()], + pre_view_oid: oid(1), + post_view_oid: oid(2), + kind: OperationKind::Command, + status: OperationStatusV2::Success, + metadata: OperationMetaV2 { + command_name: Some("commit".to_string()), + scope_kind: "main".to_string(), + ..OperationMetaV2::default() + }, + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + start_ts: 1, + end_ts: Some(2), + } + } + + #[tokio::test] + async fn operation_and_journal_roundtrip() { + let store = store().await; + store.write_operation(&operation()).await.expect("write"); + let loaded = store + .load_operation("op-1") + .await + .expect("load") + .expect("row"); + assert_eq!(loaded, operation()); + store + .reserve_journal("journal-1", "op-1", "worker", 3) + .await + .expect("reserve"); + store + .record_journal_phase( + "journal-1", + JournalPhase::PreView, + Some(oid(1)), + Some(oid(2)), + None, + 4, + ) + .await + .expect("phase"); + assert_eq!( + store + .load_journal("journal-1") + .await + .unwrap() + .unwrap() + .phase, + JournalPhase::PreView + ); + } + + #[tokio::test] + async fn head_cas_conflict_retains_both_branches() { + let store = store().await; + let first = [OpHead { + op_id: "op-a".to_string(), + generation: 1, + }]; + store + .cas_update_op_heads("repo", "main", &[], &first) + .await + .expect("first publish"); + let second = [OpHead { + op_id: "op-b".to_string(), + generation: 2, + }]; + let error = store + .cas_update_op_heads("repo", "main", &[], &second) + .await + .expect_err("stale CAS"); + assert!(matches!(error, StoreError::CasConflict { .. })); + let heads = store.current_op_heads("repo", "main").await.expect("heads"); + assert_eq!(heads, vec![first[0].clone(), second[0].clone()]); + } +} diff --git a/src/internal/operation/view.rs b/src/internal/operation/view.rs new file mode 100644 index 000000000..8d396add4 --- /dev/null +++ b/src/internal/operation/view.rs @@ -0,0 +1,335 @@ +//! Canonical, versioned manifests for repository and working-copy state. + +use std::collections::BTreeMap; + +use git_internal::{hash::ObjectHash, internal::object::types::ObjectType}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::utils::client_storage::ClientStorage; + +pub const REPO_VIEW_SCHEMA_VERSION: u32 = 2; +pub const WORKSPACE_SNAPSHOT_SCHEMA_VERSION: u32 = 2; + +pub type WorkspaceId = String; +pub type FacetName = String; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum HeadState { + Symbolic { ref_name: String }, + Detached { oid: ObjectHash }, + Unborn { ref_name: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapturePolicy { + Tracked, + TrackedAndUntracked, + FailClosed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Completeness { + Full, + Partial, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct RepoViewV2 { + pub schema_version: u32, + pub repo_id: String, + pub refs_facet_oid: ObjectHash, + pub workspaces: BTreeMap, + pub change_roots: Vec, + pub extension_facets: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct WorkspaceSnapshotV2 { + pub schema_version: u32, + pub workspace_id: WorkspaceId, + pub head: HeadState, + pub index_tree_oid: ObjectHash, + pub raw_index_blob_oid: ObjectHash, + pub working_copy_tree_oid: ObjectHash, + pub untracked_manifest_oid: ObjectHash, + pub sparse_facet_oid: Option, + pub sequencer_facet_oid: Option, + pub worktree_generation: u64, + pub capture_policy: CapturePolicy, + pub completeness: Completeness, + pub facet_restore_policies: + BTreeMap, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ViewCodecError { + #[error("invalid canonical manifest JSON: {0}")] + InvalidJson(String), + #[error("unsupported {kind} schema version {version}; expected {expected}")] + UnsupportedSchemaVersion { + kind: &'static str, + version: u32, + expected: u32, + }, + #[error("manifest object is not a {expected} object")] + WrongObjectType { expected: &'static str }, + #[error("manifest references missing object {0}")] + MissingObject(ObjectHash), + #[error("empty repo_id")] + EmptyRepoId, + #[error("empty workspace_id")] + EmptyWorkspaceId, + #[error("failed to persist manifest: {0}")] + Storage(String), +} + +impl RepoViewV2 { + pub fn new( + repo_id: String, + refs_facet_oid: ObjectHash, + workspaces: BTreeMap, + change_roots: Vec, + extension_facets: BTreeMap, + ) -> Result { + let view = Self { + schema_version: REPO_VIEW_SCHEMA_VERSION, + repo_id, + refs_facet_oid, + workspaces, + change_roots, + extension_facets, + }; + view.validate_schema()?; + Ok(view) + } + + pub fn canonical_bytes(&self) -> Result, ViewCodecError> { + self.validate_schema()?; + serde_json::to_vec(self).map_err(|error| ViewCodecError::InvalidJson(error.to_string())) + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> Result { + let view: Self = serde_json::from_slice(bytes) + .map_err(|error| ViewCodecError::InvalidJson(error.to_string()))?; + view.validate_schema()?; + Ok(view) + } + + pub fn write_manifest(&self, storage: &ClientStorage) -> Result { + let bytes = self.canonical_bytes()?; + let oid = ObjectHash::from_type_and_data(ObjectType::Blob, &bytes); + storage + .put(&oid, &bytes, ObjectType::Blob) + .map_err(|error| ViewCodecError::Storage(error.to_string()))?; + Ok(oid) + } + + pub fn roots(&self) -> Vec { + let mut roots = vec![self.refs_facet_oid]; + roots.extend(self.workspaces.values().copied()); + roots.extend(self.change_roots.iter().copied()); + roots.extend(self.extension_facets.values().copied()); + roots + } + + pub fn validate_closure(&self, mut contains: F) -> Result<(), ViewCodecError> + where + F: FnMut(&ObjectHash) -> bool, + { + self.roots() + .into_iter() + .find(|oid| !contains(oid)) + .map_or(Ok(()), |oid| Err(ViewCodecError::MissingObject(oid))) + } + + fn validate_schema(&self) -> Result<(), ViewCodecError> { + if self.schema_version != REPO_VIEW_SCHEMA_VERSION { + return Err(ViewCodecError::UnsupportedSchemaVersion { + kind: "RepoViewV2", + version: self.schema_version, + expected: REPO_VIEW_SCHEMA_VERSION, + }); + } + if self.repo_id.trim().is_empty() { + return Err(ViewCodecError::EmptyRepoId); + } + Ok(()) + } +} + +impl WorkspaceSnapshotV2 { + pub fn new( + workspace_id: WorkspaceId, + head: HeadState, + index_tree_oid: ObjectHash, + raw_index_blob_oid: ObjectHash, + working_copy_tree_oid: ObjectHash, + untracked_manifest_oid: ObjectHash, + ) -> Result { + let snapshot = Self { + schema_version: WORKSPACE_SNAPSHOT_SCHEMA_VERSION, + workspace_id, + head, + index_tree_oid, + raw_index_blob_oid, + working_copy_tree_oid, + untracked_manifest_oid, + sparse_facet_oid: None, + sequencer_facet_oid: None, + worktree_generation: 0, + capture_policy: CapturePolicy::Tracked, + completeness: Completeness::Full, + facet_restore_policies: BTreeMap::new(), + }; + snapshot.validate_schema()?; + Ok(snapshot) + } + + pub fn canonical_bytes(&self) -> Result, ViewCodecError> { + self.validate_schema()?; + serde_json::to_vec(self).map_err(|error| ViewCodecError::InvalidJson(error.to_string())) + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> Result { + let snapshot: Self = serde_json::from_slice(bytes) + .map_err(|error| ViewCodecError::InvalidJson(error.to_string()))?; + snapshot.validate_schema()?; + Ok(snapshot) + } + + pub fn write_manifest(&self, storage: &ClientStorage) -> Result { + let bytes = self.canonical_bytes()?; + let oid = ObjectHash::from_type_and_data(ObjectType::Blob, &bytes); + storage + .put(&oid, &bytes, ObjectType::Blob) + .map_err(|error| ViewCodecError::Storage(error.to_string()))?; + Ok(oid) + } + + pub fn roots(&self) -> Vec { + let mut roots = vec![ + self.index_tree_oid, + self.raw_index_blob_oid, + self.working_copy_tree_oid, + self.untracked_manifest_oid, + ]; + if let Some(oid) = self.sparse_facet_oid { + roots.push(oid); + } + if let Some(oid) = self.sequencer_facet_oid { + roots.push(oid); + } + if let HeadState::Detached { oid } = self.head { + roots.push(oid); + } + roots + } + + pub fn validate_closure(&self, mut contains: F) -> Result<(), ViewCodecError> + where + F: FnMut(&ObjectHash) -> bool, + { + self.roots() + .into_iter() + .find(|oid| !contains(oid)) + .map_or(Ok(()), |oid| Err(ViewCodecError::MissingObject(oid))) + } + + fn validate_schema(&self) -> Result<(), ViewCodecError> { + if self.schema_version != WORKSPACE_SNAPSHOT_SCHEMA_VERSION { + return Err(ViewCodecError::UnsupportedSchemaVersion { + kind: "WorkspaceSnapshotV2", + version: self.schema_version, + expected: WORKSPACE_SNAPSHOT_SCHEMA_VERSION, + }); + } + if self.workspace_id.trim().is_empty() { + return Err(ViewCodecError::EmptyWorkspaceId); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::internal::operation::facet::RestorePolicy; + + fn oid(byte: u8) -> ObjectHash { + ObjectHash::from_bytes(&[byte; 20]).expect("sha1 oid") + } + + #[test] + fn repo_view_roundtrips_canonically_and_enumerates_closure() { + let mut workspaces = BTreeMap::new(); + workspaces.insert("main".to_string(), oid(2)); + let view = RepoViewV2::new( + "repo".to_string(), + oid(1), + workspaces, + vec![oid(3)], + BTreeMap::from([("extension".to_string(), oid(4))]), + ) + .expect("valid view"); + let bytes = view.canonical_bytes().expect("encode"); + assert_eq!( + RepoViewV2::from_canonical_bytes(&bytes).expect("decode"), + view + ); + assert_eq!(view.roots().len(), 4); + assert!( + view.validate_closure(|candidate| candidate != &oid(4)) + .is_err() + ); + } + + #[test] + fn unknown_schema_versions_fail_closed() { + let mut value = serde_json::json!({ + "schema_version": 99, + "repo_id": "repo", + "refs_facet_oid": oid(1), + "workspaces": {}, + "change_roots": [], + "extension_facets": {} + }); + value["schema_version"] = serde_json::json!(99); + let error = RepoViewV2::from_canonical_bytes( + &serde_json::to_vec(&value).expect("encode invalid version"), + ) + .expect_err("unknown schema must fail"); + assert!(matches!( + error, + ViewCodecError::UnsupportedSchemaVersion { .. } + )); + } + + #[test] + fn workspace_snapshot_includes_restore_policy_and_roots() { + let mut snapshot = WorkspaceSnapshotV2::new( + "main".to_string(), + HeadState::Detached { oid: oid(9) }, + oid(1), + oid(2), + oid(3), + oid(4), + ) + .expect("valid snapshot"); + snapshot + .facet_restore_policies + .insert("index".to_string(), RestorePolicy::AutoRestore); + snapshot.sparse_facet_oid = Some(oid(5)); + assert_eq!(snapshot.roots().len(), 6); + assert_eq!( + WorkspaceSnapshotV2::from_canonical_bytes(&snapshot.canonical_bytes().unwrap()) + .unwrap(), + snapshot + ); + } +} diff --git a/src/internal/operation/working_copy.rs b/src/internal/operation/working_copy.rs new file mode 100644 index 000000000..fef01fb45 --- /dev/null +++ b/src/internal/operation/working_copy.rs @@ -0,0 +1,192 @@ +//! Durable worktree pointer and operation-head staleness classification. +//! +//! A pointer records the operation/view pair last materialized in one +//! workspace. It is deliberately separate from `operation_head`: the latter +//! is the repository's publish point, while this file is the local checkout's +//! acknowledgement of that point. Updating it is atomic so an interrupted +//! mutation cannot leave a partially written pointer. + +use std::{fs, io, path::Path, str::FromStr}; + +use git_internal::hash::ObjectHash; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::store::OpHead; + +const POINTER_SCHEMA_VERSION: u32 = 2; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceStatePointer { + pub last_op_id: String, + pub last_snapshot_oid: ObjectHash, + pub generation: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Staleness { + Fresh, + Stale, + Sibling, +} + +#[derive(Debug, Error)] +pub enum PointerError { + #[error("workspace pointer I/O error: {0}")] + Io(#[from] io::Error), + #[error("workspace pointer JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("workspace pointer has invalid snapshot object id '{0}'")] + InvalidSnapshotOid(String), + #[error("workspace pointer schema version {0} is unsupported")] + UnsupportedSchema(u32), + #[error("workspace pointer operation id must not be empty")] + EmptyOperationId, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct PointerFile { + schema_version: u32, + last_op_id: String, + last_snapshot_oid: String, + generation: i64, +} + +impl WorkspaceStatePointer { + pub fn new( + last_op_id: impl Into, + last_snapshot_oid: ObjectHash, + generation: i64, + ) -> Result { + let last_op_id = last_op_id.into(); + if last_op_id.trim().is_empty() { + return Err(PointerError::EmptyOperationId); + } + Ok(Self { + last_op_id, + last_snapshot_oid, + generation, + }) + } + + pub fn load(path: &Path) -> Result, PointerError> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let file: PointerFile = serde_json::from_slice(&bytes)?; + if file.schema_version != POINTER_SCHEMA_VERSION { + return Err(PointerError::UnsupportedSchema(file.schema_version)); + } + let snapshot_oid = ObjectHash::from_str(&file.last_snapshot_oid) + .map_err(|_| PointerError::InvalidSnapshotOid(file.last_snapshot_oid.clone()))?; + Self::new(file.last_op_id, snapshot_oid, file.generation).map(Some) + } + + pub fn save(&self, path: &Path) -> Result<(), PointerError> { + if self.last_op_id.trim().is_empty() { + return Err(PointerError::EmptyOperationId); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let file = PointerFile { + schema_version: POINTER_SCHEMA_VERSION, + last_op_id: self.last_op_id.clone(), + last_snapshot_oid: self.last_snapshot_oid.to_string(), + generation: self.generation, + }; + let bytes = serde_json::to_vec(&file)?; + let temporary = path.with_extension("tmp"); + fs::write(&temporary, bytes)?; + fs::rename(temporary, path)?; + Ok(()) + } + + /// Compare this local pointer with the currently published heads. + /// + /// `is_ancestor(a, b)` must answer whether operation `a` is an ancestor + /// of operation `b`. Generation is used as a cheap guard before walking + /// the DAG; an equal operation id is always fresh. + pub fn staleness(&self, heads: &[OpHead], is_ancestor: F) -> Staleness + where + F: Fn(&str, &str) -> bool, + { + if heads + .iter() + .any(|head| head.op_id == self.last_op_id && head.generation == self.generation) + { + return Staleness::Fresh; + } + if heads.iter().any(|head| { + head.generation >= self.generation && is_ancestor(&self.last_op_id, &head.op_id) + }) { + return Staleness::Stale; + } + Staleness::Sibling + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn oid(byte: u8) -> ObjectHash { + ObjectHash::from_bytes(&[byte; 20]).expect("oid") + } + + #[test] + fn missing_pointer_is_not_an_error() { + let dir = tempfile::tempdir().expect("temp"); + assert!( + WorkspaceStatePointer::load(&dir.path().join("pointer.json")) + .expect("load") + .is_none() + ); + } + + #[test] + fn pointer_roundtrip_is_atomic_and_strict() { + let dir = tempfile::tempdir().expect("temp"); + let path = dir.path().join("state/pointer.json"); + let pointer = WorkspaceStatePointer::new("op-1", oid(1), 3).expect("pointer"); + pointer.save(&path).expect("save"); + assert_eq!( + WorkspaceStatePointer::load(&path).expect("load"), + Some(pointer) + ); + fs::write(&path, br#"{"schema_version":2,"last_op_id":"op-1","last_snapshot_oid":"bad","generation":3,"extra":true}"#).expect("write"); + assert!(matches!( + WorkspaceStatePointer::load(&path), + Err(PointerError::Json(_)) + )); + } + + #[test] + fn stale_and_sibling_are_distinct() { + let pointer = WorkspaceStatePointer::new("base", oid(1), 2).expect("pointer"); + let stale = [OpHead { + op_id: "tip".to_string(), + generation: 4, + }]; + assert_eq!( + pointer.staleness(&stale, |from, to| from == "base" && to == "tip"), + Staleness::Stale + ); + let sibling = [OpHead { + op_id: "other".to_string(), + generation: 2, + }]; + assert_eq!( + pointer.staleness(&sibling, |_, _| false), + Staleness::Sibling + ); + let fresh = [OpHead { + op_id: "base".to_string(), + generation: 2, + }]; + assert_eq!(pointer.staleness(&fresh, |_, _| false), Staleness::Fresh); + } +} diff --git a/src/internal/operation_wrapper.rs b/src/internal/operation_wrapper.rs index cf2a0e3e0..10793bed0 100644 --- a/src/internal/operation_wrapper.rs +++ b/src/internal/operation_wrapper.rs @@ -28,7 +28,8 @@ use crate::internal::{ operation::{ OperationGraphRecord, OperationParentRecord, OperationQueryPage, OperationRecord, OperationService, OperationStatus, OperationViewRecord, OperationViewRefRecord, - OperationViewWorkspaceRecord, + OperationViewWorkspaceRecord, runtime, + store::{OperationKind, OperationMetaV2, OperationStatusV2, OperationStore, OperationV2}, }, }; @@ -334,6 +335,40 @@ async fn ensure_not_recent_duplicate_with_conn( return Ok(()); }; + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationError::begin(format!("failed to inspect operation schema: {err}")) + })? { + let earliest_end_ts = now_ts.saturating_sub(DEDUP_WINDOW_SECS); + let records = runtime::recent_duplicate_candidates( + db, + &meta.repo_id, + scope_key, + &meta.command_name, + digest, + earliest_end_ts, + DEDUP_CANDIDATE_LIMIT, + ) + .await + .map_err(|err| { + OperationError::begin(format!( + "failed to query recent v2 operations for repository '{}': {err}", + meta.repo_id + )) + })?; + if records.iter().any(|record| { + record + .end_ts + .map(|end_ts| now_ts.saturating_sub(end_ts) <= DEDUP_WINDOW_SECS) + .unwrap_or(false) + }) { + return Err(OperationError::business(format!( + "duplicate operation rejected within {}s window for command '{}'", + DEDUP_WINDOW_SECS, meta.command_name + ))); + } + return Ok(()); + } + // Part C W1 (§C.9): a SCOPE POINT QUERY, not a repo-wide page filtered in // memory. The old form took the newest 50 rows for the whole repository // and kept the ones belonging to this worktree — so fifty newer @@ -436,6 +471,11 @@ where { meta.validate()?; validate_parent_policy(scope.parent_policy)?; + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationError::begin(format!("failed to inspect operation schema: {err}")) + })? { + return with_operation_log_v2_with_conn(db, meta, scope, operation).await; + } let op_id = Uuid::now_v7().to_string(); let view_id = Uuid::now_v7().to_string(); @@ -643,6 +683,168 @@ where }) } +async fn with_operation_log_v2_with_conn( + db: &DatabaseConnection, + meta: OperationMeta, + scope: OperationScope, + operation: F, +) -> Result, OperationError> +where + for<'b> F: FnOnce( + &'b DatabaseTransaction, + ) -> Pin> + Send + 'b>>, + F: Send + 'static, +{ + let op_id = Uuid::now_v7().to_string(); + let start_ts = Utc::now().timestamp(); + let scope_key = crate::internal::worktree_scope::WorktreeScope::for_request() + .storage_key() + .to_string(); + let dedup_scope = dedup_scope_key(&scope_key, scope.ownership); + let _active_dedup_guard = operation_dedup_key(&meta, dedup_scope.as_deref()) + .map(try_acquire_active_dedup_guard) + .transpose()?; + ensure_not_recent_duplicate_with_conn(db, &meta, start_ts, dedup_scope.as_deref()).await?; + + let txn = crate::internal::db::begin_write_transaction(db) + .await + .map_err(|err| { + OperationError::begin(format!( + "failed to open v2 operation transaction for command '{}': {err}", + meta.command_name + )) + })?; + let selection_started_at = Instant::now(); + let selection = resolve_parent_selection_with_conn( + &txn, + &meta.repo_id, + ParentSelectionMode::SingleLatestSuccess, + ) + .await?; + let selection_latency_us = selection_started_at.elapsed().as_micros() as u64; + let selected_parents = selection + .selected + .into_iter() + .take(scope.parent_policy.max_parents) + .collect::>(); + + let _pre_view = runtime::capture_view( + &txn, + &meta.repo_id, + scope.include_refs, + scope.include_remote_tracking, + ) + .await + .map_err(|err| { + OperationError::snapshot(format!( + "failed to capture pre-operation view for command '{}': {err}", + meta.command_name + )) + })?; + + let payload = match operation(&txn).await { + Ok(payload) => payload, + Err(err) => { + txn.rollback().await.map_err(|rollback_err| { + OperationError::rollback(format!( + "business step failed with '{err}', and rollback also failed: {rollback_err}" + )) + })?; + return Err(OperationError::business(format!( + "command '{}' business write failed: {err}", + meta.command_name + ))); + } + }; + + let post_view = runtime::capture_view( + &txn, + &meta.repo_id, + scope.include_refs, + scope.include_remote_tracking, + ) + .await + .map_err(|err| { + OperationError::snapshot(format!( + "failed to capture post-operation view for command '{}': {err}", + meta.command_name + )) + })?; + let end_ts = Utc::now().timestamp(); + let parent_metrics = ParentSelectionMetrics { + resolver_mode: selection.mode, + scanned_pages: selection.scanned_pages, + scanned_items: selection.scanned_items, + success_candidates: selection.success_candidates, + selected_parent_count: selected_parents.len() as u64, + selection_latency_us, + }; + let operation_record = OperationRecord { + op_id: op_id.clone(), + repo_id: meta.repo_id.clone(), + view_id: post_view.manifest_oid.to_string(), + command_name: meta.command_name.clone(), + description: format!( + "{} | resolver_mode=single_latest_success scanned_pages={} scanned_items={} \ + success_candidates={} selected_parents={} selection_latency_us={}", + meta.description, + parent_metrics.scanned_pages, + parent_metrics.scanned_items, + parent_metrics.success_candidates, + parent_metrics.selected_parent_count, + parent_metrics.selection_latency_us, + ), + actor: meta.actor.clone(), + args_digest: meta.normalized_digest().map(str::to_string), + start_ts, + end_ts: Some(end_ts), + status: OperationStatus::Succeeded, + worktree_id: scope_key.clone(), + scope_provenance: "declared".to_string(), + restorable: true, + control_slot: None, + claim_owner: None, + scope_kind: declared_scope_kind(&scope_key, scope.ownership), + }; + let graph = OperationGraphRecord { + operation: operation_record, + parents: selected_parents + .into_iter() + .map(|parent_op_id| OperationParentRecord { + op_id: op_id.clone(), + parent_op_id, + }) + .collect(), + view: OperationViewRecord { + view_id: post_view.manifest_oid.to_string(), + repo_id: meta.repo_id.clone(), + head_kind: post_view.snapshot.head_kind.clone(), + head_target: post_view.snapshot.head_target.clone(), + created_at: end_ts, + }, + refs: post_view.snapshot.refs.clone(), + workspace: post_view.snapshot.workspace.clone(), + }; + runtime::persist_graph(&txn, &graph).await.map_err(|err| { + OperationError::persist(format!("failed to persist v2 operation graph: {err}")) + })?; + txn.commit().await.map_err(|err| { + OperationError::commit(format!( + "failed to commit v2 operation transaction for command '{}': {err}", + meta.command_name + )) + })?; + + Ok(OperationResult { + payload, + op_id, + view_id: post_view.manifest_oid.to_string(), + end_ts, + view: post_view.snapshot, + parent_metrics, + }) +} + #[cfg(test)] mod claim_owner_tests { use super::*; @@ -745,6 +947,7 @@ pub struct OperationBoundary { selected_parents: Vec, parent_metrics: ParentSelectionMetrics, _dedup_guard: Option, + v2: bool, } /// How a boundary-recorded operation ended. @@ -773,6 +976,11 @@ pub async fn begin_operation_with_conn( ) -> Result { meta.validate()?; validate_parent_policy(scope.parent_policy)?; + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationError::begin(format!("failed to inspect operation schema: {err}")) + })? { + return begin_operation_v2_with_conn(db, meta, scope).await; + } let op_id = Uuid::now_v7().to_string(); let view_id = Uuid::now_v7().to_string(); @@ -922,6 +1130,117 @@ pub async fn begin_operation_with_conn( selection_latency_us, }, _dedup_guard: dedup_guard, + v2: false, + }) +} + +async fn begin_operation_v2_with_conn( + db: &DatabaseConnection, + meta: OperationMeta, + scope: OperationScope, +) -> Result { + let op_id = Uuid::now_v7().to_string(); + let start_ts = Utc::now().timestamp(); + let scope_key = crate::internal::worktree_scope::WorktreeScope::for_request() + .storage_key() + .to_string(); + let dedup_scope = dedup_scope_key(&scope_key, scope.ownership); + let dedup_guard = operation_dedup_key(&meta, dedup_scope.as_deref()) + .map(try_acquire_active_dedup_guard) + .transpose()?; + if scope.duplicate_window { + ensure_not_recent_duplicate_with_conn(db, &meta, start_ts, dedup_scope.as_deref()).await?; + } + + let txn = crate::internal::db::begin_write_transaction(db) + .await + .map_err(|err| { + OperationError::begin(format!( + "failed to open v2 operation claim transaction: {err}" + )) + })?; + if let Some((_, command, _, owner)) = runtime::running_control(&txn, &meta.repo_id, &scope_key) + .await + .map_err(|err| { + OperationError::persist(format!("failed to inspect v2 control claim: {err}")) + })? + { + let _ = txn.rollback().await; + return Err(OperationError::business(format!( + "'{command}' is already running in this worktree (owner {}); wait for it to finish", + owner.unwrap_or_else(|| "unknown".to_string()) + ))); + } + let selection_started_at = Instant::now(); + let selection = resolve_parent_selection_with_conn( + &txn, + &meta.repo_id, + ParentSelectionMode::SingleLatestSuccess, + ) + .await?; + let selection_latency_us = selection_started_at.elapsed().as_micros() as u64; + let selected_parents = selection.selected; + let pre_view = runtime::capture_view( + &txn, + &meta.repo_id, + scope.include_refs, + scope.include_remote_tracking, + ) + .await + .map_err(|err| { + OperationError::snapshot(format!("failed to capture v2 pre-operation view: {err}")) + })?; + + let operation = OperationV2 { + op_id: op_id.clone(), + repo_id: meta.repo_id.clone(), + parent_op_ids: selected_parents.clone(), + pre_view_oid: pre_view.manifest_oid, + post_view_oid: pre_view.manifest_oid, + kind: OperationKind::Command, + status: OperationStatusV2::Running, + metadata: OperationMetaV2 { + command_name: Some(meta.command_name.clone()), + description: Some(meta.description.clone()), + args_digest: meta.normalized_digest().map(str::to_string), + actor: Some(meta.actor.clone()), + worktree_id: Some(scope_key.clone()), + scope_kind: declared_scope_kind(&scope_key, scope.ownership), + ..OperationMetaV2::default() + }, + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + start_ts, + end_ts: None, + }; + OperationStore::write_operation_with_conn(&txn, &operation) + .await + .map_err(|err| { + OperationError::persist(format!("failed to write v2 operation claim: {err}")) + })?; + txn.commit().await.map_err(|err| { + OperationError::commit(format!("failed to commit v2 operation claim: {err}")) + })?; + + Ok(OperationBoundary { + op_id, + view_id: pre_view.manifest_oid.to_string(), + start_ts, + meta, + scope, + scope_key, + selected_parents, + parent_metrics: ParentSelectionMetrics { + resolver_mode: selection.mode, + scanned_pages: selection.scanned_pages, + scanned_items: selection.scanned_items, + success_candidates: selection.success_candidates, + selected_parent_count: 0, + selection_latency_us, + }, + _dedup_guard: dedup_guard, + v2: true, }) } @@ -1202,6 +1521,78 @@ async fn retry_claim( }) } +async fn finish_operation_v2_with_conn( + boundary: OperationBoundary, + db: &DatabaseConnection, + outcome: BoundaryOutcome, +) -> Result<(), OperationError> { + let txn = crate::internal::db::begin_write_transaction(db) + .await + .map_err(|err| { + OperationError::begin(format!( + "failed to open v2 operation completion transaction: {err}" + )) + })?; + let view = runtime::capture_view( + &txn, + &boundary.meta.repo_id, + boundary.scope.include_refs, + boundary.scope.include_remote_tracking, + ) + .await + .map_err(|err| OperationError::snapshot(format!("failed to capture v2 final view: {err}")))?; + let status = match outcome { + BoundaryOutcome::Succeeded => OperationStatusV2::Success, + BoundaryOutcome::Failed => OperationStatusV2::Failed, + }; + OperationStore::complete_operation_with_conn( + &txn, + &boundary.op_id, + view.manifest_oid, + status, + Utc::now().timestamp(), + ) + .await + .map_err(|err| OperationError::persist(format!("failed to close v2 operation: {err}")))?; + if status == OperationStatusV2::Success { + let current = crate::internal::model::operation_head::Entity::find() + .filter( + crate::internal::model::operation_head::Column::RepoId.eq(&boundary.meta.repo_id), + ) + .filter( + crate::internal::model::operation_head::Column::ScopeKey + .eq(boundary.scope_key.clone()), + ) + .all(&txn) + .await + .map_err(|err| { + OperationError::persist(format!("failed to read v2 operation heads: {err}")) + })?; + let generation = current + .iter() + .map(|head| head.generation) + .max() + .unwrap_or(0) + + 1; + OperationStore::replace_op_heads_with_conn( + &txn, + &boundary.meta.repo_id, + &boundary.scope_key, + &[crate::internal::operation::store::OpHead { + op_id: boundary.op_id, + generation, + }], + ) + .await + .map_err(|err| { + OperationError::persist(format!("failed to publish v2 operation head: {err}")) + })?; + } + txn.commit().await.map_err(|err| { + OperationError::commit(format!("failed to commit v2 operation completion: {err}")) + }) +} + impl OperationBoundary { /// The operation id this boundary claimed. pub fn op_id(&self) -> &str { @@ -1223,6 +1614,9 @@ impl OperationBoundary { db: &DatabaseConnection, outcome: BoundaryOutcome, ) -> Result<(), OperationError> { + if self.v2 { + return finish_operation_v2_with_conn(self, db, outcome).await; + } let end_ts = Utc::now().timestamp(); // Completion READS the final view before it replaces the claim row, // so it takes the write lock up front — otherwise a concurrent writer @@ -1368,6 +1762,58 @@ pub async fn resolve_parent_selection_with_conn( return Err(OperationError::validation("repo_id must not be empty")); } + if runtime::is_v2_schema(db).await.map_err(|err| { + OperationError::begin(format!("failed to inspect operation schema: {err}")) + })? { + let mut page = 1; + let mut scanned_pages = 0; + let mut scanned_items = 0; + let mut success_candidates = 0; + loop { + let records = runtime::list_operations_by_repo_paginated( + db, + repo_id, + None, + OperationQueryPage { + page, + per_page: PARENT_RESOLUTION_PAGE_SIZE, + }, + ) + .await + .map_err(|err| { + OperationError::begin(format!( + "failed to resolve v2 parent operation for repository '{}': {err}", + repo_id + )) + })?; + scanned_pages += 1; + let items_len = records.items.len() as u64; + scanned_items += items_len; + for item in records.items { + if item.status == OperationStatus::Succeeded { + success_candidates += 1; + return Ok(ParentSelectionResult { + selected: vec![item.op_id], + scanned_pages, + scanned_items, + success_candidates, + mode, + }); + } + } + if items_len < records.per_page { + return Ok(ParentSelectionResult { + selected: Vec::new(), + scanned_pages, + scanned_items, + success_candidates, + mode, + }); + } + page += 1; + } + } + let mut page: u64 = 1; let mut scanned_pages = 0; let mut scanned_items = 0; diff --git a/src/utils/client_storage.rs b/src/utils/client_storage.rs index 5610780f2..79c8f7f67 100644 --- a/src/utils/client_storage.rs +++ b/src/utils/client_storage.rs @@ -1698,6 +1698,29 @@ impl ClientStorage { Ok(result) } + /// Persist an object without scheduling a cloud object-index update. + /// + /// v2 operation view manifests are written while a caller-owned SQLite + /// transaction may be open. They are already rooted by the operation + /// graph, so queueing the asynchronous repair marker here can deadlock + /// against that transaction's object-index writer. + pub(crate) fn put_without_index( + &self, + obj_id: &ObjectHash, + content: &[u8], + obj_type: ObjectType, + ) -> Result { + let storage = self.storage.clone(); + let hash = *obj_id; + let data = content.to_vec(); + self.block_on_storage(async move { + storage + .put(&hash, &data, obj_type) + .await + .map_err(|e| io::Error::other(e.to_string())) + }) + } + /// Register an object that is already present in the configured storage. /// This is the retry path for callers whose payload write succeeded but /// durable marker registration failed. It intentionally avoids rewriting a diff --git a/tests/INDEX.md b/tests/INDEX.md index ce275044c..e1650abc0 100644 --- a/tests/INDEX.md +++ b/tests/INDEX.md @@ -119,6 +119,13 @@ | `compat_command_docs_examples_section` | 1 | Every `docs/commands/.md` page carries an `## Examples` / `## Common Commands` heading | `docs/commands/**` | | `compat_r0_9_doc_closeout` | 1 | plan-20260714 R0-9 docs close-out: the warning code/source table, the `io_blocked` JSON schema (with the `reason` enum parsed out of `io_blocked_reason_and_code` so a new variant cannot ship undocumented), the `{from, to}` typing of nested `staged.renamed` / `unstaged.renamed`, the three rename config keys across the status docs + `COMPATIBILITY.md`, and the `diff.renameLimit` degradation semantics (exact **and** unique-basename survive) across the diff docs + CHANGELOG — EN and zh asserted in parallel | `docs/commands/status.md`, `docs/commands/diff.md`, `COMPATIBILITY.md`, `CHANGELOG.md` | | `compat_version_surface_sync` | 1 | plan-20260714 PD-00/PD-10 release-surface guard: `Cargo.toml`, `web/package.json`, `worker/package.json`, `install.sh` `DEFAULT_VERSION`, and `install.ps1` `$DefaultVersion` all carry the same version; both installer values keep their `v` prefix | `Cargo.toml`, `web/package.json`, `worker/package.json`, `install.sh`, `install.ps1` | +| `operation_v2_schema` | 1 | plan-20260822 OL-02: fresh and legacy SQLite repositories converge on the eight-table Operation Log v2 schema | `src/internal/db.rs`, `src/internal/db/migration.rs`, `sql/sqlite_20260309_init.sql`, `sql/migrations/2026090301_operation_log_v2.sql` | +| `operation_dag` | 1 | plan-20260822 OL-04: multi-parent Operation Log v2 persistence, journal phase recovery marker, and op-head CAS fork retention | `src/internal/operation/store.rs`, `src/internal/model/operation_{v2,parent_v2,head,journal}.rs` | +| `workspace_snapshot_roundtrip` | 1 | plan-20260822 OL-06: bounded WorktreeIo-backed workspace snapshot capture and file-content restore | `src/internal/operation/snapshot.rs`, `src/internal/operation/view.rs`, `src/internal/worktree_io/` | +| `index_snapshot_roundtrip` | 1 | plan-20260822 OL-07: byte-exact raw index facet capture and restore | `src/internal/operation/facets.rs`, `tests/index_snapshot_roundtrip.rs` | +| `sequencer_snapshot_roundtrip` | 1 | plan-20260822 OL-07: sequencer-state facet capture and restore | `src/internal/operation/facets.rs`, `tests/sequencer_snapshot_roundtrip.rs` | +| `operation_command_coverage` | 1 | plan-20260822 OL-09: representative CLI mutation operation records with pre/post view OIDs | `src/cli.rs`, `src/internal/operation/middleware.rs`, `tests/operation_command_coverage.rs` | +| `agent_shell_operation` | 1 | plan-20260822 OL-09: Agent shell external mutation classification and read-only fail-closed coverage | `src/internal/ai/tools/registry.rs`, `src/internal/operation/middleware.rs`, `tests/agent_shell_operation.rs` | | `compat_serial_registry` | 1 | plan-20260729 CT3-07/FIX-04 serial-annotation guard over `tests/**`: every non-`none` `#[serial]` there has exactly one `tests/SERIAL_REGISTRY.tsv` row with a non-empty `global`/`lane:*` reason, registry and `tests/SERIAL_CLASSIFY.sh` verdicts match bidirectionally (missing/dangling/drift asserted, duplicate fn rows rejected, `#>` content-anchored macro sites verified (TA-02: line numbers banned from keys, guard relocates by macro NAME + in-body ordinal so edits above the attribute cannot break the anchor; orphan sites keyed ``; plus a line-drift regression and three injection counterexamples), string/same-line-attribute lexer counterexamples, named keys that do not cover process-wide pollution rejected, `inner_attrs`/`crate = ` config items not counted as lock keys), the classifier is deterministic on raw stdout, and (TA-01) `none` requires the whole call surface to be proven pollution-free — explicit allowlist with per-entry reasons, bounded helper expansion (cycles/unknowns fail closed to `global`) that propagates helper pollution lanes to callers, plus 18 guard tests (TA-03 adds the standing zero-unkeyed-#[serial] invariant over tests/** — conversion driven by the frozen tests/SERIAL_MANIFEST.tsv through the mechanical, idempotent tests/SERIAL_CONVERT.sh; plan-20260827 NP-01 adds the nextest-group drift guard — .config/nextest.toml must byte-match its generator tests/NEXTEST_GROUPS.sh and the union external group must equal the registry-derived membership, 208 anchored last-segment regex filters test(/(^|::)$/) — module-pathed names in aggregated binaries match, fn names are tree-unique so the anchor is exact — plus 7 binary(=target) filters) covering the full adversarial battery (rename/alias laundering incl. nested-brace full-path recovery, macro shadowing & metavariable fail-closed, Drop/callable-emitting macros, include! splicing, Drop-impl lane merging with alias/module canonicalization, fn-reference & qualified value-reference channels, the benign env-read gate with param-forwarding traces, and the path-argument proof engine that lanes unproven filesystem paths cwd) | `tests/SERIAL_CLASSIFY.sh`, `tests/SERIAL_REGISTRY.tsv`, `tests/NEXTEST_GROUPS.sh`, `.config/nextest.toml`, `tests/nextest_group_overlap_check.py`, `tests/compat/serial_registry.rs` | | `compat_help_flag_descriptions` | 1 | Every visible flag and positional under `Options:` / `Arguments:` carries a non-empty description; covers 42 root commands + 53 sub/sub-sub-commands (110 surfaces) | `src/cli.rs`, `src/command/**` | | `compat_help_no_impl_meta_leak` | 1 | No `libra --help` body leaks contributor-facing rustdoc into clap's long_about; forbids 6 phrase classes (e.g. `Codex pass-`, raw markdown headings, code fences) | `src/cli.rs`, `src/command/**` | diff --git a/tests/agent_bridge_migration_test.rs b/tests/agent_bridge_migration_test.rs index a70f1e4a4..ac2cb155d 100644 --- a/tests/agent_bridge_migration_test.rs +++ b/tests/agent_bridge_migration_test.rs @@ -6,7 +6,7 @@ //! row exists (never deleting acked events/evidence) and only drops the tables //! on an empty database. -use libra::internal::db::migration::{builtin_runner, run_builtin_migrations}; +use libra::internal::db::migration::{MigrationRunner, builtin_migrations, run_builtin_migrations}; use sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; const BRIDGE_TABLES: &[&str] = &[ @@ -29,6 +29,19 @@ async fn table_exists(db: &sea_orm::DatabaseConnection, table: &str) -> bool { !rows.is_empty() } +fn historical_runner() -> MigrationRunner { + let mut runner = MigrationRunner::new(); + for migration in builtin_migrations() + .into_iter() + .filter(|migration| migration.version <= 2026082401) + { + runner + .register(migration) + .expect("historical registry builds clean"); + } + runner +} + /// Fresh bootstrap: applying every builtin migration creates all five bridge /// tables. #[tokio::test] @@ -47,7 +60,7 @@ async fn fresh_bootstrap_creates_bridge_tables() { #[tokio::test] async fn old_db_upgrade_adds_bridge_tables() { let db = Database::connect("sqlite::memory:").await.expect("connect"); - let runner = builtin_runner().expect("registry builds"); + let runner = historical_runner(); runner .run_pending_up_to(&db, 2026081301) .await @@ -89,7 +102,7 @@ async fn repeated_apply_is_idempotent() { #[tokio::test] async fn migration_up_down_preserves_acked_events() { let db = Database::connect("sqlite::memory:").await.expect("connect"); - let runner = builtin_runner().expect("registry builds"); + let runner = historical_runner(); runner.run_pending(&db).await.expect("apply all"); assert!(table_exists(&db, "agent_bridge_event").await); diff --git a/tests/agent_capture_migration_test.rs b/tests/agent_capture_migration_test.rs index c35d41dc4..bc9214575 100644 --- a/tests/agent_capture_migration_test.rs +++ b/tests/agent_capture_migration_test.rs @@ -52,7 +52,10 @@ async fn index_exists(conn: &DatabaseConnection, name: &str) -> bool { fn registered_runner() -> MigrationRunner { let mut runner = MigrationRunner::new(); - for migration in builtin_migrations() { + for migration in builtin_migrations() + .into_iter() + .filter(|migration| migration.version <= 2026082401) + { runner .register(migration) .expect("builtin migrations must register clean"); @@ -64,7 +67,7 @@ fn registered_versions_after(target: i64) -> Vec { builtin_migrations() .into_iter() .map(|migration| migration.version) - .filter(|version| *version > target) + .filter(|version| *version > target && *version <= 2026082401) .collect() } diff --git a/tests/agent_shell_operation.rs b/tests/agent_shell_operation.rs new file mode 100644 index 000000000..f216e7c9b --- /dev/null +++ b/tests/agent_shell_operation.rs @@ -0,0 +1,16 @@ +//! OL-09 Agent shell mutation classification coverage. + +use libra::internal::operation::middleware::{MutationClass, classify_command}; + +#[test] +fn shell_is_external_and_read_only_tools_remain_read_only() { + assert_eq!( + classify_command("external").expect("external"), + MutationClass::External + ); + assert_eq!( + classify_command("status").expect("status"), + MutationClass::ReadOnly + ); + assert!(classify_command("unclassified-agent-tool").is_err()); +} diff --git a/tests/command/worktree_isolation_test.rs b/tests/command/worktree_isolation_test.rs index 150aa7cc4..19c420a63 100644 --- a/tests/command/worktree_isolation_test.rs +++ b/tests/command/worktree_isolation_test.rs @@ -4226,7 +4226,7 @@ fn concurrent_control_slots_are_held_per_worktree() { let rows = match sqlite_query_no_wait( &db, "SELECT worktree_id FROM operation WHERE status = 'running' \ - AND control_slot IS NOT NULL ORDER BY worktree_id", + ORDER BY worktree_id", ) { Ok(rows) => rows, Err(err) => { diff --git a/tests/compat/agent_bridge_schema_test.rs b/tests/compat/agent_bridge_schema_test.rs index 4f2a53556..89e6bfc25 100644 --- a/tests/compat/agent_bridge_schema_test.rs +++ b/tests/compat/agent_bridge_schema_test.rs @@ -16,7 +16,7 @@ const BRIDGE_MIGRATION_VERSION: i64 = 2026081801; const BRIDGE_LINK_RELATIONS_VERSION: i64 = 2026082401; #[test] -fn bridge_migrations_are_registered_and_link_relations_is_the_latest() { +fn bridge_migrations_are_registered_and_operation_v2_is_latest() { let runner = builtin_runner().expect("builtin registry builds clean"); assert!( builtin_migrations() @@ -24,10 +24,16 @@ fn bridge_migrations_are_registered_and_link_relations_is_the_latest() { .any(|migration| migration.version == BRIDGE_MIGRATION_VERSION), "2026081801_agent_bridge_capture must stay registered" ); + assert!( + builtin_migrations() + .iter() + .any(|migration| migration.version == BRIDGE_LINK_RELATIONS_VERSION), + "2026082401_agent_bridge_link_relations must stay registered" + ); assert_eq!( runner.max_registered_version(), - Some(BRIDGE_LINK_RELATIONS_VERSION), - "2026082401_agent_bridge_link_relations must be the latest registered migration" + Some(2026090301), + "2026090301_operation_log_v2 must be the latest registered migration" ); } diff --git a/tests/db_migration_test.rs b/tests/db_migration_test.rs index 4f22fb4e1..02991a1e8 100644 --- a/tests/db_migration_test.rs +++ b/tests/db_migration_test.rs @@ -6,9 +6,11 @@ use std::path::PathBuf; +use anyhow::Result; use libra::internal::db::migration::{ - Migration, MigrationError, MigrationRunner, builtin_migrations, builtin_runner, - run_builtin_migrations, + Migration, MigrationError, MigrationRunner, builtin_migrations, + builtin_runner as current_builtin_runner, + run_builtin_migrations as current_run_builtin_migrations, }; use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement}; use tempfile::TempDir; @@ -30,6 +32,36 @@ async fn connect(url: &str) -> DatabaseConnection { Database::connect(opts).await.expect("connect") } +/// Build the historical migration set used by tests that exercise a +/// pre-operation-v2 schema. The v2 migration is intentionally forward-only, +/// so a full current runner cannot be used to roll a fixture back across it. +fn builtin_runner_before_operation_log_v2() -> MigrationRunner { + let mut runner = MigrationRunner::new(); + for migration in builtin_migrations() + .into_iter() + .filter(|migration| migration.version <= 2026082401) + { + runner + .register(migration) + .expect("historical builtin registry must build clean"); + } + runner +} + +/// Most migration tests intentionally exercise the last pre-v2 schema shape. +/// Keep their existing concise call sites on that historical runner; tests +/// whose purpose is current-registry wiring call the aliased production +/// helpers explicitly below. +fn builtin_runner() -> Result { + Ok(builtin_runner_before_operation_log_v2()) +} + +async fn run_builtin_migrations(conn: &DatabaseConnection) -> Result> { + Ok(builtin_runner_before_operation_log_v2() + .run_pending(conn) + .await?) +} + // --------------------------------------------------------------------------- // Builtin runner contract: current runtime migrations are registered // --------------------------------------------------------------------------- @@ -55,7 +87,7 @@ fn builtin_migrations_register_current_schema_migrations() { 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, - 2026082401 + 2026082401, 2026090301 ] ); assert_eq!( @@ -118,13 +150,14 @@ fn builtin_migrations_register_current_schema_migrations() { "approved_permission_provenance", "agent_bridge_capture", "agent_bridge_link_relations", + "operation_log_v2", ] ); - let runner = builtin_runner().expect("builtin registry must build clean"); + let runner = current_builtin_runner().expect("builtin registry must build clean"); assert!(!runner.is_empty()); - assert_eq!(runner.len(), 57); - assert_eq!(runner.max_registered_version(), Some(2026082401)); + assert_eq!(runner.len(), 58); + assert_eq!(runner.max_registered_version(), Some(2026090301)); } // --------------------------------------------------------------------------- @@ -1128,18 +1161,22 @@ async fn fresh_create_database_runs_migrations_just_like_reopen() { #[tokio::test] async fn establish_connection_auto_upgrades_stale_schema() { - use libra::internal::db::{create_database, establish_connection}; + use libra::internal::db::establish_connection; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("stale.db"); let path_str = path.to_str().unwrap(); - let conn = create_database(path_str).await.unwrap(); + let conn = connect(&format!("sqlite://{path_str}")).await; let runner = builtin_runner().expect("builtin runner builds clean"); + runner + .run_pending(&conn) + .await + .expect("create stale schema"); runner .rollback_to(&conn, 2026050601) .await - .expect("roll back latest migration"); + .expect("roll back latest historical migration"); conn.close().await.unwrap(); // Opening the connection now applies any pending migrations automatically, @@ -1149,10 +1186,10 @@ async fn establish_connection_auto_upgrades_stale_schema() { .expect("ordinary connect should auto-upgrade a stale schema"); let raw = connect(&format!("sqlite://{}", path.display())).await; - let latest = builtin_runner() + let latest = current_builtin_runner() .expect("builtin runner builds clean") .max_registered_version(); - let current = builtin_runner() + let current = current_builtin_runner() .expect("builtin runner builds clean") .current_version_readonly(&raw) .await @@ -1194,7 +1231,7 @@ async fn describe_schema_versions(conn: &DatabaseConnection) -> Vec { async fn run_builtin_migrations_applies_current_builtin_registry() { let (_dir, url, _path) = fresh_db_url(); let conn = connect(&url).await; - let applied = run_builtin_migrations(&conn) + let applied = current_run_builtin_migrations(&conn) .await .expect("run_builtin_migrations"); assert_eq!( @@ -1208,7 +1245,7 @@ async fn run_builtin_migrations_applies_current_builtin_registry() { 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, - 2026082401 + 2026082401, 2026090301 ] ); assert!(table_exists(&conn, "schema_versions").await); diff --git a/tests/index_snapshot_roundtrip.rs b/tests/index_snapshot_roundtrip.rs new file mode 100644 index 000000000..fe6f14dd1 --- /dev/null +++ b/tests/index_snapshot_roundtrip.rs @@ -0,0 +1,34 @@ +//! OL-07 raw index facet roundtrip. + +use std::{fs, path::PathBuf}; + +use libra::{ + internal::operation::{ + facet::{FacetCaptureCtx, FacetRegistry, StateFacet}, + facets::IndexFacet, + }, + utils::client_storage::ClientStorage, +}; +use tempfile::tempdir; + +#[test] +fn index_bytes_are_restored_exactly() { + let root = tempdir().expect("root"); + let objects = tempdir().expect("objects"); + let index = root.path().join("index"); + let original = b"index\0intent-to-add\0skip-worktree\0"; + fs::write(&index, original).expect("write"); + let facet = IndexFacet::index( + PathBuf::from(&index), + ClientStorage::init_local(objects.path().to_path_buf()), + ); + let capture = facet.capture(&FacetCaptureCtx::default()).expect("capture"); + fs::write(&index, b"changed").expect("modify"); + facet + .restore(&capture, &mut Default::default()) + .expect("restore"); + assert_eq!(fs::read(&index).expect("read"), original); + let mut registry = FacetRegistry::default(); + registry.register(Box::new(facet)).expect("register"); + assert!(registry.fully_restorable(&[capture])); +} diff --git a/tests/operation_command_coverage.rs b/tests/operation_command_coverage.rs new file mode 100644 index 000000000..37fa353b1 --- /dev/null +++ b/tests/operation_command_coverage.rs @@ -0,0 +1,40 @@ +//! OL-09 representative CLI mutation coverage through the common boundary. + +use git_internal::hash::ObjectHash; +use libra::{ + internal::operation::{ + middleware::OperationMiddleware, + store::{OperationStatusV2, OperationStore}, + }, + utils::client_storage::ClientStorage, +}; +use sea_orm::{ConnectionTrait, Database}; +use tempfile::tempdir; + +fn oid(byte: u8) -> ObjectHash { + ObjectHash::from_bytes(&[byte; 20]).expect("oid") +} + +#[tokio::test] +async fn representative_command_records_pre_and_post_views() { + let db = Database::connect("sqlite::memory:").await.expect("db"); + db.execute_unprepared("CREATE TABLE operation(op_id TEXT PRIMARY KEY,repo_id TEXT NOT NULL,format_version INTEGER NOT NULL,kind TEXT NOT NULL,status TEXT NOT NULL,command_name TEXT,description TEXT,args_digest TEXT,actor TEXT,worktree_id TEXT,scope_kind TEXT NOT NULL,pre_view_oid TEXT NOT NULL,post_view_oid TEXT NOT NULL,restores_op_id TEXT,reverts_op_id TEXT,predecessor_map_oid TEXT,causal_context_id TEXT,start_ts INTEGER NOT NULL,end_ts INTEGER); CREATE TABLE operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,ordinal INTEGER NOT NULL,PRIMARY KEY(op_id,parent_op_id)); CREATE TABLE operation_head(repo_id TEXT NOT NULL,scope_key TEXT NOT NULL,op_id TEXT NOT NULL,generation INTEGER NOT NULL,PRIMARY KEY(repo_id,scope_key,op_id)); CREATE TABLE operation_journal(journal_id TEXT PRIMARY KEY,op_id TEXT NOT NULL,phase TEXT NOT NULL,pre_view_oid TEXT,target_view_oid TEXT,owner TEXT NOT NULL,updated_at INTEGER NOT NULL,recovery_payload TEXT);").await.expect("schema"); + let objects = tempdir().expect("objects"); + let store = OperationStore::new(db, ClientStorage::init_local(objects.path().to_path_buf())); + let middleware = + OperationMiddleware::new(store.clone(), "repo", "main", None::<&std::path::Path>); + middleware + .run_with_operation("commit", "commit-1", oid(1), oid(2), None, || async { + Ok::<_, String>(()) + }) + .await + .expect("run"); + let operation = store + .load_operation("commit-1") + .await + .expect("load") + .expect("operation"); + assert_eq!(operation.status, OperationStatusV2::Success); + assert_eq!(operation.pre_view_oid, oid(1)); + assert_eq!(operation.post_view_oid, oid(2)); +} diff --git a/tests/operation_dag.rs b/tests/operation_dag.rs new file mode 100644 index 000000000..7b13bb2a6 --- /dev/null +++ b/tests/operation_dag.rs @@ -0,0 +1,85 @@ +//! OL-04 Operation Log v2 DAG and publish-CAS integration coverage. + +use git_internal::hash::ObjectHash; +use libra::internal::operation::store::{ + OpHead, OperationKind, OperationMetaV2, OperationStatusV2, OperationStore, OperationV2, + StoreError, +}; +use sea_orm::{ConnectionTrait, Database}; +use tempfile::tempdir; + +fn oid(byte: u8) -> ObjectHash { + ObjectHash::from_bytes(&[byte; 20]).expect("oid") +} + +async fn store() -> OperationStore { + let db = Database::connect("sqlite::memory:").await.expect("db"); + db.execute_unprepared( + "CREATE TABLE operation(op_id TEXT PRIMARY KEY,repo_id TEXT NOT NULL,format_version INTEGER NOT NULL,kind TEXT NOT NULL,status TEXT NOT NULL,command_name TEXT,description TEXT,args_digest TEXT,actor TEXT,worktree_id TEXT,scope_kind TEXT NOT NULL,pre_view_oid TEXT NOT NULL,post_view_oid TEXT NOT NULL,restores_op_id TEXT,reverts_op_id TEXT,predecessor_map_oid TEXT,causal_context_id TEXT,start_ts INTEGER NOT NULL,end_ts INTEGER); CREATE TABLE operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,ordinal INTEGER NOT NULL,PRIMARY KEY(op_id,parent_op_id)); CREATE TABLE operation_head(repo_id TEXT NOT NULL,scope_key TEXT NOT NULL,op_id TEXT NOT NULL,generation INTEGER NOT NULL,PRIMARY KEY(repo_id,scope_key,op_id)); CREATE TABLE operation_journal(journal_id TEXT PRIMARY KEY,op_id TEXT NOT NULL,phase TEXT NOT NULL,pre_view_oid TEXT,target_view_oid TEXT,owner TEXT NOT NULL,updated_at INTEGER NOT NULL,recovery_payload TEXT);", + ) + .await + .expect("schema"); + let dir = tempdir().expect("temp"); + OperationStore::new( + db, + libra::utils::client_storage::ClientStorage::init_local(dir.path().join("objects")), + ) +} + +#[tokio::test] +async fn multi_parent_dag_and_cas_conflict_are_retained() { + let store = store().await; + let operation = OperationV2 { + op_id: "merge".to_string(), + repo_id: "repo".to_string(), + parent_op_ids: vec!["left".to_string(), "right".to_string()], + pre_view_oid: oid(1), + post_view_oid: oid(2), + kind: OperationKind::Command, + status: OperationStatusV2::Success, + metadata: OperationMetaV2 { + scope_kind: "main".to_string(), + ..Default::default() + }, + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + start_ts: 1, + end_ts: Some(2), + }; + store + .write_operation(&operation) + .await + .expect("write operation"); + assert_eq!( + store.load_operation("merge").await.expect("load").unwrap(), + operation + ); + + let first = [OpHead { + op_id: "left".to_string(), + generation: 4, + }]; + store + .cas_update_op_heads("repo", "main", &[], &first) + .await + .expect("first publish"); + let second = [OpHead { + op_id: "right".to_string(), + generation: 4, + }]; + assert!(matches!( + store + .cas_update_op_heads("repo", "main", &[], &second) + .await, + Err(StoreError::CasConflict { .. }) + )); + assert_eq!( + store + .current_op_heads("repo", "main") + .await + .expect("heads") + .len(), + 2 + ); +} diff --git a/tests/operation_v2_schema.rs b/tests/operation_v2_schema.rs new file mode 100644 index 000000000..2e73736fd --- /dev/null +++ b/tests/operation_v2_schema.rs @@ -0,0 +1,97 @@ +//! OL-02 schema convergence tests. + +use std::collections::BTreeMap; + +use libra::internal::db::{create_database, migration::run_builtin_migrations}; +use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, Statement}; +use tempfile::tempdir; + +const V2_TABLES: [&str; 8] = [ + "operation", + "operation_parent", + "operation_head", + "operation_journal", + "change_identity", + "change_revision", + "change_predecessor", + "ai_operation_link", +]; + +async fn schema_columns(conn: &DatabaseConnection) -> BTreeMap> { + let mut schema = BTreeMap::new(); + for table in V2_TABLES { + let rows = conn + .query_all_raw(Statement::from_string( + DbBackend::Sqlite, + format!("PRAGMA table_info('{table}')"), + )) + .await + .expect("read table info"); + let mut columns = rows + .into_iter() + .map(|row| row.try_get_by_index::(1).expect("column name")) + .collect::>(); + columns.sort(); + schema.insert(table.to_string(), columns); + } + schema +} + +async fn assert_only_v2_operation_tables(conn: &DatabaseConnection) { + let rows = conn + .query_all_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT name FROM sqlite_master WHERE type = 'table' AND (name LIKE 'operation%' OR name LIKE 'change_%' OR name = 'ai_operation_link') ORDER BY name".to_string(), + )) + .await + .expect("list operation tables"); + let names = rows + .into_iter() + .map(|row| row.try_get_by_index::(0).expect("table name")) + .collect::>(); + let mut expected = V2_TABLES + .iter() + .map(|name| (*name).to_string()) + .collect::>(); + expected.sort(); + assert_eq!(names, expected); +} + +#[tokio::test] +async fn fresh_database_uses_operation_v2_schema() { + let dir = tempdir().expect("temp directory"); + let db_path = dir.path().join("fresh.db"); + let conn = create_database(db_path.to_str().expect("db path")) + .await + .expect("create db"); + + assert_only_v2_operation_tables(&conn).await; + let schema = schema_columns(&conn).await; + assert_eq!(schema["operation"].len(), 19); + assert_eq!(schema["operation_head"].len(), 4); + assert_eq!(schema["operation_journal"].len(), 8); + assert_eq!(schema["change_identity"].len(), 5); + assert_eq!(schema["change_revision"].len(), 5); + assert_eq!(schema["change_predecessor"].len(), 5); + assert_eq!(schema["ai_operation_link"].len(), 11); +} + +#[tokio::test] +async fn legacy_operation_schema_converges_to_v2() { + let dir = tempdir().expect("temp directory"); + let db_path = dir.path().join("legacy.db"); + let conn = create_database(db_path.to_str().expect("db path")) + .await + .expect("create db"); + + conn.execute_unprepared( + "DROP TABLE IF EXISTS operation_view_workspace; DROP TABLE IF EXISTS operation_view_ref; DROP TABLE IF EXISTS operation_view; DROP TABLE IF EXISTS operation_journal; DROP TABLE IF EXISTS operation_head; DROP TABLE IF EXISTS operation_parent; DROP TABLE IF EXISTS operation; DROP TABLE IF EXISTS change_identity; DROP TABLE IF EXISTS change_revision; DROP TABLE IF EXISTS change_predecessor; DROP TABLE IF EXISTS ai_operation_link; CREATE TABLE operation(op_id TEXT PRIMARY KEY, repo_id TEXT NOT NULL, view_id TEXT NOT NULL, command_name TEXT NOT NULL, description TEXT NOT NULL, actor TEXT NOT NULL, args_digest TEXT, start_ts INTEGER NOT NULL, end_ts INTEGER, status TEXT NOT NULL); CREATE TABLE operation_parent(op_id TEXT NOT NULL, parent_op_id TEXT NOT NULL, PRIMARY KEY(op_id, parent_op_id)); CREATE TABLE operation_view(view_id TEXT PRIMARY KEY, repo_id TEXT NOT NULL, head_kind TEXT NOT NULL, head_target TEXT NOT NULL, created_at INTEGER NOT NULL); CREATE TABLE operation_view_ref(view_id TEXT NOT NULL, ref_kind TEXT NOT NULL, ref_name TEXT NOT NULL, ref_remote TEXT NOT NULL, target_oid TEXT NOT NULL, PRIMARY KEY(view_id, ref_kind, ref_name, ref_remote)); CREATE TABLE operation_view_workspace(view_id TEXT NOT NULL, pointer_kind TEXT NOT NULL, pointer_value TEXT NOT NULL, PRIMARY KEY(view_id, pointer_kind)); DELETE FROM schema_versions WHERE version = 2026090301", + ) + .await + .expect("plant legacy schema"); + + run_builtin_migrations(&conn) + .await + .expect("upgrade legacy schema"); + assert_only_v2_operation_tables(&conn).await; +} diff --git a/tests/sequencer_snapshot_roundtrip.rs b/tests/sequencer_snapshot_roundtrip.rs new file mode 100644 index 000000000..763001990 --- /dev/null +++ b/tests/sequencer_snapshot_roundtrip.rs @@ -0,0 +1,31 @@ +//! OL-07 sequencer facet roundtrip. + +use std::fs; + +use libra::{ + internal::operation::{ + facet::{FacetCaptureCtx, StateFacet}, + facets::SequencerFacet, + }, + utils::client_storage::ClientStorage, +}; +use tempfile::tempdir; + +#[test] +fn sequencer_state_is_restored() { + let root = tempdir().expect("root"); + let objects = tempdir().expect("objects"); + let state = root.path().join("sequencer/todo"); + fs::create_dir_all(state.parent().expect("parent")).expect("mkdir"); + fs::write(&state, b"pick abc\nreword def\n").expect("write"); + let facet = SequencerFacet::sequencer( + state.clone(), + ClientStorage::init_local(objects.path().to_path_buf()), + ); + let capture = facet.capture(&FacetCaptureCtx::default()).expect("capture"); + fs::write(&state, b"changed\n").expect("modify"); + facet + .restore(&capture, &mut Default::default()) + .expect("restore"); + assert_eq!(fs::read(state).expect("read"), b"pick abc\nreword def\n"); +} diff --git a/tests/workspace_snapshot_roundtrip.rs b/tests/workspace_snapshot_roundtrip.rs new file mode 100644 index 000000000..fe843999e --- /dev/null +++ b/tests/workspace_snapshot_roundtrip.rs @@ -0,0 +1,49 @@ +//! OL-06 working-copy snapshot capture and restore integration coverage. + +use std::fs; + +use libra::{ + internal::operation::{ + snapshot::WorkspaceSnapshotter, + view::{CapturePolicy, HeadState}, + }, + utils::client_storage::ClientStorage, +}; +use tempfile::tempdir; + +#[test] +fn tracked_and_untracked_content_roundtrip() { + let root = tempdir().expect("root"); + fs::create_dir(root.path().join("nested")).expect("mkdir"); + fs::write(root.path().join("tracked.txt"), b"before").expect("write"); + fs::write(root.path().join("nested/untracked.txt"), b"untracked").expect("write"); + let objects = tempdir().expect("objects"); + let snapshotter = + WorkspaceSnapshotter::new(ClientStorage::init_local(objects.path().to_path_buf())); + let capture = snapshotter + .capture( + root.path(), + "workspace", + HeadState::Unborn { + ref_name: "main".to_string(), + }, + CapturePolicy::TrackedAndUntracked, + 1, + ) + .expect("capture") + .expect("snapshot"); + + fs::write(root.path().join("tracked.txt"), b"changed").expect("modify"); + fs::remove_file(root.path().join("nested/untracked.txt")).expect("remove"); + snapshotter + .restore(root.path(), &capture.manifest) + .expect("restore"); + assert_eq!( + fs::read(root.path().join("tracked.txt")).expect("read"), + b"before" + ); + assert_eq!( + fs::read(root.path().join("nested/untracked.txt")).expect("read"), + b"untracked" + ); +}