From 817763f111eff3523595bc976101bf6c8b3934ed Mon Sep 17 00:00:00 2001 From: jackieismpc Date: Sat, 5 Sep 2026 14:39:24 +0800 Subject: [PATCH 1/2] feat(operation): complete operation v2 foundation Signed-off-by: jackieismpc --- Cargo.toml | 8 + .../operation-log-working-copy-change-id.md | 18 +- docs/development/plan/plan-20260822.md | 99 +- sql/migrations/2026090101_operation_v2.sql | 107 +++ src/command/maintenance.rs | 457 ++++++--- src/command/mod.rs | 4 +- src/command/worktree.rs | 860 ++++++++--------- src/internal/config_ownership.rs | 3 + src/internal/db.rs | 79 +- src/internal/db/migration.rs | 391 +++++++- .../{operation.rs => legacy_operation.rs} | 28 +- src/internal/legacy_operation_model/mod.rs | 8 + .../legacy_operation_model/operation.rs | 30 + .../operation_parent.rs | 17 + .../operation_view.rs | 4 +- .../operation_view_ref.rs | 4 +- .../operation_view_workspace.rs | 4 +- src/internal/mod.rs | 2 + src/internal/model/ai_operation_link.rs | 25 + src/internal/model/change_identity.rs | 19 + src/internal/model/change_predecessor.rs | 21 + src/internal/model/change_revision.rs | 20 + src/internal/model/mod.rs | 9 +- src/internal/model/operation.rs | 59 +- src/internal/model/operation_head.rs | 20 + src/internal/model/operation_journal.rs | 22 + src/internal/model/operation_parent.rs | 3 +- src/internal/mutable_state_ownership.rs | 263 +++-- src/internal/operation/facet.rs | 398 ++++++++ src/internal/operation/mod.rs | 21 + src/internal/operation/store.rs | 908 ++++++++++++++++++ src/internal/operation/view.rs | 382 ++++++++ src/internal/operation_wrapper.rs | 64 +- tests/INDEX.md | 2 + tests/agent_bridge_migration_test.rs | 14 +- tests/agent_capture_migration_test.rs | 14 +- tests/command/schema_upgrade_test.rs | 93 +- tests/command/worktree_isolation_test.rs | 91 +- tests/compat/agent_bridge_schema_test.rs | 12 +- tests/db_migration_test.rs | 60 +- tests/operation_dag.rs | 280 ++++++ tests/operation_schema_v2.rs | 310 ++++++ tests/operation_service_test.rs | 102 +- tests/operation_wrapper_test.rs | 131 +-- 44 files changed, 4426 insertions(+), 1040 deletions(-) create mode 100644 sql/migrations/2026090101_operation_v2.sql rename src/internal/{operation.rs => legacy_operation.rs} (99%) create mode 100644 src/internal/legacy_operation_model/mod.rs create mode 100644 src/internal/legacy_operation_model/operation.rs create mode 100644 src/internal/legacy_operation_model/operation_parent.rs rename src/internal/{model => legacy_operation_model}/operation_view.rs (79%) rename src/internal/{model => legacy_operation_model}/operation_view_ref.rs (82%) rename src/internal/{model => legacy_operation_model}/operation_view_workspace.rs (77%) create mode 100644 src/internal/model/ai_operation_link.rs create mode 100644 src/internal/model/change_identity.rs create mode 100644 src/internal/model/change_predecessor.rs create mode 100644 src/internal/model/change_revision.rs create mode 100644 src/internal/model/operation_head.rs create mode 100644 src/internal/model/operation_journal.rs create mode 100644 src/internal/operation/facet.rs create mode 100644 src/internal/operation/mod.rs create mode 100644 src/internal/operation/store.rs create mode 100644 src/internal/operation/view.rs create mode 100644 tests/operation_dag.rs create mode 100644 tests/operation_schema_v2.rs diff --git a/Cargo.toml b/Cargo.toml index ebb863cdc..8d73b305d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -600,3 +600,11 @@ path = "tests/commit_change_id_header_spike.rs" # debug_assertions are independent of opt-level, so behavior is unchanged. [profile.dev.package."*"] opt-level = 2 +# OL-02..04: v2 persistence foundation targets. +[[test]] +name = "operation_schema_v2" +path = "tests/operation_schema_v2.rs" + +[[test]] +name = "operation_dag" +path = "tests/operation_dag.rs" diff --git a/docs/development/internal/operation-log-working-copy-change-id.md b/docs/development/internal/operation-log-working-copy-change-id.md index 63ed0e8c5..69879ad21 100644 --- a/docs/development/internal/operation-log-working-copy-change-id.md +++ b/docs/development/internal/operation-log-working-copy-change-id.md @@ -467,7 +467,15 @@ graph TB ### 5.1 模块划分与文件落点 -当前 `src/internal/operation_wrapper.rs` 与 `src/internal/operation.rs`(`OperationService`)、`operation_view*` 三表构成 v1。本方案处于开发阶段,**不维护 v1 兼容层**:v2 直接替换 v1 的模块与 schema,v2 每阶段验证通过后即删除被替换的 v1 代码(`operation_wrapper.rs`、v1 表与 model、v1 专用命令路径),最终不存在双写或兼容 adapter。过渡期只保留最短并行窗口用于 shadow 对比,不投入长期兼容成本。 +当前 `src/internal/operation_wrapper.rs` 与 v1 `OperationService`、`operation_view*` 三表构成旧 operation 路径。最终方案仍**不维护 v1 长期兼容层或双写 adapter**;但 OL-02~OL-04 的安全基础窗口必须保持 active wrapper 可用。因此 migration 在单事务内以 copy-first 方式将 v1 表迁移到明确的 `legacy_operation*` 命名空间,再创建 v2 canonical schema;现有 v1 DAO/service 仅改为访问 legacy 表,新 `OperationStoreV2` 只访问 v2 表。OL-09/OL-15 完成 runtime cutover、回归和删除验收后,才移除 legacy 表与 v1 代码。 + +#### ADR-OL-01b:OL-02~OL-04 legacy staging + +- **Status:** Accepted for the 2026-09-05 execution window +- **Atomic migration:** migration runner 在同一 SQLite 写事务中 claim version、创建 staging 表、copy v1 行、校验行数和关键字段、删除旧 source 并 rename 为 `legacy_*`,最后创建八张 v2 表;任一步失败都回滚 version claim、staging、rename 与数据。 +- **No false genealogy:** v1 的 view/workspace 快照不能无损重建 `RepoViewV2`/`WorkspaceSnapshotV2`,因此旧行不转换成 v2 view、journal、head、change genealogy 或 AI link。无法无损映射的字段继续保留在 legacy 数据中。 +- **Runtime boundary:** 本窗口 active operation logging 继续只读写 `legacy_operation*`;v2 store/codec 只读写 v2 表;禁止 v1/v2 双写。maintenance/object-root 路径也显式使用 legacy 命名空间,避免迁移后运行时断裂。 +- **Deletion condition:** 只有 OL-09/OL-15 完成所有 CLI/Agent mutation cutover、active logging smoke、legacy 读写零命中守卫、object-root/maintenance 收口、备份与删除演练、兼容矩阵验收后,才允许单独删除 `legacy_*` 表、legacy model/service 和 fixture;在此之前不得删除或清空 legacy 数据。 | 新模块/文件 | 职责 | 借鉴的 jj 实现 | |---|---|---| @@ -622,9 +630,9 @@ pub struct PredecessorEdge { jj 的 `ChangeId` 是 `id_type!(ChangeId { reverse_hex() })`(`jj/lib/src/backend.rs:52-56`),`Commit` 结构自带 `change_id` 字段并在 rewrite 时默认继承(`jj/lib/src/commit_builder.rs:336-344` 的 `set_change_id` / `generate_new_change_id`)。Libra 不把 ChangeId 塞进 Git commit 对象格式(避免改动 OID),而是:随机新 ID 写入 sidecar/operation manifest;legacy import 用 `synthetic_for_commit`(`SHA-256("libra-change-id-v1\0" || object_format || commit_oid_bytes)` 前 16 字节);`ChangeRevision` 投影记录 `(change_id, commit_oid)` 二元组,解决“一个 change 有多个 revision”的查询;`PredecessorEdge` 对应 jj `Operation.commit_predecessors`(`BTreeMap>`),但加上 relation kind 以表达 squash/split/duplicate 多边语义。 -#### 5.2.5 SQLite v2 表结构(开发期直接替换 v1) +#### 5.2.5 SQLite v2 表结构(legacy staging 后的 canonical schema) -开发期直接替换 v1 schema:删除 `operation / operation_parent / operation_view / operation_view_ref / operation_view_workspace`(`src/internal/db.rs:557-606`)及其 SeaORM model,按下面 v2 表重建;旧仓库若需保留审计数据,用一次性导入脚本,不做长期兼容层。 +v2 schema 是最终 canonical schema。OL-02~OL-04 不直接删除仍被 active wrapper 使用的 v1 表,而是先将 `operation / operation_parent / operation_view / operation_view_ref / operation_view_workspace` 在事务内迁移为 `legacy_operation / legacy_operation_parent / legacy_operation_view / legacy_operation_view_ref / legacy_operation_view_workspace`,再按下面定义创建 v2 八表。该 staging 不是长期双写兼容层:v1 runtime 仅为本窗口访问 legacy,所有新 v2 store 写入只落 v2;legacy 删除条件由 ADR-OL-01b 规定。 ```sql CREATE TABLE IF NOT EXISTS operation ( @@ -889,14 +897,14 @@ pub fn resolve_change_id_prefix(db: &DatabaseConnection, repo_id: &str, ### 5.4 实现路径与阶段门 -实施顺序与主计划(`/Users/jackie/ospp/libra-operation-log-change-id-plan-20260814.md`)的 Phase 0-8 依赖顺序保持一致:先证明快照完整,再开放 Undo;先单 worktree,再并发收敛;先冻结后端事实源,再做 Web projection。但本仓库文档明确:**开发期不维护 v1 兼容层**,v2 每阶段验证通过后即删除被替换的 v1 代码,最终不存在双写或兼容 adapter。 +实施顺序与主计划(`/Users/jackie/ospp/libra-operation-log-change-id-plan-20260814.md`)的 Phase 0-8 依赖顺序保持一致:先证明快照完整,再开放 Undo;先单 worktree,再并发收敛;先冻结后端事实源,再做 Web projection。OL-02~OL-04 的落地窗口采用 ADR-OL-01b 的 legacy staging;最终仍不维护 v1 长期兼容层,OL-09/OL-15 完成切换后删除 legacy。 每个阶段进入下一阶段的 Gate 必要条件:本阶段验证入口的测试全部通过(含既有 `status`/CLI 回归与 fail-closed 守卫),任一未通过不得推进,不允许把未验证代码带进下一阶段。每阶段对应具体写集与验证入口: | 阶段 | 落点(写集) | 验证入口 | 决策门 | |---|---|---|---| | 0 设计冻结 | 第 5 节结构体/函数定稿;OL-00 header spike | `cargo test --test commit_change_id_header_spike` | Gate-0:ADR 冻结、sidecar-only 写入决策;已有 header 仅作导入兼容 | -| 1 持久化与 I/O 底座 | `src/internal/worktree_io/`、`operation/{store,view,facet}.rs`、`db.rs` v2 schema(替换 v1 表与 model) | `cargo test internal::operation::store`;`cargo test --test operation_dag`;status 零回归 benchmark | Gate-1:对象格式、journal phase、I/O 协议冻结 | +| 1 持久化与 I/O 底座 | `src/internal/worktree_io/`、`operation/{store,view,facet}.rs`、`db.rs` v2 schema + legacy staging(替换 v1 表的 canonical 角色) | `cargo test internal::operation::store`;`cargo test --test operation_dag`;status 零回归 benchmark | Gate-1:对象格式、journal phase、I/O 协议冻结 | | 2 完整快照 | `operation/{snapshot,working_copy}.rs`、HEAD/refs/index/sequencer/sparse facet adapter | `cargo test --test workspace_snapshot_roundtrip`;`--test index_snapshot_roundtrip`;`--test sequencer_snapshot_roundtrip` | Gate-2:facet restore policy、untracked/ignored/large-file 政策 | | 3 CLI + Agent 全修改记录 | `operation/middleware.rs`、`src/cli.rs` classification、`ai/tools/*` gateway | `cargo test --test operation_command_coverage`;`--test agent_shell_operation`;census zero-unclassified guard | Gate-3:shadow mismatch、失败 operation、lease takeover | | 4 可逆用户工作流 | `operation/{restore,undo,doctor}.rs`、`src/command/op.rs` 新子命令 | `cargo test --test operation_restore_faults`;`--test op_undo_redo`;crash matrix | Gate-4:crash/数据安全 review、秒级 SLO、机器接口冻结 | diff --git a/docs/development/plan/plan-20260822.md b/docs/development/plan/plan-20260822.md index 0e2597380..154feb042 100644 --- a/docs/development/plan/plan-20260822.md +++ b/docs/development/plan/plan-20260822.md @@ -2,6 +2,12 @@ 本计划依据 `docs/development/plan/plan-template.md`(模板版本 `v2`)编写,覆盖 `docs/development/internal/operation-log-working-copy-change-id.md`(下文简称「设计文档」)从阶段 0 到阶段 8 的全部实施内容。 +## 本次执行窗口(2026-09-05) + +本窗口在同一 `feat/scheam-update` 分支联合落地 OL-02、OL-03、OL-04:完成 v2 SQLite schema、canonical view/facet codec、`OperationStoreV2`、journal 与 op-head CAS。为保持现有 active `operation_wrapper` 可用,v1 表迁移到明确的 `legacy_*` 命名空间;不进行 v1/v2 双写,不伪造 v2 view/journal/head/genealogy/AI link。legacy 表在 OL-09/OL-15 完成运行时切换及删除验收前保留。 + +本窗口版本冻结:`Cargo.toml`、`web/package.json`、`worker/package.json`、`install.sh`、`install.ps1` 五个版本面均保持 `0.22.14`;不 bump、不打 tag、不生成 release artifact,只执行代码、测试、签名 commit 与 feature branch push。OL-00 继续遵守 sidecar-only:不写 `change-id` commit header,不重写 Git commit,不改变 Commit OID。 + ## 文档职责 本文解决「一次 CLI 操作修改了哪些仓库状态、未提交状态能否统一撤销、commit 经 rewrite 后如何继续识别同一项逻辑工作」三个能力缺口,目标是交付设计文档定义的三项能力:全命令 Operation Log、Working Copy Snapshot 与 Undo/Redo、稳定 Change ID 与 Rewrite Genealogy,并最终以 v2 替换 v1 operation 实现。 @@ -29,14 +35,14 @@ ### 适用范围 - 包含的命令/模块:全部可修改仓库状态的 `libra `(mutation census)、`libra op` 子命令族(restore/undo/redo/revert/doctor)、Agent tool mutation gateway(`src/internal/ai/tools/*`、`src/internal/ai/libra_vcs.rs`)。 -- 包含的存储、schema、协议或 UI 表面:SQLite operation v2 schema(替换 v1 `operation/operation_parent/operation_view/operation_view_ref/operation_view_workspace`)、`RepoViewV2` / `WorkspaceSnapshotV2` canonical manifest(写入 `ClientStorage` Git ODB)、`change_identity/change_revision/change_predecessor/ai_operation_link` 表、`libra op` 机器接口、Web Operation/Change 图。 +- 包含的存储、schema、协议或 UI 表面:SQLite operation v2 schema(最终替换 v1 `operation/operation_parent/operation_view/operation_view_ref/operation_view_workspace`;OL-02~OL-04 窗口先迁移为 `legacy_*`)、`RepoViewV2` / `WorkspaceSnapshotV2` canonical manifest(写入 `ClientStorage` Git ODB)、`change_identity/change_revision/change_predecessor/ai_operation_link` 表、`libra op` 机器接口、Web Operation/Change 图。 - 包含的测试、文档、兼容矩阵或发布动作:每阶段验证 target(`operation_dag`、`workspace_snapshot_roundtrip` 等,见「测试矩阵」)、`tests/INDEX.md` 与 `Cargo.toml [[test]]` 注册、`docs/commands/op.md` EN+zh、`COMPATIBILITY.md`、`docs/error-codes.md`、版本面五处 bump、v1 移除。 ### 非目标 - 不设计用户、Agent 或命令权限模型;默认调用者拥有执行操作所需的最大权限(设计文档第 1 节范围假设)。「最大权限」只移除 authorization/approval/sandbox 设计,不移除 ref lock、CAS、worktree lease、冲突检测与 destructive 确认。 - 不更换现有 Git 后端:不把 Libra 迁移成 jj 的 repository model,保留 Git refs/index/worktree/远端协议(设计文档第 1 节「为什么借鉴,而不取代现有 Git 后端」)。 -- 不为 v1 operation 维护长期兼容层或双写 adapter;v2 每阶段验证通过后即删除被替换的 v1 代码(设计文档 §5.1)。 +- 不为 v1 operation 维护长期兼容层或双写 adapter;本窗口仅保留 `legacy_*` staging 命名空间以保护 active v1 runtime,最终删除仍依赖 OL-09/OL-15 的运行时切换与验收(设计文档 §5.1)。 - 不实现 SQLite projection、stat cache 等派生状态的旧值恢复;派生状态一律按事实源重建(设计文档第 3 节)。 - 不承诺本地 Undo 撤回 push/网络发布等外部系统副作用,只记录 receipt(设计文档第 3 节)。 - 不把 Working Copy 自动变成 Git commit;自动 snapshot 不产生 Commit OID 或 Change ID(设计文档第 2 节)。 @@ -46,7 +52,7 @@ - 用户或系统行为变化:任何经 Libra CLI 的持久修改都形成完整可恢复的 Operation;`libra op undo/redo/restore/revert` 可安全恢复单 worktree 最近或指定 Operation;`libra commit`/amend/rebase 后同一逻辑 change 的 Change ID 稳定;Undo commit 不改变已有 Commit OID。 - 机器接口或数据状态变化:SQLite v2 schema 落地;`RepoViewV2`/`WorkspaceSnapshotV2` manifest 可序列化、校验、闭包可枚举;op-head CAS 与多父 DAG 可发布;`libra op` 新子命令输出 JSON/machine receipt;Operation/Change Web 图可查询。 -- 文档、测试、发布证据:每阶段 Gate 的指定 test target 全绿;`tests/INDEX.md` 注册新增 target;`docs/commands/op.md` EN+zh、`COMPATIBILITY.md`、`docs/error-codes.md` 同步;v1 代码 `rg` 零命中守卫通过;版本面五处按卡 bump 并发布。 +- 文档、测试、发布证据:每阶段 Gate 的指定 test target 全绿;`tests/INDEX.md` 注册新增 target;`docs/commands/op.md` EN+zh、`COMPATIBILITY.md`、`docs/error-codes.md` 同步;v1 代码 `rg` 零命中守卫属于 OL-15;版本面五处按发布卡 bump 并发布,本窗口不 bump。 - 何时可标记计划完成:满足「完成判据」全部条目,Codex review 最终结论为 PASS,所有非延后任务 `Lifecycle=done` 且 `Acceptance=complete`。 ## 事实基线 @@ -121,15 +127,23 @@ 实现时若需偏离本节,必须先修改计划并说明原因,不得在代码中静默改语义。 -### ADR-OL-01: v2 直接替换 v1,不维护兼容层 +### ADR-OL-01: v2 最终替换 v1,不维护长期兼容层 - **Status:** Accepted -- **Context:** 设计文档 §5.1 明确本方案处于开发阶段,不维护 v1 兼容层;v2 每阶段验证通过后即删除被替换的 v1 代码(`operation_wrapper.rs`、v1 表与 model、v1 专用命令路径),最终不存在双写或兼容 adapter。 -- **Decision:** v2 schema 与模块落地后直接替换 v1;旧仓库如需保留审计数据,用一次性导入脚本,不做长期兼容层。 -- **Alternatives considered:** additive schema 升级与双写适配(拒绝:成本高、违背开发期替换策略);长期兼容 adapter(拒绝:无用户价值且增加维护面)。 -- **Consequences:** v1 operation 表与命令路径存在破坏性替换窗口;OL-02/OL-15 的 `Version increment` 必须为 `minor`,并同步 `COMPATIBILITY.md`。 +- **Context:** 设计文档 §5.1 的最终目标是不维护 v1 长期兼容层;但 OL-02~OL-04 必须先完成 schema、codec、store 的安全基础,并保持 active `operation_wrapper` 可用。 +- **Decision:** v2 是最终事实源,禁止 v1/v2 双写和永久 adapter。OL-02~OL-04 使用独立 `legacy_*` staging 命名空间承接现有 v1 读写;OL-09/OL-15 完成所有运行时切换、验证与删除后,移除 legacy 表和 v1 model/service。 +- **Alternatives considered:** 直接删除 v1 表(拒绝:会在基础窗口切断 active operation logging);双写适配(拒绝:引入两个事实源和一致性风险);长期兼容 adapter(拒绝:无用户价值且增加维护面)。 +- **Consequences:** 本窗口保留明确的 legacy 存储边界,但不将旧行伪造为 v2 view/journal/head/genealogy/AI link;OL-02/OL-15 的 `Version increment` 仍为 `minor`,并同步 `COMPATIBILITY.md`。 - **Revisit when:** 若未来出现必须与 v1 仓库数据长期互操作的场景。 +### ADR-OL-01b: OL-02~OL-04 legacy staging 与删除条件 + +- **Status:** Accepted for the 2026-09-05 execution window +- **Context:** migration runner 从 bootstrap v1 逐步迁移到 v2;active `operation_wrapper`、现有 DAO 与 maintenance/object-root 读取仍需要原有 operation 语义。v1 行中的 view、workspace、历史关系不足以无损构造 v2 manifest 或 operation genealogy。 +- **Decision:** 在单事务内 copy-first 创建 staging 表,校验行数和关键字段后,将 v1 表原子更名为 `legacy_operation*`;再创建八张 v2 canonical 表。新运行时只写 v2,现有 v1 logging 在本窗口继续只读写 legacy 表,不启用双写。无法无损映射的字段只保留在 legacy 表,不填充伪造 v2 行。 +- **Deletion condition:** 仅当 OL-09/OL-15 已完成 mutation runtime cutover、active logging smoke、legacy 读写零命中守卫、对象 root/maintenance 收口、备份/删除演练与兼容验收后,才允许单独提交删除 `legacy_*` 表、legacy model/service 及其测试 fixture;在此之前禁止删除或清空 legacy 数据。 +- **Failure and rollback:** copy、关键字段验证、DDL 或 canonical schema 创建任一步失败,migration version claim、staging、source rename 与数据修改全部回滚;该 migration forward-only,不提供含义不明的 down migration。 + ### ADR-OL-02: 命令前先 snapshot 的统一 mutation 边界 - **Status:** Accepted @@ -360,11 +374,13 @@ 默认每张卡独立发布(G-07)。本计划未登记合并发布 `REL-*` 组:OL-00 为 `no-release` spike;其余实现/迁移/移除卡各自是完整发布切片。v1 移除(OL-15)是单一可独立发布切片,按 `removal` 卡独立发布(minor bump),不构成 G-08 家族卡。 +**本次联合执行窗口:** `REL-OL-02-04-foundation` 仅在 `feat/scheam-update` 内联合落地 OL-02、OL-03、OL-04。唯一发布动作是最终签名 commit 与该 feature branch 的 origin push;窗口内五个版本面保持 `0.22.14`,不创建 tag/release artifact,不推送 upstream。联合窗口不改变三张卡各自的行为轴、验收记录或后续依赖;OL-05~OL-09/OL-15 仍按原计划推进。 + **并发声明:** 实现阶段可并发组(`Implementation write set` 互不相交,G-10):组 A = {OL-01, OL-02, CH-01}(`worktree_io/`、`db.rs+sql+model`、`change/identity.rs` 互不相交);组 B = {OL-03, CH-02}(`operation/view.rs+facet.rs` vs `change/store.rs+resolve.rs`);组 C = {OL-05, CH-03};其余卡按依赖边串行推进。任何并发组必须满足:I–I 不相交、发布窗口不重叠(ER-12)。 **发布者:** 每张独立发布卡的 C 组(bump/构建/安装/提交/branch 推送)由执行该卡的同一 Agent 执行;计划收口(OL-15)的发布与 D 组远端证据跟踪由计划收口负责人执行。同一时刻只允许一张卡处于「已 bump 未完成推送」状态。 -**发布窗口顺序:** 按依赖边顺序逐卡串行发布:OL-01/OL-02/CH-01(组 A 内实现可并发,发布串行)→ OL-03/OL-04/CH-02 → OL-05/OL-06 → OL-07/OL-08 → OL-09 → OL-10 → OL-11/OL-12 → CH-03 → CH-04 → OL-13 → OL-14 → OL-15。每张卡发布前重读 `Cargo.toml` 权威版本并做 ER-08 parity 预检。 +**发布窗口顺序:** 按依赖边顺序执行:OL-01/OL-02/CH-01(组 A 内实现可并发,发布串行)→ OL-03/OL-04/CH-02 → OL-05/OL-06 → OL-07/OL-08 → OL-09 → OL-10 → OL-11/OL-12 → CH-03 → CH-04 → OL-13 → OL-14 → OL-15。当前 `REL-OL-02-04-foundation` 是已登记的联合窗口;每个后续发布点仍需重读 `Cargo.toml` 权威版本并做 ER-08 parity 预检。 ### Phase 0: 基线冻结与设计消歧 @@ -647,7 +663,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 持久化路径的可行性判定。 @@ -797,29 +813,29 @@ **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 落地。 +**Description:** 按设计文档 §5.2.5 用 v2 表(`operation/operation_parent/operation_head/operation_journal/change_identity/change_revision/change_predecessor/ai_operation_link`)替换 v1 表的 canonical 角色,借助版本化 copy-first migration 将现有 v1 表迁移到 `legacy_operation*` 命名空间,保持 active v1 operation logging;同步完整 SeaORM model。旧行不伪造为 v2 view、journal、head、genealogy 或 AI link,无法无损映射的字段保留在 legacy 数据中。唯一行为轴:v2 持久化 schema 与安全 staging 落地。 -**Out of scope:** 不实现 OperationStoreV2 读写逻辑(OL-04 承接);不实现 codec(OL-03 承接);不维护 v1 兼容 adapter。 +**Out of scope:** 不实现 OperationStoreV2 读写逻辑(OL-04 承接);不实现 codec(OL-03 承接);不切换 CLI/Agent middleware(OL-05~OL-09);不删除 legacy 表(OL-09/OL-15 承接)。 **Current evidence:** `src/internal/db.rs:557-606`(v1 `OPERATION_SCHEMA_SQL`);`src/internal/model/` 现有 v1 operation model;设计文档 §5.2.5 SQL。 **Acceptance criteria:** -- [ ] v2 八张表按设计文档 §5.2.5 定义落地,v1 五张表移除;schema 版本号递增并同步 bootstrap/migration SQL。 -- [ ] 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] v2 八张表按设计文档 §5.2.5 定义落地;版本化 migration 在单事务中完成 v1 五张表到 `legacy_*` 的 copy-first、行数/关键字段校验、原子 rename 与 canonical v2 DDL。 +- [x] SeaORM model 与 v2 八表及 legacy operation 表一一对应;`operation_head` 支持 `(repo_id, scope_key)` 多 head 行(CAS 用)。 +- [x] 不做有损的一次性 v1→v2 业务导入;旧 operation/view/workspace 数据无损保留在 legacy 命名空间,v2 不生成伪造历史关系。 +- [x] 新建库(bootstrap + migration 路径)与既有 v1 数据库(迁移路径)最终 schema 一致,且旧关键字段保留。 +- [x] `operation_schema_v2` 覆盖 fresh、data-bearing copy、idempotence、rollback、reopen;active operation service/wrapper 回归通过。 +- [x] 计划与设计文档记录 legacy staging、禁止双写和 OL-09/OL-15 删除条件;版本冻结窗口保持五个版本面 `0.22.14`。 **Verification:** -- [ ] 新建库与既有库 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 组,独立发布卡自行执行) +- [x] `cargo test --test operation_schema_v2 -- --nocapture`:fresh/data-bearing schema convergence、重复迁移、失败回滚与 reopen 用例通过。 +- [x] `cargo test --test operation_service_test`:10/10;`cargo test --test operation_wrapper_test`:17/17,确认 active v1 logging 继续访问 legacy 表。 +- [x] 手工审计:v2 migration 不生成 v2 历史 view/journal/head/genealogy/AI link;legacy 删除条件与 no-dual-write 约束已写入计划/设计文档。 +- [x] 版本面 parity 预检保持 `0.22.14`;本窗口不执行 bump、tag 或 artifact 发布。 **Dependencies:** 无 @@ -853,7 +869,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,17 +879,17 @@ **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 版本拒绝、闭包缺对象失败路径,以及 facet metadata canonical/registration policy。 +- [x] `RestorePolicy` 三种取值(AutoRestore/Rebuild/NeverRestore)与设计文档一致,并由 registry 的完整性判定使用。 **Verification:** -- [ ] `source .env.test && cargo test --lib internal::operation::view`(含 `(new)` 用例) -- [ ] `source .env.test && cargo test --lib internal::operation::facet`(含 `(new)` 用例) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `cargo test --lib internal::operation::view` 与 `cargo test --lib internal::operation::facet`:canonical/closure/registry 用例通过。 +- [x] `cargo +nightly fmt --all --check` 与 clippy `-D warnings` 通过;本窗口无凭据依赖。 +- [x] 版本面保持 `0.22.14`;不 bump、不打 tag、不生成 release artifact。 **Dependencies:** OL-02(v2 表/类型基线) @@ -907,7 +923,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 +933,17 @@ **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] Store 单元测试与 `operation_dag` 覆盖 CAS 失败、多父 DAG、多 head 与 journal 持久化。 +- [x] v1 `persist_operation_graph` 未被新代码调用;CLI/Agent middleware 接入明确留给 OL-05~OL-09。 **Verification:** -- [ ] `source .env.test && cargo test --lib internal::operation::store`(含 `(new)` 用例) -- [ ] `source .env.test && cargo test --test operation_dag`(`(new)` target,同卡注册) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `cargo test --lib internal::operation::store` 与 `cargo test --test operation_dag`:通过多父、CAS 冲突/保持原 head、journal 读回。 +- [x] `Cargo.toml [[test]]` 与 `tests/INDEX.md` 已注册 `operation_schema_v2`、`operation_dag`。 +- [x] 版本面保持 `0.22.14`;本窗口只允许最终签名 feature-branch commit/push。 **Dependencies:** OL-02(v2 表)、OL-03(RepoViewV2 codec) diff --git a/sql/migrations/2026090101_operation_v2.sql b/sql/migrations/2026090101_operation_v2.sql new file mode 100644 index 000000000..706fe0e70 --- /dev/null +++ b/sql/migrations/2026090101_operation_v2.sql @@ -0,0 +1,107 @@ + -- OL-02: replace the development-only operation schema with v2. + -- + -- This migration is intentionally forward-only. The old operation tables did + -- not contain enough information to reconstruct a WorkspaceSnapshotV2, so a + -- down migration would imply a lossy rollback. Repositories that need the old + -- audit rows must export them before upgrading. + +-- Copy-first legacy migration is orchestrated by the Rust runner so it can +-- inspect the on-disk v1 shape, validate counts and key fields, and roll +-- back the version claim together with every schema/data change. + CREATE TABLE IF NOT EXISTS `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 IF NOT EXISTS `idx_operation_v2_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, + `ordinal` INTEGER NOT NULL, + PRIMARY KEY (`op_id`, `parent_op_id`) + ); + CREATE INDEX IF NOT EXISTS `idx_operation_parent_v2_parent` + ON `operation_parent`(`parent_op_id`, `op_id`); + + CREATE TABLE IF NOT EXISTS `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 IF NOT EXISTS `idx_operation_head_v2_scope_generation` + ON `operation_head`(`repo_id`, `scope_key`, `generation` DESC, `op_id`); + + CREATE TABLE IF NOT EXISTS `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 IF NOT EXISTS `idx_operation_journal_v2_op` + ON `operation_journal`(`op_id`, `updated_at` DESC); + + CREATE TABLE IF NOT EXISTS `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 TABLE IF NOT EXISTS `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 IF NOT EXISTS `idx_change_revision_v2_commit` + ON `change_revision`(`commit_oid`); + + CREATE TABLE IF NOT EXISTS `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 TABLE IF NOT EXISTS `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 + ); diff --git a/src/command/maintenance.rs b/src/command/maintenance.rs index ae561bfdd..c4526648f 100644 --- a/src/command/maintenance.rs +++ b/src/command/maintenance.rs @@ -66,14 +66,14 @@ const LOOSE_OBJECT_AGE_SECONDS: u64 = 14 * 24 * 60 * 60; // 2 weeks /// `--help` examples shown in `libra maintenance --help` output. pub const MAINTENANCE_EXAMPLES: &str = "\ -EXAMPLES: - libra maintenance run Run all maintenance tasks - libra maintenance run --task gc Run only the garbage-collection task - libra maintenance run --task loose-objects Pack old loose objects - libra maintenance run --dry-run Show what would be done, without changes - libra maintenance register Register this repo for periodic maintenance - libra maintenance unregister Unregister this repo - libra maintenance status Show maintenance registration state"; + EXAMPLES: + libra maintenance run Run all maintenance tasks + libra maintenance run --task gc Run only the garbage-collection task + libra maintenance run --task loose-objects Pack old loose objects + libra maintenance run --dry-run Show what would be done, without changes + libra maintenance register Register this repo for periodic maintenance + libra maintenance unregister Unregister this repo + libra maintenance status Show maintenance registration state"; /// Maintenance subcommands matching Git's `git maintenance` interface. #[derive(Subcommand, Debug)] @@ -246,27 +246,27 @@ async fn run_tasks( // cannot be upgraded. The two sets are disjoint by construction, and // this match is exhaustive so a new task must classify itself. let publish_lock = match task { - // `prefetch` runs the ordinary fetch writer in-process: it writes - // objects AND publishes remote-tracking refs. - MaintenanceTask::Prefetch - // `pack-refs` rewrites the ref store. It deletes loose REF - // files, never object payloads, so a shared hold is the right - // mode for it. - | MaintenanceTask::PackRefs => { - Some(crate::internal::maintenance_lock::MaintenanceLock::shared(&repo_path)?) - } - // These take the lock themselves, in the mode each PHASE needs. - // `loose-objects` and `incremental-repack` both publish a pack - // and then UNLINK — shared for the write, exclusive for the - // deletion; `gc` and `cache-evict` are deletion phases outright. - // `commit-graph` derives a file from objects it neither - // publishes nor deletes. - MaintenanceTask::LooseObjects - | MaintenanceTask::IncrementalRepack - | MaintenanceTask::Gc - | MaintenanceTask::CacheEvict - | MaintenanceTask::CommitGraph => None, - }; + // `prefetch` runs the ordinary fetch writer in-process: it writes + // objects AND publishes remote-tracking refs. + MaintenanceTask::Prefetch + // `pack-refs` rewrites the ref store. It deletes loose REF + // files, never object payloads, so a shared hold is the right + // mode for it. + | MaintenanceTask::PackRefs => { + Some(crate::internal::maintenance_lock::MaintenanceLock::shared(&repo_path)?) + } + // These take the lock themselves, in the mode each PHASE needs. + // `loose-objects` and `incremental-repack` both publish a pack + // and then UNLINK — shared for the write, exclusive for the + // deletion; `gc` and `cache-evict` are deletion phases outright. + // `commit-graph` derives a file from objects it neither + // publishes nor deletes. + MaintenanceTask::LooseObjects + | MaintenanceTask::IncrementalRepack + | MaintenanceTask::Gc + | MaintenanceTask::CacheEvict + | MaintenanceTask::CommitGraph => None, + }; let result = match task { MaintenanceTask::Gc => run_gc(&repo_path, dry_run, quiet, output).await, MaintenanceTask::LooseObjects => { @@ -420,14 +420,14 @@ fn read_prune_candidate_ledger( Ok(meta) if meta.len() > MAX_PRUNE_LEDGER_BYTES => { return Err(CliError::fatal(format!( "the GC prune-candidate ledger '{}' is {} bytes, past the \ - {MAX_PRUNE_LEDGER_BYTES}-byte cap", + {MAX_PRUNE_LEDGER_BYTES}-byte cap", path.display(), meta.len() )) .with_stable_code(StableErrorCode::RepoStateInvalid) .with_hint( "delete it to restart the quarantine clock (this delays pruning by one grace \ - window; it never deletes an object)", + window; it never deletes an object)", )); } Ok(_) => {} @@ -513,15 +513,15 @@ fn write_prune_candidate_ledger( if text.len() as u64 > MAX_PRUNE_LEDGER_BYTES { return Err(CliError::fatal(format!( "the GC prune-candidate ledger would grow to {} bytes, past the \ - {MAX_PRUNE_LEDGER_BYTES}-byte cap; '{}' was left unchanged", + {MAX_PRUNE_LEDGER_BYTES}-byte cap; '{}' was left unchanged", text.len(), path.display() )) .with_stable_code(StableErrorCode::RepoStateInvalid) .with_hint( "this repository has more quarantined objects than the ledger can track: run \ - `libra gc` again after the current grace window expires so the backlog drains, \ - or delete the ledger to restart the quarantine clock", + `libra gc` again after the current grace window expires so the backlog drains, \ + or delete the ledger to restart the quarantine clock", )); } crate::utils::atomic_write::write_atomic(path, text.as_bytes(), false).map_err(|error| { @@ -677,9 +677,9 @@ async fn run_gc( object_index_rows_removed: 0, message: format!( "deferred loose-object prune: {live_ordinary} live traces-inflight \ - marker(s) — an agent write is in flight and may hold uncataloged \ - objects; the marker TTL bounds this, re-run after it completes or \ - expires" + marker(s) — an agent write is in flight and may hold uncataloged \ + objects; the marker TTL bounds this, re-run after it completes or \ + expires" ), }); } @@ -807,7 +807,7 @@ async fn run_gc( output, &format!( " {newly_quarantined} newly unreachable object(s) recorded; a later run \ - deletes them if they are still unreachable" + deletes them if they are still unreachable" ), ); } @@ -868,8 +868,8 @@ async fn run_gc( object_index_rows_removed: 0, message: format!( "deferred the deletion of {} unreachable loose object(s): another command \ - is still publishing objects in this repository (a long-running `libra \ - code` session counts). Re-run when it finishes.", + is still publishing objects in this repository (a long-running `libra \ + code` session counts). Re-run when it finishes.", prune_targets.len() ), }); @@ -1164,7 +1164,7 @@ async fn run_loose_objects( object_index_rows_removed: 0, message: format!( "packed {} loose object(s); kept the loose copies because the new pack's \ - directory entry could not be made durable", + directory entry could not be made durable", old_loose.len() ), }); @@ -1185,8 +1185,8 @@ async fn run_loose_objects( object_index_rows_removed: 0, message: format!( "packed {} loose object(s), then deferred removing the loose copies: another \ - command is still publishing objects in this repository. The objects are safe \ - in the new pack — re-run when it finishes.", + command is still publishing objects in this repository. The objects are safe \ + in the new pack — re-run when it finishes.", old_loose.len() ), }); @@ -1540,7 +1540,7 @@ async fn run_incremental_repack( object_index_rows_removed: 0, message: format!( "consolidated {} object(s) into a new pack, then kept the {} old pack(s): the \ - new pack's directory entry could not be made durable", + new pack's directory entry could not be made durable", all_hashes.len(), packs.len() ), @@ -1566,9 +1566,9 @@ async fn run_incremental_repack( object_index_rows_removed: 0, message: format!( "consolidated {} object(s) into a new pack, then deferred deleting the {} old \ - pack(s): another command is still publishing objects in this repository. The \ - consolidated pack was kept (harmless duplicate data) — re-run when it \ - finishes.", + pack(s): another command is still publishing objects in this repository. The \ + consolidated pack was kept (harmless duplicate data) — re-run when it \ + finishes.", all_hashes.len(), packs.len() ), @@ -1596,7 +1596,7 @@ async fn run_incremental_repack( object_index_rows_removed: 0, message: format!( "consolidated {} object(s) into a new pack; skipped deleting the old packs: \ - {refusal}", + {refusal}", all_hashes.len() ), }); @@ -1618,9 +1618,9 @@ async fn run_incremental_repack( object_index_rows_removed: 0, message: format!( "consolidated {} object(s) into a new pack, then aborted deleting the {} old \ - pack(s): concurrent repository activity created new reachability roots during \ - the repack. The consolidated pack was kept (harmless duplicate data) — re-run \ - when the repository is quiescent.", + pack(s): concurrent repository activity created new reachability roots during \ + the repack. The consolidated pack was kept (harmless duplicate data) — re-run \ + when the repository is quiescent.", all_hashes.len(), packs.len() ), @@ -1684,7 +1684,7 @@ async fn run_incremental_repack( if kept_pinned > 0 { message.push_str(&format!( "; retained {kept_pinned} pack(s) pinned by a .keep sentinel (an in-flight or \ - crashed fetch) — remove the stale sentinel to let a later repack reclaim them" + crashed fetch) — remove the stale sentinel to let a later repack reclaim them" )); } Ok(TaskResult { @@ -2181,14 +2181,14 @@ fn write_scheduler_entry( let interval = schedule_interval_secs(schedule); let plist = format!( "\n\ -\n\ -\n\n \ -Label\n {label}\n \ -ProgramArguments\n \n {exe}\n \ -maintenance\n run\n \n \ -WorkingDirectory\n {repo}\n \ -StartInterval\n {interval}\n \ -RunAtLoad\n \n\n\n" + \n\ + \n\n \ + Label\n {label}\n \ + ProgramArguments\n \n {exe}\n \ + maintenance\n run\n \n \ + WorkingDirectory\n {repo}\n \ + StartInterval\n {interval}\n \ + RunAtLoad\n \n\n\n" ); fs::write(&path, plist)?; Ok(path) @@ -2471,8 +2471,8 @@ where Err(git_internal::errors::GitError::ObjectNotFound(_)) => { return Err(CliError::fatal(format!( "index GC root '{}' entry '{}' (stage {stage}) names object {}, \ - which does not exist — pruning would delete the remaining \ - anchors of state that is already damaged", + which does not exist — pruning would delete the remaining \ + anchors of state that is already damaged", index_path.display(), entry.name, entry.hash @@ -2534,9 +2534,9 @@ pub(crate) fn worktree_index_roots( crate::command::worktree::run_list_worktrees_at(&storage_root.join("worktrees.json")) .map_err(|error| { CliError::fatal(format!( - "cannot enumerate worktree GC roots: the worktree registry is unreadable: {error}" - )) - .with_stable_code(StableErrorCode::IoReadFailed) + "cannot enumerate worktree GC roots: the worktree registry is unreadable: {error}" + )) + .with_stable_code(StableErrorCode::IoReadFailed) })?; for entry in list.worktrees { if entry.is_main { @@ -2553,13 +2553,13 @@ pub(crate) fn worktree_index_roots( if !entry.exists { return Err(CliError::fatal(format!( "cannot enumerate worktree GC roots: registered worktree '{}' is missing on \ - disk — its private index (a reachability root) cannot be read", + disk — its private index (a reachability root) cannot be read", entry.path )) .with_stable_code(StableErrorCode::IoReadFailed) .with_hint( "restore the worktree directory, or unregister it with `libra worktree \ - prune` / `libra worktree remove` first", + prune` / `libra worktree remove` first", )); } let candidate = std::path::Path::new(&entry.path) @@ -2757,8 +2757,8 @@ pub const GC_OBJECT_FILE_SOURCE_INVENTORY: &[GcObjectSource] = &[ read_bound: "single file, full read", corruption: GcCorruptionPolicy::LenientSkip, note: "read_prune_candidate_ledger — the writer-vs-deleter quarantine ledger: OIDs seen \ - unreachable, with when. Keeps nothing alive and confers no authority; losing it \ - costs one delayed prune cycle, never an object", + unreachable, with when. Keeps nothing alive and confers no authority; losing it \ + costs one delayed prune cycle, never an object", }, // ── W2 §C.4.3 re-verification: file-backed roots the walk ALREADY // collects, which this inventory did not name. An inventory that @@ -3080,7 +3080,7 @@ pub const GC_OBJECT_SOURCE_INVENTORY: &[GcObjectSource] = &[ }, GcObjectSource { origin: GcSourceOrigin::Column, - location: "operation_view_ref", + location: "legacy_operation_view_ref", column: "target_oid", status: GcSourceStatus::TracedRoot, kind: GcStorageKind::SqliteColumn, @@ -3179,7 +3179,7 @@ pub const GC_OBJECT_SOURCE_INVENTORY: &[GcObjectSource] = &[ }, GcObjectSource { origin: GcSourceOrigin::Column, - location: "operation_view", + location: "legacy_operation_view", column: "head_target", status: GcSourceStatus::TracedRoot, kind: GcStorageKind::SqliteColumn, @@ -3190,7 +3190,7 @@ pub const GC_OBJECT_SOURCE_INVENTORY: &[GcObjectSource] = &[ }, GcObjectSource { origin: GcSourceOrigin::Column, - location: "operation_view_workspace", + location: "legacy_operation_view_workspace", column: "pointer_value", status: GcSourceStatus::TracedRoot, kind: GcStorageKind::SqliteColumn, @@ -3199,6 +3199,94 @@ pub const GC_OBJECT_SOURCE_INVENTORY: &[GcObjectSource] = &[ corruption: GcCorruptionPolicy::FailClosed, note: "undo view workspace pointer — rooted when it is an OID", }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation", + column: "pre_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "v2 operation DAG", + 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: "v2 operation DAG", + 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: "v2 operation DAG", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 operation predecessor map", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation_journal", + column: "pre_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "v2 operation journal", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "in-flight v2 journal pre-view manifest", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation_journal", + column: "target_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "v2 operation journal", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "in-flight v2 journal target manifest", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "change_revision", + column: "commit_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "v2 change projection", + 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: "v2 change genealogy", + 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: "v2 change genealogy", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 change genealogy predecessor", + }, GcObjectSource { origin: GcSourceOrigin::Column, location: "metadata_kv", @@ -3322,8 +3410,11 @@ async fn collect_registered_store_roots( /// A non-empty cell MUST be a valid OID (fail closed otherwise). StrictOid, /// The cell may hold a ref/branch NAME or an OID — only an - /// OID-parsing value roots (names are anchored via `reference`). + /// OID-parsing value roots (names are anchored via repository refs). OidIfParses, + /// An operation view manifest whose workspace snapshots must be + /// expanded before the ordinary Git object walk. + V2View, } type Source = ( &'static str, @@ -3339,27 +3430,57 @@ async fn collect_registered_store_roots( CellMode::StrictOid, ), ( - "operation_view_ref", - "SELECT target_oid FROM operation_view_ref", + "legacy_operation_view_ref", + "SELECT target_oid FROM legacy_operation_view_ref", &["target_oid"], CellMode::StrictOid, ), ( - "operation_view", - "SELECT head_target FROM operation_view", + "legacy_operation_view", + "SELECT head_target FROM legacy_operation_view", &["head_target"], CellMode::OidIfParses, ), ( - "operation_view_workspace", - "SELECT pointer_value FROM operation_view_workspace", + "legacy_operation_view_workspace", + "SELECT pointer_value FROM legacy_operation_view_workspace", &["pointer_value"], CellMode::OidIfParses, ), + ( + "operation", + "SELECT pre_view_oid, post_view_oid FROM operation", + &["pre_view_oid", "post_view_oid"], + CellMode::V2View, + ), + ( + "operation", + "SELECT predecessor_map_oid FROM operation", + &["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, + ), ( "agent_checkpoint", "SELECT parent_commit, tree_oid, metadata_blob_oid, traces_commit \ - FROM agent_checkpoint", + FROM agent_checkpoint", &[ "parent_commit", "tree_oid", @@ -3383,7 +3504,7 @@ async fn collect_registered_store_roots( ( "workspace_record", "SELECT base_commit FROM workspace_record WHERE base_commit IS NOT NULL \ - AND state IN ('provisioning', 'active', 'releasing', 'orphaned')", + AND state IN ('provisioning', 'active', 'releasing', 'orphaned')", &["base_commit"], CellMode::StrictOid, ), @@ -3415,7 +3536,7 @@ async fn collect_registered_store_roots( let hash = parse_object_hash(trimmed).ok_or_else(|| { CliError::fatal(format!( "{table}.{column} contains invalid object id \ - '{trimmed}' while computing GC roots" + '{trimmed}' while computing GC roots" )) .with_stable_code(StableErrorCode::RepoCorrupt) })?; @@ -3426,6 +3547,17 @@ async fn collect_registered_store_roots( walk_reachable(&hash, storage, boundaries, reachable)?; } } + CellMode::V2View => { + let hash = parse_object_hash(trimmed).ok_or_else(|| { + CliError::fatal(format!( + "{table}.{column} contains invalid object id \ + '{trimmed}' while computing GC roots" + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + })?; + walk_reachable(&hash, storage, boundaries, reachable)?; + walk_v2_operation_view(&hash, storage, boundaries, reachable)?; + } } } } @@ -3455,7 +3587,7 @@ async fn collect_registered_store_roots( .map_err(|err| { CliError::fatal(format!( "traces-inflight markers cannot be trusted while computing GC roots \ - (destructive maintenance stops): {err:#}" + (destructive maintenance stops): {err:#}" )) .with_stable_code(StableErrorCode::RepoCorrupt) })?; @@ -3554,7 +3686,7 @@ fn collect_worktree_sidecar_roots( let hash = parse_object_hash(oid.trim()).ok_or_else(|| { CliError::fatal(format!( "sidecar GC root '{}' field {field} holds an invalid object id \ - '{oid}'", + '{oid}'", path.display() )) .with_stable_code(StableErrorCode::RepoCorrupt) @@ -3564,8 +3696,8 @@ fn collect_worktree_sidecar_roots( Err(git_internal::errors::GitError::ObjectNotFound(_)) => { return Err(CliError::fatal(format!( "sidecar GC root '{}' field {field} names object {hash}, which \ - does not exist — an in-progress operation's anchor is missing, \ - so the prune stops rather than deleting its remaining ones", + does not exist — an in-progress operation's anchor is missing, \ + so the prune stops rather than deleting its remaining ones", path.display() )) .with_stable_code(StableErrorCode::RepoCorrupt)); @@ -3573,7 +3705,7 @@ fn collect_worktree_sidecar_roots( Err(error) => { return Err(CliError::fatal(format!( "failed to probe sidecar GC root '{}' field {field} ({hash}): \ - {error}", + {error}", path.display() )) .with_stable_code(StableErrorCode::IoReadFailed)); @@ -3624,7 +3756,7 @@ fn collect_worktree_sidecar_roots( let replacement = parse_object_hash(content.trim()).ok_or_else(|| { CliError::fatal(format!( "replace ref '{}' contains invalid object id '{}' while computing GC \ - roots", + roots", path.display(), content.trim() )) @@ -3703,11 +3835,11 @@ fn collect_agent_run_manifest_roots( scanned += 1; if scanned > MAX_RUN_DIRS { return Err(CliError::fatal(format!( - "more than {MAX_RUN_DIRS} agent-run directories in '{}'; the mandatory root scan is no longer bounded, so pruning would proceed on a partial root set", - runs_dir.display() - )) - .with_stable_code(StableErrorCode::RepoStateInvalid) - .with_hint("run `libra agent clean` to retire completed runs, then retry")); + "more than {MAX_RUN_DIRS} agent-run directories in '{}'; the mandatory root scan is no longer bounded, so pruning would proceed on a partial root set", + runs_dir.display() + )) + .with_stable_code(StableErrorCode::RepoStateInvalid) + .with_hint("run `libra agent clean` to retire completed runs, then retry")); } let dir = entry.path(); if !dir.is_dir() { @@ -3718,11 +3850,11 @@ fn collect_agent_run_manifest_roots( Ok(meta) => { if meta.len() > MAX_MANIFEST_BYTES { return Err(CliError::fatal(format!( - "agent-run manifest '{}' is {} bytes, past the {MAX_MANIFEST_BYTES}-byte cap; its roots cannot be enumerated safely", - manifest.display(), - meta.len() - )) - .with_stable_code(StableErrorCode::RepoCorrupt)); + "agent-run manifest '{}' is {} bytes, past the {MAX_MANIFEST_BYTES}-byte cap; its roots cannot be enumerated safely", + manifest.display(), + meta.len() + )) + .with_stable_code(StableErrorCode::RepoCorrupt)); } std::fs::read_to_string(&manifest).map_err(|error| { CliError::fatal(format!( @@ -3748,13 +3880,13 @@ fn collect_agent_run_manifest_roots( // forever — it just refuses to guess. return Err(CliError::fatal(format!( "agent-run directory '{}' has no manifest, so the objects it may still own \ - cannot be enumerated; pruning would proceed on a partial root set", + cannot be enumerated; pruning would proceed on a partial root set", dir.display() )) .with_stable_code(StableErrorCode::ConflictOperationBlocked) .with_hint( "re-run once the agent run completes; if the run was interrupted and will \ - not resume, retire it with `libra agent clean` and try again", + not resume, retire it with `libra agent clean` and try again", )); } Err(error) => { @@ -3785,7 +3917,7 @@ fn collect_agent_run_manifest_roots( .with_stable_code(StableErrorCode::RepoCorrupt) .with_hint( "restore or delete the manifest (`libra agent doctor` reports what a run \ - should contain), then re-run", + should contain), then re-run", )); } @@ -3812,11 +3944,11 @@ fn collect_agent_run_manifest_roots( } None if finalized => { return Err(CliError::fatal(format!( - "agent-run manifest '{}' is finalized but has no findings_oid field; its evidence blob has no root and pruning would take it", - manifest.display() - )) - .with_stable_code(StableErrorCode::RepoCorrupt) - .with_hint("run `libra agent doctor` to reconcile the run manifests")); + "agent-run manifest '{}' is finalized but has no findings_oid field; its evidence blob has no root and pruning would take it", + manifest.display() + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + .with_hint("run `libra agent doctor` to reconcile the run manifests")); } None => {} } @@ -3832,27 +3964,27 @@ fn collect_agent_run_manifest_roots( })?; if list.len() > MAX_ATTACHMENTS { return Err(CliError::fatal(format!( - "agent-run manifest '{}' lists {} attachments, past the {MAX_ATTACHMENTS} cap", - manifest.display(), - list.len() - )) - .with_stable_code(StableErrorCode::RepoCorrupt)); + "agent-run manifest '{}' lists {} attachments, past the {MAX_ATTACHMENTS} cap", + manifest.display(), + list.len() + )) + .with_stable_code(StableErrorCode::RepoCorrupt)); } for item in list { // An attachment entry exists BECAUSE something was attached. // Missing, null or non-string here is corruption — skipping // it would quietly surrender that attachment's only root. let oid = item - .get("oid") - .and_then(|value| value.as_str()) - .ok_or_else(|| { - CliError::fatal(format!( - "agent-run manifest '{}' has a manual_attach entry without a usable oid; its attachment has no root and pruning would take it", - manifest.display() - )) - .with_stable_code(StableErrorCode::RepoCorrupt) - .with_hint("run `libra agent doctor` to reconcile the run manifests") - })?; + .get("oid") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + CliError::fatal(format!( + "agent-run manifest '{}' has a manual_attach entry without a usable oid; its attachment has no root and pruning would take it", + manifest.display() + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + .with_hint("run `libra agent doctor` to reconcile the run manifests") + })?; oids.push(oid.to_string()); } } @@ -3911,7 +4043,7 @@ async fn collect_sequencer_state_roots( let hash = parse_object_hash(trimmed).ok_or_else(|| { CliError::fatal(format!( "{table}.{column} contains invalid object id '{trimmed}' while computing GC \ - roots" + roots" )) .with_stable_code(StableErrorCode::RepoCorrupt) })?; @@ -3971,7 +4103,7 @@ async fn collect_sequencer_state_roots( match db .query_all_raw(stmt_of( "SELECT worktree_id, onto, orig_head, current_head, todo, done, stopped_sha \ - FROM rebase_state", + FROM rebase_state", )) .await { @@ -4053,7 +4185,7 @@ async fn collect_sequencer_state_roots( let oids: Vec = serde_json::from_str(&json).map_err(|error| { CliError::fatal(format!( "bisect_state.{column} contains invalid JSON while computing GC \ - roots: {error}" + roots: {error}" )) .with_stable_code(StableErrorCode::RepoCorrupt) })?; @@ -4238,6 +4370,65 @@ fn walk_reachable( Ok(()) } +/// Expand a v2 operation view manifest after its blob has been rooted. +/// +/// Git's generic blob walk intentionally treats blobs as leaves. Operation +/// manifests are a typed exception: the repository view blob names workspace +/// snapshot manifests, and those snapshots name trees/blobs/facets that must +/// remain live as well. The typed recursive closure check fails closed before +/// any prune can act on a partial graph. +fn walk_v2_operation_view( + hash: &ObjectHash, + storage: &ClientStorage, + boundaries: &HashSet, + reachable: &mut HashSet, +) -> CliResult<()> { + let bytes = storage.get(hash).map_err(|error| { + CliError::fatal(format!( + "operation view manifest {hash} cannot be read while computing GC roots: {error}" + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + })?; + let view = + crate::internal::operation::RepoViewV2::from_canonical_bytes(&bytes).map_err(|error| { + CliError::fatal(format!( + "operation view manifest {hash} is invalid while computing GC roots: {error}" + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + })?; + view.validate_recursive_closure(|oid| storage.get(oid).ok()) + .map_err(|error| { + CliError::fatal(format!( + "operation view manifest {hash} has an incomplete closure while computing GC roots: {error}" + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + })?; + + for root in view.roots() { + walk_reachable(&root, storage, boundaries, reachable)?; + } + for workspace_oid in view.workspaces.values() { + let snapshot_bytes = storage.get(workspace_oid).map_err(|error| { + CliError::fatal(format!( + "workspace snapshot {workspace_oid} cannot be read from operation view {hash}: {error}" + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + })?; + let snapshot = + crate::internal::operation::WorkspaceSnapshotV2::from_canonical_bytes(&snapshot_bytes) + .map_err(|error| { + CliError::fatal(format!( + "workspace snapshot {workspace_oid} in operation view {hash} is invalid: {error}" + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + })?; + for root in snapshot.roots() { + walk_reachable(&root, storage, boundaries, reachable)?; + } + } + Ok(()) +} + /// List all loose objects in the repository, returning (hash, path) pairs. pub(crate) fn list_loose_objects(repo_path: &Path) -> io::Result> { let objects_dir = repo_path.join("objects"); @@ -4919,8 +5110,8 @@ mod tests { assert!( resolver_joined.contains(surface.location) || found.contains(surface.location), "UnifiedResolver surface `{}` ({}) has no resolver callsite and no \ - literal storage-root join left in production — fix the loader or \ - drop the registration and the NOT_AN_OBJECT_SOURCE entry", + literal storage-root join left in production — fix the loader or \ + drop the registration and the NOT_AN_OBJECT_SOURCE entry", surface.location, surface.surface ); @@ -4932,10 +5123,10 @@ mod tests { assert!( inventoried || excluded, "production code creates `/{name}`, which is in neither \ - GC_OBJECT_FILE_SOURCE_INVENTORY nor NOT_AN_OBJECT_SOURCE. Classify it: \ - if it can hold object ids give it a row (TracedRoot / AntiRoot / \ - Boundary / IndexOnly / NonRoot with the reason), otherwise add it to \ - the exclusion list (plan-20260714 §C.4.3)" + GC_OBJECT_FILE_SOURCE_INVENTORY nor NOT_AN_OBJECT_SOURCE. Classify it: \ + if it can hold object ids give it a row (TracedRoot / AntiRoot / \ + Boundary / IndexOnly / NonRoot with the reason), otherwise add it to \ + the exclusion list (plan-20260714 §C.4.3)" ); } @@ -4955,14 +5146,14 @@ mod tests { assert!( stale.is_empty(), "excluded as non-object-sources, but no production code joins them onto a \ - storage root any more — drop the entries: {stale:?}" + storage root any more — drop the entries: {stale:?}" ); for (name, reason) in NOT_AN_OBJECT_SOURCE { assert!( !classified.iter().any(|entry| entry == name), "`{name}` is BOTH inventoried as a GC source and excluded as a \ - non-source — the exclusion would keep this guard green if the \ - inventory row were deleted. Keep one" + non-source — the exclusion would keep this guard green if the \ + inventory row were deleted. Keep one" ); assert!( reason.len() > 10, diff --git a/src/command/mod.rs b/src/command/mod.rs index e89f71002..71b145f52 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -246,7 +246,7 @@ where // `show`, `rev-parse` peeling, etc. transparently see the replacement. // Cheap no-op when no replacements exist. let hash = replace::resolve(*hash); - let storage = util::objects_storage(); + let storage = util::try_objects_storage().map_err(GitError::IOError)?; let data = storage.get(&hash)?; T::from_bytes(&data.to_vec(), hash) } @@ -260,7 +260,7 @@ pub fn load_object_raw(hash: &ObjectHash) -> Result where T: ObjectTrait, { - let storage = util::objects_storage(); + let storage = util::try_objects_storage().map_err(GitError::IOError)?; let data = storage.get(hash)?; T::from_bytes(&data.to_vec(), *hash) } diff --git a/src/command/worktree.rs b/src/command/worktree.rs index 1ee01af78..cd656adf8 100644 --- a/src/command/worktree.rs +++ b/src/command/worktree.rs @@ -35,35 +35,35 @@ use crate::{ /// `--help` examples shown in `libra worktree --help` output. pub const WORKTREE_EXAMPLES: &str = "\ -EXAMPLES: - libra worktree add ../feature-x Create a linked worktree (detached at - the source commit) - libra worktree add ../fix-1 hotfix Check the existing branch `hotfix` out - libra worktree add --detach ../probe v1.2.0 Detached worktree at a commit-ish - libra worktree add -b topic ../topic main Create branch `topic` from `main` and - check it out - libra worktree list List every registered worktree - libra worktree list --porcelain Machine-readable worktree list - libra worktree lock ../feature-x --reason wip Lock a worktree to prevent prune/remove - libra worktree unlock ../feature-x Release the lock - libra worktree move ../old ../new Rename a worktree - libra worktree prune Drop entries whose paths vanished - libra worktree remove ../feature-x Unregister, keep the directory on disk - libra worktree remove ../feature-x --delete-dir - Unregister and delete the directory - (refused on a dirty worktree) - libra worktree repair --confirm Fix stale or duplicate registry rows - libra worktree repair --confirm ../feature-x Restore that worktree's gitdir identity - from the registry (registry v2) - libra worktree repair --migrate-layout --confirm - Migrate every legacy shared-.libra - symlink worktree to the isolated layout - libra worktree repair --migrate-layout --dry-run - Report what would be migrated (read-only) - libra worktree doctor Read-only diagnostics of per-worktree - scopes and Agent workspaces (paginated) - libra --json worktree doctor --limit 20 One machine-readable page - libra worktree doctor ws-3f0c Diagnose a single workspace scope"; + EXAMPLES: + libra worktree add ../feature-x Create a linked worktree (detached at + the source commit) + libra worktree add ../fix-1 hotfix Check the existing branch `hotfix` out + libra worktree add --detach ../probe v1.2.0 Detached worktree at a commit-ish + libra worktree add -b topic ../topic main Create branch `topic` from `main` and + check it out + libra worktree list List every registered worktree + libra worktree list --porcelain Machine-readable worktree list + libra worktree lock ../feature-x --reason wip Lock a worktree to prevent prune/remove + libra worktree unlock ../feature-x Release the lock + libra worktree move ../old ../new Rename a worktree + libra worktree prune Drop entries whose paths vanished + libra worktree remove ../feature-x Unregister, keep the directory on disk + libra worktree remove ../feature-x --delete-dir + Unregister and delete the directory + (refused on a dirty worktree) + libra worktree repair --confirm Fix stale or duplicate registry rows + libra worktree repair --confirm ../feature-x Restore that worktree's gitdir identity + from the registry (registry v2) + libra worktree repair --migrate-layout --confirm + Migrate every legacy shared-.libra + symlink worktree to the isolated layout + libra worktree repair --migrate-layout --dry-run + Report what would be migrated (read-only) + libra worktree doctor Read-only diagnostics of per-worktree + scopes and Agent workspaces (paginated) + libra --json worktree doctor --limit 20 One machine-readable page + libra worktree doctor ws-3f0c Diagnose a single workspace scope"; /// Manage multiple working trees attached to this repository. // @@ -188,11 +188,11 @@ pub enum WorktreeSubcommand { /// workspace. Requires a workspace id and --confirm; the default /// doctor command remains strictly read-only. #[clap( - long, - value_name = "SESSION_ID", - requires = "workspace_id", - conflicts_with_all = ["limit", "cursor"] - )] + long, + value_name = "SESSION_ID", + requires = "workspace_id", + conflicts_with_all = ["limit", "cursor"] + )] adopt_capture_session: Option, /// Copy the repository's common `info/exclude`/`info/attributes` /// (main's `.libra/info/*`) into ONE linked worktree's local gitdir @@ -200,48 +200,48 @@ pub enum WorktreeSubcommand { /// only to main; adoption is explicit and per-worktree, never /// automatic). Requires --confirm. #[clap( - long, - value_name = "WORKTREE_PATH", - conflicts_with_all = [ - "workspace_id", "limit", "cursor", "adopt_capture_session", - "adopt_approved_project", "clear_approved_project" - ] - )] + long, + value_name = "WORKTREE_PATH", + conflicts_with_all = [ + "workspace_id", "limit", "cursor", "adopt_capture_session", + "adopt_approved_project", "clear_approved_project" + ] + )] adopt_info_to: Option, /// Delete the repository's common `.libra/info/exclude` and /// `info/attributes` (explicit clear for rules that should no longer /// apply anywhere). Requires --confirm. #[clap( - long, - conflicts_with_all = [ - "workspace_id", "limit", "cursor", "adopt_capture_session", "adopt_info_to", - "adopt_approved_project", "clear_approved_project" - ] - )] + long, + conflicts_with_all = [ + "workspace_id", "limit", "cursor", "adopt_capture_session", "adopt_info_to", + "adopt_approved_project", "clear_approved_project" + ] + )] clear_common_info: bool, /// Re-home Always approvals whose opaque `project_id` is not the /// current `libra.repoid` onto the canonical repository identity /// (plan-20260715 W4-07). Migrations never do this; requires /// --confirm. #[clap( - long, - value_name = "LEGACY_PROJECT_ID", - conflicts_with_all = [ - "workspace_id", "limit", "cursor", "adopt_capture_session", "adopt_info_to", - "clear_common_info", "clear_approved_project" - ] - )] + long, + value_name = "LEGACY_PROJECT_ID", + conflicts_with_all = [ + "workspace_id", "limit", "cursor", "adopt_capture_session", "adopt_info_to", + "clear_common_info", "clear_approved_project" + ] + )] adopt_approved_project: Option, /// Delete Always approvals under a legacy (non-canonical) `project_id` /// without adopting them. Requires --confirm. #[clap( - long, - value_name = "LEGACY_PROJECT_ID", - conflicts_with_all = [ - "workspace_id", "limit", "cursor", "adopt_capture_session", "adopt_info_to", - "clear_common_info", "adopt_approved_project" - ] - )] + long, + value_name = "LEGACY_PROJECT_ID", + conflicts_with_all = [ + "workspace_id", "limit", "cursor", "adopt_capture_session", "adopt_info_to", + "clear_common_info", "adopt_approved_project" + ] + )] clear_approved_project: Option, /// Confirm a mutating doctor action (capture-scope adoption, /// info-file adoption, common-info clearing, or approved_permission @@ -489,7 +489,7 @@ impl WorktreeState { if has_v2_keys && has_v1_keys { return Err( "registry mixes v2 (`schema_version`/`entries`) and legacy v1 (`worktrees`) \ - keys; refusing the ambiguous file" + keys; refusing the ambiguous file" .to_string(), ); } @@ -1073,7 +1073,7 @@ pub(crate) async fn reject_bare_repository_without_migrations() -> CliResult<()> .map_err(|source| { CliError::fatal(format!( "cannot open the repository database without applying migrations to classify \ - this repository: {source}" + this repository: {source}" )) .with_stable_code(StableErrorCode::IoReadFailed) })?; @@ -1112,7 +1112,7 @@ fn reject_bare_repository_impl( if is_bare { return Err(CliError::fatal(format!( "this is a bare repository ('{}'): it has no working trees, so the \ - `worktree` command family is unavailable here", + `worktree` command family is unavailable here", storage.display() )) .with_stable_code(StableErrorCode::RepoStateInvalid)); @@ -1160,7 +1160,7 @@ pub async fn execute_safe(args: WorktreeArgs, output: &OutputConfig) -> CliResul .map_err(|source| { CliError::fatal(format!( "cannot open the repository database before touching the worktree \ - registry: {source}" + registry: {source}" )) .with_stable_code(StableErrorCode::IoReadFailed) })?; @@ -1321,9 +1321,9 @@ pub async fn execute_safe(args: WorktreeArgs, output: &OutputConfig) -> CliResul if !yes { return Err(WorktreeError::OperationBlocked(format!( "--resolve-identity detaches '{path}' from the registry — its files and \ - scoped state are kept, and every command inside it will fail closed \ - until you re-add it or run `remove --delete-dir`; re-run with --yes to \ - confirm" + scoped state are kept, and every command inside it will fail closed \ + until you re-add it or run `remove --delete-dir`; re-run with --yes to \ + confirm" )) .into_cli_error()); } @@ -1512,7 +1512,7 @@ pub(crate) async fn begin_repair_operation( .with_stable_code(StableErrorCode::ConflictOperationBlocked) .with_hint( "another control action is running or just completed in this worktree; wait for \ - it to finish, or inspect it with `libra op log`", + it to finish, or inspect it with `libra op log`", ) }) } @@ -1541,7 +1541,7 @@ pub(crate) async fn finish_repair_operation( .with_stable_code(StableErrorCode::IoWriteFailed) .with_hint( "the repair's effects stand; inspect the unclosed record with `libra op log`, \ - and re-run the repair once the operation log is writable again", + and re-run the repair once the operation log is writable again", )); } result @@ -1591,7 +1591,7 @@ async fn adopted_scope_settings_present( // `sparse_view_meta`, so counting patterns alone would miss it. let sparse_enabled = count( "SELECT COUNT(*) FROM `sparse_view_meta` \ - WHERE `worktree_id` = '' AND `enabled` <> 0", + WHERE `worktree_id` = '' AND `enabled` <> 0", ) .await?; if sparse_enabled > 0 { @@ -1643,7 +1643,7 @@ async fn resolve_identity_collision(path: &str) -> WorktreeResult { if claimants < 2 { return Err(WorktreeError::OperationBlocked(format!( "'{target_key}' is the only live entry claiming identity '{identity}' — there is \ - no collision to resolve here" + no collision to resolve here" ))); } @@ -1700,9 +1700,9 @@ async fn resolve_identity_collision(path: &str) -> WorktreeResult { } Ok(format!( "Detached '{target_key}' from the registry (identity '{identity}'). Its files and \ - scoped state are kept and every command inside it now fails closed; the remaining \ - claimant owns the identity again. Finish with `libra worktree remove --delete-dir \ - {target_key}`, or `libra worktree add {target_key}` to re-attach it." + scoped state are kept and every command inside it now fails closed; the remaining \ + claimant owns the identity again. Finish with `libra worktree remove --delete-dir \ + {target_key}`, or `libra worktree add {target_key}` to re-attach it." )) } @@ -1813,9 +1813,9 @@ fn load_state() -> WorktreeResult { if let Some(conflict) = state.identity_conflict() { return Err(WorktreeError::OperationBlocked(format!( "the worktree registry is ambiguous: {conflict}. Run `libra worktree doctor` to see \ - which entries collide, then \ - `libra worktree repair --resolve-identity --yes` to unregister the one you \ - do not want (the directory is left on disk)" + which entries collide, then \ + `libra worktree repair --resolve-identity --yes` to unregister the one you \ + do not want (the directory is left on disk)" ))); } Ok(state) @@ -1847,8 +1847,8 @@ fn load_state_impl(heal_identity_invariants: bool) -> WorktreeResult WorktreeResult { return Err(WorktreeError::OperationBlocked(format!( "'{}' is a tombstone (scoped cleanup pending); run `libra worktree \ - repair --confirm` first, then add", + repair --confirm` first, then add", canonical_target.display() ))); } @@ -2329,7 +2329,7 @@ async fn add_worktree( if target_spec.is_some() || detach || new_branch.is_some() { return Err(WorktreeError::InvalidTarget(format!( "'{}' is already a registered worktree; switch branches inside \ - it instead", + it instead", canonical_target.display() ))); } @@ -2388,7 +2388,7 @@ async fn add_worktree( { return Err(WorktreeError::OperationBlocked(format!( "branch '{name}' already exists; -B/--force are not supported — pick a new \ - name or check the existing branch out with `worktree add {name}`" + name or check the existing branch out with `worktree add {name}`" ))); } let start = match &target_spec { @@ -2421,13 +2421,13 @@ async fn add_worktree( Err(error) => { return Err(WorktreeError::IoRead(format!( "cannot verify whether branch '{spec}' is checked out: \ - {error}" + {error}" ))); } Ok(Some(scope)) => { return Err(WorktreeError::OperationBlocked(format!( "branch '{spec}' is already checked out at worktree \ - '{scope}'; use --detach to share its tip read-only" + '{scope}'; use --detach to share its tip read-only" ))); } Ok(None) => {} @@ -2439,9 +2439,9 @@ async fn add_worktree( let commit = util::get_commit_base(spec).await.map_err(|error| { WorktreeError::InvalidTarget(format!( "'{spec}' is neither a local branch nor a resolvable commit \ - ({error}); Libra does not create branches from remote-tracking \ - names automatically (Git's DWIM is deferred) — use `-b {spec} \ - /{spec}` explicitly" + ({error}); Libra does not create branches from remote-tracking \ + names automatically (Git's DWIM is deferred) — use `-b {spec} \ + /{spec}` explicitly" )) })?; AddCheckout::Detached(commit) @@ -2501,7 +2501,7 @@ async fn add_worktree( .map_err(|e| { WorktreeError::IoWrite(format!( "cannot register worktree '{}': failed to clear stale scoped rows for its \ - instance id: {e}", + instance id: {e}", target.display() )) })?; @@ -2607,7 +2607,7 @@ async fn add_worktree( let _ = journal_resolve(&db, add_journal_id).await; return Err(WorktreeError::OperationBlocked(format!( "branch '{name}' is already checked out at worktree \ - '{scope}'; use --detach to share its tip read-only" + '{scope}'; use --detach to share its tip read-only" ))); } Ok(None) => {} @@ -2868,10 +2868,10 @@ async fn warn_on_case_probe_mismatch(target: &std::path::Path) { if probed != persisted { eprintln!( "warning: this worktree's filesystem is case-{} but the repository's persisted \ - core.ignorecase is {persisted} (probed from the main worktree at init); \ - case-collision guards here may misjudge until the per-worktree config overlay \ - lands (plan-20260714 W4). Set core.ignorecase explicitly if this repository \ - spans differing filesystems.", + core.ignorecase is {persisted} (probed from the main worktree at init); \ + case-collision guards here may misjudge until the per-worktree config overlay \ + lands (plan-20260714 W4). Set core.ignorecase explicitly if this repository \ + spans differing filesystems.", if probed { "insensitive" } else { "sensitive" } ); } @@ -2890,7 +2890,7 @@ async fn reattach_worktree( let Some(expected_id) = state.entries[index].worktree_id.clone() else { return Err(WorktreeError::OperationBlocked(format!( "cannot re-attach '{}': the registry entry has no persisted worktree id; run \ - `libra worktree repair --confirm` first", + `libra worktree repair --confirm` first", target.display() ))); }; @@ -2902,7 +2902,7 @@ async fn reattach_worktree( if current_id.as_deref() != Some(expected_id.as_str()) { return Err(WorktreeError::OperationBlocked(format!( "cannot re-attach '{}': its gitdir identity ({}) does not match the registry's \ - persisted id ({expected_id}); run `libra worktree repair --confirm {}` first", + persisted id ({expected_id}); run `libra worktree repair --confirm {}` first", target.display(), current_id.as_deref().unwrap_or("missing"), target.display() @@ -2936,7 +2936,7 @@ async fn reattach_worktree( if !commondir_ok { return Err(WorktreeError::OperationBlocked(format!( "cannot re-attach '{}': its commondir pointer is missing, corrupt, or targets a \ - different repository's storage; run `libra worktree repair --confirm {}` first", + different repository's storage; run `libra worktree repair --confirm {}` first", target.display(), target.display() ))); @@ -2976,8 +2976,8 @@ async fn reattach_worktree( { return Err(WorktreeError::OperationBlocked(format!( "cannot re-attach '{}': identity '{identity}' is already claimed by another ACTIVE \ - worktree. Run `libra worktree doctor`, then \ - `libra worktree repair --resolve-identity --yes` on the one you do not want", + worktree. Run `libra worktree doctor`, then \ + `libra worktree repair --resolve-identity --yes` on the one you do not want", state.entries[index].path ))); } @@ -2991,7 +2991,7 @@ async fn reattach_worktree( // Journal kept: repair finishes lifting the marker. return Err(WorktreeError::IoWrite(format!( "cannot remove the detached marker '{}' (run `libra worktree repair \ - --confirm` to finish the re-attach): {error}", + --confirm` to finish the re-attach): {error}", marker.display() ))); } @@ -3077,9 +3077,9 @@ async fn lifecycle_upsert( db.execute_raw(Statement::from_sql_and_values( DbBackend::Sqlite, "INSERT INTO worktree_lifecycle (worktree_id, state, path, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?) \ - ON CONFLICT(worktree_id) DO UPDATE SET state = excluded.state, \ - path = excluded.path, updated_at = excluded.updated_at", + VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT(worktree_id) DO UPDATE SET state = excluded.state, \ + path = excluded.path, updated_at = excluded.updated_at", [ worktree_id.into(), state.into(), @@ -3179,7 +3179,7 @@ async fn journal_append( .execute_raw(Statement::from_sql_and_values( DbBackend::Sqlite, "INSERT INTO worktree_intent_journal (op, worktree_id, payload, created_at) \ - VALUES (?, ?, ?, ?)", + VALUES (?, ?, ?, ?)", [ op.into(), worktree_id.into(), @@ -3608,19 +3608,19 @@ pub(crate) async fn collect_worktree_scope_report( match entry.layout { "legacy-symlink" => findings.push( "uses the pre-isolation shared-`.libra` symlink layout; mutations here are \ - refused because they would move the MAIN worktree's HEAD/index. Migrate with \ - `libra worktree repair --migrate-layout --confirm ` from the main \ - worktree." + refused because they would move the MAIN worktree's HEAD/index. Migrate with \ + `libra worktree repair --migrate-layout --confirm ` from the main \ + worktree." .to_string(), ), "missing" => findings.push( "the registered directory is gone; `libra worktree prune` removes entries whose \ - path is confirmed missing." + path is confirmed missing." .to_string(), ), "corrupt" => findings.push( "the worktree's `.libra` metadata could not be read; `libra worktree repair \ - --confirm ` restores its identity from the registry." + --confirm ` restores its identity from the registry." .to_string(), ), _ => {} @@ -3628,22 +3628,22 @@ pub(crate) async fn collect_worktree_scope_report( if !identity_registered && entry.layout != "missing" { findings.push( "this worktree's identity is not one the registry knows, so mutations here are \ - refused; `libra worktree repair --confirm ` restores it from the \ - registry's persisted id." + refused; `libra worktree repair --confirm ` restores it from the \ + registry's persisted id." .to_string(), ); } if entry.state == "detached_from_registry" { findings.push( "detached from the registry: re-attach with `libra worktree add `, or \ - finish the removal with `libra worktree remove --delete-dir `." + finish the removal with `libra worktree remove --delete-dir `." .to_string(), ); } if entry.state == "tombstone" { findings.push( "a removal did not finish cleaning up; `libra worktree repair --confirm` \ - retries it." + retries it." .to_string(), ); } @@ -3661,20 +3661,20 @@ pub(crate) async fn collect_worktree_scope_report( match adopted_scope_settings_present(conn).await { Ok(Some(kinds)) => findings.push(format!( "this worktree holds {kinds} that may have been adopted from a linked \ - worktree removed before the scope migration — their provenance cannot be \ - established (§C.4.3). Review them with `libra layer list` / \ - `libra sparse-view list`; `libra layer remove ` and \ - `libra sparse-view clear` drop the ones that are not yours. No file in the \ - working tree is affected either way." + worktree removed before the scope migration — their provenance cannot be \ + established (§C.4.3). Review them with `libra layer list` / \ + `libra sparse-view list`; `libra layer remove ` and \ + `libra sparse-view clear` drop the ones that are not yours. No file in the \ + working tree is affected either way." )), Ok(None) => {} // Fail closed (§C.13): a diagnostic that could not look must say // so, not report "nothing to see". Err(error) => findings.push(format!( "this repository has linked-worktree history, and whether it holds \ - layer/sparse settings of unknown provenance COULD NOT BE DETERMINED: \ - {error}. Treat the answer as unknown until the repository database is \ - readable." + layer/sparse settings of unknown provenance COULD NOT BE DETERMINED: \ + {error}. Treat the answer as unknown until the repository database is \ + readable." )), } } @@ -3698,10 +3698,10 @@ pub(crate) async fn collect_worktree_scope_report( if !common_info.is_empty() { findings.push(format!( "common info file(s) {} apply ONLY to this main worktree since W0 \ - (info files are worktree-local; linked worktrees read their own \ - `.libra/info/*`). Copy them into one linked worktree with \ - `libra worktree doctor --adopt-info-to --confirm`, or delete \ - them with `libra worktree doctor --clear-common-info --confirm`.", + (info files are worktree-local; linked worktrees read their own \ + `.libra/info/*`). Copy them into one linked worktree with \ + `libra worktree doctor --adopt-info-to --confirm`, or delete \ + them with `libra worktree doctor --clear-common-info --confirm`.", common_info .iter() .map(|name| format!("info/{name}")) @@ -3722,9 +3722,9 @@ pub(crate) async fn collect_worktree_scope_report( { findings.push(format!( "this identity ('{id}') is claimed by {} entries ({}); every worktree MUTATION \ - is refused until one is unregistered — \ - `libra worktree repair --resolve-identity --yes` on the one you do not \ - want. Ordinary remove cannot do it: it needs the same loader that is refusing.", + is refused until one is unregistered — \ + `libra worktree repair --resolve-identity --yes` on the one you do not \ + want. Ordinary remove cannot do it: it needs the same loader that is refusing.", other.len(), other.join(", ") )); @@ -3822,7 +3822,7 @@ fn adopt_common_info_files(target: &str) -> CliResult { ) { return Err(CliError::command_usage(format!( "'{target}' is not a linked worktree of this repository (no `.libra/commondir`); \ - --adopt-info-to copies INTO a linked worktree's own gitdir" + --adopt-info-to copies INTO a linked worktree's own gitdir" ))); } let target_common = crate::utils::util::try_get_storage_path(Some(target_path.clone())) @@ -3834,7 +3834,7 @@ fn adopt_common_info_files(target: &str) -> CliResult { if target_common != common { return Err(CliError::command_usage(format!( "'{target}' belongs to a DIFFERENT repository (its common storage is '{}'); \ - refusing to copy this repository's info files there", + refusing to copy this repository's info files there", target_common.display() ))); } @@ -3900,7 +3900,7 @@ fn adopt_common_info_files(target: &str) -> CliResult { if copied.is_empty() && skipped.is_empty() { return Ok( "nothing to adopt: the common storage has no info/exclude or \ - info/attributes" + info/attributes" .to_string(), ); } @@ -3997,8 +3997,8 @@ async fn list_worktrees( if schema_version != 2 { return Err(CliError::failure(format!( "unsupported worktree list schema version {schema_version}: the shipped shape is \ - version 2 (worktree_id/layout/epoch fields); the pre-identity v1 shape gained \ - those fields in place and no frozen v1 remains to serve" + version 2 (worktree_id/layout/epoch fields); the pre-identity v1 shape gained \ + those fields in place and no frozen v1 remains to serve" )) .with_exit_code(129) .with_stable_code(StableErrorCode::CliInvalidArguments)); @@ -4239,8 +4239,8 @@ async fn stale_fence_capture_finding( DbBackend::Sqlite, format!( "SELECT COUNT(*) AS n FROM {table} \ - WHERE scope_state = 'scoped' AND workspace_id = ? \ - AND workspace_fence <> ?" + WHERE scope_state = 'scoped' AND workspace_id = ? \ + AND workspace_fence <> ?" ), [ record.workspace_id.clone().into(), @@ -4256,9 +4256,9 @@ async fn stale_fence_capture_finding( "capture_rows_stale_fence", format!( "{stale} scoped capture row(s) carry an earlier lease fence of this \ - workspace; their owner claims are immutable, so capture/import/export \ - writes for those provider sessions fail closed under the current fence — \ - the rows remain readable provenance" + workspace; their owner claims are immutable, so capture/import/export \ + writes for those provider sessions fail closed under the current fence — \ + the rows remain readable provenance" ), ) }) @@ -4295,8 +4295,8 @@ fn diagnose_workspace( "foreign_repository_identity", format!( "the record was written under repository identity {} but this repository is \ - now {current_repo_id}; it is invisible to the normal listings and blocks new \ - workspace registrations until it is settled", + now {current_repo_id}; it is invisible to the normal listings and blocks new \ + workspace registrations until it is settled", record.repo_id ), )); @@ -4312,7 +4312,7 @@ fn diagnose_workspace( "lease_expired", format!( "the lease deadline passed (expires_at {}, now {now_ms}); the lease still \ - belongs to its owner until it is explicitly reclaimed", + belongs to its owner until it is explicitly reclaimed", record.lease_expires_at.unwrap_or_default() ), )); @@ -4340,7 +4340,7 @@ fn diagnose_workspace( "registry_path_mismatch", format!( "the registry entry for this scope lives at '{}' but the workspace \ - record claims '{}'", + record claims '{}'", entry.path, record.path ), )); @@ -4351,14 +4351,14 @@ fn diagnose_workspace( findings.push(ScopeDiagnostic::warning( "registry_entry_detached", "the worktree was unregistered with `worktree remove` (keep-dir); \ - commands inside it fail closed until it is re-added", + commands inside it fail closed until it is re-added", )); } WorktreeEntryState::Tombstone => { findings.push(ScopeDiagnostic::warning( "registry_entry_tombstoned", "the worktree directory was deleted but its scoped rows are still \ - pending cleanup; `libra worktree repair --confirm` retries it", + pending cleanup; `libra worktree repair --confirm` retries it", )); } } @@ -4371,7 +4371,7 @@ fn diagnose_workspace( "scope_layout_legacy_symlink", format!( "'{}' still uses the pre-isolation shared-`.libra` symlink layout; \ - migrate it with `libra worktree repair --migrate-layout --confirm`", + migrate it with `libra worktree repair --migrate-layout --confirm`", entry.path ), )), @@ -4448,7 +4448,7 @@ async fn adopt_or_clear_legacy_approved_project( .map_err(|init_error| { CliError::fatal(format!( "cannot initialize libra.repoid before approved_permission recovery: \ - {init_error}" + {init_error}" )) })?; begin_repair_operation(action, Some(legacy_project_id)).await? @@ -4463,7 +4463,7 @@ async fn adopt_or_clear_legacy_approved_project( .map_err(|error| { CliError::fatal(format!( "cannot clear approved_permission project_id '{legacy_project_id}': \ - {error}" + {error}" )) })?; Ok(serde_json::json!({ @@ -4477,7 +4477,7 @@ async fn adopt_or_clear_legacy_approved_project( .map_err(|error| { CliError::fatal(format!( "cannot adopt approved_permission project_id '{legacy_project_id}': \ - {error}" + {error}" )) })?; Ok(serde_json::json!({ @@ -4506,7 +4506,7 @@ async fn adopt_or_clear_legacy_approved_project( } else { println!( "adopted {} approved_permission row(s) from legacy project_id '{legacy_project_id}' \ - onto libra.repoid", + onto libra.repoid", payload["rows_affected"] ); } @@ -4526,7 +4526,7 @@ async fn adopt_legacy_capture_scope( if !confirm { return Err(CliError::command_usage( "legacy capture adoption changes persistent ownership; re-run with --confirm after \ - verifying the workspace and session", + verifying the workspace and session", )); } let db_path = crate::utils::path::database(); @@ -4551,7 +4551,7 @@ async fn adopt_legacy_capture_scope( if !record.state.holds_identity() || record.lease_owner.is_none() || record.lease_fence <= 0 { return Err(CliError::fatal(format!( "workspace '{workspace_id}' has no live lease fence; refuse to attribute capture \ - state to a released, orphaned, or unleased scope" + state to a released, orphaned, or unleased scope" ))); } if record @@ -4560,7 +4560,7 @@ async fn adopt_legacy_capture_scope( { return Err(CliError::fatal(format!( "workspace '{workspace_id}' lease has expired; refuse to attribute capture state \ - until its owner renews or an explicit reclaim issues a new fence" + until its owner renews or an explicit reclaim issues a new fence" ))); } let identity = RepoIdentity::resolve(&conn).await.map_err(|error| { @@ -4573,36 +4573,36 @@ async fn adopt_legacy_capture_scope( .await .map_err(|error| CliError::fatal(format!("begin capture-scope adoption: {error}")))?; let legacy = txn - .query_one_raw(Statement::from_sql_and_values( - txn.get_database_backend(), - "SELECT agent_kind, provider_session_id FROM ( - SELECT 0 AS match_priority, agent_kind, provider_session_id FROM agent_session - WHERE scope_state = 'legacy_unknown' AND session_id = ? - UNION ALL - SELECT 1 AS match_priority, agent_kind, provider_session_id FROM agent_session - WHERE scope_state = 'legacy_unknown' AND provider_session_id = ? - UNION ALL - SELECT 1 AS match_priority, agent_kind, provider_session_id FROM agent_export_job - WHERE scope_state = 'legacy_unknown' AND provider_session_id = ? - UNION ALL - SELECT 1 AS match_priority, agent_kind, provider_session_id FROM agent_import_identity - WHERE scope_state = 'legacy_unknown' AND provider_session_id = ? - ) ORDER BY match_priority LIMIT 1", - [ - session_id.into(), - session_id.into(), - session_id.into(), - session_id.into(), - ], - )) - .await - .map_err(|error| CliError::fatal(format!("read legacy capture session: {error}")))? - .ok_or_else(|| { - CliError::fatal(format!( - "capture session identifier '{session_id}' has no legacy unscoped capture row; \ - doctor adoption accepts an agent session id or an orphan provider session id" - )) - })?; + .query_one_raw(Statement::from_sql_and_values( + txn.get_database_backend(), + "SELECT agent_kind, provider_session_id FROM ( + SELECT 0 AS match_priority, agent_kind, provider_session_id FROM agent_session + WHERE scope_state = 'legacy_unknown' AND session_id = ? + UNION ALL + SELECT 1 AS match_priority, agent_kind, provider_session_id FROM agent_session + WHERE scope_state = 'legacy_unknown' AND provider_session_id = ? + UNION ALL + SELECT 1 AS match_priority, agent_kind, provider_session_id FROM agent_export_job + WHERE scope_state = 'legacy_unknown' AND provider_session_id = ? + UNION ALL + SELECT 1 AS match_priority, agent_kind, provider_session_id FROM agent_import_identity + WHERE scope_state = 'legacy_unknown' AND provider_session_id = ? + ) ORDER BY match_priority LIMIT 1", + [ + session_id.into(), + session_id.into(), + session_id.into(), + session_id.into(), + ], + )) + .await + .map_err(|error| CliError::fatal(format!("read legacy capture session: {error}")))? + .ok_or_else(|| { + CliError::fatal(format!( + "capture session identifier '{session_id}' has no legacy unscoped capture row; \ + doctor adoption accepts an agent session id or an orphan provider session id" + )) + })?; let agent_kind: String = legacy .try_get_by("agent_kind") .map_err(|error| CliError::fatal(format!("decode legacy capture agent kind: {error}")))?; @@ -4614,15 +4614,15 @@ async fn adopt_legacy_capture_scope( .query_one_raw(Statement::from_sql_and_values( txn.get_database_backend(), "SELECT 1 FROM ( - SELECT provider_session_id FROM agent_session - WHERE provider_session_id = ? AND scope_state = 'scoped' - UNION ALL - SELECT provider_session_id FROM agent_export_job - WHERE provider_session_id = ? AND scope_state = 'scoped' - UNION ALL - SELECT provider_session_id FROM agent_import_identity - WHERE provider_session_id = ? AND scope_state = 'scoped' - ) LIMIT 1", + SELECT provider_session_id FROM agent_session + WHERE provider_session_id = ? AND scope_state = 'scoped' + UNION ALL + SELECT provider_session_id FROM agent_export_job + WHERE provider_session_id = ? AND scope_state = 'scoped' + UNION ALL + SELECT provider_session_id FROM agent_import_identity + WHERE provider_session_id = ? AND scope_state = 'scoped' + ) LIMIT 1", [ provider_session_id.clone().into(), provider_session_id.clone().into(), @@ -4637,7 +4637,7 @@ async fn adopt_legacy_capture_scope( txn.rollback().await.ok(); return Err(CliError::fatal(format!( "provider session '{provider_session_id}' already has a scoped capture claim; \ - refusing to merge a legacy row into it" + refusing to merge a legacy row into it" ))); } let target_repo_id = identity.as_str().to_string(); @@ -4647,16 +4647,16 @@ async fn adopt_legacy_capture_scope( .execute_raw(Statement::from_sql_and_values( txn.get_database_backend(), "UPDATE agent_session - SET repo_id = ?, worktree_id = ?, workspace_id = ?, workspace_fence = ?, - scope_state = 'scoped' - WHERE provider_session_id = ? AND scope_state = 'legacy_unknown' - AND EXISTS ( - SELECT 1 FROM workspace_record - WHERE workspace_id = ? AND repo_id = ? AND lease_fence = ? - AND state IN ('provisioning', 'active', 'releasing') - AND lease_owner IS NOT NULL - AND lease_expires_at > (unixepoch('now') * 1000) - )", + SET repo_id = ?, worktree_id = ?, workspace_id = ?, workspace_fence = ?, + scope_state = 'scoped' + WHERE provider_session_id = ? AND scope_state = 'legacy_unknown' + AND EXISTS ( + SELECT 1 FROM workspace_record + WHERE workspace_id = ? AND repo_id = ? AND lease_fence = ? + AND state IN ('provisioning', 'active', 'releasing') + AND lease_owner IS NOT NULL + AND lease_expires_at > (unixepoch('now') * 1000) + )", [ target_repo_id.clone().into(), target_worktree_id.clone().into(), @@ -4674,16 +4674,16 @@ async fn adopt_legacy_capture_scope( for table in ["agent_export_job", "agent_import_identity"] { let sql = format!( "UPDATE {table} - SET repo_id = ?, worktree_id = ?, workspace_id = ?, workspace_fence = ?, - scope_state = 'scoped' - WHERE provider_session_id = ? AND scope_state = 'legacy_unknown' - AND EXISTS ( - SELECT 1 FROM workspace_record - WHERE workspace_id = ? AND repo_id = ? AND lease_fence = ? - AND state IN ('provisioning', 'active', 'releasing') - AND lease_owner IS NOT NULL - AND lease_expires_at > (unixepoch('now') * 1000) - )" + SET repo_id = ?, worktree_id = ?, workspace_id = ?, workspace_fence = ?, + scope_state = 'scoped' + WHERE provider_session_id = ? AND scope_state = 'legacy_unknown' + AND EXISTS ( + SELECT 1 FROM workspace_record + WHERE workspace_id = ? AND repo_id = ? AND lease_fence = ? + AND state IN ('provisioning', 'active', 'releasing') + AND lease_owner IS NOT NULL + AND lease_expires_at > (unixepoch('now') * 1000) + )" ); adopted_rows += txn .execute_raw(Statement::from_sql_and_values( @@ -4708,7 +4708,7 @@ async fn adopt_legacy_capture_scope( txn.rollback().await.ok(); return Err(CliError::fatal( "capture-scope adoption was fenced out because the target workspace changed; rerun \ - doctor and choose the current live workspace", + doctor and choose the current live workspace", )); } let actor = env::var("LIBRA_ACTOR") @@ -4717,9 +4717,9 @@ async fn adopt_legacy_capture_scope( txn.execute_raw(Statement::from_sql_and_values( txn.get_database_backend(), "INSERT INTO agent_workspace_scope_audit ( - audit_id, action, agent_kind, provider_session_id, repo_id, worktree_id, - workspace_id, workspace_fence, actor, created_at - ) VALUES (?, 'adopt_legacy_capture_scope', ?, ?, ?, ?, ?, ?, ?, ?)", + audit_id, action, agent_kind, provider_session_id, repo_id, worktree_id, + workspace_id, workspace_fence, actor, created_at + ) VALUES (?, 'adopt_legacy_capture_scope', ?, ?, ?, ?, ?, ?, ?, ?)", [ uuid::Uuid::new_v4().to_string().into(), agent_kind.into(), @@ -4767,12 +4767,12 @@ async fn legacy_capture_scope_exists(conn: &sea_orm::DatabaseConnection) -> CliR .query_one_raw(Statement::from_string( conn.get_database_backend(), "SELECT 1 FROM ( - SELECT 1 FROM agent_session WHERE scope_state = 'legacy_unknown' - UNION ALL - SELECT 1 FROM agent_export_job WHERE scope_state = 'legacy_unknown' - UNION ALL - SELECT 1 FROM agent_import_identity WHERE scope_state = 'legacy_unknown' - ) LIMIT 1" + SELECT 1 FROM agent_session WHERE scope_state = 'legacy_unknown' + UNION ALL + SELECT 1 FROM agent_export_job WHERE scope_state = 'legacy_unknown' + UNION ALL + SELECT 1 FROM agent_import_identity WHERE scope_state = 'legacy_unknown' + ) LIMIT 1" .to_string(), )) .await; @@ -4800,8 +4800,8 @@ fn print_legacy_capture_scope_guidance(output: &OutputConfig, legacy_exists: boo if legacy_exists && !output.is_json() && !output.quiet { println!( "legacy capture scope: unscoped capture rows exist and are intentionally excluded \ - from new writes; inspect the session and adopt only its verified owner with \ - `libra worktree doctor --adopt-capture-session --confirm`" + from new writes; inspect the session and adopt only its verified owner with \ + `libra worktree doctor --adopt-capture-session --confirm`" ); } } @@ -4813,8 +4813,8 @@ fn print_legacy_approved_project_guidance(output: &OutputConfig, legacy_ids: &[S let listed = legacy_ids.join(", "); let message = format!( "legacy approved_permission project_id(s): {listed}; they are invisible to the runtime \ - until adopted with `libra worktree doctor --adopt-approved-project --confirm` or \ - removed with `libra worktree doctor --clear-approved-project --confirm`" + until adopted with `libra worktree doctor --adopt-approved-project --confirm` or \ + removed with `libra worktree doctor --clear-approved-project --confirm`" ); if output.is_json() { // Keep the frozen worktree.doctor JSON page schema untouched; surface @@ -4859,7 +4859,7 @@ pub(crate) async fn run_worktree_doctor( if workspace_id.is_some() && (limit.is_some() || cursor.is_some()) { return Err(CliError::command_usage( "`libra worktree doctor ` diagnoses one scope and takes no \ - --limit/--cursor; drop the id for the paginated view", + --limit/--cursor; drop the id for the paginated view", )); } @@ -4906,7 +4906,7 @@ pub(crate) async fn run_worktree_doctor( .ok_or_else(|| { CliError::fatal(format!( "no workspace matches id '{workspace_id}'; list them with \ - `libra worktree doctor`" + `libra worktree doctor`" )) .with_stable_code(StableErrorCode::CliInvalidTarget) })?; @@ -5000,7 +5000,7 @@ async fn doctor_repo_identity(conn: &sea_orm::DatabaseConnection) -> CliResult WorktreeResult WorktreeResult { if mirror_failed { tracing::warn!( "prune left a tombstone whose mirror write failed; the journal row \ - stays pending for `worktree repair`" + stays pending for `worktree repair`" ); } else if let Err(error) = journal_resolve(&db, journal_id).await { tracing::warn!( @@ -5509,7 +5509,7 @@ async fn remove_worktree(path: String, delete_dir: bool) -> WorktreeResult WorktreeResult WorktreeResult WorktreeResult WorktreeResult { return Err(WorktreeError::OperationBlocked(format!( "'{}' is no longer the expected legacy symlink; not deleting it — \ - investigate, then rerun `worktree repair --confirm`", + investigate, then rerun `worktree repair --confirm`", backup.display() ))); } @@ -6302,7 +6302,7 @@ async fn verify_migrated_worktree( if seen != Some(head_commit) { return Err(WorktreeError::OperationBlocked(format!( "verification failed: '{}' resolves HEAD to {:?}, expected {head_commit}; \ - materials kept — rerun `worktree repair --confirm`", + materials kept — rerun `worktree repair --confirm`", target.display(), seen ))); @@ -6310,7 +6310,7 @@ async fn verify_migrated_worktree( if !target.join(util::ROOT_DIR).join("index").exists() { return Err(WorktreeError::OperationBlocked(format!( "verification failed: '{}' has no private index; materials kept — rerun \ - `worktree repair --confirm`", + `worktree repair --confirm`", target.display() ))); } @@ -6413,7 +6413,7 @@ fn write_detached_marker(target: &Path, worktree_id: &str) -> WorktreeResult<()> &marker, format!( "{worktree_id}\nremoved from the worktree registry; re-add or delete this \ - directory\n" + directory\n" ) .as_bytes(), true, @@ -6501,8 +6501,8 @@ fn refuse_active_sidecar_state(gitdir: &Path, action: &str) -> WorktreeResult<() Ok(_) => { return Err(WorktreeError::OperationBlocked(format!( "'{}' holds in-progress state ({name}); conclude the merge/revert \ - (or run any `libra stash` command there to finish a journaled \ - rollback) before {action}", + (or run any `libra stash` command there to finish a journaled \ + rollback) before {action}", gitdir.display() ))); } @@ -6579,7 +6579,7 @@ fn render_remove_worktree(result: &WorktreeRemoveOutput, output: &OutputConfig) if result.tombstone { println!( "Deleted worktree directory '{}', but the scoped-state cleanup failed — a \ - tombstone entry remains; run `libra worktree repair --confirm` to retry.", + tombstone entry remains; run `libra worktree repair --confirm` to retry.", result.path ); } else if result.disk_directory_deleted { @@ -6590,8 +6590,8 @@ fn render_remove_worktree(result: &WorktreeRemoveOutput, output: &OutputConfig) } else { println!( "Detached worktree '{}' from the registry. Directory and its state kept on \ - disk (frozen); re-add it with `libra worktree add` or delete it with \ - `--delete-dir`.", + disk (frozen); re-add it with `libra worktree add` or delete it with \ + `--delete-dir`.", result.path ); } @@ -6690,8 +6690,8 @@ async fn repair_worktree_identity(path: String) -> WorktreeResult WorktreeResult WorktreeResult WorktreeResult { @@ -7140,8 +7140,8 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "could not lift the marker while completing \ - the re-attach of '{path}' ({error}); journal \ - kept for the next repair" + the re-attach of '{path}' ({error}); journal \ + kept for the next repair" )); } } @@ -7159,15 +7159,15 @@ async fn recover_pending_intents( WorktreeEntryState::DetachedFromRegistry => { notes.push(format!( "stale re-attach intent for '{path}' rolled back — \ - the entry is (still or again) detached and stays \ - frozen; rerun `libra worktree add {path}` to \ - re-attach it" + the entry is (still or again) detached and stays \ + frozen; rerun `libra worktree add {path}` to \ + re-attach it" )); } WorktreeEntryState::Tombstone => { notes.push(format!( "stale re-attach intent for '{path}' resolved — the \ - entry is now a tombstone" + entry is now a tombstone" )); } } @@ -7207,8 +7207,8 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot acquire the branch-attach lock to roll \ - back branch '{name}' ({error}); journal kept for \ - the next repair" + back branch '{name}' ({error}); journal kept for \ + the next repair" )); } Ok(_attach_lock) => { @@ -7221,47 +7221,47 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot verify whether branch '{name}' is \ - attached ({error}); not deleting it — \ - journal kept for the next repair" + attached ({error}); not deleting it — \ + journal kept for the next repair" )); } Ok(Some(scope)) => { resolve_row = false; notes.push(format!( "branch '{name}' from an interrupted \ - `worktree add -b` is checked out at \ - worktree '{scope}'; not deleting it — \ - journal kept, resolve manually" + `worktree add -b` is checked out at \ + worktree '{scope}'; not deleting it — \ + journal kept, resolve manually" )); } Ok(None) => { match Branch::delete_branch_if_tip_result(name, &start) - .await - { - Ok(crate::internal::branch::ConditionalDeleteOutcome::Deleted) => { - notes.push(format!( - "rolled back branch '{name}' from an interrupted \ - `worktree add -b`" - )); - } - Ok(crate::internal::branch::ConditionalDeleteOutcome::NotFound) => { - } - Ok(crate::internal::branch::ConditionalDeleteOutcome::TipMoved) => { - resolve_row = false; - notes.push(format!( - "branch '{name}' from an interrupted `worktree add \ - -b` has NEW commits; not deleting it — journal \ - kept, resolve manually" - )); - } - Err(error) => { - resolve_row = false; - notes.push(format!( - "could not roll back branch '{name}' ({error}); \ - journal kept for the next repair" - )); - } - } + .await + { + Ok(crate::internal::branch::ConditionalDeleteOutcome::Deleted) => { + notes.push(format!( + "rolled back branch '{name}' from an interrupted \ + `worktree add -b`" + )); + } + Ok(crate::internal::branch::ConditionalDeleteOutcome::NotFound) => { + } + Ok(crate::internal::branch::ConditionalDeleteOutcome::TipMoved) => { + resolve_row = false; + notes.push(format!( + "branch '{name}' from an interrupted `worktree add \ + -b` has NEW commits; not deleting it — journal \ + kept, resolve manually" + )); + } + Err(error) => { + resolve_row = false; + notes.push(format!( + "could not roll back branch '{name}' ({error}); \ + journal kept for the next repair" + )); + } + } } } } @@ -7271,7 +7271,7 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "interrupted `worktree add -b {name}' journal has an \ - unparsable start tip; journal kept — resolve manually" + unparsable start tip; journal kept — resolve manually" )); } } @@ -7285,14 +7285,14 @@ async fn recover_pending_intents( let _ = lifecycle_delete(db, id_str).await; notes.push(format!( "rolled back interrupted add of '{path}' (scoped rows \ - swept; any partial directory was left in place)" + swept; any partial directory was left in place)" )); } Err(error) => { resolve_row = false; notes.push(format!( "sweep for the unpublished add of '{path}' failed \ - ({error}); journal kept for the next repair" + ({error}); journal kept for the next repair" )); } } @@ -7307,14 +7307,14 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot determine whether '{src}' still exists ({error}); journal \ - kept for the next repair" + kept for the next repair" )); } if let PathPresence::Unknown(error) = &dest_presence { resolve_row = false; notes.push(format!( "cannot determine whether '{dest}' exists ({error}); journal kept \ - for the next repair" + for the next repair" )); } let src_exists = matches!(src_presence, PathPresence::Present); @@ -7347,15 +7347,15 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "interrupted move '{src}' -> '{dest}' carries no worktree \ - id; journal kept — investigate manually" + id; journal kept — investigate manually" )); } _ if dest_taken_by_other || src_taken_by_other => { resolve_row = false; notes.push(format!( "interrupted move '{src}' -> '{dest}': another registry \ - entry now occupies one of the paths; journal kept — \ - resolve manually, then rerun repair" + entry now occupies one of the paths; journal kept — \ + resolve manually, then rerun repair" )); } Some(idx) if state.entries[idx].path == dest => { @@ -7370,9 +7370,9 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "interrupted move '{src}' -> '{dest}': the directory \ - at the source no longer carries this worktree's \ - identity; journal kept — resolve manually, then \ - rerun repair" + at the source no longer carries this worktree's \ + identity; journal kept — resolve manually, then \ + rerun repair" )); } else if src_exists && dest_missing { // Registry updated, rename never happened: @@ -7388,22 +7388,22 @@ async fn recover_pending_intents( *changed = true; notes.push(format!( "rolled back interrupted move '{src}' -> \ - '{dest}' (rename failed: {error})" + '{dest}' (rename failed: {error})" )); } } } else if src_missing && dest_exists { notes.push(format!( "interrupted move '{src}' -> '{dest}' was already \ - complete" + complete" )); } else { resolve_row = false; notes.push(format!( "interrupted move '{src}' -> '{dest}' is ambiguous \ - (src present: {src_exists}, dest present: \ - {dest_exists}); journal kept — resolve the \ - directories manually, then rerun repair" + (src present: {src_exists}, dest present: \ + {dest_exists}); journal kept — resolve the \ + directories manually, then rerun repair" )); } } @@ -7415,9 +7415,9 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "interrupted move '{src}' -> '{dest}': the directory \ - at the destination does not carry this worktree's \ - identity; journal kept — resolve manually, then \ - rerun repair" + at the destination does not carry this worktree's \ + identity; journal kept — resolve manually, then \ + rerun repair" )); } else if src_missing && dest_exists { // Directory moved but the registry write was @@ -7426,20 +7426,20 @@ async fn recover_pending_intents( *changed = true; notes.push(format!( "finished registry update for interrupted move \ - '{src}' -> '{dest}'" + '{src}' -> '{dest}'" )); } else if src_exists && dest_missing { notes.push(format!( "interrupted move '{src}' -> '{dest}' never started; \ - nothing to do" + nothing to do" )); } else { resolve_row = false; notes.push(format!( "interrupted move '{src}' -> '{dest}' is ambiguous \ - (src present: {src_exists}, dest present: \ - {dest_exists}); journal kept — resolve the \ - directories manually, then rerun repair" + (src present: {src_exists}, dest present: \ + {dest_exists}); journal kept — resolve the \ + directories manually, then rerun repair" )); } } @@ -7448,16 +7448,16 @@ async fn recover_pending_intents( let elsewhere = state.entries[idx].path.clone(); notes.push(format!( "interrupted move '{src}' -> '{dest}': its worktree is \ - now registered at '{elsewhere}'; journal kept — \ - investigate manually" + now registered at '{elsewhere}'; journal kept — \ + investigate manually" )); } None => { resolve_row = false; notes.push(format!( "interrupted move '{src}' -> '{dest}': no registry entry \ - carries its worktree id; journal kept — investigate \ - manually, then rerun repair" + carries its worktree id; journal kept — investigate \ + manually, then rerun repair" )); } } @@ -7473,7 +7473,7 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot determine whether '{path}' still exists ({error}); \ - journal kept for the next repair" + journal kept for the next repair" )); continue; } @@ -7502,14 +7502,14 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "tombstone mirror write for '{path}' failed \ - ({mirror_error}); journal kept for the \ - next repair" + ({mirror_error}); journal kept for the \ + next repair" )); } *changed = true; notes.push(format!( "prune of '{path}' left a tombstone (cleanup \ - failed: {error})" + failed: {error})" )); false } @@ -7565,13 +7565,13 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot remove the backup link '{}' ({remove_error}); \ - journal kept", + journal kept", backup.display() )); } else { notes.push(format!( "rolled back interrupted layout migration of '{}' \ - (legacy link untouched)", + (legacy link untouched)", path.display() )); } @@ -7585,7 +7585,7 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot remove the backup link '{}' ({remove_error}); \ - journal kept", + journal kept", backup.display() )); } @@ -7596,13 +7596,13 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot remove the prepared dir '{}' ({error}); \ - journal kept", + journal kept", prepared.display() )); } else { notes.push(format!( "rolled back interrupted layout migration of '{}' \ - (legacy link untouched)", + (legacy link untouched)", path.display() )); } @@ -7624,7 +7624,7 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot remove the backup link '{}' ({remove_error}); \ - journal kept", + journal kept", backup.display() )); } @@ -7635,13 +7635,13 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot remove the prepared dir '{}' ({error}); \ - journal kept", + journal kept", prepared.display() )); } else { notes.push(format!( "rolled back interrupted layout migration of '{}' \ - (legacy link untouched)", + (legacy link untouched)", path.display() )); } @@ -7650,8 +7650,8 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "prepared artifact '{}' does not carry this journal's \ - marker; journal kept — investigate manually, nothing \ - was deleted", + marker; journal kept — investigate manually, nothing \ + was deleted", prepared.display() )); } @@ -7666,7 +7666,7 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "cannot finish installing the migrated gitdir for '{}' \ - ({error}); journal kept", + ({error}); journal kept", path.display() )); } else { @@ -7681,7 +7681,7 @@ async fn recover_pending_intents( // journal row stays pending by construction. notes.push(format!( "installed gitdir for '{}' failed identity validation \ - ({error}); journal kept — investigate manually", + ({error}); journal kept — investigate manually", path.display() )); if *changed { @@ -7712,7 +7712,7 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "layout migration of '{}' still incomplete ({error}); \ - journal kept", + journal kept", path.display() )); } @@ -7757,7 +7757,7 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "layout migration of '{}' still incomplete ({error}); \ - journal kept", + journal kept", path.display() )); } @@ -7766,8 +7766,8 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "interrupted layout migration of '{}': on-disk state matches no \ - known stage (identity check failed); journal kept — investigate \ - manually, nothing was deleted", + known stage (identity check failed); journal kept — investigate \ + manually, nothing was deleted", path.display() )); } @@ -7779,7 +7779,7 @@ async fn recover_pending_intents( resolve_row = false; notes.push(format!( "unknown intent op '{other}' (id {id}); journal kept — rerun repair \ - with the binary that recorded it" + with the binary that recorded it" )); } } @@ -7827,7 +7827,7 @@ async fn finish_migration_recovery( if fs::canonicalize(backup).ok().as_deref() != Some(canonical_storage.as_path()) { return Err(format!( "backup '{}' does not resolve to this repository's storage; not \ - touching it", + touching it", backup.display() )); } @@ -7870,7 +7870,7 @@ async fn retry_tombstones( pending += 1; notes.push(format!( "tombstone '{path}': a directory now exists at that path; not adopting it \ - — remove or rename it, then rerun repair" + — remove or rename it, then rerun repair" )); index += 1; continue; @@ -7892,7 +7892,7 @@ async fn retry_tombstones( pending += 1; notes.push(format!( "tombstone '{path}': scoped cleanup failed again ({error}); will retry \ - on the next repair" + on the next repair" )); index += 1; } @@ -7937,7 +7937,7 @@ async fn reconcile_lifecycle( } Err(error) => notes.push(format!( "FAILED to restore the detached marker for '{}' ({error}); the \ - directory is NOT frozen — rerun repair after fixing the cause", + directory is NOT frozen — rerun repair after fixing the cause", entry.path )), } @@ -7974,7 +7974,7 @@ async fn reconcile_lifecycle( } else { notes.push(format!( "active '{}' carries a detached marker but its gitdir identity \ - does not match; leaving it frozen — investigate manually", + does not match; leaving it frozen — investigate manually", entry.path )); } @@ -8023,15 +8023,15 @@ async fn reconcile_lifecycle( )), Err(error) => notes.push(format!( "FAILED to clear the resolved migration marker from '{}' \ - ({error}); the worktree stays frozen — fix the cause and rerun \ - repair", + ({error}); the worktree stays frozen — fix the cause and rerun \ + repair", entry.path )), } } else { notes.push(format!( "'{}' carries a migration marker with no pending journal but its \ - install identity cannot be verified; leaving it frozen", + install identity cannot be verified; leaving it frozen", entry.path )); } @@ -8069,7 +8069,7 @@ pub(crate) fn render_repair_worktrees( if result.tombstones_pending > 0 { println!( "{} tombstone(s) still pending; rerun `libra worktree repair --confirm` \ - after addressing the notes above", + after addressing the notes above", result.tombstones_pending ); } @@ -8086,13 +8086,13 @@ mod tests { #[test] fn registry_parse_accepts_v2_shape() { let data = br#"{ - "schema_version": 2, - "entries": [ - {"path": "/m", "is_main": true, "locked": false, "lock_reason": null}, - {"path": "/w", "is_main": false, "locked": false, "lock_reason": null, - "worktree_id": "abc123"} - ] - }"#; + "schema_version": 2, + "entries": [ + {"path": "/m", "is_main": true, "locked": false, "lock_reason": null}, + {"path": "/w", "is_main": false, "locked": false, "lock_reason": null, + "worktree_id": "abc123"} + ] + }"#; let state = WorktreeState::parse(data).expect("v2 parses"); assert_eq!(state.schema_version, REGISTRY_SCHEMA_VERSION); assert_eq!(state.entries.len(), 2); @@ -8103,11 +8103,11 @@ mod tests { #[test] fn registry_parse_upgrades_v1_shape_in_memory() { let data = br#"{ - "worktrees": [ - {"path": "/m", "is_main": true, "locked": false, "lock_reason": null}, - {"path": "/w", "is_main": false, "locked": true, "lock_reason": "keep"} - ] - }"#; + "worktrees": [ + {"path": "/m", "is_main": true, "locked": false, "lock_reason": null}, + {"path": "/w", "is_main": false, "locked": true, "lock_reason": "keep"} + ] + }"#; let state = WorktreeState::parse(data).expect("v1 upgrades in memory"); assert_eq!(state.schema_version, REGISTRY_SCHEMA_VERSION); assert_eq!(state.entries.len(), 2); @@ -8127,12 +8127,12 @@ mod tests { // v2 marker + malformed entries + a plausible legacy array: must NOT // fall back to reading the stale v1 array. let hybrid = br#"{ - "schema_version": 2, - "entries": "corrupt", - "worktrees": [ - {"path": "/m", "is_main": true, "locked": false, "lock_reason": null} - ] - }"#; + "schema_version": 2, + "entries": "corrupt", + "worktrees": [ + {"path": "/m", "is_main": true, "locked": false, "lock_reason": null} + ] + }"#; assert!(WorktreeState::parse(hybrid).is_err()); // Valid v2 alongside a stray legacy array is ambiguous — refused, @@ -8160,10 +8160,10 @@ mod tests { assert!(err.contains("exactly one main"), "{err}"); let sole_linked = br#"{ - "worktrees": [ - {"path": "/w", "is_main": false, "locked": false, "lock_reason": null} - ] - }"#; + "worktrees": [ + {"path": "/w", "is_main": false, "locked": false, "lock_reason": null} + ] + }"#; assert!(WorktreeState::parse(sole_linked).is_err()); } @@ -8172,22 +8172,22 @@ mod tests { #[test] fn registry_parse_requires_exactly_one_main() { let zero_main = br#"{ - "schema_version": 2, - "entries": [ - {"path": "/w", "is_main": false, "locked": false, "lock_reason": null, - "worktree_id": "abc123"} - ] - }"#; + "schema_version": 2, + "entries": [ + {"path": "/w", "is_main": false, "locked": false, "lock_reason": null, + "worktree_id": "abc123"} + ] + }"#; let err = WorktreeState::parse(zero_main).expect_err("zero mains fail closed"); assert!(err.contains("exactly one main"), "{err}"); let two_mains = br#"{ - "schema_version": 2, - "entries": [ - {"path": "/m", "is_main": true, "locked": false, "lock_reason": null}, - {"path": "/n", "is_main": true, "locked": false, "lock_reason": null} - ] - }"#; + "schema_version": 2, + "entries": [ + {"path": "/m", "is_main": true, "locked": false, "lock_reason": null}, + {"path": "/n", "is_main": true, "locked": false, "lock_reason": null} + ] + }"#; let err = WorktreeState::parse(two_mains).expect_err("two mains fail closed"); assert!(err.contains("exactly one main"), "{err}"); } @@ -8198,22 +8198,22 @@ mod tests { #[test] fn registry_parse_enforces_v2_identity_invariants() { let linked_without_id = br#"{ - "schema_version": 2, - "entries": [ - {"path": "/m", "is_main": true, "locked": false, "lock_reason": null}, - {"path": "/w", "is_main": false, "locked": false, "lock_reason": null} - ] - }"#; + "schema_version": 2, + "entries": [ + {"path": "/m", "is_main": true, "locked": false, "lock_reason": null}, + {"path": "/w", "is_main": false, "locked": false, "lock_reason": null} + ] + }"#; let err = WorktreeState::parse(linked_without_id).expect_err("missing id fails closed"); assert!(err.contains("missing its persisted worktree_id"), "{err}"); let main_with_id = br#"{ - "schema_version": 2, - "entries": [ - {"path": "/m", "is_main": true, "locked": false, "lock_reason": null, - "worktree_id": "oops"} - ] - }"#; + "schema_version": 2, + "entries": [ + {"path": "/m", "is_main": true, "locked": false, "lock_reason": null, + "worktree_id": "oops"} + ] + }"#; let err = WorktreeState::parse(main_with_id).expect_err("main id fails closed"); assert!(err.contains("must not carry a worktree_id"), "{err}"); } @@ -8240,7 +8240,7 @@ mod tests { // v2 is still READ and promoted in memory — its entries simply carry // no service-fence generations yet. let v2 = br#"{"schema_version": 2, "entries": - [{"path": "/w", "is_main": true, "locked": false}]}"#; + [{"path": "/w", "is_main": true, "locked": false}]}"#; let parsed = WorktreeState::parse(v2).expect("a v2 registry is read"); assert_eq!( parsed.schema_version, REGISTRY_SCHEMA_VERSION, @@ -8328,11 +8328,11 @@ mod tests { .await .expect("in-memory db"); for sql in [ - "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,worktree_id TEXT NOT NULL DEFAULT '',scope_provenance TEXT NOT NULL DEFAULT 'declared',restorable INTEGER NOT NULL DEFAULT 1,control_slot TEXT,claim_owner TEXT,scope_kind TEXT NOT NULL DEFAULT 'main');", - "CREATE TABLE operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id));", + "CREATE TABLE legacy_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,worktree_id TEXT NOT NULL DEFAULT '',scope_provenance TEXT NOT NULL DEFAULT 'declared',restorable INTEGER NOT NULL DEFAULT 1,control_slot TEXT,claim_owner TEXT,scope_kind TEXT NOT NULL DEFAULT 'main');", + "CREATE TABLE legacy_operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id));", "CREATE TABLE config_kv(id INTEGER PRIMARY KEY AUTOINCREMENT,key TEXT NOT NULL,value TEXT NOT NULL,encrypted INTEGER NOT NULL DEFAULT 0);", - "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));", + "CREATE TABLE legacy_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 legacy_operation_view_workspace(view_id TEXT NOT NULL,pointer_kind TEXT NOT NULL,pointer_value TEXT NOT NULL,PRIMARY KEY (view_id,pointer_kind));", "CREATE TABLE reference (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,kind TEXT NOT NULL,\"commit\" TEXT,remote TEXT,worktree_id TEXT)", ] { db.execute_raw(Statement::from_string(DbBackend::Sqlite, sql.to_string())) @@ -8354,7 +8354,7 @@ mod tests { // Fault injection: the close cannot write its outcome row. db.execute_raw(Statement::from_string( DbBackend::Sqlite, - "DROP TABLE operation".to_string(), + "DROP TABLE legacy_operation".to_string(), )) .await .expect("drop the operation table"); @@ -8405,8 +8405,8 @@ mod tests { callers, vec!["match tokio::task::spawn_blocking(acquire_registry_lock).await {"], "the blocking registry acquisition grew a caller outside \ - `acquire_registry_lock_async`: take it on the blocking pool \ - instead (plan-20260714 W1)" + `acquire_registry_lock_async`: take it on the blocking pool \ + instead (plan-20260714 W1)" ); } } diff --git a/src/internal/config_ownership.rs b/src/internal/config_ownership.rs index 2bbcec936..7921e4bf9 100644 --- a/src/internal/config_ownership.rs +++ b/src/internal/config_ownership.rs @@ -366,6 +366,9 @@ pub const CODE_AGENT_TABLE_OWNERSHIP: &[(&str, ConfigOwner)] = &[ ("ai_final_decision", ConfigOwner::Repository), ("ai_risk_score_breakdown", ConfigOwner::Repository), ("ai_validation_report", ConfigOwner::Repository), + // V2 operation records carry optional AI/session provenance but remain + // repository-owned; the operation store is not a configuration surface. + ("ai_operation_link", ConfigOwner::Repository), ]; /// §C.4.1.1 process-cache inventory: every `static` synchronization/cache diff --git a/src/internal/db.rs b/src/internal/db.rs index 1a1be4ccb..4a7ce6e27 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"; @@ -691,14 +641,14 @@ async fn ensure_config_kv_schema(conn: &DatabaseConnection) -> Result<(), IOErro let backend = conn.get_database_backend(); let ddl = r#" -CREATE TABLE IF NOT EXISTS `config_kv` ( - `id` INTEGER PRIMARY KEY AUTOINCREMENT, - `key` TEXT NOT NULL, - `value` TEXT NOT NULL, - `encrypted` INTEGER NOT NULL DEFAULT 0 -); -CREATE INDEX IF NOT EXISTS idx_config_kv_key ON config_kv(`key`); -"#; + CREATE TABLE IF NOT EXISTS `config_kv` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `key` TEXT NOT NULL, + `value` TEXT NOT NULL, + `encrypted` INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_config_kv_key ON config_kv(`key`); + "#; conn.execute_raw(Statement::from_string(backend, ddl)) .await .map_err(|err| IOError::other(format!("Failed to create config_kv table: {err}")))?; @@ -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 @@ -1002,7 +941,7 @@ mod tests { cached .execute_unprepared( "INSERT INTO schema_versions (version, name, applied_at) \ - VALUES (2126010101, 'from-the-future', datetime('now'))", + VALUES (2126010101, 'from-the-future', datetime('now'))", ) .await .expect("plant a future version"); diff --git a/src/internal/db/migration.rs b/src/internal/db/migration.rs index 9d1b628dc..a765a5f17 100644 --- a/src/internal/db/migration.rs +++ b/src/internal/db/migration.rs @@ -38,6 +38,8 @@ //! Future CEXes only touch the runner — no new `ensure_*` helpers should be //! added. +use std::collections::BTreeSet; + use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr, Statement, TransactionTrait}; @@ -640,7 +642,11 @@ async fn apply_one_migration_guarded( ))); } apply_migration_compatibility(txn, version, name).await?; - txn.execute_raw(Statement::from_string(backend, up)).await?; + if version == OPERATION_V2_MIGRATION_VERSION { + apply_operation_v2_migration(txn, up).await?; + } else { + txn.execute_raw(Statement::from_string(backend, up)).await?; + } if version == 2026072902 && history != RegistryLinkedHistory::Never { // The registry witness the migration's SQL cannot see // (§C.9): a linked worktree removed before this upgrade @@ -667,6 +673,373 @@ async fn apply_one_migration_guarded( Ok(inserted) } +/// The operation-log migration is the one exception to the static-DDL path. +/// +/// The active v1 operation wrapper must keep working during the OL-02..04 +/// foundation window, so old rows are copied into an explicit legacy_* +/// namespace before the v2 tables are created. The copy, validation, source +/// removal, and version claim all share the runner transaction. A failure +/// therefore rolls back both data and schema, while a fresh v2 database simply +/// receives empty legacy tables for the still-active v1 reader/writer. +async fn apply_operation_v2_migration( + txn: &sea_orm::DatabaseTransaction, + canonical_sql: &str, +) -> Result<(), DbErr> { + let operation_exists = sqlite_table_exists(txn, "operation").await?; + let operation_columns = if operation_exists { + sqlite_table_columns(txn, "operation").await? + } else { + BTreeSet::new() + }; + let operation_is_v1 = operation_columns.contains("view_id"); + if operation_exists && !operation_is_v1 && !operation_columns.contains("format_version") { + return Err(DbErr::Custom( + "operation table has neither the v1 view_id nor the v2 format_version shape" + .to_string(), + )); + } + + if operation_is_v1 { + if sqlite_table_exists(txn, "legacy_operation").await? { + return Err(DbErr::Custom( + "legacy_operation already exists while the v1 operation table is present" + .to_string(), + )); + } + for required in [ + "op_id", + "repo_id", + "view_id", + "command_name", + "description", + "actor", + "start_ts", + "status", + ] { + if !operation_columns.contains(required) { + return Err(DbErr::Custom(format!( + "v1 operation table is missing required column {required}" + ))); + } + } + + txn.execute_raw(Statement::from_string( + txn.get_database_backend(), + legacy_operation_staging_ddl(), + )) + .await?; + let expression = |column: &str, fallback: &str| { + if operation_columns.contains(column) { + column.to_string() + } else { + fallback.to_string() + } + }; + let insert = format!( + "INSERT INTO legacy_operation__staging ( + op_id, repo_id, view_id, command_name, description, actor, args_digest, + start_ts, end_ts, status, worktree_id, scope_provenance, restorable, + control_slot, claim_owner, scope_kind) SELECT {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, + COALESCE({}, ''), COALESCE({}, 'unknown'), COALESCE({}, 1), {}, {}, + COALESCE({}, 'unknown') FROM operation", + expression("op_id", "''"), + expression("repo_id", "''"), + expression("view_id", "''"), + expression("command_name", "''"), + expression("description", "''"), + expression("actor", "''"), + expression("args_digest", "NULL"), + expression("start_ts", "0"), + expression("end_ts", "NULL"), + expression("status", "''"), + expression("worktree_id", "NULL"), + expression("scope_provenance", "NULL"), + expression("restorable", "NULL"), + expression("control_slot", "NULL"), + expression("claim_owner", "NULL"), + expression("scope_kind", "NULL"), + ); + txn.execute_raw(Statement::from_string(txn.get_database_backend(), insert)) + .await?; + validate_copy( + txn, + "operation", + "legacy_operation__staging", + &[ + "op_id", + "repo_id", + "view_id", + "command_name", + "description", + "actor", + ], + ) + .await?; + txn.execute_raw(Statement::from_string( + txn.get_database_backend(), + "DROP TABLE operation; ALTER TABLE legacy_operation__staging RENAME TO legacy_operation;", + )) + .await?; + } + + let parent_exists = sqlite_table_exists(txn, "operation_parent").await?; + if parent_exists { + let parent_columns = sqlite_table_columns(txn, "operation_parent").await?; + if !parent_columns.contains("ordinal") { + copy_legacy_table( + txn, + "operation_parent", + "legacy_operation_parent", + "CREATE TABLE legacy_operation_parent__staging (op_id TEXT NOT NULL, parent_op_id TEXT NOT NULL, PRIMARY KEY (op_id, parent_op_id));", + "INSERT INTO legacy_operation_parent__staging (op_id, parent_op_id) SELECT op_id, parent_op_id FROM operation_parent;", + &["op_id", "parent_op_id"], + ) + .await?; + } + } + copy_legacy_table_if_present( + txn, + "operation_view", + "legacy_operation_view", + "CREATE TABLE legacy_operation_view__staging (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);", + "INSERT INTO legacy_operation_view__staging (view_id, repo_id, head_kind, head_target, created_at) SELECT view_id, repo_id, head_kind, head_target, created_at FROM operation_view;", + &["view_id", "repo_id", "head_kind", "head_target"], + ) + .await?; + copy_legacy_table_if_present( + txn, + "operation_view_ref", + "legacy_operation_view_ref", + "CREATE TABLE legacy_operation_view_ref__staging (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));", + "INSERT INTO legacy_operation_view_ref__staging (view_id, ref_kind, ref_name, ref_remote, target_oid) SELECT view_id, ref_kind, ref_name, ref_remote, target_oid FROM operation_view_ref;", + &["view_id", "ref_kind", "ref_name", "target_oid"], + ) + .await?; + copy_legacy_table_if_present( + txn, + "operation_view_workspace", + "legacy_operation_view_workspace", + "CREATE TABLE legacy_operation_view_workspace__staging (view_id TEXT NOT NULL, pointer_kind TEXT NOT NULL, pointer_value TEXT NOT NULL, PRIMARY KEY (view_id, pointer_kind));", + "INSERT INTO legacy_operation_view_workspace__staging (view_id, pointer_kind, pointer_value) SELECT view_id, pointer_kind, pointer_value FROM operation_view_workspace;", + &["view_id", "pointer_kind", "pointer_value"], + ) + .await?; + + txn.execute_raw(Statement::from_string( + txn.get_database_backend(), + legacy_operation_namespace_ddl(), + )) + .await?; + txn.execute_raw(Statement::from_string( + txn.get_database_backend(), + canonical_sql.to_string(), + )) + .await?; + Ok(()) +} + +const OPERATION_V2_MIGRATION_VERSION: i64 = 2026090101; + +fn legacy_operation_staging_ddl() -> String { + "CREATE TABLE legacy_operation__staging ( + 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, + worktree_id TEXT NOT NULL DEFAULT '', scope_provenance TEXT NOT NULL DEFAULT 'unknown', + restorable INTEGER NOT NULL DEFAULT 1, control_slot TEXT, claim_owner TEXT, + scope_kind TEXT NOT NULL DEFAULT 'unknown');" + .to_string() +} + +fn legacy_operation_namespace_ddl() -> String { + "CREATE TABLE IF NOT EXISTS legacy_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, + worktree_id TEXT NOT NULL DEFAULT '', scope_provenance TEXT NOT NULL DEFAULT 'unknown', + restorable INTEGER NOT NULL DEFAULT 1, control_slot TEXT, claim_owner TEXT, + scope_kind TEXT NOT NULL DEFAULT 'unknown'); + CREATE INDEX IF NOT EXISTS idx_legacy_operation_repo_order + ON legacy_operation(repo_id, end_ts DESC, start_ts DESC, op_id DESC); + CREATE INDEX IF NOT EXISTS idx_legacy_operation_dedup_scope + ON legacy_operation(repo_id, worktree_id, command_name, args_digest, status, end_ts); + CREATE UNIQUE INDEX IF NOT EXISTS idx_legacy_operation_control_slot + ON legacy_operation(repo_id, worktree_id) + WHERE status = 'running' AND control_slot IS NOT NULL; + CREATE TRIGGER IF NOT EXISTS legacy_operation_scope_provenance_domain_insert + BEFORE INSERT ON legacy_operation + FOR EACH ROW WHEN NEW.scope_provenance NOT IN ('declared', 'unknown') + BEGIN + SELECT RAISE(ABORT, 'legacy_operation.scope_provenance must be either declared or unknown'); + END; + CREATE TRIGGER IF NOT EXISTS legacy_operation_scope_provenance_domain_update + BEFORE UPDATE OF scope_provenance ON legacy_operation + FOR EACH ROW WHEN NEW.scope_provenance NOT IN ('declared', 'unknown') + BEGIN + SELECT RAISE(ABORT, 'legacy_operation.scope_provenance must be either declared or unknown'); + END; + CREATE TRIGGER IF NOT EXISTS legacy_operation_scope_kind_domain_insert + BEFORE INSERT ON legacy_operation + FOR EACH ROW WHEN NEW.scope_kind NOT IN ('main', 'linked', 'repository', 'unknown') + BEGIN + SELECT RAISE(ABORT, 'legacy_operation.scope_kind must be main, linked, repository or unknown'); + END; + CREATE TRIGGER IF NOT EXISTS legacy_operation_scope_kind_domain_update + BEFORE UPDATE OF scope_kind ON legacy_operation + FOR EACH ROW WHEN NEW.scope_kind NOT IN ('main', 'linked', 'repository', 'unknown') + BEGIN + SELECT RAISE(ABORT, 'legacy_operation.scope_kind must be main, linked, repository or unknown'); + END; + CREATE TABLE IF NOT EXISTS legacy_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_legacy_operation_parent_parent + ON legacy_operation_parent(parent_op_id, op_id); + CREATE TABLE IF NOT EXISTS legacy_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_legacy_operation_view_repo_created + ON legacy_operation_view(repo_id, created_at DESC); + CREATE TABLE IF NOT EXISTS legacy_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 legacy_operation_view_workspace ( + view_id TEXT NOT NULL, pointer_kind TEXT NOT NULL, pointer_value TEXT NOT NULL, + PRIMARY KEY (view_id, pointer_kind));" + .to_string() +} + +async fn copy_legacy_table_if_present( + txn: &sea_orm::DatabaseTransaction, + source: &str, + target: &str, + staging_ddl: &str, + insert_sql: &str, + keys: &[&str], +) -> Result<(), DbErr> { + if sqlite_table_exists(txn, source).await? { + copy_legacy_table(txn, source, target, staging_ddl, insert_sql, keys).await?; + } + Ok(()) +} + +async fn copy_legacy_table( + txn: &sea_orm::DatabaseTransaction, + source: &str, + target: &str, + staging_ddl: &str, + insert_sql: &str, + keys: &[&str], +) -> Result<(), DbErr> { + if sqlite_table_exists(txn, target).await? { + return Err(DbErr::Custom(format!( + "{target} already exists while {source} still needs migration" + ))); + } + let staging = format!("{target}__staging"); + txn.execute_raw(Statement::from_string( + txn.get_database_backend(), + format!("DROP TABLE IF EXISTS {staging}; {staging_ddl}"), + )) + .await?; + txn.execute_raw(Statement::from_string( + txn.get_database_backend(), + insert_sql.to_string(), + )) + .await?; + validate_copy(txn, source, &staging, keys).await?; + txn.execute_raw(Statement::from_string( + txn.get_database_backend(), + format!("DROP TABLE {source}; ALTER TABLE {staging} RENAME TO {target};"), + )) + .await?; + Ok(()) +} + +async fn validate_copy( + txn: &sea_orm::DatabaseTransaction, + source: &str, + staging: &str, + keys: &[&str], +) -> Result<(), DbErr> { + let source_count = count_rows(txn, source, None).await?; + let staging_count = count_rows(txn, staging, None).await?; + if source_count != staging_count { + return Err(DbErr::Custom(format!( + "copy-first migration row-count mismatch for {source}: source={source_count}, staging={staging_count}" + ))); + } + let invalid_predicate = keys + .iter() + .map(|key| format!("COALESCE(TRIM({key}), '') = ''")) + .collect::>() + .join(" OR "); + if count_rows(txn, staging, Some(&invalid_predicate)).await? != 0 { + return Err(DbErr::Custom(format!( + "copy-first migration found an empty key in {source}" + ))); + } + let key_predicate = keys + .iter() + .map(|key| format!("s.{key} = t.{key}")) + .collect::>() + .join(" AND "); + let source_missing = count_rows( + txn, + &format!( + "{source} AS s WHERE NOT EXISTS (SELECT 1 FROM {staging} AS t WHERE {key_predicate})" + ), + None, + ) + .await?; + let staging_missing = count_rows( + txn, + &format!( + "{staging} AS t WHERE NOT EXISTS (SELECT 1 FROM {source} AS s WHERE {key_predicate})" + ), + None, + ) + .await?; + if source_missing != 0 || staging_missing != 0 { + return Err(DbErr::Custom(format!( + "copy-first migration key-set mismatch for {source}: source_missing={source_missing}, staging_missing={staging_missing}" + ))); + } + Ok(()) +} + +async fn sqlite_table_exists(conn: &C, name: &str) -> Result { + Ok(conn + .query_one_raw(Statement::from_sql_and_values( + conn.get_database_backend(), + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + [name.into()], + )) + .await? + .is_some()) +} + +async fn sqlite_table_columns( + conn: &C, + table: &str, +) -> Result, DbErr> { + let rows = conn + .query_all_raw(Statement::from_string( + conn.get_database_backend(), + format!("PRAGMA table_info('{table}')"), + )) + .await?; + let mut columns = BTreeSet::new(); + for row in rows { + let name: String = row + .try_get_by_index(1) + .map_err(|error| DbErr::Custom(format!("cannot decode {table} column: {error}")))?; + columns.insert(name); + } + Ok(columns) +} + /// Install additive columns that SQLite cannot express idempotently in a SQL /// migration (`ADD COLUMN IF NOT EXISTS` is unsupported). Early M5 /// development repositories applied the 1406 base schema, while later ones @@ -1403,6 +1776,15 @@ 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"), ), + // OL-02..04 foundation: v2 schema, canonical manifests, and the + // durable store are landed together. The migration is forward-only; + // legacy rows remain under legacy_* until the later runtime cutover. + Migration { + version: OPERATION_V2_MIGRATION_VERSION, + name: "operation_v2", + up: include_str!("../../../sql/migrations/2026090101_operation_v2.sql"), + down: None, + }, ] } @@ -1850,9 +2232,12 @@ 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(OPERATION_V2_MIGRATION_VERSION) + ); } #[test] diff --git a/src/internal/operation.rs b/src/internal/legacy_operation.rs similarity index 99% rename from src/internal/operation.rs rename to src/internal/legacy_operation.rs index 96f759473..cf77e75d5 100644 --- a/src/internal/operation.rs +++ b/src/internal/legacy_operation.rs @@ -10,7 +10,7 @@ use sea_orm::{ }; use thiserror::Error; -use crate::internal::model::{ +use crate::internal::legacy_operation_model::{ operation, operation_parent, operation_view, operation_view_ref, operation_view_workspace, }; @@ -1276,12 +1276,12 @@ mod tests { db.execute(Statement::from_string( DbBackend::Sqlite, r#" - 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 TABLE IF NOT EXISTS operation_parent ( + op_id TEXT NOT NULL, + parent_op_id TEXT NOT NULL, + PRIMARY KEY (op_id, parent_op_id) + ); + "#, )) .await .unwrap(); @@ -1423,13 +1423,13 @@ mod tests { db.execute(Statement::from_string( DbBackend::Sqlite, r#" - 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) - ); - "#, + 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) + ); + "#, )) .await .unwrap(); diff --git a/src/internal/legacy_operation_model/mod.rs b/src/internal/legacy_operation_model/mod.rs new file mode 100644 index 000000000..5c547211f --- /dev/null +++ b/src/internal/legacy_operation_model/mod.rs @@ -0,0 +1,8 @@ +//! SeaORM entities for the legacy operation-log schema retained during the +//! OL-02..04 foundation window. + +pub mod operation; +pub mod operation_parent; +pub mod operation_view; +pub mod operation_view_ref; +pub mod operation_view_workspace; diff --git a/src/internal/legacy_operation_model/operation.rs b/src/internal/legacy_operation_model/operation.rs new file mode 100644 index 000000000..9dc0c750f --- /dev/null +++ b/src/internal/legacy_operation_model/operation.rs @@ -0,0 +1,30 @@ +//! SeaORM entity for the pre-OL-02 operation table. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "legacy_operation")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub op_id: String, + pub repo_id: String, + pub view_id: String, + pub command_name: String, + pub description: String, + pub actor: String, + pub args_digest: Option, + pub start_ts: i64, + pub end_ts: Option, + pub status: String, + pub worktree_id: String, + pub scope_provenance: String, + pub restorable: i32, + pub control_slot: Option, + pub claim_owner: Option, + pub scope_kind: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/legacy_operation_model/operation_parent.rs b/src/internal/legacy_operation_model/operation_parent.rs new file mode 100644 index 000000000..e2a5e4e20 --- /dev/null +++ b/src/internal/legacy_operation_model/operation_parent.rs @@ -0,0 +1,17 @@ +//! SeaORM entity for pre-OL-02 operation parent edges. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "legacy_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, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/internal/model/operation_view.rs b/src/internal/legacy_operation_model/operation_view.rs similarity index 79% rename from src/internal/model/operation_view.rs rename to src/internal/legacy_operation_model/operation_view.rs index 0fb4224e5..dda1f6011 100644 --- a/src/internal/model/operation_view.rs +++ b/src/internal/legacy_operation_model/operation_view.rs @@ -1,9 +1,9 @@ -//! SeaORM entity definition for operation view snapshots. +//! SeaORM entity for the pre-OL-02 operation view table. use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "operation_view")] +#[sea_orm(table_name = "legacy_operation_view")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub view_id: String, diff --git a/src/internal/model/operation_view_ref.rs b/src/internal/legacy_operation_model/operation_view_ref.rs similarity index 82% rename from src/internal/model/operation_view_ref.rs rename to src/internal/legacy_operation_model/operation_view_ref.rs index d11c56b64..105708a72 100644 --- a/src/internal/model/operation_view_ref.rs +++ b/src/internal/legacy_operation_model/operation_view_ref.rs @@ -1,9 +1,9 @@ -//! SeaORM entity definition for operation view reference snapshots. +//! SeaORM entity for pre-OL-02 operation view reference snapshots. use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "operation_view_ref")] +#[sea_orm(table_name = "legacy_operation_view_ref")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub view_id: String, diff --git a/src/internal/model/operation_view_workspace.rs b/src/internal/legacy_operation_model/operation_view_workspace.rs similarity index 77% rename from src/internal/model/operation_view_workspace.rs rename to src/internal/legacy_operation_model/operation_view_workspace.rs index cf4db1262..cbd50b576 100644 --- a/src/internal/model/operation_view_workspace.rs +++ b/src/internal/legacy_operation_model/operation_view_workspace.rs @@ -1,9 +1,9 @@ -//! SeaORM entity definition for operation workspace pointer snapshots. +//! SeaORM entity for pre-OL-02 workspace pointer snapshots. use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "operation_view_workspace")] +#[sea_orm(table_name = "legacy_operation_view_workspace")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub view_id: String, diff --git a/src/internal/mod.rs b/src/internal/mod.rs index 36f4aeb9e..2067b6b9e 100644 --- a/src/internal/mod.rs +++ b/src/internal/mod.rs @@ -32,6 +32,8 @@ pub mod deps; pub mod dirty; pub mod head; pub mod layer; +pub mod legacy_operation; +pub mod legacy_operation_model; pub mod log; pub mod maintenance_lock; pub mod merge_base; 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..8f0caa401 --- /dev/null +++ b/src/internal/model/change_identity.rs @@ -0,0 +1,19 @@ +//! SeaORM entity for stable logical change identities. + +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..eb4f1be73 --- /dev/null +++ b/src/internal/model/change_predecessor.rs @@ -0,0 +1,21 @@ +//! SeaORM entity for rewrite 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: i32, +} + +#[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..b43116cf8 --- /dev/null +++ b/src/internal/model/change_revision.rs @@ -0,0 +1,20 @@ +//! SeaORM entity for change-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..fdcad5ac8 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,10 +31,9 @@ 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_view; -pub mod operation_view_ref; -pub mod operation_view_workspace; pub mod reference; pub mod reflog; pub mod revision_ordinal; diff --git a/src/internal/model/operation.rs b/src/internal/model/operation.rs index 62dfd85a2..c398873bf 100644 --- a/src/internal/model/operation.rs +++ b/src/internal/model/operation.rs @@ -1,4 +1,4 @@ -//! SeaORM entity definition for command-level operation audit records. +//! SeaORM entity for the v2 append-only operation record. use sea_orm::entity::prelude::*; @@ -8,52 +8,23 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub op_id: String, pub repo_id: String, - pub view_id: String, - pub command_name: String, - pub description: String, - pub actor: 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, - pub status: String, - /// Worktree scope the operation ran in (Part C W1 §C.9): main = `""`, - /// linked = its stable instance id. Scopes the duplicate-submission - /// window per-worktree. - pub worktree_id: String, - /// How `worktree_id` came to hold its value (Part C W0 §C.11): - /// `"declared"` — the process that ran the operation recorded its own - /// scope; `"unknown"` — the row predates the scope column in a - /// repository with linked-worktree evidence, so its `""` means "not - /// recorded", not "main". `op restore` refuses `unknown` rows rather - /// than guess (ADR-0714-08). - pub scope_provenance: String, - /// Whether `op restore` may replay this operation (Part C W1 §C.9). - /// - /// The snapshot covers HEAD and refs only — it cannot restore an index, a - /// working tree, or sequencer state. Operations that changed one of those - /// (every sequencer control action) record `0` here, and `op restore` - /// refuses them before doing anything. A stored property, not a check - /// against `command_name`: the name is a mutable label, and a renamed - /// command must not silently become restorable. - pub restorable: i32, - /// Non-NULL while this row is a sequencer CONTROL action's claim on its - /// worktree's single control slot (Part C W1 §C.9). The partial unique - /// index on `(repo_id, worktree_id) WHERE status = 'running' AND - /// control_slot IS NOT NULL` is what makes one control per worktree an - /// invariant rather than a check. - pub control_slot: Option, - /// `/` of the process holding a `running` claim, so a claim - /// left by a killed process can be PROVEN dead rather than guessed from - /// age — a control action may legitimately sit for a long time in an - /// editor or a hook. - pub claim_owner: Option, - /// What KIND of scope this operation ran in (Part C W1 §C.9): - /// `main` / `linked` / `repository` / `unknown`. - /// - /// `worktree_id` alone cannot express it: a repository-scope operation - /// recorded from main carries the same empty id as a main-scope one, and - /// `op restore` must refuse the former while allowing the latter. - pub scope_kind: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/internal/model/operation_head.rs b/src/internal/model/operation_head.rs new file mode 100644 index 000000000..5f9b5b62a --- /dev/null +++ b/src/internal/model/operation_head.rs @@ -0,0 +1,20 @@ +//! SeaORM entity for the per-repository/per-scope operation-head set. + +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..258e3206f --- /dev/null +++ b/src/internal/model/operation_journal.rs @@ -0,0 +1,22 @@ +//! SeaORM entity for crash-recovery phases of an operation publication. + +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.rs b/src/internal/model/operation_parent.rs index 1ff4b7499..4aa3b30ac 100644 --- a/src/internal/model/operation_parent.rs +++ b/src/internal/model/operation_parent.rs @@ -1,4 +1,4 @@ -//! SeaORM entity definition for operation parent edges. +//! SeaORM entity for v2 operation parent edges. use sea_orm::entity::prelude::*; @@ -9,6 +9,7 @@ pub struct Model { 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)] diff --git a/src/internal/mutable_state_ownership.rs b/src/internal/mutable_state_ownership.rs index ac197da14..a4d62bafa 100644 --- a/src/internal/mutable_state_ownership.rs +++ b/src/internal/mutable_state_ownership.rs @@ -109,7 +109,7 @@ pub const MUTABLE_STATE_OWNERSHIP: &[MutableStateSurface] = &[ table: "agent_session", owner: StateOwner::Composite, rationale: "captured sessions are repository-owned and record the worktree they were \ - observed in (W4 adds the workspace scope key)", + observed in (W4 adds the workspace scope key)", }, MutableStateSurface { table: "agent_export_job", @@ -130,19 +130,59 @@ pub const MUTABLE_STATE_OWNERSHIP: &[MutableStateSurface] = &[ table: "operation", owner: StateOwner::Composite, rationale: "the operation log is repository-wide, but its worktree_id is a real \ - routing key: dedup windows and `op restore` are scope-fenced (§C.9)", + routing key: dedup windows and `op restore` are scope-fenced (§C.9)", + }, + MutableStateSurface { + table: "legacy_operation", + owner: StateOwner::Composite, + rationale: "the active v1 operation logger remains scope-routed during the OL-02 staging window", + }, + MutableStateSurface { + table: "ai_operation_link", + owner: StateOwner::Composite, + rationale: "AI provenance links are repository-owned and optionally carry worktree/workspace scope", + }, + MutableStateSurface { + table: "operation_parent", + owner: StateOwner::Repository, + rationale: "v2 operation genealogy edges are repository-wide and keyed by operation ids", + }, + MutableStateSurface { + table: "operation_head", + owner: StateOwner::Repository, + rationale: "v2 scope heads are repository records keyed by an opaque scope key", + }, + MutableStateSurface { + table: "operation_journal", + owner: StateOwner::Repository, + rationale: "v2 recovery journal entries are repository-wide and keyed by operation ids", + }, + MutableStateSurface { + table: "change_identity", + owner: StateOwner::Repository, + rationale: "change identities are repository-wide causal projections", + }, + MutableStateSurface { + table: "change_revision", + owner: StateOwner::Repository, + rationale: "change revisions are repository-wide commit projections", + }, + MutableStateSurface { + table: "change_predecessor", + owner: StateOwner::Repository, + rationale: "change genealogy edges are repository-wide and keyed by commit ids", }, MutableStateSurface { table: "reference", owner: StateOwner::Composite, rationale: "branches/tags/remotes are repository-shared; HEAD rows are per-worktree \ - via worktree_id (partial unique index per scope, W0)", + via worktree_id (partial unique index per scope, W0)", }, MutableStateSurface { table: "reflog", owner: StateOwner::Composite, rationale: "branch reflogs are repository-shared; HEAD reflog is per-worktree, so \ - enumeration and expire are keyed by (ref_name, worktree_id)", + enumeration and expire are keyed by (ref_name, worktree_id)", }, // ── Repository-owned mutable state ────────────────────────────────── MutableStateSurface { @@ -337,7 +377,7 @@ pub const MUTABLE_STATE_OWNERSHIP: &[MutableStateSurface] = &[ table: "approved_permission", owner: StateOwner::Composite, rationale: "Always-approvals are repository-wide by trust design (§C.4.1.1); W4-07 \ - added the worktree_id provenance scope key (audit-only)", + added the worktree_id provenance scope key (audit-only)", }, MutableStateSurface { table: "automation_log", @@ -363,7 +403,7 @@ pub const MUTABLE_STATE_OWNERSHIP: &[MutableStateSurface] = &[ table: "schema_versions", owner: StateOwner::Repository, rationale: "migration bookkeeping for this repository's database (created by the \ - migration runner in Rust, not by a .sql file)", + migration runner in Rust, not by a .sql file)", }, MutableStateSurface { table: "config", @@ -411,22 +451,22 @@ pub const MUTABLE_STATE_OWNERSHIP: &[MutableStateSurface] = &[ rationale: "object-graph side tables (repository-wide by definition)", }, MutableStateSurface { - table: "operation_parent", + table: "legacy_operation_parent", owner: StateOwner::Repository, rationale: "operation-log companions (the log itself is the Composite row above)", }, MutableStateSurface { - table: "operation_view", + table: "legacy_operation_view", owner: StateOwner::Repository, rationale: "operation-log companions (the log itself is the Composite row above)", }, MutableStateSurface { - table: "operation_view_ref", + table: "legacy_operation_view_ref", owner: StateOwner::Repository, rationale: "operation-log companions (the log itself is the Composite row above)", }, MutableStateSurface { - table: "operation_view_workspace", + table: "legacy_operation_view_workspace", owner: StateOwner::Repository, rationale: "operation-log companions (the log itself is the Composite row above)", }, @@ -492,6 +532,14 @@ pub const MIGRATION_ONLY_TABLES: &[&str] = &[ "layer__legacy_rows_need_explicit_adopt_2026072303", "operation__down_guard_2026073003", "operation__down_guard_2026073004", + "legacy_operation__staging", + "legacy_operation_parent__staging", + "legacy_operation_view__staging", + "legacy_operation_view_ref__staging", + "legacy_operation_view_workspace__staging", + "operation_view", + "operation_view_ref", + "operation_view_workspace", "operation_scope_provenance_down_guard", "rebase_state__down_guard_2026072101", "sequence_state__down_guard_2026071901", @@ -711,7 +759,7 @@ mod tests { .join("\n") } - /// Every `CREATE TABLE` in the SQL corpus whose body declares a + /// Every `CREATE TABLE` in the SQL/Rust DDL corpus whose body declares a /// `worktree_id` column, paired with the table name. fn tables_with_scope_column() -> BTreeSet { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -728,7 +776,6 @@ mod tests { } } collect(&manifest_dir.join("sql"), &mut corpus); - // Strip `--` line comments BEFORE parsing: this corpus documents // itself heavily, and a comment containing `;` or an unbalanced // paren would otherwise cut a column list short (one such comment — @@ -742,6 +789,12 @@ mod tests { .collect::>() .join("\n"); let mut scoped = BTreeSet::new(); + // `legacy_operation` is created by the copy-first Rust migration + // helper, whose final table name is assembled outside a standalone + // SQL file. Keep that known production shape in the scope inventory; + // the broader Rust corpus contains remote D1 schemas that are not + // part of the local repository database. + scoped.insert("legacy_operation".to_string()); for chunk in lowered.split("create table").skip(1) { let after = body_after_create_table(chunk); let name = table_name_after(chunk); @@ -784,6 +837,12 @@ mod tests { .chars() .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_') .collect(); + // Rust doc literals are included in the DDL corpus so generated + // SQL is visible, but prose such as `ALTER TABLE ADD COLUMN` + // does not name a table. Do not classify that keyword as one. + if matches!(name.as_str(), "add" | "drop" | "rename" | "if") { + continue; + } let statement = after.split(';').next().unwrap_or(""); if !name.is_empty() && statement.contains("add column") @@ -805,93 +864,93 @@ mod tests { #[test] fn rust_ddl_scanner_ignores_only_real_test_items() { const FIXTURE: &str = r##" - // A doc/line comment that merely MENTIONS #[cfg(test)] — the - // text stripper skipped to the next brace and ate this file. - fn commented_marker() { - let _ = "CREATE TABLE IF NOT EXISTS `after_comment` (id TEXT)"; - } + // A doc/line comment that merely MENTIONS #[cfg(test)] — the + // text stripper skipped to the next brace and ate this file. + fn commented_marker() { + let _ = "CREATE TABLE IF NOT EXISTS `after_comment` (id TEXT)"; + } - struct Mixed { - real: u8, - #[cfg(test)] - only_for_tests: u8, - also_real: u8, - } + struct Mixed { + real: u8, + #[cfg(test)] + only_for_tests: u8, + also_real: u8, + } - #[cfg(test)] - use std::collections::{BTreeMap, BTreeSet}; + #[cfg(test)] + use std::collections::{BTreeMap, BTreeSet}; - fn after_braced_use() { - let _ = "CREATE TABLE `after_use` (id TEXT)"; - } + fn after_braced_use() { + let _ = "CREATE TABLE `after_use` (id TEXT)"; + } - #[cfg(feature = "test-provider")] - fn feature_gated_is_production() { - let _ = "CREATE TABLE `after_feature_gate` (id TEXT)"; - } + #[cfg(feature = "test-provider")] + fn feature_gated_is_production() { + let _ = "CREATE TABLE `after_feature_gate` (id TEXT)"; + } - // MENTIONING `test` is not being test-only: this one is compiled - // in production and nowhere else. - #[cfg(not(test))] - fn production_only() { - let _ = "CREATE TABLE `not_test` (id TEXT)"; - } + // MENTIONING `test` is not being test-only: this one is compiled + // in production and nowhere else. + #[cfg(not(test))] + fn production_only() { + let _ = "CREATE TABLE `not_test` (id TEXT)"; + } - // Compiled in any debug build, test or not. - #[cfg(any(test, debug_assertions))] - fn test_or_debug() { - let _ = "CREATE TABLE `any_test_or_debug` (id TEXT)"; - } + // Compiled in any debug build, test or not. + #[cfg(any(test, debug_assertions))] + fn test_or_debug() { + let _ = "CREATE TABLE `any_test_or_debug` (id TEXT)"; + } - // A NAME-VALUE predicate BEFORE `test`: the predecessor walked - // nested meta with a callback and stopped on the first item it - // could not read, so ordering could hide the `test` term. - #[cfg(all(feature = "x", test))] - fn name_value_then_test() { - let _ = "CREATE TABLE `name_value_then_test` (id TEXT)"; - } + // A NAME-VALUE predicate BEFORE `test`: the predecessor walked + // nested meta with a callback and stopped on the first item it + // could not read, so ordering could hide the `test` term. + #[cfg(all(feature = "x", test))] + fn name_value_then_test() { + let _ = "CREATE TABLE `name_value_then_test` (id TEXT)"; + } - #[cfg(any(feature = "x", test))] - fn name_value_or_test() { - let _ = "CREATE TABLE `any_feature_or_test` (id TEXT)"; - } + #[cfg(any(feature = "x", test))] + fn name_value_or_test() { + let _ = "CREATE TABLE `any_feature_or_test` (id TEXT)"; + } - fn attributed_statements() { - #[cfg(test)] - { - let _ = "CREATE TABLE `stmt_test_only` (id TEXT)"; - } - #[cfg(not(test))] - { - let _ = "CREATE TABLE `stmt_production` (id TEXT)"; - } - #[cfg(test)] - let _fixture = "CREATE TABLE `local_test_only` (id TEXT)"; - // Macro statements in both directions. These are only - // meaningful because `visit_macro` scans macro ARGUMENTS — - // without that, neither string would be collected and the - // `Stmt::Macro` filter could not be mutation-tested. - #[cfg(all(test, unix))] - assert_eq!("CREATE TABLE `macro_stmt_test_only` (id TEXT)", ""); - let _ = format!("CREATE TABLE `macro_stmt_production` (id TEXT){}", ""); - } + fn attributed_statements() { + #[cfg(test)] + { + let _ = "CREATE TABLE `stmt_test_only` (id TEXT)"; + } + #[cfg(not(test))] + { + let _ = "CREATE TABLE `stmt_production` (id TEXT)"; + } + #[cfg(test)] + let _fixture = "CREATE TABLE `local_test_only` (id TEXT)"; + // Macro statements in both directions. These are only + // meaningful because `visit_macro` scans macro ARGUMENTS — + // without that, neither string would be collected and the + // `Stmt::Macro` filter could not be mutation-tested. + #[cfg(all(test, unix))] + assert_eq!("CREATE TABLE `macro_stmt_test_only` (id TEXT)", ""); + let _ = format!("CREATE TABLE `macro_stmt_production` (id TEXT){}", ""); + } - fn entity_ddl() { - let _ = schema.create_table_from_entity(object_index::Entity); - } + fn entity_ddl() { + let _ = schema.create_table_from_entity(object_index::Entity); + } - #[cfg(all(test, unix))] - mod tests { - fn fixture() { - let _ = "CREATE TABLE `fixture_only` (id TEXT)"; - let _ = schema.create_table_from_entity(fixture_entity::Entity); - } - } + #[cfg(all(test, unix))] + mod tests { + fn fixture() { + let _ = "CREATE TABLE `fixture_only` (id TEXT)"; + let _ = schema.create_table_from_entity(fixture_entity::Entity); + } + } - fn trailing_production() { - let _ = "CREATE TABLE `after_test_module` (id TEXT)"; - } - "##; + fn trailing_production() { + let _ = "CREATE TABLE `after_test_module` (id TEXT)"; + } + "##; let scanned = scan_rust_source("fixture.rs", FIXTURE); let tables = tables_in(&scanned.sql); @@ -909,7 +968,7 @@ mod tests { "stmt_production", ], "production DDL must survive every marker-shaped construct, and \ - test-module DDL must not" + test-module DDL must not" ); assert_eq!( scanned @@ -951,7 +1010,7 @@ mod tests { .query_all_raw(Statement::from_string( DbBackend::Sqlite, "SELECT name FROM sqlite_schema WHERE type = 'table' \ - AND name NOT LIKE 'sqlite_%' ORDER BY name", + AND name NOT LIKE 'sqlite_%' ORDER BY name", )) .await .expect("read the materialized schema"); @@ -975,8 +1034,8 @@ mod tests { declared.contains(table.as_str()) || MIGRATION_ONLY_TABLES.contains(&table.as_str()), "a REAL repository database contains table `{table}`, which is in \ - neither MUTABLE_STATE_OWNERSHIP nor MIGRATION_ONLY_TABLES — the \ - source scan missed it (plan-20260714 §C.4.1.1, line 2246)" + neither MUTABLE_STATE_OWNERSHIP nor MIGRATION_ONLY_TABLES — the \ + source scan missed it (plan-20260714 §C.4.1.1, line 2246)" ); } } @@ -993,7 +1052,7 @@ mod tests { assert!( scoped_in_schema.contains("sequence_state"), "scan self-check: the known scoped table `sequence_state` was not \ - found — the SQL scan is broken, not the schema" + found — the SQL scan is broken, not the schema" ); // Self-check for the RUST half of the corpus: `schema_versions` is @@ -1021,8 +1080,8 @@ mod tests { assert!( entity_built.contains("object_index"), "scan self-check: `object_index` (built from a sea-orm ENTITY in \ - src/command/cloud.rs, with no CREATE TABLE text) is missing — \ - entity-built DDL is no longer being read" + src/command/cloud.rs, with no CREATE TABLE text) is missing — \ + entity-built DDL is no longer being read" ); // And the non-repository exclusion must stay honest: each listed // file must exist AND actually issue DDL, or the entry is stale. @@ -1048,9 +1107,9 @@ mod tests { assert!( declared_scoped.contains(table.as_str()), "table `{table}` carries a `worktree_id` scope column but is not \ - declared in MUTABLE_STATE_OWNERSHIP — declare its \ - Repository|Worktree|Composite ownership (plan-20260714 §C.4.1.1, \ - line 2246)" + declared in MUTABLE_STATE_OWNERSHIP — declare its \ + Repository|Worktree|Composite ownership (plan-20260714 §C.4.1.1, \ + line 2246)" ); } @@ -1060,8 +1119,8 @@ mod tests { StateOwner::Worktree | StateOwner::Composite => assert!( scoped_in_schema.contains(surface.table), "`{}` is declared scope-carrying but no CREATE TABLE in the SQL \ - corpus gives it a `worktree_id` column — a declaration that \ - outran the schema", + corpus gives it a `worktree_id` column — a declaration that \ + outran the schema", surface.table ), StateOwner::Repository => {} @@ -1096,9 +1155,9 @@ mod tests { assert!( declared.contains(table.as_str()) || migration_only.contains(table.as_str()), "mutable table `{table}` is not classified: add a row to \ - MUTABLE_STATE_OWNERSHIP with its Repository|Worktree|Composite \ - ownership, or — if a single migration creates AND drops it — to \ - MIGRATION_ONLY_TABLES (plan-20260714 §C.4.1.1, line 2246)" + MUTABLE_STATE_OWNERSHIP with its Repository|Worktree|Composite \ + ownership, or — if a single migration creates AND drops it — to \ + MIGRATION_ONLY_TABLES (plan-20260714 §C.4.1.1, line 2246)" ); } // ...and no registry row may name a table the schema never creates diff --git a/src/internal/operation/facet.rs b/src/internal/operation/facet.rs new file mode 100644 index 000000000..9d16b6b69 --- /dev/null +++ b/src/internal/operation/facet.rs @@ -0,0 +1,398 @@ +//! Uniform capture and restore contracts for mutable repository state. +//! +//! A facet owns one part of repository state. Registering facets centrally +//! lets snapshot and restore code fail closed when a new mutable state owner +//! has not yet supplied capture/validation/restore semantics. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, +}; + +use git_internal::hash::ObjectHash; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Stable name of a state facet. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct FacetName(String); + +impl FacetName { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for FacetName { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for FacetName { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl fmt::Display for FacetName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// How a facet participates in recovery. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RestorePolicy { + AutoRestore, + Rebuild, + NeverRestore, +} + +/// A captured facet payload and its bounded metadata. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FacetCapture { + pub facet: FacetName, + pub schema_version: u32, + pub payload_oid: Option, + pub meta: serde_json::Value, +} + +/// Context supplied to a facet while capturing state. +#[derive(Debug, Default)] +pub struct FacetCaptureCtx { + pub repo_id: Option, + pub workspace_id: Option, +} + +/// Context supplied to a facet while restoring state. +#[derive(Debug, Default)] +pub struct FacetRestoreCtx { + pub repo_id: Option, + pub workspace_id: Option, +} + +/// Semantic facet delta used by future `op revert` implementations. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FacetDiff { + pub changes: serde_json::Value, +} + +/// Errors returned by facet implementations and the registry boundary. +#[derive(Debug, Error)] +pub enum FacetError { + #[error("facet '{0}' is not registered")] + Unregistered(FacetName), + #[error("facet name must not be empty")] + EmptyName, + #[error("facet '{facet}' returned a capture for '{returned}'")] + NameMismatch { + facet: FacetName, + returned: FacetName, + }, + #[error("facet '{facet}' schema version mismatch: expected {expected}, got {actual}")] + SchemaVersionMismatch { + facet: FacetName, + expected: u32, + actual: u32, + }, + #[error("facet metadata contains a floating-point number")] + NonCanonicalMetadata, + #[error("facet capture is not fully registered")] + IncompleteCapture, + #[error("facet capture failed: {0}")] + Capture(String), + #[error("facet validation failed: {0}")] + Validation(String), + #[error("facet restore failed: {0}")] + Restore(String), + #[error("facet diff failed: {0}")] + Diff(String), +} + +/// Registry of every mutable state owner known to the operation layer. +#[derive(Default)] +pub struct FacetRegistry { + facets: BTreeMap>, +} + +impl fmt::Debug for FacetRegistry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FacetRegistry") + .field("facets", &self.facets.keys().collect::>()) + .finish() + } +} + +impl FacetRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, facet: Box) -> Result<(), FacetError> { + let name = facet.name(); + if name.as_str().trim().is_empty() { + return Err(FacetError::EmptyName); + } + if self.facets.contains_key(&name) { + return Err(FacetError::Validation(format!( + "facet '{name}' was registered more than once" + ))); + } + self.facets.insert(name, facet); + Ok(()) + } + + pub fn get(&self, name: &FacetName) -> Option<&dyn StateFacet> { + self.facets.get(name).map(Box::as_ref) + } + + pub fn len(&self) -> usize { + self.facets.len() + } + + pub fn is_empty(&self) -> bool { + self.facets.is_empty() + } + + /// Capture one registered facet and validate the returned envelope before + /// it can be included in a fully-restorable snapshot. + pub fn capture( + &self, + name: &FacetName, + ctx: &FacetCaptureCtx, + ) -> Result { + let facet = self + .get(name) + .ok_or_else(|| FacetError::Unregistered(name.clone()))?; + let capture = facet.capture(ctx)?; + self.validate_capture(&capture)?; + Ok(capture) + } + + pub fn validate_capture(&self, capture: &FacetCapture) -> Result<(), FacetError> { + let facet = self + .get(&capture.facet) + .ok_or_else(|| FacetError::Unregistered(capture.facet.clone()))?; + if capture.schema_version != facet.schema_version() { + return Err(FacetError::SchemaVersionMismatch { + facet: capture.facet.clone(), + expected: facet.schema_version(), + actual: capture.schema_version, + }); + } + validate_metadata(&capture.meta)?; + facet.validate(capture) + } + + /// Unknown or unregistered facets are never considered fully restorable. + pub fn is_fully_restorable(&self, captures: &[FacetCapture]) -> bool { + self.validate_captures(captures).is_ok() + && captures.iter().all(|capture| { + self.get(&capture.facet) + .is_some_and(|facet| facet.restore_policy() != RestorePolicy::NeverRestore) + }) + } + + /// Validate a complete capture set before it can be used for restore. + /// + /// The registry is the source of truth for the mutable-state surface. + /// Requiring an exact, duplicate-free set prevents an omitted facet from + /// being mistaken for a clean snapshot. Individual captures are passed + /// through the same schema, metadata, and facet-specific validation used + /// by capture. + pub fn validate_captures(&self, captures: &[FacetCapture]) -> Result<(), FacetError> { + if captures.is_empty() || captures.len() != self.facets.len() { + return Err(FacetError::IncompleteCapture); + } + let mut names = BTreeSet::new(); + for capture in captures { + if !names.insert(capture.facet.clone()) { + return Err(FacetError::IncompleteCapture); + } + self.validate_capture(capture)?; + } + if names.len() != self.facets.len() || self.facets.keys().any(|name| !names.contains(name)) + { + return Err(FacetError::IncompleteCapture); + } + Ok(()) + } + + pub fn policies(&self, captures: &[FacetCapture]) -> BTreeMap { + captures + .iter() + .filter_map(|capture| { + self.get(&capture.facet) + .map(|facet| (capture.facet.clone(), facet.restore_policy())) + }) + .collect() + } +} + +/// Trait implemented by each mutable state owner. +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; +} + +fn validate_metadata(value: &serde_json::Value) -> Result<(), FacetError> { + match value { + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => { + Ok(()) + } + serde_json::Value::Number(number) => { + if number.is_i64() || number.is_u64() { + Ok(()) + } else { + Err(FacetError::NonCanonicalMetadata) + } + } + serde_json::Value::Array(values) => values.iter().try_for_each(validate_metadata), + serde_json::Value::Object(values) => values.values().try_for_each(validate_metadata), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestFacet { + policy: RestorePolicy, + } + + impl StateFacet for TestFacet { + fn name(&self) -> FacetName { + FacetName::from("test") + } + + 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: None, + meta: serde_json::json!({"count": 1}), + }) + } + + fn validate(&self, _capture: &FacetCapture) -> Result<(), FacetError> { + Ok(()) + } + + fn restore( + &self, + _capture: &FacetCapture, + _ctx: &mut FacetRestoreCtx, + ) -> Result<(), FacetError> { + Ok(()) + } + + fn diff(&self, _from: &FacetCapture, _to: &FacetCapture) -> Result { + Ok(FacetDiff { + changes: serde_json::json!({}), + }) + } + + fn roots(&self, _capture: &FacetCapture) -> Vec { + Vec::new() + } + } + + #[test] + fn registry_rejects_unregistered_capture() { + let registry = FacetRegistry::new(); + let error = registry + .capture(&FacetName::from("missing"), &FacetCaptureCtx::default()) + .expect_err("unregistered facets must fail closed"); + assert!(matches!(error, FacetError::Unregistered(_))); + } + + #[test] + fn never_restore_facet_is_not_fully_restorable() { + let mut registry = FacetRegistry::new(); + registry + .register(Box::new(TestFacet { + policy: RestorePolicy::NeverRestore, + })) + .expect("register facet"); + let capture = registry + .capture(&FacetName::from("test"), &FacetCaptureCtx::default()) + .expect("capture facet"); + assert!(!registry.is_fully_restorable(&[capture])); + } + + #[test] + fn floating_point_metadata_is_rejected() { + let mut registry = FacetRegistry::new(); + registry + .register(Box::new(TestFacet { + policy: RestorePolicy::AutoRestore, + })) + .expect("register facet"); + let capture = FacetCapture { + facet: FacetName::from("test"), + schema_version: 1, + payload_oid: None, + meta: serde_json::json!({"ratio": 1.5}), + }; + assert!(matches!( + registry.validate_capture(&capture), + Err(FacetError::NonCanonicalMetadata) + )); + } + + #[test] + fn incomplete_capture_sets_fail_closed() { + let mut registry = FacetRegistry::new(); + registry + .register(Box::new(TestFacet { + policy: RestorePolicy::AutoRestore, + })) + .expect("register facet"); + assert!(!registry.is_fully_restorable(&[])); + let capture = registry + .capture(&FacetName::from("test"), &FacetCaptureCtx::default()) + .expect("capture facet"); + assert!(registry.is_fully_restorable(&[capture])); + } + + #[test] + fn invalid_capture_cannot_be_fully_restorable() { + let mut registry = FacetRegistry::new(); + registry + .register(Box::new(TestFacet { + policy: RestorePolicy::AutoRestore, + })) + .expect("register facet"); + let capture = FacetCapture { + facet: FacetName::from("test"), + schema_version: 1, + payload_oid: None, + meta: serde_json::json!({"ratio": 1.5}), + }; + assert!(!registry.is_fully_restorable(&[capture])); + } +} diff --git a/src/internal/operation/mod.rs b/src/internal/operation/mod.rs new file mode 100644 index 000000000..19ae2f6d2 --- /dev/null +++ b/src/internal/operation/mod.rs @@ -0,0 +1,21 @@ +//! Version 2 operation-log primitives. + +pub mod facet; +pub mod store; +pub mod view; + +// OL-15 removes this compatibility service. Re-exporting it keeps existing +// command integrations source-compatible while all new code uses v2 types. +pub use facet::{ + FacetCapture, FacetCaptureCtx, FacetDiff, FacetError, FacetName, FacetRegistry, + FacetRestoreCtx, RestorePolicy, +}; +pub use store::{ + JournalEntry, JournalPhase, OpHeadsView, OperationKind, OperationMetaV2, OperationStatusV2, + OperationStoreV2, OperationV2, StoreError, +}; +pub use view::{ + CapturePolicy, Completeness, HeadState, RepoViewV2, WorkspaceId, WorkspaceSnapshotV2, +}; + +pub use crate::internal::legacy_operation::*; diff --git a/src/internal/operation/store.rs b/src/internal/operation/store.rs new file mode 100644 index 000000000..b61307900 --- /dev/null +++ b/src/internal/operation/store.rs @@ -0,0 +1,908 @@ +//! Durable storage for the v2 operation DAG. +//! +//! The operation row is deliberately small and redacted. View manifests are +//! content-addressed objects, while SQLite stores the searchable DAG edges, +//! head generations, and recovery journal. A publish therefore follows one +//! ordering rule: write immutable objects first, then publish the relational +//! rows in a write-locked transaction. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + str::FromStr, +}; + +use chrono::Utc; +use git_internal::{hash::ObjectHash, internal::object::types::ObjectType}; +use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, DbErr, QueryResult, Statement}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + internal::{ + db::begin_write_transaction, + operation::view::{RepoViewV2, ViewError}, + }, + utils::client_storage::ClientStorage, +}; + +/// The semantic kind of an operation-log entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationKind { + Command, + ExternalSnapshot, + Undo, + Redo, + Restore, + Revert, + Reconcile, +} + +impl OperationKind { + 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", + } + } +} + +impl fmt::Display for OperationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for OperationKind { + type Err = StoreError; + + fn from_str(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), + _ => Err(StoreError::InvalidEnum { + field: "operation kind", + value: value.to_string(), + }), + } + } +} + +/// Lifecycle state persisted for an operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationStatusV2 { + Running, + Success, + Failed, + Partial, + Aborted, +} + +impl OperationStatusV2 { + fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Success => "success", + Self::Failed => "failed", + Self::Partial => "partial", + Self::Aborted => "aborted", + } + } +} + +impl fmt::Display for OperationStatusV2 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for OperationStatusV2 { + type Err = StoreError; + + fn from_str(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), + _ => Err(StoreError::InvalidEnum { + field: "operation status", + value: value.to_string(), + }), + } + } +} + +/// Searchable, redacted operation metadata. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperationMetaV2 { + pub command_name: Option, + pub description: Option, + pub args_digest: Option, + pub actor: Option, + pub causal_context_id: Option, +} + +/// Immutable operation payload stored alongside the operation row. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperationV2 { + pub op_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, +} + +/// Recovery journal phase. Phases are monotonic for a given journal id. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JournalPhase { + Reserved, + PreView, + Mutation, + PostView, + Publish, +} + +impl JournalPhase { + fn rank(self) -> u8 { + match self { + Self::Reserved => 0, + Self::PreView => 1, + Self::Mutation => 2, + Self::PostView => 3, + Self::Publish => 4, + } + } + + 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", + } + } +} + +impl fmt::Display for JournalPhase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for JournalPhase { + type Err = StoreError; + + fn from_str(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), + _ => Err(StoreError::InvalidEnum { + field: "journal phase", + value: value.to_string(), + }), + } + } +} + +/// One durable recovery-journal record. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct JournalEntry { + 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, +} + +/// The currently published heads and their generation numbers. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OpHeadsView { + pub heads: BTreeMap, + /// Direct parent edges, keyed by child operation id. Keeping these in + /// the view lets working-copy freshness distinguish a sibling from a + /// descendant without making a database query on every check. + pub ancestors: BTreeMap>, +} + +impl OpHeadsView { + pub fn new(heads: Vec) -> Result { + Self::with_generations(heads.into_iter().map(|head| (head, 0)).collect()) + } + + pub fn with_generations(heads: Vec<(String, u64)>) -> Result { + let mut view = Self::default(); + for (head, generation) in heads { + if head.is_empty() { + return Err(StoreError::Validation( + "operation head id cannot be empty".to_string(), + )); + } + if view.heads.insert(head.clone(), generation).is_some() { + return Err(StoreError::Validation(format!( + "duplicate operation head id '{head}'" + ))); + } + } + Ok(view) + } + + pub fn head_ids(&self) -> Vec { + self.heads.keys().cloned().collect() + } + + pub fn generation(&self, op_id: &str) -> Option { + self.heads.get(op_id).copied() + } + + pub fn add_ancestor( + &mut self, + child_op_id: impl Into, + parent_op_id: impl Into, + ) -> Result<(), StoreError> { + let child_op_id = child_op_id.into(); + let parent_op_id = parent_op_id.into(); + if child_op_id.is_empty() || parent_op_id.is_empty() { + return Err(StoreError::Validation( + "operation parent ids cannot be empty".to_string(), + )); + } + self.ancestors + .entry(child_op_id) + .or_default() + .insert(parent_op_id); + Ok(()) + } + + /// Returns true when `ancestor` is the same operation or is reachable by + /// following parent edges from `descendant`. + pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> bool { + if ancestor == descendant { + return true; + } + let mut pending = vec![descendant.to_string()]; + let mut visited = BTreeSet::new(); + while let Some(current) = pending.pop() { + if !visited.insert(current.clone()) { + continue; + } + if let Some(parents) = self.ancestors.get(¤t) { + if parents.contains(ancestor) { + return true; + } + pending.extend(parents.iter().cloned()); + } + } + false + } +} + +#[derive(Debug, Error)] +pub enum StoreError { + #[error("database error: {0}")] + Database(#[from] DbErr), + #[error("object storage error: {0}")] + Object(String), + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + #[error("invalid object hash '{0}'")] + InvalidObjectHash(String), + #[error("invalid {field}: {value}")] + InvalidEnum { field: &'static str, value: String }, + #[error("compare-and-swap conflict; current heads: {current_heads:?}")] + CasConflict { current_heads: Vec }, + #[error("validation error: {0}")] + Validation(String), + #[error("view error: {0}")] + View(#[from] ViewError), + #[error("operation or journal entry not found: {0}")] + NotFound(String), +} + +/// SQLite plus content-addressed object storage for operation-log v2. +#[derive(Clone)] +pub struct OperationStoreV2 { + db: DatabaseConnection, + storage: ClientStorage, + repo_id: String, +} + +const HEAD_GENERATION_SENTINEL: &str = "__scope_generation__"; + +impl OperationStoreV2 { + /// Construct a store without a repository id. Use [`Self::for_repo`] or + /// [`Self::new_for_repo`] for writes; the empty-id constructor is useful + /// for view-only callers and keeps database/storage wiring lightweight. + pub fn new(db: DatabaseConnection, storage: ClientStorage) -> Self { + Self { + db, + storage, + repo_id: String::new(), + } + } + + pub fn new_for_repo( + repo_id: impl Into, + db: DatabaseConnection, + storage: ClientStorage, + ) -> Self { + Self { + db, + storage, + repo_id: repo_id.into(), + } + } + + pub fn for_repo(mut self, repo_id: impl Into) -> Self { + self.repo_id = repo_id.into(); + self + } + + pub fn db(&self) -> &DatabaseConnection { + &self.db + } + + pub fn write_view_manifest(&self, view: &RepoViewV2) -> Result { + view.validate_recursive_closure(|oid| self.storage.get(oid).ok())?; + let bytes = view.to_canonical_bytes()?; + let oid = ObjectHash::from_type_and_data(ObjectType::Blob, &bytes); + self.storage + .put(&oid, &bytes, ObjectType::Blob) + .map_err(|error| StoreError::Object(error.to_string()))?; + Ok(oid) + } + + pub fn load_view(&self, oid: &ObjectHash) -> Result { + let bytes = self + .storage + .get(oid) + .map_err(|error| StoreError::Object(error.to_string()))?; + RepoViewV2::from_canonical_bytes(&bytes).map_err(StoreError::View) + } + + pub async fn write_operation(&self, operation: &OperationV2) -> Result<(), StoreError> { + if self.repo_id.is_empty() { + return Err(StoreError::Validation( + "operation store repository id cannot be empty".to_string(), + )); + } + if operation.op_id.is_empty() || operation.op_id == HEAD_GENERATION_SENTINEL { + return Err(StoreError::Validation( + "operation id cannot be empty".to_string(), + )); + } + validate_parent_ids(&operation.op_id, &operation.parent_op_ids)?; + + let txn = begin_write_transaction(&self.db).await?; + let start_ts = Utc::now().timestamp_millis(); + let insert_result = txn + .execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO operation (op_id, repo_id, format_version, kind, status, \ + command_name, description, args_digest, actor, worktree_id, scope_kind, \ + pre_view_oid, post_view_oid, restores_op_id, reverts_op_id, \ + predecessor_map_oid, causal_context_id, start_ts, end_ts) \ + VALUES (?, ?, 2, ?, ?, ?, ?, ?, ?, NULL, 'repository', ?, ?, ?, ?, ?, ?, ?, NULL)", + [ + operation.op_id.clone().into(), + self.repo_id.clone().into(), + operation.kind.to_string().into(), + operation.status.to_string().into(), + operation.metadata.command_name.clone().into(), + operation.metadata.description.clone().into(), + operation.metadata.args_digest.clone().into(), + operation.metadata.actor.clone().into(), + operation.pre_view_oid.to_string().into(), + operation.post_view_oid.to_string().into(), + operation.restores_op_id.clone().into(), + operation.reverts_op_id.clone().into(), + operation + .predecessor_map_oid + .map(|oid| oid.to_string()) + .into(), + operation.metadata.causal_context_id.clone().into(), + start_ts.into(), + ], + )) + .await; + if let Err(error) = insert_result { + let _ = txn.rollback().await; + return Err(StoreError::Database(error)); + } + + for (ordinal, parent_op_id) in operation.parent_op_ids.iter().enumerate() { + if let Err(error) = txn + .execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO operation_parent (op_id, parent_op_id, ordinal) VALUES (?, ?, ?)", + [ + operation.op_id.clone().into(), + parent_op_id.clone().into(), + (ordinal as i64).into(), + ], + )) + .await + { + let _ = txn.rollback().await; + return Err(StoreError::Database(error)); + } + } + txn.commit().await?; + Ok(()) + } + + /// Atomically replaces the head set if its current value equals + /// `expected_heads`. A conflict leaves the database untouched. + pub async fn cas_update_op_heads( + &self, + repo_id: &str, + scope_key: &str, + expected_heads: &[String], + new_heads: &[String], + ) -> Result { + self.cas_update_op_heads_inner(repo_id, scope_key, expected_heads, new_heads, None) + .await + } + + /// Strict CAS with an explicit scope generation token. + /// + /// The generation is persisted in the same operation_head table even when + /// the logical head set is empty, so an A -> B -> A sequence cannot pass + /// an old expected token. + pub async fn cas_update_op_heads_at_generation( + &self, + repo_id: &str, + scope_key: &str, + expected_generation: u64, + expected_heads: &[String], + new_heads: &[String], + ) -> Result { + self.cas_update_op_heads_inner( + repo_id, + scope_key, + expected_heads, + new_heads, + Some(expected_generation), + ) + .await + } + + async fn cas_update_op_heads_inner( + &self, + repo_id: &str, + scope_key: &str, + expected_heads: &[String], + new_heads: &[String], + expected_generation: Option, + ) -> Result { + let expected = normalize_heads(expected_heads)?; + let replacement = normalize_heads(new_heads)?; + let txn = begin_write_transaction(&self.db).await?; + let current_rows = match query_head_rows(&txn, repo_id, scope_key).await { + Ok(rows) => rows, + Err(error) => { + let _ = txn.rollback().await; + return Err(error); + } + }; + let current_heads: Vec = current_rows + .iter() + .map(|(op_id, _)| op_id.clone()) + .collect(); + let current_generation = match query_scope_generation(&txn, repo_id, scope_key).await { + Ok(generation) => generation, + Err(error) => { + let _ = txn.rollback().await; + return Err(error); + } + }; + if current_heads != expected + || expected_generation.is_some_and(|generation| generation != current_generation) + { + let _ = txn.rollback().await; + return Err(StoreError::CasConflict { current_heads }); + } + + let generation = current_generation.saturating_add(1); + if let Err(error) = + replace_head_rows(&txn, repo_id, scope_key, generation, &replacement).await + { + let _ = txn.rollback().await; + return Err(error); + } + txn.commit().await?; + Ok(generation) + } + + /// Publish a candidate head set, preserving a concurrent candidate when + /// the caller's expected set is stale. This is deliberately separate from + /// strict CAS: callers opt into sibling retention instead of silently + /// weakening the compare-and-swap contract. + pub async fn merge_op_heads( + &self, + repo_id: &str, + scope_key: &str, + expected_heads: &[String], + candidate_heads: &[String], + ) -> Result { + let expected = normalize_heads(expected_heads)?; + let candidate = normalize_heads(candidate_heads)?; + let txn = begin_write_transaction(&self.db).await?; + let current_rows = match query_head_rows(&txn, repo_id, scope_key).await { + Ok(rows) => rows, + Err(error) => { + let _ = txn.rollback().await; + return Err(error); + } + }; + let current_heads: Vec = current_rows + .iter() + .map(|(op_id, _)| op_id.clone()) + .collect(); + let replacement = if current_heads == expected { + candidate + } else { + current_heads + .into_iter() + .chain(candidate) + .collect::>() + .into_iter() + .collect() + }; + let current_generation = match query_scope_generation(&txn, repo_id, scope_key).await { + Ok(generation) => generation, + Err(error) => { + let _ = txn.rollback().await; + return Err(error); + } + }; + let generation = current_generation.saturating_add(1); + if let Err(error) = + replace_head_rows(&txn, repo_id, scope_key, generation, &replacement).await + { + let _ = txn.rollback().await; + return Err(error); + } + txn.commit().await?; + Ok(generation) + } + + pub async fn read_heads( + &self, + repo_id: &str, + scope_key: &str, + ) -> Result, StoreError> { + Ok(query_head_rows(&self.db, repo_id, scope_key) + .await? + .into_iter() + .map(|(op_id, _)| op_id) + .collect()) + } + + pub async fn read_head_generation( + &self, + repo_id: &str, + scope_key: &str, + ) -> Result { + query_scope_generation(&self.db, repo_id, scope_key).await + } + + pub async fn read_heads_view( + &self, + repo_id: &str, + scope_key: &str, + ) -> Result { + let rows = query_head_rows(&self.db, repo_id, scope_key).await?; + let mut view = OpHeadsView::with_generations(rows)?; + let parent_rows = self + .db + .query_all_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT op_id, parent_op_id FROM operation_parent ORDER BY op_id, ordinal", + [], + )) + .await?; + for row in parent_rows { + view.add_ancestor( + row.try_get_by_index::(0)?, + row.try_get_by_index::(1)?, + )?; + } + Ok(view) + } + + pub async fn append_journal(&self, entry: &JournalEntry) -> Result<(), StoreError> { + if entry.journal_id.is_empty() || entry.op_id.is_empty() || entry.owner.is_empty() { + return Err(StoreError::Validation( + "journal id, operation id, and owner cannot be empty".to_string(), + )); + } + let txn = begin_write_transaction(&self.db).await?; + let current_phase = txn + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT phase FROM operation_journal WHERE journal_id = ?", + [entry.journal_id.clone().into()], + )) + .await? + .map(|row| row.try_get_by_index::(0)) + .transpose()? + .map(|phase| phase.parse::()) + .transpose()?; + if let Some(current_phase) = current_phase + && entry.phase.rank() < current_phase.rank() + { + let _ = txn.rollback().await; + return Err(StoreError::Validation(format!( + "journal phase cannot move backwards from {current_phase} to {}", + entry.phase + ))); + } + if let Err(error) = txn.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO operation_journal (journal_id, op_id, phase, pre_view_oid, \ + target_view_oid, owner, updated_at, recovery_payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(journal_id) DO UPDATE SET op_id = excluded.op_id, \ + phase = excluded.phase, pre_view_oid = excluded.pre_view_oid, \ + target_view_oid = excluded.target_view_oid, owner = excluded.owner, \ + updated_at = excluded.updated_at, recovery_payload = excluded.recovery_payload", + [ + entry.journal_id.clone().into(), + entry.op_id.clone().into(), + entry.phase.to_string().into(), + entry.pre_view_oid.map(|oid| oid.to_string()).into(), + entry.target_view_oid.map(|oid| oid.to_string()).into(), + entry.owner.clone().into(), + entry.updated_at.into(), + entry.recovery_payload.clone().into(), + ], + )) + .await + { + let _ = txn.rollback().await; + return Err(StoreError::Database(error)); + } + txn.commit().await?; + Ok(()) + } + + pub async fn read_journal(&self, op_id: &str) -> Result, StoreError> { + let rows = self + .db + .query_all_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT journal_id, op_id, phase, pre_view_oid, target_view_oid, owner, \ + updated_at, recovery_payload FROM operation_journal WHERE op_id = ? \ + ORDER BY updated_at ASC, journal_id ASC", + [op_id.into()], + )) + .await?; + rows.into_iter().map(journal_from_row).collect() + } +} + +async fn query_head_rows( + db: &C, + repo_id: &str, + scope_key: &str, +) -> Result, StoreError> { + let rows = db + .query_all_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT op_id, generation FROM operation_head WHERE repo_id = ? AND scope_key = ? \ + AND op_id <> ? \ + ORDER BY op_id", + [ + repo_id.into(), + scope_key.into(), + HEAD_GENERATION_SENTINEL.into(), + ], + )) + .await?; + rows.into_iter() + .map(|row| { + Ok(( + row.try_get_by_index::(0)?, + row.try_get_by_index::(1)? as u64, + )) + }) + .collect() +} + +async fn query_scope_generation( + db: &C, + repo_id: &str, + scope_key: &str, +) -> Result { + let row = db + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT generation FROM operation_head WHERE repo_id = ? AND scope_key = ? \ + AND op_id = ?", + [ + repo_id.into(), + scope_key.into(), + HEAD_GENERATION_SENTINEL.into(), + ], + )) + .await?; + if let Some(row) = row { + return Ok(row.try_get_by_index::(0)? as u64); + } + let row = db + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT COALESCE(MAX(generation), 0) FROM operation_head \ + WHERE repo_id = ? AND scope_key = ? AND op_id <> ?", + [ + repo_id.into(), + scope_key.into(), + HEAD_GENERATION_SENTINEL.into(), + ], + )) + .await?; + Ok(row + .map(|row| row.try_get_by_index::(0)) + .transpose()? + .unwrap_or(0) as u64) +} + +async fn replace_head_rows( + db: &C, + repo_id: &str, + scope_key: &str, + generation: u64, + replacement: &[String], +) -> Result<(), StoreError> { + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM operation_head WHERE repo_id = ? AND scope_key = ? AND op_id <> ?", + [ + repo_id.into(), + scope_key.into(), + HEAD_GENERATION_SENTINEL.into(), + ], + )) + .await?; + for op_id in replacement { + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO operation_head (repo_id, scope_key, op_id, generation) \ + VALUES (?, ?, ?, ?)", + [ + repo_id.into(), + scope_key.into(), + op_id.clone().into(), + generation.into(), + ], + )) + .await?; + } + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO operation_head (repo_id, scope_key, op_id, generation) \ + VALUES (?, ?, ?, ?) ON CONFLICT(repo_id, scope_key, op_id) \ + DO UPDATE SET generation = excluded.generation", + [ + repo_id.into(), + scope_key.into(), + HEAD_GENERATION_SENTINEL.into(), + generation.into(), + ], + )) + .await?; + Ok(()) +} + +fn journal_from_row(row: QueryResult) -> Result { + Ok(JournalEntry { + journal_id: row.try_get_by_index(0)?, + op_id: row.try_get_by_index(1)?, + phase: row.try_get_by_index::(2)?.parse()?, + pre_view_oid: parse_optional_hash(row.try_get_by_index(3)?)?, + target_view_oid: parse_optional_hash(row.try_get_by_index(4)?)?, + owner: row.try_get_by_index(5)?, + updated_at: row.try_get_by_index(6)?, + recovery_payload: row.try_get_by_index(7)?, + }) +} + +fn parse_optional_hash(value: Option) -> Result, StoreError> { + value + .map(|value| { + value + .parse() + .map_err(|_| StoreError::InvalidObjectHash(value)) + }) + .transpose() +} + +fn validate_parent_ids(op_id: &str, parent_ids: &[String]) -> Result<(), StoreError> { + let mut seen = BTreeSet::new(); + for parent_id in parent_ids { + if parent_id.is_empty() { + return Err(StoreError::Validation( + "operation parent id cannot be empty".to_string(), + )); + } + if parent_id == op_id { + return Err(StoreError::Validation( + "operation cannot be its own parent".to_string(), + )); + } + if parent_id == HEAD_GENERATION_SENTINEL { + return Err(StoreError::Validation( + "reserved operation head id cannot be a parent".to_string(), + )); + } + if !seen.insert(parent_id) { + return Err(StoreError::Validation(format!( + "duplicate operation parent id '{parent_id}'" + ))); + } + } + Ok(()) +} + +fn normalize_heads(heads: &[String]) -> Result, StoreError> { + let mut normalized = BTreeSet::new(); + for head in heads { + if head.is_empty() || head == HEAD_GENERATION_SENTINEL { + return Err(StoreError::Validation( + "operation head id cannot be empty".to_string(), + )); + } + if !normalized.insert(head.clone()) { + return Err(StoreError::Validation(format!( + "duplicate operation head id '{head}'" + ))); + } + } + Ok(normalized.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn head_ancestry_is_transitive() { + let mut heads = OpHeadsView::new(vec!["c".to_string()]).expect("valid head"); + heads.add_ancestor("c", "b").expect("valid edge"); + heads.add_ancestor("b", "a").expect("valid edge"); + assert!(heads.is_ancestor("a", "c")); + assert!(!heads.is_ancestor("c", "a")); + } + + #[test] + fn duplicate_heads_are_rejected() { + let result = OpHeadsView::new(vec!["same".to_string(), "same".to_string()]); + assert!(matches!(result, Err(StoreError::Validation(_)))); + } +} diff --git a/src/internal/operation/view.rs b/src/internal/operation/view.rs new file mode 100644 index 000000000..629ab057e --- /dev/null +++ b/src/internal/operation/view.rs @@ -0,0 +1,382 @@ +//! Canonical, content-addressed v2 repository and workspace manifests. + +use std::collections::{BTreeMap, BTreeSet}; + +use git_internal::hash::ObjectHash; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::facet::{FacetName, RestorePolicy}; + +pub const REPO_VIEW_SCHEMA_VERSION: u32 = 2; +pub const WORKSPACE_SNAPSHOT_SCHEMA_VERSION: u32 = 2; + +pub type WorkspaceId = String; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum HeadState { + Symbolic { reference: String }, + Detached { oid: ObjectHash }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapturePolicy { + Tracked, + TrackedAndUntracked, + FailClosed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Completeness { + Full, + Partial, +} + +#[derive(Clone, Debug, 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(Clone, Debug, 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)] +pub enum ViewError { + #[error("unsupported repository view schema version {0}; expected {REPO_VIEW_SCHEMA_VERSION}")] + UnknownRepoSchema(u32), + #[error( + "unsupported workspace snapshot schema version {0}; expected {WORKSPACE_SNAPSHOT_SCHEMA_VERSION}" + )] + UnknownWorkspaceSchema(u32), + #[error("repository view has an empty repo_id")] + EmptyRepoId, + #[error("workspace snapshot has an empty workspace_id")] + EmptyWorkspaceId, + #[error("canonical manifest JSON is invalid: {0}")] + Json(#[from] serde_json::Error), + #[error("manifest is not in canonical JSON form")] + NonCanonical, + #[error("manifest object closure is missing {0}")] + MissingObject(ObjectHash), + #[error("workspace manifest {oid} is not canonical: {source}")] + InvalidWorkspaceManifest { + oid: ObjectHash, + source: Box, + }, +} + +impl RepoViewV2 { + pub fn validate(&self) -> Result<(), ViewError> { + if self.schema_version != REPO_VIEW_SCHEMA_VERSION { + return Err(ViewError::UnknownRepoSchema(self.schema_version)); + } + if self.repo_id.trim().is_empty() { + return Err(ViewError::EmptyRepoId); + } + Ok(()) + } + + pub fn to_canonical_bytes(&self) -> Result, ViewError> { + self.validate()?; + let mut canonical = self.clone(); + canonical.change_roots.sort(); + Ok(serde_json::to_vec(&canonical)?) + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> Result { + let view: Self = serde_json::from_slice(bytes)?; + view.validate()?; + if view.to_canonical_bytes()? != bytes { + return Err(ViewError::NonCanonical); + } + Ok(view) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + Self::from_canonical_bytes(bytes) + } + + pub fn roots(&self) -> Vec { + let mut roots = std::collections::BTreeSet::new(); + roots.insert(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.into_iter().collect() + } + + pub fn validate_closed(&self, mut has_object: F) -> Result<(), ViewError> + where + F: FnMut(&ObjectHash) -> bool, + { + self.validate()?; + for root in self.roots() { + if !has_object(&root) { + return Err(ViewError::MissingObject(root)); + } + } + Ok(()) + } + + pub fn validate_closure(&self, has_object: F) -> Result<(), ViewError> + where + F: FnMut(&ObjectHash) -> bool, + { + self.validate_closed(has_object) + } + + /// Validate the transitive repository-view closure using an object loader. + /// + /// The direct roots remain opaque content-addressed objects, but workspace + /// roots are typed WorkspaceSnapshotV2 manifests and must themselves be + /// decoded and checked. This keeps a valid repository view from anchoring + /// only its top-level blob while losing the snapshot objects it names. + pub fn validate_recursive_closure(&self, mut load_object: F) -> Result<(), ViewError> + where + F: FnMut(&ObjectHash) -> Option>, + { + self.validate()?; + for root in self.roots() { + if load_object(&root).is_none() { + return Err(ViewError::MissingObject(root)); + } + } + let mut expanded = BTreeSet::new(); + for oid in self.workspaces.values() { + validate_workspace_closure(*oid, &mut load_object, &mut expanded)?; + } + Ok(()) + } +} + +fn validate_workspace_closure( + oid: ObjectHash, + load_object: &mut F, + seen: &mut BTreeSet, +) -> Result<(), ViewError> +where + F: FnMut(&ObjectHash) -> Option>, +{ + if !seen.insert(oid) { + return Ok(()); + } + let bytes = load_object(&oid).ok_or(ViewError::MissingObject(oid))?; + let snapshot = WorkspaceSnapshotV2::from_canonical_bytes(&bytes).map_err(|source| { + ViewError::InvalidWorkspaceManifest { + oid, + source: Box::new(source), + } + })?; + for root in snapshot.roots() { + if load_object(&root).is_none() { + return Err(ViewError::MissingObject(root)); + } + seen.insert(root); + } + Ok(()) +} + +impl WorkspaceSnapshotV2 { + pub fn validate(&self) -> Result<(), ViewError> { + if self.schema_version != WORKSPACE_SNAPSHOT_SCHEMA_VERSION { + return Err(ViewError::UnknownWorkspaceSchema(self.schema_version)); + } + if self.workspace_id.trim().is_empty() { + return Err(ViewError::EmptyWorkspaceId); + } + if let HeadState::Symbolic { reference } = &self.head + && reference.trim().is_empty() + { + return Err(ViewError::EmptyWorkspaceId); + } + Ok(()) + } + + pub fn to_canonical_bytes(&self) -> Result, ViewError> { + self.validate()?; + Ok(serde_json::to_vec(self)?) + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> Result { + let snapshot: Self = serde_json::from_slice(bytes)?; + snapshot.validate()?; + if snapshot.to_canonical_bytes()? != bytes { + return Err(ViewError::NonCanonical); + } + Ok(snapshot) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + Self::from_canonical_bytes(bytes) + } + + pub fn roots(&self) -> Vec { + let mut roots = std::collections::BTreeSet::new(); + roots.insert(self.index_tree_oid); + roots.insert(self.raw_index_blob_oid); + roots.insert(self.working_copy_tree_oid); + roots.insert(self.untracked_manifest_oid); + if let HeadState::Detached { oid } = self.head { + roots.insert(oid); + } + if let Some(oid) = self.sparse_facet_oid { + roots.insert(oid); + } + if let Some(oid) = self.sequencer_facet_oid { + roots.insert(oid); + } + roots.into_iter().collect() + } + + pub fn validate_closed(&self, mut has_object: F) -> Result<(), ViewError> + where + F: FnMut(&ObjectHash) -> bool, + { + self.validate()?; + for root in self.roots() { + if !has_object(&root) { + return Err(ViewError::MissingObject(root)); + } + } + Ok(()) + } + + pub fn validate_closure(&self, has_object: F) -> Result<(), ViewError> + where + F: FnMut(&ObjectHash) -> bool, + { + self.validate_closed(has_object) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn oid(byte: u8) -> ObjectHash { + ObjectHash::from_bytes(&[byte; 20]).expect("test SHA-1 object id") + } + + fn snapshot() -> WorkspaceSnapshotV2 { + WorkspaceSnapshotV2 { + schema_version: WORKSPACE_SNAPSHOT_SCHEMA_VERSION, + workspace_id: "workspace-a".to_string(), + head: HeadState::Symbolic { + reference: "refs/heads/main".to_string(), + }, + index_tree_oid: oid(1), + raw_index_blob_oid: oid(2), + working_copy_tree_oid: oid(3), + untracked_manifest_oid: oid(4), + sparse_facet_oid: None, + sequencer_facet_oid: Some(oid(5)), + worktree_generation: 7, + capture_policy: CapturePolicy::TrackedAndUntracked, + completeness: Completeness::Full, + facet_restore_policies: BTreeMap::from([( + FacetName::from("refs"), + RestorePolicy::AutoRestore, + )]), + } + } + + #[test] + fn workspace_snapshot_roundtrips_canonical_bytes() { + let original = snapshot(); + let bytes = original.to_canonical_bytes().expect("serialize snapshot"); + assert_eq!( + WorkspaceSnapshotV2::from_canonical_bytes(&bytes).expect("decode snapshot"), + original + ); + } + + #[test] + fn repo_view_sorts_change_roots_before_hashing() { + let view = RepoViewV2 { + schema_version: REPO_VIEW_SCHEMA_VERSION, + repo_id: "repo-a".to_string(), + refs_facet_oid: oid(1), + workspaces: BTreeMap::from([("workspace-a".to_string(), oid(2))]), + change_roots: vec![oid(4), oid(3)], + extension_facets: BTreeMap::new(), + }; + let bytes = view.to_canonical_bytes().expect("serialize view"); + let decoded = RepoViewV2::from_canonical_bytes(&bytes).expect("decode view"); + assert_eq!(decoded.change_roots, vec![oid(3), oid(4)]); + } + + #[test] + fn unknown_schema_version_is_rejected_before_closure_check() { + let mut value = serde_json::to_value(snapshot()).expect("serialize test snapshot"); + value["schema_version"] = serde_json::json!(99); + let bytes = serde_json::to_vec(&value).expect("serialize unknown schema"); + assert!(matches!( + WorkspaceSnapshotV2::from_canonical_bytes(&bytes), + Err(ViewError::UnknownWorkspaceSchema(99)) + )); + } + + #[test] + fn missing_root_fails_closed() { + let original = snapshot(); + let error = original + .validate_closed(|candidate| *candidate != oid(3)) + .expect_err("missing object must fail closure validation"); + assert_eq!( + error.to_string(), + format!("manifest object closure is missing {}", oid(3)) + ); + } + + #[test] + fn recursive_closure_checks_workspace_manifest_and_its_roots() { + let mut view = RepoViewV2 { + schema_version: REPO_VIEW_SCHEMA_VERSION, + repo_id: "repo-a".to_string(), + refs_facet_oid: oid(20), + workspaces: BTreeMap::from([("workspace-a".to_string(), oid(21))]), + change_roots: Vec::new(), + extension_facets: BTreeMap::new(), + }; + let snapshot = snapshot(); + let snapshot_bytes = snapshot.to_canonical_bytes().expect("serialize snapshot"); + let mut objects = BTreeMap::from([(oid(20), b"refs".to_vec()), (oid(21), snapshot_bytes)]); + for root in snapshot.roots() { + objects.insert(root, b"object".to_vec()); + } + view.validate_recursive_closure(|object| objects.get(object).cloned()) + .expect("nested snapshot closure is complete"); + view.workspaces.insert("workspace-b".to_string(), oid(22)); + let error = view + .validate_recursive_closure(|object| objects.get(object).cloned()) + .expect_err("missing nested workspace must fail closed"); + assert_eq!( + error.to_string(), + format!("manifest object closure is missing {}", oid(22)) + ); + } +} diff --git a/src/internal/operation_wrapper.rs b/src/internal/operation_wrapper.rs index cf2a0e3e0..01abcc9de 100644 --- a/src/internal/operation_wrapper.rs +++ b/src/internal/operation_wrapper.rs @@ -869,7 +869,7 @@ pub async fn begin_operation_with_conn( Ok(Reclaim::TookOver { op_id, command }) => { crate::utils::error::emit_warning(format!( "released the control claim of '{command}' (operation {}) — the process that \ - held it is gone; its record is kept as failed", + held it is gone; its record is kept as failed", &op_id[..8.min(op_id.len())] )); } @@ -881,7 +881,7 @@ pub async fn begin_operation_with_conn( let _ = txn.rollback().await; return Err(OperationError::business(format!( "'{command}' is already running in this worktree (owner {owner}, started \ - {age}s ago); wait for it to finish, or stop that process" + {age}s ago); wait for it to finish, or stop that process" ))); } Err(err) => { @@ -1260,7 +1260,7 @@ impl OperationBoundary { command_name: self.meta.command_name.clone(), description: format!( "{} | resolver_mode={} scanned_pages={} scanned_items={} success_candidates={} \ - selected_parents={} selection_latency_us={}", + selected_parents={} selection_latency_us={}", self.meta.description, match metrics.resolver_mode { ParentSelectionMode::SingleLatestSuccess => "single_latest_success", @@ -1326,7 +1326,7 @@ impl OperationBoundary { let _ = txn.rollback().await; return Err(OperationError::business(format!( "the operation claim for '{}' was released by another process while it ran; \ - its record was not written", + its record was not written", self.meta.command_name ))); } @@ -1633,21 +1633,21 @@ mod tests { db.execute(Statement::from_string( DbBackend::Sqlite, r#" - 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, - worktree_id TEXT NOT NULL DEFAULT '', - scope_provenance TEXT NOT NULL DEFAULT 'declared' - ) - "# + CREATE TABLE legacy_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, + worktree_id TEXT NOT NULL DEFAULT '', + scope_provenance TEXT NOT NULL DEFAULT 'declared' + ) + "# .to_string(), )) .await @@ -1657,19 +1657,19 @@ mod tests { async fn create_operation_graph_tables_missing_view(db: &sea_orm::DatabaseConnection) { db.execute(Statement::from_string( DbBackend::Sqlite, - "CREATE TABLE operation_parent (op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id))".to_string(), + "CREATE TABLE legacy_operation_parent (op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id))".to_string(), )) .await .unwrap(); db.execute(Statement::from_string( DbBackend::Sqlite, - "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))".to_string(), + "CREATE TABLE legacy_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))".to_string(), )) .await .unwrap(); db.execute(Statement::from_string( DbBackend::Sqlite, - "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))".to_string(), + "CREATE TABLE legacy_operation_view_workspace (view_id TEXT NOT NULL,pointer_kind TEXT NOT NULL,pointer_value TEXT NOT NULL,PRIMARY KEY (view_id,pointer_kind))".to_string(), )) .await .unwrap(); @@ -1678,25 +1678,25 @@ mod tests { async fn create_operation_graph_tables(db: &sea_orm::DatabaseConnection) { db.execute(Statement::from_string( DbBackend::Sqlite, - "CREATE TABLE operation_parent (op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id))".to_string(), + "CREATE TABLE legacy_operation_parent (op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id))".to_string(), )) .await .unwrap(); db.execute(Statement::from_string( DbBackend::Sqlite, - "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)".to_string(), + "CREATE TABLE legacy_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)".to_string(), )) .await .unwrap(); db.execute(Statement::from_string( DbBackend::Sqlite, - "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))".to_string(), + "CREATE TABLE legacy_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))".to_string(), )) .await .unwrap(); db.execute(Statement::from_string( DbBackend::Sqlite, - "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))".to_string(), + "CREATE TABLE legacy_operation_view_workspace (view_id TEXT NOT NULL,pointer_kind TEXT NOT NULL,pointer_value TEXT NOT NULL,PRIMARY KEY (view_id,pointer_kind))".to_string(), )) .await .unwrap(); @@ -1970,7 +1970,7 @@ mod tests { let op_row = db .query_one(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation".to_string(), + "SELECT COUNT(*) FROM legacy_operation".to_string(), )) .await .unwrap() @@ -1981,7 +1981,7 @@ mod tests { let view_row = db .query_one(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation_view".to_string(), + "SELECT COUNT(*) FROM legacy_operation_view".to_string(), )) .await .unwrap() @@ -1992,7 +1992,7 @@ mod tests { let parent_row = db .query_one(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation_parent".to_string(), + "SELECT COUNT(*) FROM legacy_operation_parent".to_string(), )) .await .unwrap() @@ -2099,7 +2099,7 @@ mod tests { let op_row = db .query_one(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation".to_string(), + "SELECT COUNT(*) FROM legacy_operation".to_string(), )) .await .unwrap() @@ -2110,7 +2110,7 @@ mod tests { let view_row = db .query_one(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation_view".to_string(), + "SELECT COUNT(*) FROM legacy_operation_view".to_string(), )) .await .unwrap() @@ -2121,7 +2121,7 @@ mod tests { let parent_row = db .query_one(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation_parent".to_string(), + "SELECT COUNT(*) FROM legacy_operation_parent".to_string(), )) .await .unwrap() diff --git a/tests/INDEX.md b/tests/INDEX.md index ce275044c..568475580 100644 --- a/tests/INDEX.md +++ b/tests/INDEX.md @@ -17,6 +17,8 @@ | target | wave | one-line purpose | relevant src | |---|---|---|---| +| `operation_schema_v2` | 1 | OL-02 fresh/legacy schema convergence, idempotence, and rollback guards | `src/internal/db/migration.rs`, `sql/migrations/2026090101_operation_v2.sql` | +| `operation_dag` | 1 | OL-04 v2 operation, journal, and op-head CAS persistence | `src/internal/operation/store.rs` | | `commit_change_id_header_spike` | 1 | OL-00 real-Git Change ID header vs sidecar-only compatibility spike | `docs/development/internal/operation-log-working-copy-change-id.md` | | `command_test` | 1 | Top-level dispatcher covering most `libra ` integration paths, including W4 `worktree doctor` read-only/schema, confirmed legacy-capture adoption, W4-08 linked-worktree `libra code`/`automation` enablement, and the W5-08 `graph_machine_survives_tui_removal` breaking guard (interactive graph entry refused with a migration hint; `--json`/`--machine` wire intact) | `src/command/`, `src/cli.rs`, `tests/command/worktree_doctor_test.rs`, `tests/command/code_agent_linked_guard_test.rs` | | `compat_stash_subcommand_surface` | 1 | Guards `libra stash` subcommand surface vs. git CLI | `src/command/stash.rs` | diff --git a/tests/agent_bridge_migration_test.rs b/tests/agent_bridge_migration_test.rs index a70f1e4a4..4e7d17d6c 100644 --- a/tests/agent_bridge_migration_test.rs +++ b/tests/agent_bridge_migration_test.rs @@ -6,7 +6,9 @@ //! 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::{ + MigrationError, MigrationRunner, builtin_migrations, run_builtin_migrations, +}; use sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; const BRIDGE_TABLES: &[&str] = &[ @@ -17,6 +19,16 @@ const BRIDGE_TABLES: &[&str] = &[ "agent_bridge_link", ]; +fn builtin_runner() -> Result { + let mut runner = MigrationRunner::new(); + runner.extend( + builtin_migrations() + .into_iter() + .filter(|migration| migration.version < 2026090101), + )?; + Ok(runner) +} + async fn table_exists(db: &sea_orm::DatabaseConnection, table: &str) -> bool { let rows = db .query_all_raw(Statement::from_sql_and_values( diff --git a/tests/agent_capture_migration_test.rs b/tests/agent_capture_migration_test.rs index c35d41dc4..6a8da5ccc 100644 --- a/tests/agent_capture_migration_test.rs +++ b/tests/agent_capture_migration_test.rs @@ -52,11 +52,13 @@ async fn index_exists(conn: &DatabaseConnection, name: &str) -> bool { fn registered_runner() -> MigrationRunner { let mut runner = MigrationRunner::new(); - for migration in builtin_migrations() { - runner - .register(migration) - .expect("builtin migrations must register clean"); - } + runner + .extend( + builtin_migrations() + .into_iter() + .filter(|migration| migration.version < 2026090101), + ) + .expect("historical builtin migrations must register clean"); runner } @@ -64,7 +66,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 < 2026090101) .collect() } diff --git a/tests/command/schema_upgrade_test.rs b/tests/command/schema_upgrade_test.rs index d0977bc65..61be126a2 100644 --- a/tests/command/schema_upgrade_test.rs +++ b/tests/command/schema_upgrade_test.rs @@ -2,7 +2,9 @@ use std::{path::Path, time::Duration}; -use libra::internal::db::migration::builtin_runner; +use libra::internal::db::migration::{ + MigrationError, MigrationRunner, builtin_migrations, builtin_runner as current_builtin_runner, +}; use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement}; use tempfile::tempdir; @@ -18,12 +20,87 @@ async fn connect_raw_repo_db(repo: &Path) -> DatabaseConnection { .expect("connect raw repository database") } +/// Reconstruct the real pre-OL-02 operation shape for rollback fixtures. +/// Production v2 is intentionally forward-only, so a fixture that exercises +/// older migration downs must not run those downs against the v2 `operation` +/// table and pretend that it is v1. +async fn restore_v1_operation_shape(conn: &DatabaseConnection) { + for table in [ + "ai_operation_link", + "change_predecessor", + "change_revision", + "change_identity", + "operation_journal", + "operation_head", + "operation_parent", + "operation", + ] { + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + format!("DROP TABLE IF EXISTS `{table}`"), + )) + .await + .unwrap_or_else(|error| panic!("drop v2 fixture table {table}: {error}")); + } + for trigger in [ + "legacy_operation_scope_provenance_domain_insert", + "legacy_operation_scope_provenance_domain_update", + "legacy_operation_scope_kind_domain_insert", + "legacy_operation_scope_kind_domain_update", + ] { + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + format!("DROP TRIGGER IF EXISTS {trigger}"), + )) + .await + .unwrap_or_else(|error| panic!("drop v2 fixture trigger {trigger}: {error}")); + } + for index in [ + "idx_legacy_operation_repo_order", + "idx_legacy_operation_dedup_scope", + "idx_legacy_operation_control_slot", + "idx_legacy_operation_parent_parent", + "idx_legacy_operation_view_repo_created", + ] { + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + format!("DROP INDEX IF EXISTS {index}"), + )) + .await + .unwrap_or_else(|error| panic!("drop v2 fixture index {index}: {error}")); + } + for (legacy, v1) in [ + ("legacy_operation", "operation"), + ("legacy_operation_parent", "operation_parent"), + ("legacy_operation_view", "operation_view"), + ("legacy_operation_view_ref", "operation_view_ref"), + ( + "legacy_operation_view_workspace", + "operation_view_workspace", + ), + ] { + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + format!("ALTER TABLE `{legacy}` RENAME TO `{v1}`"), + )) + .await + .unwrap_or_else(|error| panic!("restore {legacy} as {v1}: {error}")); + } +} + async fn stale_repo_at_approved_permission() -> tempfile::TempDir { let repo = tempdir().expect("create repository root"); init_repo_via_cli(repo.path()); let conn = connect_raw_repo_db(repo.path()).await; - let runner = builtin_runner().expect("built-in migration registry"); + let runner = historical_builtin_runner().expect("historical migration registry"); + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + "DELETE FROM schema_versions WHERE version = 2026090101".to_string(), + )) + .await + .expect("remove forward-only v2 version marker before rollback fixture"); + restore_v1_operation_shape(&conn).await; runner .rollback_to(&conn, 2026050601) .await @@ -32,6 +109,16 @@ async fn stale_repo_at_approved_permission() -> tempfile::TempDir { repo } +fn historical_builtin_runner() -> Result { + let mut runner = MigrationRunner::new(); + runner.extend( + builtin_migrations() + .into_iter() + .filter(|migration| migration.version < 2026090101), + )?; + Ok(runner) +} + async fn max_schema_version(conn: &DatabaseConnection) -> Option { let row = conn .query_one_raw(Statement::from_string( @@ -80,7 +167,7 @@ async fn normal_command_auto_upgrades_stale_schema() { assert_cli_success(&output, "libra status on a stale-schema repository"); let conn = connect_raw_repo_db(repo.path()).await; - let latest = builtin_runner() + let latest = current_builtin_runner() .expect("built-in migration registry") .max_registered_version(); assert_eq!( diff --git a/tests/command/worktree_isolation_test.rs b/tests/command/worktree_isolation_test.rs index 150aa7cc4..a9d32a1d9 100644 --- a/tests/command/worktree_isolation_test.rs +++ b/tests/command/worktree_isolation_test.rs @@ -37,6 +37,75 @@ fn repo_with_feature() -> tempfile::TempDir { repo } +/// Reconstruct the real pre-OL-02 operation shape for rollback fixtures. +/// Production v2 is intentionally forward-only, so older migration downs +/// must run against v1 tables rather than a same-named v2 table. +async fn restore_v1_operation_shape(conn: &sea_orm::DatabaseConnection) { + use sea_orm::{ConnectionTrait, Statement}; + + for table in [ + "ai_operation_link", + "change_predecessor", + "change_revision", + "change_identity", + "operation_journal", + "operation_head", + "operation_parent", + "operation", + ] { + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + format!("DROP TABLE IF EXISTS `{table}`"), + )) + .await + .unwrap_or_else(|error| panic!("drop v2 fixture table {table}: {error}")); + } + for trigger in [ + "legacy_operation_scope_provenance_domain_insert", + "legacy_operation_scope_provenance_domain_update", + "legacy_operation_scope_kind_domain_insert", + "legacy_operation_scope_kind_domain_update", + ] { + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + format!("DROP TRIGGER IF EXISTS {trigger}"), + )) + .await + .unwrap_or_else(|error| panic!("drop v2 fixture trigger {trigger}: {error}")); + } + for index in [ + "idx_legacy_operation_repo_order", + "idx_legacy_operation_dedup_scope", + "idx_legacy_operation_control_slot", + "idx_legacy_operation_parent_parent", + "idx_legacy_operation_view_repo_created", + ] { + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + format!("DROP INDEX IF EXISTS {index}"), + )) + .await + .unwrap_or_else(|error| panic!("drop v2 fixture index {index}: {error}")); + } + for (legacy, v1) in [ + ("legacy_operation", "operation"), + ("legacy_operation_parent", "operation_parent"), + ("legacy_operation_view", "operation_view"), + ("legacy_operation_view_ref", "operation_view_ref"), + ( + "legacy_operation_view_workspace", + "operation_view_workspace", + ), + ] { + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + format!("ALTER TABLE `{legacy}` RENAME TO `{v1}`"), + )) + .await + .unwrap_or_else(|error| panic!("restore {legacy} as {v1}: {error}")); + } +} + fn abbrev_head(dir: &std::path::Path) -> String { String::from_utf8_lossy(&run_libra_command(&["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout) .trim() @@ -4225,7 +4294,7 @@ fn concurrent_control_slots_are_held_per_worktree() { std::thread::sleep(std::time::Duration::from_millis(100)); let rows = match sqlite_query_no_wait( &db, - "SELECT worktree_id FROM operation WHERE status = 'running' \ + "SELECT worktree_id FROM legacy_operation WHERE status = 'running' \ AND control_slot IS NOT NULL ORDER BY worktree_id", ) { Ok(rows) => rows, @@ -5917,6 +5986,13 @@ async fn worktree_commands_apply_capability_marker_before_registry_io() { // Re-open the pre-v2 window: roll back ONLY the capability marker. { let conn = Database::connect(&db_url).await.expect("connect repo db"); + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + "DELETE FROM schema_versions WHERE version = 2026090101".to_string(), + )) + .await + .expect("remove forward-only v2 version marker before rollback fixture"); + restore_v1_operation_shape(&conn).await; let rolled = builtin_runner() .expect("builtin runner") .rollback_to(&conn, 2026072304) @@ -5933,7 +6009,7 @@ async fn worktree_commands_apply_capability_marker_before_registry_io() { let mut expected_rolled: Vec = libra::internal::db::migration::builtin_migrations() .into_iter() .map(|migration| migration.version) - .filter(|version| *version > 2026072304) + .filter(|version| *version > 2026072304 && *version < 2026090101) .collect(); expected_rolled.reverse(); assert_eq!(rolled, expected_rolled); @@ -8584,7 +8660,7 @@ fn worktree_doctor_reports_scope_diagnostics_without_repairing() { #[tokio::test] async fn worktree_doctor_does_not_upgrade_a_behind_schema_repository() { use libra::internal::db::migration::builtin_runner; - use sea_orm::Database; + use sea_orm::{ConnectionTrait, Database}; let dir = repo_with_feature(); let main = dir.path(); @@ -8598,12 +8674,19 @@ async fn worktree_doctor_does_not_upgrade_a_behind_schema_repository() { let conn = Database::connect(&db_url) .await .expect("open repository db"); + conn.execute_raw(sea_orm::Statement::from_string( + conn.get_database_backend(), + "DELETE FROM schema_versions WHERE version = 2026090101".to_string(), + )) + .await + .expect("remove forward-only v2 version marker before rollback fixture"); + restore_v1_operation_shape(&conn).await; // Registry-derived for the same reason as the capability-marker case // above: everything registered above 2026073101 rolls back with it. let mut expected_rolled_back: Vec = libra::internal::db::migration::builtin_migrations() .into_iter() .map(|migration| migration.version) - .filter(|version| *version > 2026073101) + .filter(|version| *version > 2026073101 && *version < 2026090101) .collect(); expected_rolled_back.reverse(); assert_eq!( diff --git a/tests/compat/agent_bridge_schema_test.rs b/tests/compat/agent_bridge_schema_test.rs index 4f2a53556..47f2e9aa4 100644 --- a/tests/compat/agent_bridge_schema_test.rs +++ b/tests/compat/agent_bridge_schema_test.rs @@ -25,9 +25,17 @@ fn bridge_migrations_are_registered_and_link_relations_is_the_latest() { "2026081801_agent_bridge_capture must stay registered" ); assert_eq!( - runner.max_registered_version(), + builtin_migrations() + .iter() + .filter(|migration| migration.version <= BRIDGE_LINK_RELATIONS_VERSION) + .map(|migration| migration.version) + .max(), Some(BRIDGE_LINK_RELATIONS_VERSION), - "2026082401_agent_bridge_link_relations must be the latest registered migration" + "2026082401_agent_bridge_link_relations must remain the latest bridge migration" + ); + assert!( + runner.max_registered_version() > Some(BRIDGE_LINK_RELATIONS_VERSION), + "newer foundation migrations may follow the bridge migration" ); } diff --git a/tests/db_migration_test.rs b/tests/db_migration_test.rs index 4f22fb4e1..d817983ff 100644 --- a/tests/db_migration_test.rs +++ b/tests/db_migration_test.rs @@ -7,8 +7,8 @@ use std::path::PathBuf; use libra::internal::db::migration::{ - Migration, MigrationError, MigrationRunner, builtin_migrations, builtin_runner, - run_builtin_migrations, + Migration, MigrationError, MigrationRunner, builtin_migrations, + builtin_runner as all_builtin_runner, run_builtin_migrations as run_all_builtin_migrations, }; use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement}; use tempfile::TempDir; @@ -30,6 +30,23 @@ async fn connect(url: &str) -> DatabaseConnection { Database::connect(opts).await.expect("connect") } +// The migration tests below exercise historical down/up contracts. OL-02 is +// intentionally forward-only, so keep those fixtures on the pre-v2 registry; +// the dedicated operation_schema_v2 target covers the current tip. +fn builtin_runner() -> Result { + let mut runner = MigrationRunner::new(); + runner.extend( + builtin_migrations() + .into_iter() + .filter(|migration| migration.version < 2026090101), + )?; + Ok(runner) +} + +async fn run_builtin_migrations(conn: &DatabaseConnection) -> Result, MigrationError> { + builtin_runner()?.run_pending(conn).await +} + // --------------------------------------------------------------------------- // Builtin runner contract: current runtime migrations are registered // --------------------------------------------------------------------------- @@ -55,7 +72,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, 2026090101 ] ); assert_eq!( @@ -118,13 +135,14 @@ fn builtin_migrations_register_current_schema_migrations() { "approved_permission_provenance", "agent_bridge_capture", "agent_bridge_link_relations", + "operation_v2", ] ); - let runner = builtin_runner().expect("builtin registry must build clean"); + let runner = all_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(2026090101)); } // --------------------------------------------------------------------------- @@ -1134,12 +1152,12 @@ async fn establish_connection_auto_upgrades_stale_schema() { let path = dir.path().join("stale.db"); let path_str = path.to_str().unwrap(); let conn = create_database(path_str).await.unwrap(); - - let runner = builtin_runner().expect("builtin runner builds clean"); - runner - .rollback_to(&conn, 2026050601) - .await - .expect("roll back latest migration"); + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + "DELETE FROM schema_versions WHERE version = 2026090101".to_string(), + )) + .await + .expect("remove the v2 version claim to simulate a stale connection"); conn.close().await.unwrap(); // Opening the connection now applies any pending migrations automatically, @@ -1149,10 +1167,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 = all_builtin_runner() .expect("builtin runner builds clean") .max_registered_version(); - let current = builtin_runner() + let current = all_builtin_runner() .expect("builtin runner builds clean") .current_version_readonly(&raw) .await @@ -1194,7 +1212,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 = run_all_builtin_migrations(&conn) .await .expect("run_builtin_migrations"); assert_eq!( @@ -1208,7 +1226,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, 2026090101 ] ); assert!(table_exists(&conn, "schema_versions").await); @@ -2259,7 +2277,7 @@ async fn rebase_state_migration_preserves_active_row_from_lazy_shape() { .await .expect("legacy in-progress row"); - run_builtin_migrations(&conn).await.expect("migrations"); + run_all_builtin_migrations(&conn).await.expect("migrations"); assert!(column_exists(&conn, "rebase_state", "worktree_id").await); assert!( @@ -2526,7 +2544,7 @@ async fn bisect_state_migration_preserves_active_row_from_lazy_shape() { .await .expect("stale + newest lazy rows"); - run_builtin_migrations(&conn).await.expect("migrations"); + run_all_builtin_migrations(&conn).await.expect("migrations"); assert!(column_exists(&conn, "bisect_state", "worktree_id").await); assert!( @@ -3693,8 +3711,8 @@ async fn gc_object_source_inventory_covers_every_oid_column() { // Semantic OID columns the name heuristic cannot flag — pinned by hand; // each must be inventoried too. for (table, column) in [ - ("operation_view", "head_target"), - ("operation_view_workspace", "pointer_value"), + ("legacy_operation_view", "head_target"), + ("legacy_operation_view_workspace", "pointer_value"), ("object_index", "o_id"), ("metadata_kv", "value"), ] { @@ -5058,7 +5076,7 @@ async fn approved_permission_old_reader_rejects_migrated_schema() { .await .expect("read tip") .expect("applied tip"); - assert_eq!(current, 2026082401); + assert_eq!(current, 2026090101); // An old binary whose registry tip is still 2026080403 would see this // repository as UnsupportedFuture. Prove the refuse path on repository // DBs (not global config.db) by planting a version above this binary. diff --git a/tests/operation_dag.rs b/tests/operation_dag.rs new file mode 100644 index 000000000..4e0b8c5df --- /dev/null +++ b/tests/operation_dag.rs @@ -0,0 +1,280 @@ +//! OL-04 focused coverage for operation objects, journal rows, and head CAS. + +use git_internal::{hash::ObjectHash, internal::object::types::ObjectType}; +use libra::internal::{ + db, + operation::{ + JournalEntry, JournalPhase, OperationKind, OperationMetaV2, OperationStatusV2, + OperationStoreV2, OperationV2, RepoViewV2, + }, +}; +use tempfile::TempDir; + +fn oid(label: &[u8]) -> ObjectHash { + ObjectHash::from_type_and_data(ObjectType::Blob, label) +} + +fn view() -> RepoViewV2 { + RepoViewV2 { + schema_version: 2, + repo_id: "repo-1".to_string(), + refs_facet_oid: oid(b"refs"), + workspaces: Default::default(), + change_roots: Vec::new(), + extension_facets: Default::default(), + } +} + +#[tokio::test] +async fn operation_store_round_trips_objects_journal_and_head_cas() { + let dir = TempDir::new().expect("temporary operation store directory"); + let db_path = dir.path().join("repo.db"); + let object_path = dir.path().join("objects"); + let reopened_object_path = object_path.clone(); + let db = db::create_database(db_path.to_str().expect("UTF-8 database path")) + .await + .expect("database initializes"); + let storage = libra::utils::client_storage::ClientStorage::init_local(object_path); + storage + .put(&view().refs_facet_oid, b"refs", ObjectType::Blob) + .expect("refs facet object writes"); + let store = OperationStoreV2::new_for_repo("repo-1", db, storage); + + let view_oid = store.write_view_manifest(&view()).expect("manifest writes"); + assert_eq!(store.load_view(&view_oid).expect("manifest loads"), view()); + + let operation = OperationV2 { + op_id: "op-1".to_string(), + parent_op_ids: vec!["parent-a".to_string(), "parent-b".to_string()], + pre_view_oid: view_oid, + post_view_oid: view_oid, + kind: OperationKind::Command, + status: OperationStatusV2::Success, + metadata: OperationMetaV2 { + command_name: Some("test".to_string()), + ..Default::default() + }, + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + }; + store + .write_operation(&operation) + .await + .expect("operation writes"); + + let generation = store + .cas_update_op_heads("repo-1", "main", &[], &["op-1".to_string()]) + .await + .expect("initial head publish"); + assert_eq!(generation, 1); + assert_eq!( + store + .read_heads("repo-1", "main") + .await + .expect("heads read"), + ["op-1"] + ); + let heads_view = store + .read_heads_view("repo-1", "main") + .await + .expect("heads view reads"); + assert!(heads_view.is_ancestor("parent-a", "op-1")); + assert!(heads_view.is_ancestor("parent-b", "op-1")); + + let second_operation = OperationV2 { + op_id: "op-2".to_string(), + parent_op_ids: vec!["op-1".to_string()], + pre_view_oid: view_oid, + post_view_oid: view_oid, + kind: OperationKind::Command, + status: OperationStatusV2::Success, + metadata: OperationMetaV2::default(), + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + }; + store + .write_operation(&second_operation) + .await + .expect("second operation writes"); + assert_eq!( + store + .cas_update_op_heads( + "repo-1", + "main", + &["op-1".to_string()], + &["op-1".to_string(), "op-2".to_string()], + ) + .await + .expect("multi-head publish"), + 2 + ); + assert_eq!( + store + .read_heads("repo-1", "main") + .await + .expect("multi-head read"), + ["op-1", "op-2"] + ); + + let conflict = store + .cas_update_op_heads("repo-1", "main", &[], &["op-2".to_string()]) + .await; + assert!(matches!( + conflict, + Err(libra::internal::operation::StoreError::CasConflict { .. }) + )); + assert_eq!( + store + .read_heads("repo-1", "main") + .await + .expect("heads remain after CAS conflict"), + ["op-1", "op-2"] + ); + + store + .append_journal(&JournalEntry { + journal_id: "journal-1".to_string(), + op_id: "op-1".to_string(), + phase: JournalPhase::Reserved, + pre_view_oid: Some(view_oid), + target_view_oid: None, + owner: "test".to_string(), + updated_at: 1, + recovery_payload: None, + }) + .await + .expect("journal writes"); + store + .append_journal(&JournalEntry { + journal_id: "journal-1".to_string(), + op_id: "op-1".to_string(), + phase: JournalPhase::Publish, + pre_view_oid: Some(view_oid), + target_view_oid: Some(view_oid), + owner: "test".to_string(), + updated_at: 2, + recovery_payload: Some("published".to_string()), + }) + .await + .expect("journal phase advances"); + let phase_regression = store + .append_journal(&JournalEntry { + journal_id: "journal-1".to_string(), + op_id: "op-1".to_string(), + phase: JournalPhase::Mutation, + pre_view_oid: Some(view_oid), + target_view_oid: Some(view_oid), + owner: "test".to_string(), + updated_at: 3, + recovery_payload: None, + }) + .await; + assert!(matches!( + phase_regression, + Err(libra::internal::operation::StoreError::Validation(message)) + if message.contains("cannot move backwards") + )); + assert_eq!( + store + .read_journal("op-1") + .await + .expect("journal reads") + .len(), + 1 + ); + assert_eq!( + store + .read_journal("op-1") + .await + .expect("journal phase reads") + .first() + .expect("journal entry") + .phase, + JournalPhase::Publish + ); + + let generation = store + .cas_update_op_heads("repo-1", "generation", &[], &["op-1".to_string()]) + .await + .expect("generation scope initializes"); + assert_eq!(generation, 1); + assert_eq!( + store + .cas_update_op_heads_at_generation( + "repo-1", + "generation", + generation, + &["op-1".to_string()], + &["op-2".to_string()], + ) + .await + .expect("generation CAS advances"), + 2 + ); + let stale_generation = store + .cas_update_op_heads_at_generation( + "repo-1", + "generation", + generation, + &["op-2".to_string()], + &["op-1".to_string()], + ) + .await; + assert!(matches!( + stale_generation, + Err(libra::internal::operation::StoreError::CasConflict { .. }) + )); + assert_eq!( + store + .read_heads("repo-1", "generation") + .await + .expect("stale generation leaves head unchanged"), + ["op-2"] + ); + assert_eq!( + store + .merge_op_heads( + "repo-1", + "generation", + &["op-1".to_string()], + &["op-1".to_string()], + ) + .await + .expect("stale candidate is retained as a sibling"), + 3 + ); + assert_eq!( + store + .read_heads("repo-1", "generation") + .await + .expect("merged sibling heads read"), + ["op-1", "op-2"] + ); + assert_eq!( + store + .read_head_generation("repo-1", "generation") + .await + .expect("generation reads"), + 3 + ); + + drop(store); + let reopened_db = db::establish_connection(db_path.to_str().expect("UTF-8 database path")) + .await + .expect("database reopens"); + let reopened = OperationStoreV2::new_for_repo( + "repo-1", + reopened_db, + libra::utils::client_storage::ClientStorage::init_local(reopened_object_path), + ); + assert_eq!( + reopened + .read_journal("op-1") + .await + .expect("journal remains after store reopen") + .len(), + 1 + ); +} diff --git a/tests/operation_schema_v2.rs b/tests/operation_schema_v2.rs new file mode 100644 index 000000000..4ba2561e2 --- /dev/null +++ b/tests/operation_schema_v2.rs @@ -0,0 +1,310 @@ +//! OL-02 focused coverage for the v1 -> v2 operation schema replacement. + +use std::{collections::BTreeMap, path::Path}; + +use libra::internal::db; +use sea_orm::{ConnectionTrait, DbBackend, Statement}; +use tempfile::TempDir; + +async fn table_columns(conn: &sea_orm::DatabaseConnection, table: &str) -> Vec { + let statement = + Statement::from_string(DbBackend::Sqlite, format!("PRAGMA table_info('{table}')")); + let rows = conn + .query_all_raw(statement) + .await + .expect("table_info query succeeds"); + rows.into_iter() + .map(|row| row.try_get_by_index::(1).expect("column name")) + .collect() +} + +async fn schema_signature(conn: &sea_orm::DatabaseConnection) -> BTreeMap> { + let rows = conn + .query_all_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (\ + 'operation', 'operation_parent', 'operation_head', 'operation_journal',\ + 'change_identity', 'change_revision', 'change_predecessor', 'ai_operation_link',\ + 'legacy_operation', 'legacy_operation_parent', 'legacy_operation_view',\ + 'legacy_operation_view_ref', 'legacy_operation_view_workspace')\ + ORDER BY name", + )) + .await + .expect("schema table query succeeds"); + let mut signature = BTreeMap::new(); + for row in rows { + let name: String = row.try_get_by_index(0).expect("table name"); + signature.insert(name.clone(), table_columns(conn, &name).await); + } + signature +} + +fn db_path(dir: &TempDir, name: &str) -> std::path::PathBuf { + dir.path().join(name) +} + +async fn make_legacy_database(path: &Path) -> sea_orm::DatabaseConnection { + let conn = db::create_database(path.to_str().expect("UTF-8 database path")) + .await + .expect("create baseline database"); + conn.execute_unprepared( + "DROP TABLE legacy_operation_view_workspace;\ + DROP TABLE legacy_operation_view_ref;\ + DROP TABLE legacy_operation_view;\ + DROP TABLE legacy_operation_parent;\ + DROP TABLE legacy_operation;\ + DROP TABLE operation_journal;\ + DROP TABLE operation_head;\ + DROP TABLE change_identity;\ + DROP TABLE change_revision;\ + DROP TABLE change_predecessor;\ + DROP TABLE ai_operation_link;\ + DROP TABLE operation_parent;\ + DROP TABLE operation;\ + 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)\ + );\ + INSERT INTO operation (op_id, repo_id, view_id, command_name, description, actor, args_digest, start_ts, end_ts, status)\ + VALUES ('legacy-op-1', 'repo-1', 'legacy-view-1', 'status', 'legacy row', 'jackie', 'digest', 10, 11, 'succeeded');\ + INSERT INTO operation_parent (op_id, parent_op_id) VALUES ('legacy-op-1', 'legacy-parent-1');\ + INSERT INTO operation_view (view_id, repo_id, head_kind, head_target, created_at)\ + VALUES ('legacy-view-1', 'repo-1', 'branch', 'main', 10);\ + INSERT INTO operation_view_ref (view_id, ref_kind, ref_name, ref_remote, target_oid)\ + VALUES ('legacy-view-1', 'branch', 'main', '', 'deadbeef');\ + INSERT INTO operation_view_workspace (view_id, pointer_kind, pointer_value)\ + VALUES ('legacy-view-1', 'head', 'deadbeef');\ + DELETE FROM schema_versions WHERE version = 2026090101", + ) + .await + .expect("install legacy operation schema"); + conn +} + +#[tokio::test] +async fn fresh_and_legacy_databases_converge_to_the_same_v2_schema() { + let dir = TempDir::new().expect("temporary schema directory"); + let fresh_path = db_path(&dir, "fresh.db"); + let legacy_path = db_path(&dir, "legacy.db"); + + let fresh = db::create_database(fresh_path.to_str().expect("UTF-8 path")) + .await + .expect("fresh database initializes"); + let fresh_signature = schema_signature(&fresh).await; + assert!(!fresh_signature.contains_key("operation_view")); + assert_eq!( + fresh_signature + .get("operation") + .and_then(|columns| columns.first()) + .map(String::as_str), + Some("op_id") + ); + assert!(fresh_signature["operation"].contains(&"pre_view_oid".to_string())); + assert!(fresh_signature.contains_key("operation_head")); + assert!(fresh_signature.contains_key("operation_journal")); + assert!(fresh_signature.contains_key("ai_operation_link")); + assert_eq!( + fresh + .query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (+ 'idx_legacy_operation_dedup_scope', 'idx_legacy_operation_control_slot')", + )) + .await + .expect("legacy index query") + .expect("legacy index row") + .try_get_by_index::(0) + .expect("legacy index count"), + 2 + ); + assert_eq!( + fresh + .query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'legacy_operation_%_domain_%'", + )) + .await + .expect("legacy trigger query") + .expect("legacy trigger row") + .try_get_by_index::(0) + .expect("legacy trigger count"), + 4 + ); + drop(fresh); + + let legacy = make_legacy_database(&legacy_path).await; + let legacy_signature = { + db::upgrade_database_schema(&legacy_path) + .await + .expect("legacy database migrates forward"); + let upgraded = db::establish_connection(legacy_path.to_str().expect("UTF-8 path")) + .await + .expect("upgraded database opens"); + let signature = schema_signature(&upgraded).await; + assert_eq!( + upgraded + .query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT op_id, repo_id, view_id FROM legacy_operation", + )) + .await + .expect("legacy row query") + .expect("legacy row") + .try_get_by_index::(0) + .expect("legacy op id"), + "legacy-op-1" + ); + assert_eq!( + upgraded + .query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) FROM operation", + )) + .await + .expect("v2 row query") + .expect("v2 count") + .try_get_by_index::(0) + .expect("v2 count value"), + 0 + ); + assert_eq!( + upgraded + .query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT restorable FROM legacy_operation WHERE op_id = 'legacy-op-1'", + )) + .await + .expect("legacy restorable query") + .expect("legacy restorable row") + .try_get_by_index::(0) + .expect("legacy restorable value"), + 1 + ); + upgraded + .execute_unprepared("DELETE FROM schema_versions WHERE version = 2026090101") + .await + .expect("remove migration marker for idempotence check"); + drop(upgraded); + signature + }; + drop(legacy); + + db::upgrade_database_schema(&legacy_path) + .await + .expect("re-running the operation migration is idempotent"); + let reopened = db::establish_connection(legacy_path.to_str().expect("UTF-8 path")) + .await + .expect("idempotently upgraded database reopens"); + assert_eq!( + reopened + .query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) FROM legacy_operation", + )) + .await + .expect("legacy count query") + .expect("legacy count row") + .try_get_by_index::(0) + .expect("legacy count"), + 1 + ); + drop(reopened); + + assert_eq!(fresh_signature, legacy_signature); +} + +#[tokio::test] +async fn operation_v2_migration_rolls_back_schema_and_data_on_validation_failure() { + let dir = TempDir::new().expect("temporary schema directory"); + let path = db_path(&dir, "rollback.db"); + let conn = make_legacy_database(&path).await; + conn.execute_unprepared("UPDATE operation SET repo_id = ''") + .await + .expect("inject invalid legacy key"); + + let error = db::upgrade_database_schema(&path) + .await + .expect_err("invalid legacy data must abort migration"); + assert!( + error + .to_string() + .contains("Failed to run schema migrations") + ); + + let version = conn + .query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT MAX(version) FROM schema_versions", + )) + .await + .expect("version query") + .expect("version row") + .try_get_by_index::(0) + .expect("version value"); + assert_ne!(version, 2026090101); + assert_eq!( + conn.query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) FROM operation", + )) + .await + .expect("v1 count query") + .expect("v1 count row") + .try_get_by_index::(0) + .expect("v1 count"), + 1 + ); + assert_eq!( + conn.query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'legacy_operation'", + )) + .await + .expect("legacy table query") + .expect("legacy table row") + .try_get_by_index::(0) + .expect("legacy table count"), + 0 + ); +} + +#[tokio::test] +async fn operation_v2_migration_is_forward_only_and_versioned() { + let dir = TempDir::new().expect("temporary schema directory"); + let path = db_path(&dir, "version.db"); + let conn = db::create_database(path.to_str().expect("UTF-8 path")) + .await + .expect("create database"); + let row = conn + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT MAX(version) FROM schema_versions", + [], + )) + .await + .expect("schema version query") + .expect("schema version row"); + let version: i64 = row.try_get_by_index(0).expect("schema version"); + assert_eq!(version, 2026090101); + assert!( + table_columns(&conn, "operation_parent") + .await + .contains(&"ordinal".to_string()) + ); +} diff --git a/tests/operation_service_test.rs b/tests/operation_service_test.rs index d3fd4ae1e..cc99f9670 100644 --- a/tests/operation_service_test.rs +++ b/tests/operation_service_test.rs @@ -10,53 +10,53 @@ use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, Statemen /// Create the operation-layer SQLite schema used by the service tests. async fn create_operation_schema(db: &DatabaseConnection) { let ddl = [ - "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,\ - worktree_id TEXT NOT NULL DEFAULT '', - scope_provenance TEXT NOT NULL DEFAULT 'declared', - restorable INTEGER NOT NULL DEFAULT 1, - control_slot TEXT, - claim_owner TEXT, - scope_kind TEXT NOT NULL DEFAULT 'main'\ - );", - "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 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 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)\ - );", - "CREATE INDEX IF NOT EXISTS idx_operation_repo_end_ts ON operation(repo_id, end_ts DESC);", - "CREATE INDEX IF NOT EXISTS idx_operation_parent_parent ON operation_parent(parent_op_id, op_id);", - "CREATE INDEX IF NOT EXISTS idx_operation_view_repo_created ON operation_view(repo_id, created_at DESC);", + "CREATE TABLE IF NOT EXISTS legacy_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,\ + worktree_id TEXT NOT NULL DEFAULT '', + scope_provenance TEXT NOT NULL DEFAULT 'declared', + restorable INTEGER NOT NULL DEFAULT 1, + control_slot TEXT, + claim_owner TEXT, + scope_kind TEXT NOT NULL DEFAULT 'main'\ + );", + "CREATE TABLE IF NOT EXISTS legacy_operation_parent(\ + op_id TEXT NOT NULL,\ + parent_op_id TEXT NOT NULL,\ + PRIMARY KEY (op_id, parent_op_id)\ + );", + "CREATE TABLE IF NOT EXISTS legacy_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 IF NOT EXISTS legacy_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 legacy_operation_view_workspace(\ + view_id TEXT NOT NULL,\ + pointer_kind TEXT NOT NULL,\ + pointer_value TEXT NOT NULL,\ + PRIMARY KEY (view_id, pointer_kind)\ + );", + "CREATE INDEX IF NOT EXISTS idx_legacy_operation_repo_end_ts ON legacy_operation(repo_id, end_ts DESC);", + "CREATE INDEX IF NOT EXISTS idx_operation_parent_parent ON legacy_operation_parent(parent_op_id, op_id);", + "CREATE INDEX IF NOT EXISTS idx_operation_view_repo_created ON legacy_operation_view(repo_id, created_at DESC);", ]; for sql in ddl { @@ -147,23 +147,23 @@ async fn duplicate_constraints_are_enforced_for_view_refs_and_workspace() { let ref_insert = Statement::from_string( DbBackend::Sqlite, - "INSERT INTO operation_view_ref(view_id, ref_kind, ref_name, ref_remote, target_oid) VALUES ('view_dup', 'branch', 'main', '', 'oid-1');", + "INSERT INTO legacy_operation_view_ref(view_id, ref_kind, ref_name, ref_remote, target_oid) VALUES ('view_dup', 'branch', 'main', '', 'oid-1');", ); db.execute_raw(ref_insert).await.unwrap(); let duplicate_ref = Statement::from_string( DbBackend::Sqlite, - "INSERT INTO operation_view_ref(view_id, ref_kind, ref_name, ref_remote, target_oid) VALUES ('view_dup', 'branch', 'main', '', 'oid-2');", + "INSERT INTO legacy_operation_view_ref(view_id, ref_kind, ref_name, ref_remote, target_oid) VALUES ('view_dup', 'branch', 'main', '', 'oid-2');", ); assert!(db.execute_raw(duplicate_ref).await.is_err()); let workspace_insert = Statement::from_string( DbBackend::Sqlite, - "INSERT INTO operation_view_workspace(view_id, pointer_kind, pointer_value) VALUES ('view_dup', 'index', 'oid-1');", + "INSERT INTO legacy_operation_view_workspace(view_id, pointer_kind, pointer_value) VALUES ('view_dup', 'index', 'oid-1');", ); db.execute_raw(workspace_insert).await.unwrap(); let duplicate_workspace = Statement::from_string( DbBackend::Sqlite, - "INSERT INTO operation_view_workspace(view_id, pointer_kind, pointer_value) VALUES ('view_dup', 'index', 'oid-2');", + "INSERT INTO legacy_operation_view_workspace(view_id, pointer_kind, pointer_value) VALUES ('view_dup', 'index', 'oid-2');", ); assert!(db.execute_raw(duplicate_workspace).await.is_err()); } diff --git a/tests/operation_wrapper_test.rs b/tests/operation_wrapper_test.rs index 391f348ea..b2b9587e2 100644 --- a/tests/operation_wrapper_test.rs +++ b/tests/operation_wrapper_test.rs @@ -53,15 +53,15 @@ fn sample_record(op_id: &str, status: OperationStatus, end_ts: i64) -> Operation /// Create the full operation-layer schema required by wrapper tests. async fn create_operation_schema(db: &DatabaseConnection) { let ddl = [ - "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,worktree_id TEXT NOT NULL DEFAULT '',scope_provenance TEXT NOT NULL DEFAULT 'declared',restorable INTEGER NOT NULL DEFAULT 1,control_slot TEXT,claim_owner TEXT,scope_kind TEXT NOT NULL DEFAULT 'main');", - "CREATE TABLE operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id));", + "CREATE TABLE legacy_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,worktree_id TEXT NOT NULL DEFAULT '',scope_provenance TEXT NOT NULL DEFAULT 'declared',restorable INTEGER NOT NULL DEFAULT 1,control_slot TEXT,claim_owner TEXT,scope_kind TEXT NOT NULL DEFAULT 'main');", + "CREATE TABLE legacy_operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id));", // Present in every real repository (bootstrap schema); the write-lock // primitive in `db::begin_write_transaction` writes a no-op row filter // against it, and a fixture without it is not a repository database. "CREATE TABLE config_kv(id INTEGER PRIMARY KEY AUTOINCREMENT,key TEXT NOT NULL,value TEXT NOT NULL,encrypted INTEGER NOT NULL DEFAULT 0);", - "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));", + "CREATE TABLE legacy_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 legacy_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 legacy_operation_view_workspace(view_id TEXT NOT NULL,pointer_kind TEXT NOT NULL,pointer_value TEXT NOT NULL,PRIMARY KEY (view_id,pointer_kind));", ]; for sql in ddl { db.execute_raw(Statement::from_string(DbBackend::Sqlite, sql.to_string())) @@ -70,17 +70,17 @@ async fn create_operation_schema(db: &DatabaseConnection) { } } -/// Create a schema that is missing `operation_view` so persist failure paths can be exercised. +/// Create a schema that is missing `legacy_operation_view` so persist failure paths can be exercised. async fn create_operation_schema_missing_view(db: &DatabaseConnection) { let ddl = [ - "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,worktree_id TEXT NOT NULL DEFAULT '',scope_provenance TEXT NOT NULL DEFAULT 'declared',restorable INTEGER NOT NULL DEFAULT 1,control_slot TEXT,claim_owner TEXT,scope_kind TEXT NOT NULL DEFAULT 'main');", - "CREATE TABLE operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id));", + "CREATE TABLE legacy_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,worktree_id TEXT NOT NULL DEFAULT '',scope_provenance TEXT NOT NULL DEFAULT 'declared',restorable INTEGER NOT NULL DEFAULT 1,control_slot TEXT,claim_owner TEXT,scope_kind TEXT NOT NULL DEFAULT 'main');", + "CREATE TABLE legacy_operation_parent(op_id TEXT NOT NULL,parent_op_id TEXT NOT NULL,PRIMARY KEY (op_id,parent_op_id));", // Present in every real repository (bootstrap schema); the write-lock // primitive in `db::begin_write_transaction` writes a no-op row filter // against it, and a fixture without it is not a repository database. "CREATE TABLE config_kv(id INTEGER PRIMARY KEY AUTOINCREMENT,key TEXT NOT NULL,value TEXT NOT NULL,encrypted INTEGER NOT NULL DEFAULT 0);", - "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));", + "CREATE TABLE legacy_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 legacy_operation_view_workspace(view_id TEXT NOT NULL,pointer_kind TEXT NOT NULL,pointer_value TEXT NOT NULL,PRIMARY KEY (view_id,pointer_kind));", ]; for sql in ddl { db.execute_raw(Statement::from_string(DbBackend::Sqlite, sql.to_string())) @@ -92,11 +92,11 @@ async fn create_operation_schema_missing_view(db: &DatabaseConnection) { /// Create the reference table with both HEAD and main branch rows. async fn create_reference_table_with_head(db: &DatabaseConnection) { db.execute_raw(Statement::from_string( - DbBackend::Sqlite, - "CREATE TABLE reference (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,kind TEXT NOT NULL,\"commit\" TEXT,remote TEXT,worktree_id TEXT)".to_string(), - )) - .await - .unwrap(); + DbBackend::Sqlite, + "CREATE TABLE reference (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,kind TEXT NOT NULL,\"commit\" TEXT,remote TEXT,worktree_id TEXT)".to_string(), + )) + .await + .unwrap(); // HEAD resolution is scoped to `WorktreeScope::current()` (cwd-derived): // seed the row for the scope this process actually runs in, so the suite // also passes when invoked from inside a linked worktree (where main's @@ -105,28 +105,28 @@ async fn create_reference_table_with_head(db: &DatabaseConnection) { .worktree_id() .map(str::to_string); db.execute_raw(Statement::from_sql_and_values( - DbBackend::Sqlite, - "INSERT INTO reference(name, kind, \"commit\", remote, worktree_id) VALUES('main', 'Head', NULL, NULL, ?)", - [scope_worktree_id.into()], - )) - .await - .unwrap(); + DbBackend::Sqlite, + "INSERT INTO reference(name, kind, \"commit\", remote, worktree_id) VALUES('main', 'Head', NULL, NULL, ?)", + [scope_worktree_id.into()], + )) + .await + .unwrap(); db.execute_raw(Statement::from_string( - DbBackend::Sqlite, - "INSERT INTO reference(name, kind, \"commit\", remote) VALUES('main', 'Branch', '1111111111111111111111111111111111111111', NULL)".to_string(), - )) - .await - .unwrap(); + DbBackend::Sqlite, + "INSERT INTO reference(name, kind, \"commit\", remote) VALUES('main', 'Branch', '1111111111111111111111111111111111111111', NULL)".to_string(), + )) + .await + .unwrap(); } /// Create the reference table without a HEAD row to force snapshot failure. async fn create_reference_table_without_head(db: &DatabaseConnection) { db.execute_raw(Statement::from_string( - DbBackend::Sqlite, - "CREATE TABLE reference (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,kind TEXT NOT NULL,\"commit\" TEXT,remote TEXT,worktree_id TEXT)".to_string(), - )) - .await - .unwrap(); + DbBackend::Sqlite, + "CREATE TABLE reference (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT,kind TEXT NOT NULL,\"commit\" TEXT,remote TEXT,worktree_id TEXT)".to_string(), + )) + .await + .unwrap(); } /// Create a probe table used to assert rollback behavior. @@ -341,7 +341,7 @@ async fn business_failure_rolls_back_all_writes() { let op_count = db .query_one_raw(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation".to_string(), + "SELECT COUNT(*) FROM legacy_operation".to_string(), )) .await .unwrap() @@ -388,7 +388,7 @@ async fn snapshot_failure_rolls_back_and_persists_nothing() { let op_count = db .query_one_raw(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation".to_string(), + "SELECT COUNT(*) FROM legacy_operation".to_string(), )) .await .unwrap() @@ -438,7 +438,7 @@ async fn persist_failure_rolls_back_business_writes() { let op_count = db .query_one_raw(Statement::from_string( DbBackend::Sqlite, - "SELECT COUNT(*) FROM operation".to_string(), + "SELECT COUNT(*) FROM legacy_operation".to_string(), )) .await .unwrap() @@ -632,10 +632,10 @@ async fn cross_scope_interference_cannot_hide_a_duplicate() { for i in 0..50 { db.execute_raw(Statement::from_sql_and_values( db.get_database_backend(), - "INSERT INTO operation (op_id, repo_id, view_id, command_name, description, actor, \ - args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ - VALUES (?, 'repo_1', ?, 'commit', 'other worktree', 'bob', 'sha256:other', ?, ?, \ - 'succeeded', ?, 'declared')", + "INSERT INTO legacy_operation (op_id, repo_id, view_id, command_name, description, actor, \ + args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ + VALUES (?, 'repo_1', ?, 'commit', 'other worktree', 'bob', 'sha256:other', ?, ?, \ + 'succeeded', ?, 'declared')", [ format!("op-other-{i}").into(), format!("view-other-{i}").into(), @@ -673,10 +673,10 @@ async fn the_same_action_in_another_worktree_is_not_a_duplicate() { let now = chrono::Utc::now().timestamp(); db.execute_raw(Statement::from_sql_and_values( db.get_database_backend(), - "INSERT INTO operation (op_id, repo_id, view_id, command_name, description, actor, \ - args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ - VALUES ('op-linked', 'repo_1', 'view-linked', 'commit', 'linked worktree', 'bob', \ - 'sha256:same-action', ?, ?, 'succeeded', 'wt-linked-1', 'declared')", + "INSERT INTO legacy_operation (op_id, repo_id, view_id, command_name, description, actor, \ + args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ + VALUES ('op-linked', 'repo_1', 'view-linked', 'commit', 'linked worktree', 'bob', \ + 'sha256:same-action', ?, ?, 'succeeded', 'wt-linked-1', 'declared')", [(now - 1).into(), (now - 1).into()], )) .await @@ -726,10 +726,10 @@ async fn op_restore_dedup_key_is_scope_aware() { let now = chrono::Utc::now().timestamp(); db.execute_raw(Statement::from_sql_and_values( db.get_database_backend(), - "INSERT INTO operation (op_id, repo_id, view_id, command_name, description, actor, \ - args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ - VALUES ('op-linked-restore', 'repo_1', 'view-linked-restore', 'op restore', \ - 'restore to 0191f0de', 'bob', ?, ?, ?, 'succeeded', 'wt-linked-1', 'declared')", + "INSERT INTO legacy_operation (op_id, repo_id, view_id, command_name, description, actor, \ + args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ + VALUES ('op-linked-restore', 'repo_1', 'view-linked-restore', 'op restore', \ + 'restore to 0191f0de', 'bob', ?, ?, ?, 'succeeded', 'wt-linked-1', 'declared')", [target_op_id.into(), (now - 1).into(), (now - 1).into()], )) .await @@ -784,10 +784,10 @@ async fn a_legacy_padded_digest_row_still_blocks_a_duplicate() { let now = chrono::Utc::now().timestamp(); db.execute_raw(Statement::from_sql_and_values( db.get_database_backend(), - "INSERT INTO operation (op_id, repo_id, view_id, command_name, description, actor, \ - args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ - VALUES ('op-legacy', 'repo_1', 'view-legacy', 'commit', 'legacy row', 'alice', \ - ' sha256:legacy-pad ', ?, ?, 'succeeded', ?, 'declared')", + "INSERT INTO legacy_operation (op_id, repo_id, view_id, command_name, description, actor, \ + args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ + VALUES ('op-legacy', 'repo_1', 'view-legacy', 'commit', 'legacy row', 'alice', \ + ' sha256:legacy-pad ', ?, ?, 'succeeded', ?, 'declared')", [ (now - 1).into(), (now - 1).into(), @@ -802,10 +802,10 @@ async fn a_legacy_padded_digest_row_still_blocks_a_duplicate() { for (op_id, digest) in [("op-tab", "\tsha256:legacy-tab\n"), ("op-ws", " ")] { db.execute_raw(Statement::from_sql_and_values( db.get_database_backend(), - "INSERT INTO operation (op_id, repo_id, view_id, command_name, description, actor, \ - args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ - VALUES (?, 'repo_1', ?, 'commit', 'legacy row', 'alice', ?, ?, ?, 'succeeded', ?, \ - 'declared')", + "INSERT INTO legacy_operation (op_id, repo_id, view_id, command_name, description, actor, \ + args_digest, start_ts, end_ts, status, worktree_id, scope_provenance) \ + VALUES (?, 'repo_1', ?, 'commit', 'legacy row', 'alice', ?, ?, ?, 'succeeded', ?, \ + 'declared')", [ op_id.into(), format!("view-{op_id}").into(), @@ -819,23 +819,24 @@ async fn a_legacy_padded_digest_row_still_blocks_a_duplicate() { .expect("seed a legacy row"); } - // Run the SHIPPED migration SQL, not a hand-written approximation: the - // point is that THAT predicate and THAT trim set canonicalize these rows. - db.execute_raw(Statement::from_string( - db.get_database_backend(), - include_str!("../sql/migrations/2026073001_operation_args_digest_canonical.sql") - .to_string(), - )) - .await - .expect("canonicalize"); + // Run the shipped migration body against the retained legacy table, not a + // hand-written approximation: the predicate and trim set must remain the + // same while the active v1 service is pointed at `legacy_operation`. + let canonical_sql = include_str!( + "../sql/migrations/2026073001_operation_args_digest_canonical.sql" + ) + .replacen("`operation`", "`legacy_operation`", 1); + db.execute_unprepared(&canonical_sql) + .await + .expect("canonicalize"); // Whitespace-only becomes NULL (no digest), and the tab/newline row is // trimmed to its token. let rows = db .query_all_raw(Statement::from_string( db.get_database_backend(), - "SELECT op_id, args_digest FROM operation WHERE op_id IN ('op-tab', 'op-ws') \ - ORDER BY op_id" + "SELECT op_id, args_digest FROM legacy_operation WHERE op_id IN ('op-tab', 'op-ws') \ + ORDER BY op_id" .to_string(), )) .await From bc0276f92886ac93f70256ec9ade90fb140878ca Mon Sep 17 00:00:00 2001 From: jackieismpc Date: Sat, 5 Sep 2026 15:35:54 +0800 Subject: [PATCH 2/2] fix(security): avoid logging capture session ids Signed-off-by: jackieismpc --- src/command/worktree.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/command/worktree.rs b/src/command/worktree.rs index cd656adf8..a1fed4627 100644 --- a/src/command/worktree.rs +++ b/src/command/worktree.rs @@ -4751,8 +4751,8 @@ async fn adopt_legacy_capture_scope( } if !output.quiet { println!( - "adopted legacy capture session {} into workspace {} at lease fence {}", - payload.session_id, payload.workspace_id, payload.workspace_fence + "adopted legacy capture session into workspace {} at lease fence {}", + payload.workspace_id, payload.workspace_fence ); } Ok(())