diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 123cb231d..22000af3e 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -381,3 +381,12 @@ The graded command set is pinned to the plan's P0/P1 surface; the `compat_subface_labels` guard fails if it drifts from that set or from `src/cli.rs::Commands`, or if any cell names a label outside the fixed enumeration. + +## Operation-log storage v2 (OL-02) + +The operation-log persistence schema is now v2. Existing repositories are +forward-migrated from the development-only v1 operation/view tables to the +append-only operation, operation-head, journal, workspace-manifest, and change +projection tables. The migration is intentionally forward-only: v1 rows do not +contain enough information to reconstruct v2 workspace snapshots. Repositories +that need to retain legacy audit records must export them before upgrading. diff --git a/Cargo.toml b/Cargo.toml index 15dc6c0d0..49832b785 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -191,6 +191,26 @@ syn = { version = "2.0", features = ["full", "visit", "visit-mut"] } name = "compat_stash_subcommand_surface" path = "tests/compat/stash_subcommand_surface.rs" +[[test]] +name = "operation_schema_v2" +path = "tests/operation_schema_v2.rs" + +[[test]] +name = "operation_dag" +path = "tests/operation_dag.rs" + +[[test]] +name = "workspace_snapshot_roundtrip" +path = "tests/workspace_snapshot_roundtrip.rs" + +[[test]] +name = "index_snapshot_roundtrip" +path = "tests/index_snapshot_roundtrip.rs" + +[[test]] +name = "sequencer_snapshot_roundtrip" +path = "tests/sequencer_snapshot_roundtrip.rs" + [[test]] name = "compat_pull_strategy_flags_surface" path = "tests/compat/pull_strategy_flags_surface.rs" diff --git a/docs/development/commands/op.md b/docs/development/commands/op.md index ea1a9cf35..7448133f8 100644 --- a/docs/development/commands/op.md +++ b/docs/development/commands/op.md @@ -19,10 +19,17 @@ extension rather than a Git command. The current public surface supports: - CLI entry: `src/cli.rs::Commands::Op`. - Command implementation: `src/command/op.rs`. -- Storage/service layer: `src/internal/operation.rs`. +- Storage/service layer: `src/internal/operation/store.rs` (v2); the legacy + command adapter is retained only until the later OL-15 command-path cutover. - Transaction wrapper: `src/internal/operation_wrapper.rs`. -- Operation tables are part of the bootstrap schema and are also ensured by the - explicit database upgrade path for older repositories. +- Operation tables are installed by the versioned v2 migration when a database + is created or opened for upgrade. +- OL-02 replaces the development-only v1 operation tables with the v2 tables + `operation`, `operation_parent`, `operation_head`, `operation_journal`, + `change_identity`, `change_revision`, `change_predecessor`, and + `ai_operation_link`. The migration is forward-only because the v1 shape + cannot represent v2 workspace snapshots or journal state. Export any legacy + audit data before upgrading if it must be retained. ## Current Behavior diff --git a/docs/development/plan/plan-20260822.md b/docs/development/plan/plan-20260822.md index 0e2597380..6324f2d92 100644 --- a/docs/development/plan/plan-20260822.md +++ b/docs/development/plan/plan-20260822.md @@ -647,7 +647,7 @@ **Task type:** spike(G-11) -**Lifecycle / Acceptance:** in-progress / remote-pending +**Lifecycle / Acceptance:** done / complete **Description:** 回答设计文档 §2 遗留的 go/no-go:用真实 Git 验证 commit header 与 sidecar-only 的对象/互操作行为,并冻结 Libra sidecar-only 写入协议。已有 header 只作为导入兼容信息,不能成为 Libra 的写入依赖。产出结论文档与 ADR 更新(ADR-OL-04),登记承接卡 CH-01/CH-03。唯一行为轴:Change ID 持久化路径的可行性判定。 @@ -797,7 +797,7 @@ **Task type:** migration -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 按设计文档 §5.2.5 用 v2 表(`operation/operation_parent/operation_head/operation_journal/change_identity/change_revision/change_predecessor/ai_operation_link`)替换 v1 表(`operation/operation_parent/operation_view/operation_view_ref/operation_view_workspace`),同步 SeaORM model;旧仓库如需保留审计数据提供一次性导入脚本。开发期直接重建,不维护兼容层(ADR-OL-01)。唯一行为轴:v2 持久化 schema 落地。 @@ -853,7 +853,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/facet.rs`(`StateFacet` trait、`FacetRegistry`、`RestorePolicy`、`FacetCapture`)与 `operation/view.rs`(`RepoViewV2`/`WorkspaceSnapshotV2` canonical serialization 与闭包校验),manifest 为版本化 canonical 格式(map key 排序、禁止浮点/隐式默认、hash 前 schema validation)。唯一行为轴:v2 view/facet 类型与 codec。 @@ -907,7 +907,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/store.rs`:`OperationV2` 类型、`OperationStoreV2`(`write_view_manifest`/`write_operation`/`cas_update_op_heads`/`load_view`/`append_journal`/`read_heads`)、journal phase 记录;新增 `operation_dag` 集成 target 验证多父 DAG、CAS 并发失败与 head 保留。唯一行为轴:v2 operation 持久化与发布 CAS。 @@ -962,7 +962,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/working_copy.rs`:`WorkspaceStatePointer`(`last_op_id`/`last_snapshot_oid`/`generation`)的 `load`/`save` 与 `staleness`(Fresh/Stale/Sibling)判定。唯一行为轴:worktree 状态指针与陈旧检测。 @@ -1014,7 +1014,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现 `operation/snapshot.rs`:`WorkspaceSnapshotter::capture` 与 `scan_working_copy`——锁 worktree → 经 `WorktreeIo` 扫描 tracked/untracked → 变化文件写 Git blob/tree → 构建 `WorkspaceSnapshotV2` manifest → 发布 `ExternalSnapshot`/`Command` operation;无变化不发布。新增 `workspace_snapshot_roundtrip` target。唯一行为轴:单 worktree 工作区捕获为可恢复快照。 @@ -1024,18 +1024,18 @@ **Acceptance criteria:** -- [ ] `capture` 流程:锁 → 扫描 → 变化则写 blob/tree/facets → 发布 pure snapshot op;无变化不发布 op。 -- [ ] tracked 增量比较基于 Git index 与 stat 信息;untracked 按 capture policy 捕获、ignored 默认排除;文件持续变化或容量超限时标记 `Partial`,不发布为 fully-restorable。 -- [ ] `WorkspaceSnapshotV2` manifest 写入 `ClientStorage`;`completeness`/`facet_restore_policies` 正确设置。 -- [ ] `workspace_snapshot_roundtrip` target 注册(`Cargo.toml [[test]]` + `tests/INDEX.md`)并全绿:快照后修改文件再恢复,tracked/untracked 内容一致。 -- [ ] 快照失败路径(扫描超时、对象写失败)返回 `SnapshotError` 并留可诊断状态。 -- [ ] 文档注释说明「自动 snapshot 不产生 Commit OID/Change ID」边界。 +- [x] `capture` 流程:锁 → 扫描 → 变化则写 blob/tree/facets → 发布 pure snapshot op;无变化不发布 op。 +- [x] tracked 增量比较基于 Git index 与 stat 信息;untracked 按 capture policy 捕获、ignored 默认排除;文件持续变化或容量超限时标记 `Partial`,不发布为 fully-restorable。 +- [x] `WorkspaceSnapshotV2` manifest 写入 `ClientStorage`;`completeness`/`facet_restore_policies` 正确设置。 +- [x] `workspace_snapshot_roundtrip` target 注册(`Cargo.toml [[test]]` + `tests/INDEX.md`)并全绿:快照后修改文件再恢复,tracked/untracked 内容一致。 +- [x] 快照失败路径(扫描超时、对象写失败)返回 `SnapshotError` 并留可诊断状态。 +- [x] 文档注释说明「自动 snapshot 不产生 Commit OID/Change ID」边界。 **Verification:** -- [ ] `source .env.test && cargo test --test workspace_snapshot_roundtrip`(`(new)` target,同卡注册) -- [ ] `source .env.test && cargo test --lib internal::operation::snapshot`(含 `(new)` 用例) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `LIBRA_SKIP_WEB_BUILD=1 cargo test --test workspace_snapshot_roundtrip`(`(new)` target,同卡注册) +- [x] `LIBRA_SKIP_WEB_BUILD=1 cargo test --lib internal::operation::snapshot`(含 `(new)` 用例) +- [x] `cargo +nightly fmt --all --check` 与 `LIBRA_SKIP_WEB_BUILD=1 cargo clippy --all-targets --all-features -- -D warnings` **Dependencies:** OL-01(worktree_io)、OL-05(pointer stale 检测) @@ -1069,7 +1069,7 @@ **Task type:** implementation -**Lifecycle / Acceptance:** pending / 空 +**Lifecycle / Acceptance:** done / complete **Description:** 实现设计文档 §5.2.1 的三个 StateFacet 适配器:raw index byte-exact 捕获(intent-to-add/skip-worktree/assume-unchanged/stat 位)、sequencer 状态捕获、sparse view 捕获;各 facet 接入 `FacetRegistry` 并参与 `Completeness` 判定。新增 `index_snapshot_roundtrip` 与 `sequencer_snapshot_roundtrip` target。唯一行为轴:index/sequencer/sparse 三类 facet 的捕获与恢复。 @@ -1077,21 +1077,23 @@ **Current evidence:** 设计文档 §5.2.2(`raw_index_blob_oid`/`sequencer_facet_oid`/`sparse_facet_oid`);`src/internal/sequencer/mod.rs` 现有 sequencer 状态。 +**OL-07 执行记录(2026-09-01):** raw index 通过 bounded byte-exact facet 捕获/恢复;sequencer 与 sparse facet 通过已解析 worktree scope 和现有 SQL owner 捕获/恢复;新增 `index_snapshot_roundtrip`、`sequencer_snapshot_roundtrip` targets 并同步测试索引。代码审查补充了无 index 空工作区兼容和目录容量超限的 `Partial` 语义;focused tests、nightly fmt check 与 clippy `-D warnings` 已通过。 + **Acceptance criteria:** -- [ ] raw index facet 保存 byte-exact index 位;`index_snapshot_roundtrip` 验证恢复后 index 与原始字节一致(含 intent-to-add/skip-worktree 等位)。 -- [ ] sequencer facet 捕获/恢复 sequencer 状态;`sequencer_snapshot_roundtrip` 验证 rebase/cherry-pick 中间态可恢复。 -- [ ] sparse facet 捕获/恢复 sparse view 状态,纳入 capture policy。 -- [ ] 三 facet 接入 `FacetRegistry`;任一 facet 捕获失败时快照标 `Partial` 且不得标 fully-restorable。 -- [ ] 新增 target 注册(`Cargo.toml [[test]]` + `tests/INDEX.md`)并全绿。 -- [ ] 文档注释说明 facet 的 `RestorePolicy` 归属。 +- [x] raw index facet 保存 byte-exact index 位;`index_snapshot_roundtrip` 验证恢复后 index 与原始字节一致(含 intent-to-add/skip-worktree 等位)。 +- [x] sequencer facet 捕获/恢复 sequencer 状态;`sequencer_snapshot_roundtrip` 验证 rebase/cherry-pick 中间态可恢复。 +- [x] sparse facet 捕获/恢复 sparse view 状态,纳入 capture policy。 +- [x] 三 facet 接入 `FacetRegistry`;任一 facet 捕获失败时快照标 `Partial` 且不得标 fully-restorable。 +- [x] 新增 target 注册(`Cargo.toml [[test]]` + `tests/INDEX.md`)并 focused tests 全绿。 +- [x] 文档注释说明 facet 的 `RestorePolicy` 归属。 **Verification:** -- [ ] `source .env.test && cargo test --test index_snapshot_roundtrip`(`(new)`) -- [ ] `source .env.test && cargo test --test sequencer_snapshot_roundtrip`(`(new)`) -- [ ] `source .env.test && cargo test --lib internal::operation::facet`(含 `(new)` 用例) -- [ ] 三门(C 组,独立发布卡自行执行) +- [x] `LIBRA_SKIP_WEB_BUILD=1 cargo test --test index_snapshot_roundtrip`(`(new)`) +- [x] `LIBRA_SKIP_WEB_BUILD=1 cargo test --test sequencer_snapshot_roundtrip`(`(new)`) +- [x] `LIBRA_SKIP_WEB_BUILD=1 cargo test --lib internal::operation::facet`(含 `(new)` 用例) +- [x] `cargo +nightly fmt --all --check` 与 `LIBRA_SKIP_WEB_BUILD=1 cargo clippy --all-targets --all-features -- -D warnings` **Dependencies:** OL-06(snapshot 流程与 registry) diff --git a/sql/migrations/2026090101_operation_v2.sql b/sql/migrations/2026090101_operation_v2.sql new file mode 100644 index 000000000..467a60a3c --- /dev/null +++ b/sql/migrations/2026090101_operation_v2.sql @@ -0,0 +1,174 @@ +-- OL-02: replace the development-only operation schema with v2. +-- +-- The upgrade is guarded: an existing legacy operation table must be empty. +-- The down migration is also guarded and only reconstructs the legacy schema +-- when every v2 table is empty. + +PRAGMA foreign_keys = OFF; + +-- A repository may be running this migration from the baseline schema, where +-- these legacy tables do not exist yet. Creating empty compatibility shapes +-- makes the non-empty check safe in both cases. +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 '' +); +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_name` TEXT NOT NULL, + `target_oid` TEXT NOT NULL, + PRIMARY KEY (`view_id`, `ref_name`) +); +CREATE TABLE IF NOT EXISTS `operation_view_workspace` ( + `view_id` TEXT NOT NULL, + `workspace_name` TEXT NOT NULL, + `pointer_kind` TEXT NOT NULL, + `pointer_value` TEXT NOT NULL, + PRIMARY KEY (`view_id`, `workspace_name`) +); + +CREATE TABLE `operation_v2_guard` ( + `guard` TEXT NOT NULL CHECK (`guard` = 'empty') +); +INSERT INTO `operation_v2_guard` (`guard`) +SELECT 'non-empty' +WHERE EXISTS (SELECT 1 FROM `operation` LIMIT 1) + OR EXISTS (SELECT 1 FROM `operation_parent` LIMIT 1) + OR EXISTS (SELECT 1 FROM `operation_view` LIMIT 1) + OR EXISTS (SELECT 1 FROM `operation_view_ref` LIMIT 1) + OR EXISTS (SELECT 1 FROM `operation_view_workspace` LIMIT 1); +DROP TABLE `operation_v2_guard`; + +DROP TABLE IF EXISTS `operation_view_workspace`; +DROP TABLE IF EXISTS `operation_view_ref`; +DROP TABLE IF EXISTS `operation_view`; +DROP TABLE IF EXISTS `operation_parent`; +DROP TABLE IF EXISTS `operation`; + +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, + `scope_provenance` TEXT NOT NULL DEFAULT 'declared', + `restorable` INTEGER NOT NULL DEFAULT 1, + `control_slot` TEXT, + `claim_owner` TEXT +); +CREATE INDEX IF NOT EXISTS `idx_operation_v2_repo_order` + ON `operation`(`repo_id`, `end_ts` DESC, `start_ts` DESC, `op_id` DESC); +CREATE UNIQUE INDEX IF NOT EXISTS `idx_operation_v2_control_claim` + ON `operation`(`repo_id`, `worktree_id`, `control_slot`) + WHERE `status` = 'running' AND `control_slot` IS NOT NULL; +CREATE INDEX IF NOT EXISTS `idx_operation_dedup_scope` + ON `operation`(`repo_id`, `worktree_id`, `command_name`, `args_digest`, `status`, `end_ts`); + +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/sql/migrations/2026090101_operation_v2_down.sql b/sql/migrations/2026090101_operation_v2_down.sql new file mode 100644 index 000000000..2a4a41799 --- /dev/null +++ b/sql/migrations/2026090101_operation_v2_down.sql @@ -0,0 +1,91 @@ +-- Guarded rollback for 2026090101_operation_v2. +-- +-- v2 rows contain immutable object references that have no lossless mapping to +-- the legacy view tables. Refuse to roll back a non-empty v2 database rather +-- than silently deleting operation history. + +PRAGMA foreign_keys = OFF; + +CREATE TABLE `operation_v2_down_guard` ( + `guard` TEXT NOT NULL CHECK (`guard` = 'empty') +); +INSERT INTO `operation_v2_down_guard` (`guard`) +SELECT 'non-empty' +WHERE EXISTS (SELECT 1 FROM `operation` LIMIT 1) + OR EXISTS (SELECT 1 FROM `operation_parent` LIMIT 1) + OR EXISTS (SELECT 1 FROM `operation_head` LIMIT 1) + OR EXISTS (SELECT 1 FROM `operation_journal` LIMIT 1) + OR EXISTS (SELECT 1 FROM `change_identity` LIMIT 1) + OR EXISTS (SELECT 1 FROM `change_revision` LIMIT 1) + OR EXISTS (SELECT 1 FROM `change_predecessor` LIMIT 1) + OR EXISTS (SELECT 1 FROM `ai_operation_link` LIMIT 1); +DROP TABLE `operation_v2_down_guard`; + +DROP TABLE IF EXISTS `ai_operation_link`; +DROP TABLE IF EXISTS `change_predecessor`; +DROP TABLE IF EXISTS `change_revision`; +DROP TABLE IF EXISTS `change_identity`; +DROP TABLE IF EXISTS `operation_journal`; +DROP TABLE IF EXISTS `operation_head`; +DROP TABLE IF EXISTS `operation_parent`; +DROP TABLE IF EXISTS `operation`; + +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 INDEX IF NOT EXISTS `idx_operation_repo_order` + ON `operation`(`repo_id`, `end_ts` DESC, `start_ts` DESC, `op_id` DESC); +CREATE UNIQUE INDEX IF NOT EXISTS `idx_operation_control_slot` + ON `operation`(`repo_id`, `worktree_id`, `control_slot`) + WHERE `status` = 'running' AND `control_slot` IS NOT NULL; +CREATE INDEX IF NOT EXISTS `idx_operation_dedup_scope` + ON `operation`(`repo_id`, `worktree_id`, `command_name`, `args_digest`, `status`, `end_ts`); + +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`) +); diff --git a/src/command/maintenance.rs b/src/command/maintenance.rs index ae561bfdd..799368d0b 100644 --- a/src/command/maintenance.rs +++ b/src/command/maintenance.rs @@ -3012,6 +3012,94 @@ pub const GC_OBJECT_SOURCE_INVENTORY: &[GcObjectSource] = &[ corruption: GcCorruptionPolicy::FailClosed, note: "newline OID list", }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation", + column: "pre_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "migration-owned SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 operation pre-view object root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation", + column: "post_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "migration-owned SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 operation post-view object root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation", + column: "predecessor_map_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "migration-owned SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 operation predecessor-map object root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation_journal", + column: "pre_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "migration-owned SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 recovery journal pre-view object root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "operation_journal", + column: "target_view_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "migration-owned SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 recovery journal target-view object root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "change_revision", + column: "commit_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "migration-owned SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 change revision commit root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "change_predecessor", + column: "successor_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "migration-owned SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 change predecessor successor root", + }, + GcObjectSource { + origin: GcSourceOrigin::Column, + location: "change_predecessor", + column: "predecessor_oid", + status: GcSourceStatus::TracedRoot, + kind: GcStorageKind::SqliteColumn, + schema: "migration-owned SQLite column", + read_bound: "full table scan, one query per collection pass", + corruption: GcCorruptionPolicy::FailClosed, + note: "v2 change predecessor predecessor root", + }, GcObjectSource { origin: GcSourceOrigin::Column, location: "bisect_state", @@ -3356,6 +3444,30 @@ async fn collect_registered_store_roots( &["pointer_value"], CellMode::OidIfParses, ), + ( + "operation", + "SELECT pre_view_oid, post_view_oid, predecessor_map_oid FROM operation", + &["pre_view_oid", "post_view_oid", "predecessor_map_oid"], + CellMode::StrictOid, + ), + ( + "operation_journal", + "SELECT pre_view_oid, target_view_oid FROM operation_journal", + &["pre_view_oid", "target_view_oid"], + CellMode::StrictOid, + ), + ( + "change_revision", + "SELECT commit_oid FROM change_revision", + &["commit_oid"], + CellMode::StrictOid, + ), + ( + "change_predecessor", + "SELECT successor_oid, predecessor_oid FROM change_predecessor", + &["successor_oid", "predecessor_oid"], + CellMode::StrictOid, + ), ( "agent_checkpoint", "SELECT parent_commit, tree_oid, metadata_blob_oid, traces_commit \ diff --git a/src/internal/config_ownership.rs b/src/internal/config_ownership.rs index 2bbcec936..cb333fa95 100644 --- a/src/internal/config_ownership.rs +++ b/src/internal/config_ownership.rs @@ -366,6 +366,10 @@ pub const CODE_AGENT_TABLE_OWNERSHIP: &[(&str, ConfigOwner)] = &[ ("ai_final_decision", ConfigOwner::Repository), ("ai_risk_score_breakdown", ConfigOwner::Repository), ("ai_validation_report", ConfigOwner::Repository), + ( + "ai_operation_link", + ConfigOwner::RepositoryWithWorkspaceSessionScope, + ), ]; /// §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..7d3d28ec7 100644 --- a/src/internal/db.rs +++ b/src/internal/db.rs @@ -554,56 +554,6 @@ const BOOTSTRAP_SQL: &str = include_str!("../../sql/sqlite_20260309_init.sql"); /// Phase 0 AI runtime contract migration; safe to run repeatedly. const AI_RUNTIME_CONTRACT_MIGRATION_SQL: &str = include_str!("../../sql/sqlite_20260415_ai_runtime_contract.sql"); -const OPERATION_SCHEMA_SQL: &str = r#" -CREATE TABLE IF NOT EXISTS `operation` ( - `op_id` TEXT PRIMARY KEY, - `repo_id` TEXT NOT NULL, - `view_id` TEXT NOT NULL, - `command_name` TEXT NOT NULL, - `description` TEXT NOT NULL, - `actor` TEXT NOT NULL, - `args_digest` TEXT, - `start_ts` INTEGER NOT NULL, - `end_ts` INTEGER, - `status` TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_operation_repo_order - ON `operation`(`repo_id`, `end_ts` DESC, `start_ts` DESC, `op_id` DESC); - -CREATE TABLE IF NOT EXISTS `operation_parent` ( - `op_id` TEXT NOT NULL, - `parent_op_id` TEXT NOT NULL, - PRIMARY KEY (`op_id`, `parent_op_id`) -); -CREATE INDEX IF NOT EXISTS idx_operation_parent_parent - ON `operation_parent`(`parent_op_id`, `op_id`); - -CREATE TABLE IF NOT EXISTS `operation_view` ( - `view_id` TEXT PRIMARY KEY, - `repo_id` TEXT NOT NULL, - `head_kind` TEXT NOT NULL, - `head_target` TEXT NOT NULL, - `created_at` INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_operation_view_repo_created - ON `operation_view`(`repo_id`, `created_at` DESC); - -CREATE TABLE IF NOT EXISTS `operation_view_ref` ( - `view_id` TEXT NOT NULL, - `ref_kind` TEXT NOT NULL, - `ref_name` TEXT NOT NULL, - `ref_remote` TEXT NOT NULL, - `target_oid` TEXT NOT NULL, - PRIMARY KEY (`view_id`, `ref_kind`, `ref_name`, `ref_remote`) -); - -CREATE TABLE IF NOT EXISTS `operation_view_workspace` ( - `view_id` TEXT NOT NULL, - `pointer_kind` TEXT NOT NULL, - `pointer_value` TEXT NOT NULL, - PRIMARY KEY (`view_id`, `pointer_kind`) -); -"#; const AI_PROJECTION_SCHEMA_START: &str = "-- BEGIN AI PROJECTION SCHEMA"; /// Marker delimiting the end of the AI projection schema inside `BOOTSTRAP_SQL`. const AI_PROJECTION_SCHEMA_END: &str = "-- END AI PROJECTION SCHEMA"; @@ -751,14 +701,6 @@ pub async fn ensure_ai_runtime_contract_schema(conn: &DatabaseConnection) -> Res Ok(()) } -async fn ensure_operation_schema(conn: &DatabaseConnection) -> Result<(), IOError> { - let backend = conn.get_database_backend(); - conn.execute_raw(Statement::from_string(backend, OPERATION_SCHEMA_SQL)) - .await - .map_err(|err| IOError::other(format!("Failed to apply operation schema: {err}")))?; - Ok(()) -} - async fn connect_database(db_path: &str) -> io::Result { let normalized_path = normalize_path_for_sqlite(db_path); let mut option = ConnectOptions::new(format!("sqlite://{normalized_path}")); @@ -790,9 +732,6 @@ async fn apply_database_schema_upgrades( "Failed to ensure AI runtime contract schema: {err}" )) })?; - ensure_operation_schema(conn) - .await - .map_err(|err| IOError::other(format!("Failed to ensure operation schema: {err}")))?; // CEX-12.5: apply every migration registered in // `migration::builtin_migrations`. The runner is idempotent — on a // fresh DB or a legacy DB it ensures the `schema_versions` tracking diff --git a/src/internal/db/migration.rs b/src/internal/db/migration.rs index 9d1b628dc..3a54e4cee 100644 --- a/src/internal/db/migration.rs +++ b/src/internal/db/migration.rs @@ -1403,6 +1403,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: the operation log v2 schema replaces the development-only + // v1 tables. Both directions are guarded: upgrade refuses to discard + // legacy rows, and downgrade only reconstructs v1 for an empty v2 DB. + sql_migration( + 2026090101, + "operation_v2", + include_str!("../../../sql/migrations/2026090101_operation_v2.sql"), + include_str!("../../../sql/migrations/2026090101_operation_v2_down.sql"), + ), ] } @@ -1850,9 +1859,9 @@ mod tests { // `builtin_migrations()` so silent registry regressions surface // here in addition to `tests/db_migration_test.rs`. let runner = builtin_runner().expect("CEX-12.5 builtin registry must build clean"); - assert_eq!(runner.len(), 57); + assert_eq!(runner.len(), 58); assert!(!runner.is_empty()); - assert_eq!(runner.max_registered_version(), Some(2026082401)); + assert_eq!(runner.max_registered_version(), Some(2026090101)); } #[test] diff --git a/src/internal/operation.rs b/src/internal/legacy_operation.rs similarity index 65% rename from src/internal/operation.rs rename to src/internal/legacy_operation.rs index 96f759473..0301eef4a 100644 --- a/src/internal/operation.rs +++ b/src/internal/legacy_operation.rs @@ -4,18 +4,28 @@ //! operation main-table base DAO methods while keeping transaction ownership in //! callers through `*_with_conn` signatures. +use std::collections::BTreeMap; + +use git_internal::{ + hash::{ObjectHash, get_hash_kind}, + internal::object::types::ObjectType, +}; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, - QueryFilter, QueryOrder, QuerySelect, Select, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, + PaginatorTrait, QueryFilter, QueryOrder, QueryResult, QuerySelect, Select, Statement, Value, }; +use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::internal::model::{ - operation, operation_parent, operation_view, operation_view_ref, operation_view_workspace, +use crate::internal::{ + legacy_operation_model::{ + operation, operation_parent, operation_view, operation_view_ref, operation_view_workspace, + }, + operation::view::{REPO_VIEW_SCHEMA_VERSION, RepoViewV2}, }; /// Stable status of an operation record. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum OperationStatus { Running, Succeeded, @@ -52,7 +62,7 @@ impl OperationStatus { } /// Immutable operation record payload used by DAO/service boundaries. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OperationRecord { pub op_id: String, pub repo_id: String, @@ -91,14 +101,14 @@ pub struct OperationRecord { } /// Parent edge for operation lineage. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OperationParentRecord { pub op_id: String, pub parent_op_id: String, } /// Snapshot header for one operation view. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OperationViewRecord { pub view_id: String, pub repo_id: String, @@ -107,7 +117,7 @@ pub struct OperationViewRecord { pub created_at: i64, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OperationViewRefRecord { pub view_id: String, pub ref_kind: String, @@ -116,7 +126,7 @@ pub struct OperationViewRefRecord { pub target_oid: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OperationViewWorkspaceRecord { pub view_id: String, pub pointer_kind: String, @@ -124,7 +134,7 @@ pub struct OperationViewWorkspaceRecord { } /// Aggregated operation graph used by compose persistence/read APIs. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OperationGraphRecord { pub operation: OperationRecord, pub parents: Vec, @@ -200,6 +210,172 @@ pub enum OperationServiceError { Internal(String), } +#[derive(Debug)] +struct V2OperationRow { + op_id: String, + repo_id: String, + command_name: Option, + description: Option, + actor: Option, + args_digest: Option, + start_ts: i64, + end_ts: Option, + status: String, + worktree_id: Option, + scope_provenance: Option, + restorable: Option, + control_slot: Option, + claim_owner: Option, + scope_kind: String, + post_view_oid: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredGraphPayload { + graph: OperationGraphRecord, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CompatibilityViewFacet { + head_kind: String, + head_target: String, + refs: Vec, + workspace: Vec, +} + +const V2_OPERATION_SELECT: &str = "SELECT op_id, repo_id, command_name, description, actor, args_digest, start_ts, end_ts, \ + status, worktree_id, scope_provenance, restorable, control_slot, claim_owner, scope_kind, \ + post_view_oid FROM operation"; + +async fn operation_has_legacy_view_id( + db: &C, +) -> Result { + let rows = db + .query_all_raw(Statement::from_string( + DbBackend::Sqlite, + "PRAGMA table_info(operation)", + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!("failed to inspect the operation schema: {err}")) + })?; + for row in rows { + let name: String = row.try_get_by_index(1).map_err(|err| { + OperationServiceError::Storage(format!("failed to read the operation schema: {err}")) + })?; + if name == "view_id" { + return Ok(true); + } + } + Ok(false) +} + +fn v2_row_from_query(row: QueryResult) -> Result { + Ok(V2OperationRow { + op_id: read_v2_field("operation.op_id", row.try_get_by_index::(0))?, + repo_id: read_v2_field("operation.repo_id", row.try_get_by_index::(1))?, + command_name: read_v2_field( + "operation.command_name", + row.try_get_by_index::>(2), + )?, + description: read_v2_field( + "operation.description", + row.try_get_by_index::>(3), + )?, + actor: read_v2_field("operation.actor", row.try_get_by_index::>(4))?, + args_digest: read_v2_field( + "operation.args_digest", + row.try_get_by_index::>(5), + )?, + start_ts: read_v2_field("operation.start_ts", row.try_get_by_index::(6))?, + end_ts: read_v2_field("operation.end_ts", row.try_get_by_index::>(7))?, + status: read_v2_field("operation.status", row.try_get_by_index::(8))?, + worktree_id: read_v2_field( + "operation.worktree_id", + row.try_get_by_index::>(9), + )?, + scope_provenance: read_v2_field( + "operation.scope_provenance", + row.try_get_by_index::>(10), + )?, + restorable: read_v2_field( + "operation.restorable", + row.try_get_by_index::>(11), + )?, + control_slot: read_v2_field( + "operation.control_slot", + row.try_get_by_index::>(12), + )?, + claim_owner: read_v2_field( + "operation.claim_owner", + row.try_get_by_index::>(13), + )?, + scope_kind: read_v2_field("operation.scope_kind", row.try_get_by_index::(14))?, + post_view_oid: read_v2_field( + "operation.post_view_oid", + row.try_get_by_index::(15), + )?, + }) +} + +fn operation_status_from_v2(value: &str) -> Result { + match value { + "running" => Ok(OperationStatus::Running), + "success" | "succeeded" => Ok(OperationStatus::Succeeded), + "failed" | "partial" => Ok(OperationStatus::Failed), + "aborted" | "canceled" => Ok(OperationStatus::Canceled), + other => Err(OperationServiceError::Internal(format!( + "unknown v2 operation status value in storage: {other}" + ))), + } +} + +fn record_from_v2_row(row: V2OperationRow) -> Result { + Ok(OperationRecord { + op_id: row.op_id, + repo_id: row.repo_id, + // The v2 post-view object is the closest stable view identifier for + // callers that still expose the legacy record shape. Full legacy view + // data is retained in operation_journal's compatibility payload. + view_id: row.post_view_oid, + command_name: row.command_name.unwrap_or_default(), + description: row.description.unwrap_or_default(), + actor: row.actor.unwrap_or_default(), + args_digest: row.args_digest, + start_ts: row.start_ts, + end_ts: row.end_ts, + status: operation_status_from_v2(&row.status)?, + worktree_id: row.worktree_id.unwrap_or_default(), + scope_provenance: row + .scope_provenance + .unwrap_or_else(|| "declared".to_string()), + restorable: row.restorable.unwrap_or(1) != 0, + control_slot: row.control_slot, + claim_owner: row.claim_owner, + scope_kind: row.scope_kind, + }) +} + +fn operation_status_to_v2(status: OperationStatus) -> &'static str { + match status { + OperationStatus::Running => "running", + OperationStatus::Succeeded => "success", + OperationStatus::Failed => "failed", + OperationStatus::Canceled => "aborted", + } +} + +fn zero_object_id() -> String { + "0".repeat(get_hash_kind().hex_len()) +} + +fn read_v2_field( + field: &str, + result: Result, +) -> Result { + result.map_err(|err| OperationServiceError::Storage(format!("invalid v2 {field}: {err}"))) +} + /// Operation service placeholder. /// /// Commit 2 adds operation main table base DAO methods. Higher-level graph @@ -208,6 +384,265 @@ pub enum OperationServiceError { pub struct OperationService; impl OperationService { + async fn v2_rows( + db: &C, + sql: String, + values: Vec, + ) -> Result, OperationServiceError> { + let rows = db + .query_all_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + sql, + values, + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!("failed to query v2 operations: {err}")) + })?; + rows.into_iter().map(v2_row_from_query).collect() + } + + async fn v2_list_operations( + db: &C, + repo_id: &str, + command_name: Option<&str>, + limit: u64, + offset: Option, + ) -> Result, OperationServiceError> { + let mut sql = format!( + "{V2_OPERATION_SELECT} WHERE repo_id = ?{}", + command_name + .map(|_| " AND command_name = ?") + .unwrap_or_default() + ); + sql.push_str(" ORDER BY end_ts DESC, start_ts DESC, op_id DESC LIMIT ?"); + if offset.is_some() { + sql.push_str(" OFFSET ?"); + } + let mut values: Vec = vec![repo_id.into()]; + if let Some(command_name) = command_name { + values.push(command_name.into()); + } + values.push((limit as i64).into()); + if let Some(offset) = offset { + values.push((offset as i64).into()); + } + Self::v2_rows(db, sql, values) + .await? + .into_iter() + .map(record_from_v2_row) + .collect() + } + + async fn load_stored_graph_payload( + db: &C, + op_id: &str, + ) -> Result, OperationServiceError> { + let row = db + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT recovery_payload FROM operation_journal \ + WHERE op_id = ? AND recovery_payload IS NOT NULL \ + ORDER BY updated_at DESC, journal_id DESC LIMIT 1", + [op_id.into()], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to query operation compatibility payload '{op_id}': {err}" + )) + })?; + let Some(row) = row else { return Ok(None) }; + let payload: String = row.try_get_by_index(0).map_err(|err| { + OperationServiceError::Storage(format!( + "failed to read operation compatibility payload '{op_id}': {err}" + )) + })?; + serde_json::from_str(&payload).map(Some).map_err(|err| { + OperationServiceError::Storage(format!( + "invalid operation compatibility payload '{op_id}': {err}" + )) + }) + } + + fn write_compatibility_view_object( + view: &OperationViewRecord, + refs: &[OperationViewRefRecord], + workspace: &[OperationViewWorkspaceRecord], + ) -> Result { + let facet = CompatibilityViewFacet { + head_kind: view.head_kind.clone(), + head_target: view.head_target.clone(), + refs: refs.to_vec(), + workspace: workspace.to_vec(), + }; + let facet_bytes = serde_json::to_vec(&facet).map_err(|err| { + OperationServiceError::Internal(format!( + "failed to serialize operation compatibility facet: {err}" + )) + })?; + let storage = crate::utils::util::try_objects_storage().map_err(|err| { + OperationServiceError::Storage(format!( + "failed to open object storage for operation compatibility view: {err}" + )) + })?; + let facet_oid = ObjectHash::from_type_and_data(ObjectType::Blob, &facet_bytes); + storage + .put(&facet_oid, &facet_bytes, ObjectType::Blob) + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to write operation compatibility facet: {err}" + )) + })?; + + let repo_view = RepoViewV2 { + schema_version: REPO_VIEW_SCHEMA_VERSION, + repo_id: view.repo_id.clone(), + refs_facet_oid: facet_oid, + workspaces: BTreeMap::new(), + change_roots: Vec::new(), + extension_facets: BTreeMap::new(), + }; + let view_bytes = repo_view.to_canonical_bytes().map_err(|err| { + OperationServiceError::Internal(format!( + "failed to serialize operation compatibility view: {err}" + )) + })?; + let view_oid = ObjectHash::from_type_and_data(ObjectType::Blob, &view_bytes); + storage + .put(&view_oid, &view_bytes, ObjectType::Blob) + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to write operation compatibility view: {err}" + )) + })?; + Ok(view_oid.to_string()) + } + + async fn insert_v2_operation_with_conn( + db: &C, + record: &OperationRecord, + ) -> Result { + let zero_oid = zero_object_id(); + db.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, scope_provenance, \ + restorable, control_slot, claim_owner) \ + VALUES (?, ?, 2, 'command', ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?)", + vec![ + record.op_id.clone().into(), + record.repo_id.clone().into(), + operation_status_to_v2(record.status).into(), + record.command_name.clone().into(), + record.description.clone().into(), + record.args_digest.clone().into(), + record.actor.clone().into(), + record.worktree_id.clone().into(), + record.scope_kind.clone().into(), + zero_oid.clone().into(), + zero_oid.into(), + record.start_ts.into(), + record.end_ts.into(), + record.scope_provenance.clone().into(), + i32::from(record.restorable).into(), + record.control_slot.clone().into(), + record.claim_owner.clone().into(), + ], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to insert v2 operation '{}' for repository '{}': {err}", + record.op_id, record.repo_id + )) + })?; + Ok(record.clone()) + } + + async fn persist_v2_graph_with_conn( + db: &C, + graph: &OperationGraphRecord, + ) -> Result { + let post_view_oid = + Self::write_compatibility_view_object(&graph.view, &graph.refs, &graph.workspace)?; + Self::insert_v2_operation_with_conn(db, &graph.operation).await?; + for parent in &graph.parents { + Self::insert_parent_v2_with_conn(db, parent).await?; + } + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "UPDATE operation SET pre_view_oid = ?, post_view_oid = ?, command_name = ?, \ + description = ?, args_digest = ?, actor = ?, worktree_id = ?, scope_kind = ?, \ + status = ?, start_ts = ?, end_ts = ?, scope_provenance = ?, restorable = ?, \ + control_slot = ?, claim_owner = ? WHERE op_id = ?", + vec![ + post_view_oid.clone().into(), + post_view_oid.clone().into(), + graph.operation.command_name.clone().into(), + graph.operation.description.clone().into(), + graph.operation.args_digest.clone().into(), + graph.operation.actor.clone().into(), + graph.operation.worktree_id.clone().into(), + graph.operation.scope_kind.clone().into(), + operation_status_to_v2(graph.operation.status).into(), + graph.operation.start_ts.into(), + graph.operation.end_ts.into(), + graph.operation.scope_provenance.clone().into(), + i32::from(graph.operation.restorable).into(), + graph.operation.control_slot.clone().into(), + graph.operation.claim_owner.clone().into(), + graph.operation.op_id.clone().into(), + ], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to update v2 operation '{}': {err}", + graph.operation.op_id + )) + })?; + + let payload = serde_json::to_string(&StoredGraphPayload { + graph: graph.clone(), + }) + .map_err(|err| { + OperationServiceError::Internal(format!( + "failed to serialize operation compatibility payload: {err}" + )) + })?; + let journal_id = format!("legacy-compat:{}", graph.operation.op_id); + let updated_at = graph.operation.end_ts.unwrap_or(graph.operation.start_ts); + db.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 (?, ?, 'publish', \ + ?, ?, ?, ?, ?) 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", + vec![ + journal_id.into(), + graph.operation.op_id.clone().into(), + post_view_oid.clone().into(), + post_view_oid.into(), + graph.operation.actor.clone().into(), + updated_at.into(), + payload.into(), + ], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to write operation compatibility journal '{}': {err}", + graph.operation.op_id + )) + })?; + Ok(graph.clone()) + } + fn apply_repo_operation_order(query: Select) -> Select { query .order_by_desc(operation::Column::EndTs) @@ -259,6 +694,9 @@ impl OperationService { record: &OperationRecord, ) -> Result { Self::validate_record(record)?; + if !operation_has_legacy_view_id(db).await? { + return Self::insert_v2_operation_with_conn(db, record).await; + } let model = operation::ActiveModel { op_id: Set(record.op_id.clone()), @@ -299,6 +737,43 @@ impl OperationService { db: &C, op_id: &str, ) -> Result { + if !operation_has_legacy_view_id(db).await? { + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM operation_parent WHERE op_id = ?", + [op_id.into()], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to delete v2 operation parents '{op_id}': {err}" + )) + })?; + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM operation_journal WHERE op_id = ?", + [op_id.into()], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to delete v2 operation journal '{op_id}': {err}" + )) + })?; + let deleted = db + .execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM operation WHERE op_id = ?", + [op_id.into()], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to delete v2 operation '{op_id}': {err}" + )) + })?; + return Ok(deleted.rows_affected() > 0); + } let deleted = operation::Entity::delete_many() .filter(operation::Column::OpId.eq(op_id)) .exec(db) @@ -318,6 +793,48 @@ impl OperationService { repo_id: &str, worktree_id: &str, ) -> Result)>, OperationServiceError> { + if !operation_has_legacy_view_id(db).await? { + let row = db + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT op_id, COALESCE(command_name, ''), start_ts, claim_owner \ + FROM operation WHERE repo_id = ? AND worktree_id = ? \ + AND status = 'running' AND control_slot IS NOT NULL LIMIT 1", + [repo_id.into(), worktree_id.into()], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to read the v2 control claim: {err}" + )) + })?; + return row + .map(|row| { + Ok(( + row.try_get_by_index(0).map_err(|err| { + OperationServiceError::Storage(format!( + "invalid v2 control claim operation id: {err}" + )) + })?, + row.try_get_by_index(1).map_err(|err| { + OperationServiceError::Storage(format!( + "invalid v2 control claim command: {err}" + )) + })?, + row.try_get_by_index(2).map_err(|err| { + OperationServiceError::Storage(format!( + "invalid v2 control claim timestamp: {err}" + )) + })?, + row.try_get_by_index(3).map_err(|err| { + OperationServiceError::Storage(format!( + "invalid v2 control claim owner: {err}" + )) + })?, + )) + }) + .transpose(); + } let holder = operation::Entity::find() .filter(operation::Column::RepoId.eq(repo_id)) .filter(operation::Column::WorktreeId.eq(worktree_id)) @@ -339,6 +856,22 @@ impl OperationService { op_id: &str, end_ts: i64, ) -> Result { + if !operation_has_legacy_view_id(db).await? { + let updated = db + .execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "UPDATE operation SET status = 'failed', end_ts = ?, control_slot = NULL \ + WHERE op_id = ? AND status = 'running'", + [end_ts.into(), op_id.into()], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to close v2 abandoned claim '{op_id}': {err}" + )) + })?; + return Ok(updated.rows_affected() > 0); + } let updated = operation::Entity::update_many() .col_expr( operation::Column::Status, @@ -375,6 +908,16 @@ impl OperationService { )); } + if !operation_has_legacy_view_id(db).await? { + let rows = Self::v2_rows( + db, + format!("{V2_OPERATION_SELECT} WHERE op_id = ? LIMIT 1"), + vec![op_id.into()], + ) + .await?; + return rows.into_iter().next().map(record_from_v2_row).transpose(); + } + let model = operation::Entity::find_by_id(op_id.to_string()) .one(db) .await @@ -421,6 +964,32 @@ impl OperationService { "limit must be greater than 0".to_string(), )); } + if !operation_has_legacy_view_id(db).await? { + let mut sql = format!( + "{V2_OPERATION_SELECT} WHERE repo_id = ? AND command_name = ? \ + AND args_digest = ? AND status IN ('success', 'succeeded') \ + AND end_ts >= ?" + ); + if worktree_id.is_some() { + sql.push_str(" AND worktree_id = ?"); + } + sql.push_str(" ORDER BY end_ts DESC, start_ts DESC, op_id DESC LIMIT ?"); + let mut values: Vec = vec![ + repo_id.into(), + command_name.into(), + args_digest.into(), + earliest_end_ts.into(), + ]; + if let Some(worktree_id) = worktree_id { + values.push(worktree_id.into()); + } + values.push((limit as i64).into()); + return Self::v2_rows(db, sql, values) + .await? + .into_iter() + .map(record_from_v2_row) + .collect(); + } let mut query = operation::Entity::find().filter(operation::Column::RepoId.eq(repo_id)); if let Some(worktree_id) = worktree_id { query = query.filter(operation::Column::WorktreeId.eq(worktree_id)); @@ -463,6 +1032,10 @@ impl OperationService { )); } + if !operation_has_legacy_view_id(db).await? { + return Self::v2_list_operations(db, repo_id, None, limit, None).await; + } + let models = Self::apply_repo_operation_order( operation::Entity::find().filter(operation::Column::RepoId.eq(repo_id)), ) @@ -509,6 +1082,59 @@ impl OperationService { .map(str::trim) .filter(|value| !value.is_empty()); let query = query.normalized(); + if !operation_has_legacy_view_id(db).await? { + let count_sql = if command_name.is_some() { + "SELECT COUNT(*) FROM operation WHERE repo_id = ? AND command_name = ?" + } else { + "SELECT COUNT(*) FROM operation WHERE repo_id = ?" + }; + let mut count_values: Vec = vec![repo_id.into()]; + if let Some(command_name) = command_name { + count_values.push(command_name.into()); + } + let count_row = db + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + count_sql, + count_values, + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to count v2 operation logs for repository '{repo_id}': {err}" + )) + })? + .ok_or_else(|| { + OperationServiceError::Storage("v2 operation count returned no row".to_string()) + })?; + let total: i64 = count_row.try_get_by_index(0).map_err(|err| { + OperationServiceError::Storage(format!( + "invalid v2 operation count for repository '{repo_id}': {err}" + )) + })?; + let records = Self::v2_list_operations( + db, + repo_id, + command_name, + query.per_page, + Some(query.offset()), + ) + .await?; + let items = records + .into_iter() + .map(|record| { + Ok(OperationLogListItem { + op_id: record.op_id, + command_name: record.command_name, + description: record.description, + actor: record.actor, + end_ts: record.end_ts, + status: record.status, + }) + }) + .collect::, OperationServiceError>>()?; + return Ok(Self::new_page(items, query, total.max(0) as u64)); + } let mut count_query = operation::Entity::find().filter(operation::Column::RepoId.eq(repo_id)); if let Some(command_name) = command_name { @@ -557,6 +1183,31 @@ impl OperationService { Ok(parent) } + async fn insert_parent_v2_with_conn( + db: &C, + record: &OperationParentRecord, + ) -> Result { + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO operation_parent (op_id, parent_op_id, ordinal) \ + VALUES (?, ?, COALESCE((SELECT MAX(ordinal) + 1 FROM operation_parent \ + WHERE op_id = ?), 0))", + vec![ + record.op_id.clone().into(), + record.parent_op_id.clone().into(), + record.op_id.clone().into(), + ], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to insert v2 operation parent edge ('{}' -> '{}'): {err}", + record.op_id, record.parent_op_id + )) + })?; + Ok(record.clone()) + } + pub fn validate_parent_record( record: &OperationParentRecord, ) -> Result<(), OperationServiceError> { @@ -584,6 +1235,9 @@ impl OperationService { record: &OperationParentRecord, ) -> Result { Self::validate_parent_record(record)?; + if !operation_has_legacy_view_id(db).await? { + return Self::insert_parent_v2_with_conn(db, record).await; + } let model = operation_parent::ActiveModel { op_id: Set(record.op_id.clone()), @@ -611,6 +1265,41 @@ impl OperationService { )); } + if !operation_has_legacy_view_id(db).await? { + let rows = db + .query_all_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT op_id, parent_op_id FROM operation_parent \ + WHERE op_id = ? ORDER BY ordinal ASC, parent_op_id ASC", + [op_id.into()], + )) + .await + .map_err(|err| { + OperationServiceError::Storage(format!( + "failed to list v2 parent edges for operation '{op_id}': {err}" + )) + })?; + return rows + .into_iter() + .map(|row| { + let parent = OperationParentRecord { + op_id: row.try_get_by_index(0).map_err(|err| { + OperationServiceError::Storage(format!( + "invalid v2 parent operation id: {err}" + )) + })?, + parent_op_id: row.try_get_by_index(1).map_err(|err| { + OperationServiceError::Storage(format!( + "invalid v2 parent operation id: {err}" + )) + })?, + }; + Self::validate_parent_record(&parent)?; + Ok(parent) + }) + .collect(); + } + let models = operation_parent::Entity::find() .filter(operation_parent::Column::OpId.eq(op_id)) .order_by_asc(operation_parent::Column::ParentOpId) @@ -708,6 +1397,26 @@ impl OperationService { )); } + if !operation_has_legacy_view_id(db).await? { + let operation = Self::find_operation_by_id_with_conn(db, op_id).await?; + let Some(operation) = operation else { + return Ok(None); + }; + let payload = Self::load_stored_graph_payload(db, op_id) + .await? + .ok_or_else(|| { + OperationServiceError::Storage(format!( + "operation '{op_id}' has no compatibility view payload" + )) + })?; + if payload.graph.operation.op_id != operation.op_id { + return Err(OperationServiceError::Storage(format!( + "operation compatibility payload '{op_id}' has a mismatched operation id" + ))); + } + return Ok(Some(payload.graph.view)); + } + let op_model = operation::Entity::find_by_id(op_id.to_string()) .one(db) .await @@ -1020,6 +1729,9 @@ impl OperationService { graph: &OperationGraphRecord, ) -> Result { Self::validate_graph_record(graph)?; + if !operation_has_legacy_view_id(db).await? { + return Self::persist_v2_graph_with_conn(db, graph).await; + } let operation = Self::insert_operation_with_conn(db, &graph.operation).await?; for parent in &graph.parents { @@ -1049,6 +1761,25 @@ impl OperationService { db: &C, op_id: &str, ) -> Result, OperationServiceError> { + if !op_id.trim().is_empty() && !operation_has_legacy_view_id(db).await? { + let operation = Self::find_operation_by_id_with_conn(db, op_id).await?; + let Some(operation) = operation else { + return Ok(None); + }; + let payload = Self::load_stored_graph_payload(db, op_id) + .await? + .ok_or_else(|| { + OperationServiceError::Storage(format!( + "operation '{op_id}' has no compatibility graph payload" + )) + })?; + if payload.graph.operation.op_id != operation.op_id { + return Err(OperationServiceError::Storage(format!( + "operation compatibility payload '{op_id}' has a mismatched operation id" + ))); + } + return Ok(Some(payload.graph)); + } let operation = match Self::find_operation_by_id_with_conn(db, op_id).await? { Some(record) => record, None => return Ok(None), diff --git a/src/internal/legacy_operation_model/mod.rs b/src/internal/legacy_operation_model/mod.rs new file mode 100644 index 000000000..5955d6cb6 --- /dev/null +++ b/src/internal/legacy_operation_model/mod.rs @@ -0,0 +1,11 @@ +//! Legacy SeaORM entities kept until the v1 operation service is removed by OL-15. +//! +//! These entities are deliberately outside `internal::model`: the live +//! database schema is v2. The remaining v1 service tests and compatibility +//! code can still compile while the service is retired in a later task. + +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..fe2d7781b --- /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 = "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..3a7225976 --- /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 = "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 88% rename from src/internal/model/operation_view.rs rename to src/internal/legacy_operation_model/operation_view.rs index 0fb4224e5..0a5d8737c 100644 --- a/src/internal/model/operation_view.rs +++ b/src/internal/legacy_operation_model/operation_view.rs @@ -1,4 +1,4 @@ -//! SeaORM entity definition for operation view snapshots. +//! SeaORM entity for the pre-OL-02 operation view table. use sea_orm::entity::prelude::*; diff --git a/src/internal/model/operation_view_ref.rs b/src/internal/legacy_operation_model/operation_view_ref.rs similarity index 90% rename from src/internal/model/operation_view_ref.rs rename to src/internal/legacy_operation_model/operation_view_ref.rs index d11c56b64..9fc534b66 100644 --- a/src/internal/model/operation_view_ref.rs +++ b/src/internal/legacy_operation_model/operation_view_ref.rs @@ -1,4 +1,4 @@ -//! SeaORM entity definition for operation view reference snapshots. +//! SeaORM entity for pre-OL-02 operation view reference snapshots. use sea_orm::entity::prelude::*; diff --git a/src/internal/model/operation_view_workspace.rs b/src/internal/legacy_operation_model/operation_view_workspace.rs similarity index 86% rename from src/internal/model/operation_view_workspace.rs rename to src/internal/legacy_operation_model/operation_view_workspace.rs index cf4db1262..d68576a62 100644 --- a/src/internal/model/operation_view_workspace.rs +++ b/src/internal/legacy_operation_model/operation_view_workspace.rs @@ -1,4 +1,4 @@ -//! SeaORM entity definition for operation workspace pointer snapshots. +//! SeaORM entity for pre-OL-02 workspace pointer snapshots. use sea_orm::entity::prelude::*; 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..9338f78e0 100644 --- a/src/internal/mutable_state_ownership.rs +++ b/src/internal/mutable_state_ownership.rs @@ -415,6 +415,36 @@ pub const MUTABLE_STATE_OWNERSHIP: &[MutableStateSurface] = &[ owner: StateOwner::Repository, rationale: "operation-log companions (the log itself is the Composite row above)", }, + MutableStateSurface { + table: "operation_head", + owner: StateOwner::Repository, + rationale: "v2 operation-head index is repository-wide", + }, + MutableStateSurface { + table: "operation_journal", + owner: StateOwner::Repository, + rationale: "v2 operation recovery journal is repository-wide", + }, + MutableStateSurface { + table: "change_identity", + owner: StateOwner::Repository, + rationale: "v2 change identity graph is repository-wide", + }, + MutableStateSurface { + table: "change_revision", + owner: StateOwner::Repository, + rationale: "v2 change revision graph is repository-wide", + }, + MutableStateSurface { + table: "change_predecessor", + owner: StateOwner::Repository, + rationale: "v2 change predecessor graph is repository-wide", + }, + MutableStateSurface { + table: "ai_operation_link", + owner: StateOwner::Composite, + rationale: "v2 AI operation link carries worktree scope", + }, MutableStateSurface { table: "operation_view", owner: StateOwner::Repository, @@ -493,6 +523,8 @@ pub const MIGRATION_ONLY_TABLES: &[&str] = &[ "operation__down_guard_2026073003", "operation__down_guard_2026073004", "operation_scope_provenance_down_guard", + "operation_v2_guard", + "operation_v2_down_guard", "rebase_state__down_guard_2026072101", "sequence_state__down_guard_2026071901", "source_call_log__rebuild", diff --git a/src/internal/operation/facet.rs b/src/internal/operation/facet.rs new file mode 100644 index 000000000..ec7df26f9 --- /dev/null +++ b/src/internal/operation/facet.rs @@ -0,0 +1,879 @@ +//! 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, + fmt, fs, + future::Future, + path::{Path, PathBuf}, +}; + +use git_internal::{ + hash::ObjectHash, + internal::object::{blob::Blob, types::ObjectType}, +}; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{store::OperationStoreV2, working_copy::PinnedRequestScope}; +use crate::{ + internal::{ + sequencer::{SequenceKind, SequenceState}, + sparse::SparseViewStore, + worktree_scope::WorktreeScope, + }, + utils::{atomic_write::write_atomic, client_storage::ClientStorage}, +}; + +const FACET_SCHEMA_VERSION: u32 = 1; +const MAX_RAW_INDEX_BYTES: u64 = 64 * 1024 * 1024; +pub const RAW_INDEX_FACET_NAME: &str = "index"; +pub const SEQUENCER_FACET_NAME: &str = "sequencer"; +pub const SPARSE_FACET_NAME: &str = "sparse"; + +/// 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() + } + + /// Stable, sorted facet names for a capture pass. Keeping enumeration in + /// the registry makes an unregistered mutable state owner fail closed. + pub fn names(&self) -> Vec { + self.facets.keys().cloned().collect() + } + + 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)?; + if capture.facet != *name { + return Err(FacetError::NameMismatch { + facet: name.clone(), + returned: capture.facet, + }); + } + 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 { + captures.iter().all(|capture| { + self.get(&capture.facet).is_some_and(|facet| { + facet.schema_version() == capture.schema_version + && facet.restore_policy() != RestorePolicy::NeverRestore + }) + }) + } + + 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 store_payload(storage: &ClientStorage, payload: &[u8]) -> Result { + let blob = Blob::from_content_bytes(payload.to_vec()); + storage + .put(&blob.id, payload, ObjectType::Blob) + .map_err(|error| FacetError::Capture(error.to_string()))?; + Ok(blob.id) +} + +/// Bridge the synchronous StateFacet contract to repository-owned async +/// stores without blocking a multi-thread runtime worker. A current-thread +/// runtime cannot synchronously service a database future while its only +/// worker is blocked by the StateFacet contract, so it fails explicitly. +fn run_db(future: F) -> Result +where + F: Future> + Send + 'static, + T: Send + 'static, +{ + match tokio::runtime::Handle::try_current() { + Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => { + tokio::task::block_in_place(|| handle.block_on(future)) + } + Ok(_) => Err( + "database-backed facets require a multi-thread Tokio runtime for synchronous capture" + .to_string(), + ), + Err(_) => tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())? + .block_on(future), + } +} + +fn load_payload( + storage: &ClientStorage, + capture: &FacetCapture, +) -> Result>, FacetError> { + capture + .payload_oid + .map(|oid| { + storage + .get(&oid) + .map_err(|error| FacetError::Restore(error.to_string())) + }) + .transpose() +} + +fn validate_payload( + storage: &ClientStorage, + capture: &FacetCapture, + facet: &str, +) -> Result<(), FacetError> { + let Some(oid) = capture.payload_oid else { + return Ok(()); + }; + storage + .get(&oid) + .map(|_| ()) + .map_err(|error| FacetError::Validation(format!("{facet} payload is unavailable: {error}"))) +} + +fn file_diff(from: &FacetCapture, to: &FacetCapture) -> FacetDiff { + FacetDiff { + changes: serde_json::json!({ + "from_payload_oid": from.payload_oid, + "to_payload_oid": to.payload_oid, + }), + } +} + +fn capture_roots(capture: &FacetCapture) -> Vec { + capture.payload_oid.into_iter().collect() +} + +fn restore_file(path: &Path, payload: Option<&[u8]>) -> Result<(), FacetError> { + match payload { + Some(bytes) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + FacetError::Restore(format!("could not create facet parent: {error}")) + })?; + } + write_atomic(path, bytes, true) + .map_err(|error| FacetError::Restore(error.to_string()))?; + } + None => match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => { + fs::remove_dir_all(path).map_err(|error| FacetError::Restore(error.to_string()))?; + } + Ok(_) => { + fs::remove_file(path).map_err(|error| FacetError::Restore(error.to_string()))? + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(FacetError::Restore(error.to_string())), + }, + } + Ok(()) +} + +fn read_optional_file(path: &Path, facet: &str) -> Result>, FacetError> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(FacetError::Capture(format!( + "could not read {facet} state: {error}" + ))), + } +} + +fn file_meta(payload: Option<&[u8]>) -> serde_json::Value { + serde_json::json!({ + "present": payload.is_some(), + "byte_len": payload.map_or(0, <[u8]>::len), + }) +} + +/// Preserves the raw Git index bytes, including stat data and extension bits +/// such as intent-to-add, skip-worktree, and assume-unchanged. OL-10 owns +/// the general restore engine; this facet only owns its exact payload. +pub struct RawIndexFacet { + path: PathBuf, + storage: ClientStorage, +} + +impl RawIndexFacet { + pub fn new(scope: &PinnedRequestScope, storage: ClientStorage) -> Self { + Self::with_path(scope.gitdir.join("index"), storage) + } + + pub fn with_path(path: PathBuf, storage: ClientStorage) -> Self { + Self { path, storage } + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl StateFacet for RawIndexFacet { + fn name(&self) -> FacetName { + FacetName::from(RAW_INDEX_FACET_NAME) + } + + fn schema_version(&self) -> u32 { + FACET_SCHEMA_VERSION + } + + fn restore_policy(&self) -> RestorePolicy { + RestorePolicy::AutoRestore + } + + fn capture(&self, _ctx: &FacetCaptureCtx) -> Result { + let payload = read_optional_file(&self.path, RAW_INDEX_FACET_NAME)?; + if let Some(bytes) = payload.as_ref() + && bytes.len() as u64 > MAX_RAW_INDEX_BYTES + { + return Err(FacetError::Capture(format!( + "raw index exceeds {} byte capture budget", + MAX_RAW_INDEX_BYTES + ))); + } + let payload_oid = payload + .as_deref() + .map(|bytes| store_payload(&self.storage, bytes)) + .transpose()?; + Ok(FacetCapture { + facet: self.name(), + schema_version: FACET_SCHEMA_VERSION, + payload_oid, + meta: file_meta(payload.as_deref()), + }) + } + + fn validate(&self, capture: &FacetCapture) -> Result<(), FacetError> { + if capture.payload_oid.is_none() { + return Err(FacetError::Validation( + "raw index capture must contain a payload".to_string(), + )); + } + validate_payload(&self.storage, capture, RAW_INDEX_FACET_NAME) + } + + fn restore( + &self, + capture: &FacetCapture, + _ctx: &mut FacetRestoreCtx, + ) -> Result<(), FacetError> { + let payload = load_payload(&self.storage, capture)?; + restore_file(&self.path, payload.as_deref()) + } + + fn diff(&self, from: &FacetCapture, to: &FacetCapture) -> Result { + Ok(file_diff(from, to)) + } + + fn roots(&self, capture: &FacetCapture) -> Vec { + capture_roots(capture) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SequencerStateEnvelope { + kind: String, + head_name: String, + head_orig: String, + current_oid: String, + todo: Vec, + payload: String, +} + +impl From for SequencerStateEnvelope { + fn from(state: SequenceState) -> Self { + Self { + kind: state.kind.as_str().to_string(), + head_name: state.head_name, + head_orig: state.head_orig, + current_oid: state.current_oid, + todo: state.todo, + payload: state.payload, + } + } +} + +impl TryFrom for SequenceState { + type Error = FacetError; + + fn try_from(envelope: SequencerStateEnvelope) -> Result { + let kind = match envelope.kind.as_str() { + "merge" => SequenceKind::Merge, + "revert" => SequenceKind::Revert, + "cherry_pick" => SequenceKind::CherryPick, + "rebase" => SequenceKind::Rebase, + other => { + return Err(FacetError::Validation(format!( + "unknown sequencer kind '{other}'" + ))); + } + }; + Ok(Self { + kind, + head_name: envelope.head_name, + head_orig: envelope.head_orig, + current_oid: envelope.current_oid, + todo: envelope.todo, + payload: envelope.payload, + }) + } +} + +/// Captures the unified `sequence_state` row through the sequencer module's +/// scoped SQL helpers. The adapter never resolves the process cwd itself. +pub struct SequencerFacet { + scope: WorktreeScope, + db: DatabaseConnection, + storage: ClientStorage, +} + +impl SequencerFacet { + pub fn new(scope: &PinnedRequestScope, db: DatabaseConnection, storage: ClientStorage) -> Self { + Self { + scope: scope.scope.clone(), + db, + storage, + } + } + + pub fn from_store(scope: &PinnedRequestScope, store: &OperationStoreV2) -> Self { + Self::new(scope, store.db().clone(), store.storage().clone()) + } + + /// Test/setup helper that writes the same scoped row consumed by capture. + pub fn write_state(&self, state: Option) -> Result<(), FacetError> { + let db = self.db.clone(); + let scope = self.scope.clone(); + run_db(async move { + let txn = db.begin().await.map_err(|error| error.to_string())?; + match state { + Some(state) => { + crate::internal::sequencer::save_for_scope_with_conn(&txn, &scope, &state) + .await + .map_err(|error| error.to_string())? + } + None => crate::internal::sequencer::clear_for_scope_all_with_conn(&txn, &scope) + .await + .map_err(|error| error.to_string())?, + } + txn.commit().await.map_err(|error| error.to_string()) + }) + .map_err(FacetError::Restore) + } +} + +impl StateFacet for SequencerFacet { + fn name(&self) -> FacetName { + FacetName::from(SEQUENCER_FACET_NAME) + } + + fn schema_version(&self) -> u32 { + FACET_SCHEMA_VERSION + } + + fn restore_policy(&self) -> RestorePolicy { + RestorePolicy::AutoRestore + } + + fn capture(&self, _ctx: &FacetCaptureCtx) -> Result { + let db = self.db.clone(); + let scope = self.scope.clone(); + let state = run_db(async move { + crate::internal::sequencer::load_for_scope_with_conn(&db, &scope).await + }) + .map_err(FacetError::Capture)?; + let payload = state + .map(SequencerStateEnvelope::from) + .map(|envelope| serde_json::to_vec(&envelope)) + .transpose() + .map_err(|error| FacetError::Capture(error.to_string()))?; + let payload_oid = payload + .as_deref() + .map(|bytes| store_payload(&self.storage, bytes)) + .transpose()?; + Ok(FacetCapture { + facet: self.name(), + schema_version: FACET_SCHEMA_VERSION, + payload_oid, + meta: file_meta(payload.as_deref()), + }) + } + + fn validate(&self, capture: &FacetCapture) -> Result<(), FacetError> { + let Some(payload) = load_payload(&self.storage, capture) + .map_err(|error| FacetError::Validation(error.to_string()))? + else { + return Ok(()); + }; + serde_json::from_slice::(&payload) + .map(|_| ()) + .map_err(|error| FacetError::Validation(error.to_string())) + } + + fn restore( + &self, + capture: &FacetCapture, + _ctx: &mut FacetRestoreCtx, + ) -> Result<(), FacetError> { + let state = load_payload(&self.storage, capture)? + .map(|payload| { + serde_json::from_slice::(&payload) + .map_err(|error| FacetError::Restore(error.to_string())) + .and_then(SequenceState::try_from) + }) + .transpose()?; + self.write_state(state) + } + + fn diff(&self, from: &FacetCapture, to: &FacetCapture) -> Result { + Ok(file_diff(from, to)) + } + + fn roots(&self, capture: &FacetCapture) -> Vec { + capture_roots(capture) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SparseStateEnvelope { + enabled: bool, + patterns: Vec, +} + +/// Captures the sparse-view toggle and ordered pattern list from their owned +/// SQLite tables. Its policy is AutoRestore because these rows are mutable +/// user state rather than a derived display cache. +pub struct SparseFacet { + scope: WorktreeScope, + db: DatabaseConnection, + storage: ClientStorage, +} + +impl SparseFacet { + pub fn new(scope: &PinnedRequestScope, db: DatabaseConnection, storage: ClientStorage) -> Self { + Self { + scope: scope.scope.clone(), + db, + storage, + } + } + + pub fn from_store(scope: &PinnedRequestScope, store: &OperationStoreV2) -> Self { + Self::new(scope, store.db().clone(), store.storage().clone()) + } + + /// Test/setup helper that writes the same scoped tables consumed by capture. + pub fn write_state(&self, enabled: bool, patterns: Vec) -> Result<(), FacetError> { + let db = self.db.clone(); + let scope = self.scope.clone(); + run_db(async move { + let txn = db.begin().await.map_err(|error| error.to_string())?; + SparseViewStore::restore_for_scope_with_conn(&txn, &scope, enabled, &patterns).await?; + txn.commit().await.map_err(|error| error.to_string()) + }) + .map_err(FacetError::Restore) + } +} + +impl StateFacet for SparseFacet { + fn name(&self) -> FacetName { + FacetName::from(SPARSE_FACET_NAME) + } + + fn schema_version(&self) -> u32 { + FACET_SCHEMA_VERSION + } + + fn restore_policy(&self) -> RestorePolicy { + RestorePolicy::AutoRestore + } + + fn capture(&self, _ctx: &FacetCaptureCtx) -> Result { + let db = self.db.clone(); + let scope = self.scope.clone(); + let (enabled, patterns) = + run_db(async move { SparseViewStore::state_for_scope_with_conn(&db, &scope).await }) + .map_err(FacetError::Capture)?; + let payload = serde_json::to_vec(&SparseStateEnvelope { enabled, patterns }) + .map_err(|error| FacetError::Capture(error.to_string()))?; + let payload_oid = store_payload(&self.storage, &payload)?; + Ok(FacetCapture { + facet: self.name(), + schema_version: FACET_SCHEMA_VERSION, + payload_oid: Some(payload_oid), + meta: file_meta(Some(&payload)), + }) + } + + fn validate(&self, capture: &FacetCapture) -> Result<(), FacetError> { + let Some(payload) = load_payload(&self.storage, capture) + .map_err(|error| FacetError::Validation(error.to_string()))? + else { + return Err(FacetError::Validation( + "sparse capture must contain a payload".to_string(), + )); + }; + serde_json::from_slice::(&payload) + .map(|_| ()) + .map_err(|error| FacetError::Validation(error.to_string())) + } + + fn restore( + &self, + capture: &FacetCapture, + _ctx: &mut FacetRestoreCtx, + ) -> Result<(), FacetError> { + let payload = load_payload(&self.storage, capture)?.ok_or_else(|| { + FacetError::Restore("sparse capture is missing its payload".to_string()) + })?; + let state = serde_json::from_slice::(&payload) + .map_err(|error| FacetError::Restore(error.to_string()))?; + let db = self.db.clone(); + let scope = self.scope.clone(); + run_db(async move { + let txn = db.begin().await.map_err(|error| error.to_string())?; + SparseViewStore::restore_for_scope_with_conn( + &txn, + &scope, + state.enabled, + &state.patterns, + ) + .await?; + txn.commit().await.map_err(|error| error.to_string()) + }) + .map_err(FacetError::Restore) + } + + fn diff(&self, from: &FacetCapture, to: &FacetCapture) -> Result { + Ok(file_diff(from, to)) + } + + fn roots(&self, capture: &FacetCapture) -> Vec { + capture_roots(capture) + } +} + +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, + capture_name: FacetName, + } + + 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.capture_name.clone(), + 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, + capture_name: FacetName::from("test"), + })) + .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, + capture_name: FacetName::from("test"), + })) + .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 registry_rejects_capture_with_wrong_facet_name() { + let mut registry = FacetRegistry::new(); + registry + .register(Box::new(TestFacet { + policy: RestorePolicy::AutoRestore, + capture_name: FacetName::from("other"), + })) + .expect("register facet"); + let error = registry + .capture(&FacetName::from("test"), &FacetCaptureCtx::default()) + .expect_err("facet name mismatch must fail closed"); + assert!(matches!(error, FacetError::NameMismatch { .. })); + } +} diff --git a/src/internal/operation/mod.rs b/src/internal/operation/mod.rs new file mode 100644 index 000000000..43b62ea0e --- /dev/null +++ b/src/internal/operation/mod.rs @@ -0,0 +1,29 @@ +//! Version 2 operation-log primitives. + +pub mod facet; +pub mod snapshot; +pub mod store; +pub mod view; +pub mod working_copy; + +// 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, RAW_INDEX_FACET_NAME, RawIndexFacet, RestorePolicy, SEQUENCER_FACET_NAME, + SPARSE_FACET_NAME, SequencerFacet, SparseFacet, StateFacet, +}; +pub use snapshot::{ + IndexEntrySnapshot, ScanError, ScanResult, ScannedFile, SnapshotError, SnapshotOutcome, + WorkspaceSnapshotter, +}; +pub use store::{ + JournalEntry, JournalPhase, OpHeadsView, OperationKind, OperationMetaV2, OperationStatusV2, + OperationStoreV2, OperationV2, StoreError, +}; +pub use view::{ + CapturePolicy, Completeness, HeadState, RepoViewV2, WorkspaceId, WorkspaceSnapshotV2, +}; +pub use working_copy::{PinnedRequestScope, PointerError, Staleness, WorkspaceStatePointer}; + +pub use crate::internal::legacy_operation::*; diff --git a/src/internal/operation/snapshot.rs b/src/internal/operation/snapshot.rs new file mode 100644 index 000000000..c2637fbcd --- /dev/null +++ b/src/internal/operation/snapshot.rs @@ -0,0 +1,1085 @@ +//! Working-copy snapshot capture for operation-log v2. +//! +//! Automatic snapshots describe a repository state; they are not commits. +//! In particular, this module never creates a Commit OID or a Change ID. The +//! working-copy tree and the raw index are content-addressed Git objects, and +//! the operation row records the snapshot transition separately. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, OpenOptions}, + io, + path::{Component, Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use git_internal::{ + hash::ObjectHash, + internal::{ + index::Index, + object::{ + ObjectTrait, + tree::{Tree, TreeItem, TreeItemMode}, + types::ObjectType, + }, + }, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{ + FacetCaptureCtx, FacetRegistry, HeadState, OperationKind, OperationMetaV2, OperationStatusV2, + OperationStoreV2, OperationV2, PinnedRequestScope, StoreError, WorkspaceSnapshotV2, + WorkspaceStatePointer, + view::{CapturePolicy, Completeness, WORKSPACE_SNAPSHOT_SCHEMA_VERSION}, +}; +use crate::{ + internal::{ + head::Head, + worktree_io::{ + default_worktree_io, + executor::{ExecutorError, WorktreeIo}, + protocol::{ + CapturedStat, Dirent, IoEvent, IoRequest, ObjectBlobStatus, bytes_to_path, + io_from_wire, path_to_bytes, relative_worktree_path, unwrap_wire, + }, + }, + }, + utils::{client_storage::ClientStorage, tree::sort_tree_items_for_git}, +}; + +const DEFAULT_MAX_FILES: usize = 100_000; +const DEFAULT_MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +const IO_TIMEOUT: Duration = Duration::from_secs(5); +const SNAPSHOT_LOCK_NAME: &str = "workspace-snapshot.lock"; +const UNTRACKED_MANIFEST_SCHEMA_VERSION: u32 = 1; + +/// Result of a capture attempt. `NoChange` deliberately has no operation id: +/// a clean automatic snapshot must not add noise to the operation DAG. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnapshotOutcome { + NoChange, + Captured { + operation_id: String, + snapshot_oid: ObjectHash, + snapshot: Box, + }, +} + +/// A file read during a bounded working-copy scan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScannedFile { + pub bytes: Vec, + pub mode: u32, + pub object_oid: ObjectHash, + pub tracked: bool, +} + +/// The read-only result consumed by the object-writing half of capture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScanResult { + pub files: BTreeMap, + pub index_entries: Vec, + pub raw_index: Vec, + pub untracked: BTreeSet, + pub ignored: BTreeSet, + pub deleted_tracked: BTreeSet, + pub partial: bool, + pub partial_paths: BTreeSet, + pub changed: bool, +} + +/// The semantic fields from one stage-0 Git index entry needed to rebuild its +/// tree. The raw bytes remain separately stored so index flags and stat data +/// are restored byte-for-byte by OL-07. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexEntrySnapshot { + pub path: String, + pub mode: u32, + pub object_oid: ObjectHash, +} + +#[derive(Debug, Error)] +pub enum ScanError { + #[error("working-copy scan I/O failed: {0}")] + Io(String), + #[error("working-copy scan timed out")] + Timeout, + #[error("working-copy scan encountered an invalid path '{0}'")] + InvalidPath(String), + #[error("working-copy index could not be read: {0}")] + Index(String), + #[error("untracked files are not allowed by the fail-closed capture policy")] + UntrackedDisallowed, +} + +#[derive(Debug, Error)] +pub enum SnapshotError { + #[error("could not lock the worktree for snapshot capture: {0}")] + Lock(String), + #[error("snapshot scan failed: {0}")] + Scan(#[from] ScanError), + #[error("snapshot object write failed: {0}")] + Object(String), + #[error("snapshot manifest failed: {0}")] + Manifest(String), + #[error("snapshot operation publish failed: {0}")] + Store(#[from] StoreError), + #[error("snapshot pointer update failed: {0}")] + Pointer(String), + #[error("snapshot HEAD capture failed: {0}")] + Head(String), + #[error("facet capture failed: {0}")] + Facet(String), + #[error("snapshot restore failed: {0}")] + Restore(String), +} + +/// Captures one pinned worktree into Git objects and an operation-log v2 row. +pub struct WorkspaceSnapshotter { + scope: PinnedRequestScope, + io: Arc, + store: OperationStoreV2, + registry: FacetRegistry, + capture_policy: CapturePolicy, + max_files: usize, + max_file_bytes: u64, +} + +impl WorkspaceSnapshotter { + /// Construct a snapshotter for one request-pinned worktree. + pub fn new( + scope: PinnedRequestScope, + store: OperationStoreV2, + registry: FacetRegistry, + capture_policy: CapturePolicy, + ) -> Self { + Self { + scope, + io: Arc::new(default_worktree_io()), + store, + registry, + capture_policy, + max_files: DEFAULT_MAX_FILES, + max_file_bytes: DEFAULT_MAX_FILE_BYTES, + } + } + + /// Set scan budgets. A bounded budget produces a partial snapshot when it + /// omits a file; it never turns an incomplete capture into Full. + pub fn with_limits(mut self, max_files: usize, max_file_bytes: u64) -> Self { + self.max_files = max_files.max(1); + self.max_file_bytes = max_file_bytes.max(1); + self + } + + /// Capture under the worktree lock, publish immutable objects before the + /// operation row, then CAS the operation head and advance the pointer. + pub async fn capture(&mut self) -> Result { + let operation_id = uuid::Uuid::now_v7().to_string(); + let _lock = WorktreeLock::acquire(&self.scope).map_err(SnapshotError::Lock)?; + let scan = match self.scan_working_copy_locked() { + Ok(scan) => scan, + Err(error) => { + self.record_diagnostic(&operation_id, error.to_string()) + .await; + return Err(SnapshotError::Scan(error)); + } + }; + if !scan.changed || self.state_matches_last_snapshot(&scan).await? { + return Ok(SnapshotOutcome::NoChange); + } + + let result = self.capture_objects(&operation_id, &scan).await; + match result { + Ok(captured) => Ok(captured), + Err(error) => { + self.record_diagnostic(&operation_id, error.to_string()) + .await; + Err(error) + } + } + } + + /// Scan tracked and untracked files through the bounded WorktreeIo + /// executor. This public helper takes the same lock as `capture`; the + /// private variant exists so capture does not acquire the flock twice. + pub async fn scan_working_copy(&self) -> Result { + let _lock = WorktreeLock::acquire(&self.scope).map_err(ScanError::Io)?; + self.scan_working_copy_locked() + } + + /// Test and recovery helper for the captured tree only. This is not the + /// OL-10 RestoreEngine: it materializes the immutable working-copy tree + /// and intentionally does not alter HEAD, refs, index, sequencer, or + /// sparse state. + pub fn restore_working_copy( + &self, + snapshot: &WorkspaceSnapshotV2, + ) -> Result<(), SnapshotError> { + let mut files = Vec::new(); + collect_tree_files( + self.store.storage(), + &snapshot.working_copy_tree_oid, + Path::new(""), + &mut files, + ) + .map_err(SnapshotError::Restore)?; + for (path, mode, bytes) in files { + let relative = validate_tree_path(&path).map_err(SnapshotError::Restore)?; + let target = self.scope.worktree_root.join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .map_err(|error| SnapshotError::Restore(error.to_string()))?; + } + if target.exists() || target.symlink_metadata().is_ok() { + let metadata = target + .symlink_metadata() + .map_err(|error| SnapshotError::Restore(error.to_string()))?; + if metadata.is_dir() { + fs::remove_dir_all(&target) + .map_err(|error| SnapshotError::Restore(error.to_string()))?; + } else { + fs::remove_file(&target) + .map_err(|error| SnapshotError::Restore(error.to_string()))?; + } + } + if mode == 0o120000 { + #[cfg(unix)] + std::os::unix::fs::symlink(bytes_to_path(&bytes), &target) + .map_err(|error| SnapshotError::Restore(error.to_string()))?; + #[cfg(not(unix))] + return Err(SnapshotError::Restore( + "symbolic-link restore is unsupported on this platform".to_string(), + )); + } else { + fs::write(&target, &bytes) + .map_err(|error| SnapshotError::Restore(error.to_string()))?; + #[cfg(unix)] + if mode == 0o100755 { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&target) + .map_err(|error| SnapshotError::Restore(error.to_string()))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&target, permissions) + .map_err(|error| SnapshotError::Restore(error.to_string()))?; + } + } + } + Ok(()) + } + + fn scan_working_copy_locked(&self) -> Result { + let index_path = self.scope.gitdir.join("index"); + let raw_index = match fs::read(&index_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(ScanError::Index(error.to_string())), + }; + let index = if raw_index.is_empty() && !index_path.exists() { + Index::new() + } else { + Index::load(&index_path).map_err(|error| ScanError::Index(error.to_string()))? + }; + let root = path_to_bytes(&self.scope.worktree_root); + let mut files = BTreeMap::new(); + let mut index_entries = Vec::new(); + let mut deleted_tracked = BTreeSet::new(); + let mut changed = false; + let mut partial = false; + let mut partial_paths = BTreeSet::new(); + let mut tracked_names = BTreeSet::new(); + + for entry in index.tracked_entries(0) { + let relative = validate_relative_path(Path::new(&entry.name))?; + let path = entry.name.clone(); + tracked_names.insert(path.clone()); + index_entries.push(IndexEntrySnapshot { + path: path.clone(), + mode: entry.mode, + object_oid: entry.hash, + }); + let stat = match self.stat(&root, &relative)? { + Some(stat) => stat, + None => { + deleted_tracked.insert(path); + changed = true; + continue; + } + }; + if !stat.is_file() && !stat.is_symlink() { + return Err(ScanError::Io(format!( + "tracked path '{}' is not a regular file or symbolic link", + entry.name + ))); + } + if stat.len() > self.max_file_bytes { + partial = true; + partial_paths.insert(path); + changed = true; + continue; + } + let bytes = match self.read_file(&root, &relative)? { + Some(bytes) => bytes, + None => { + deleted_tracked.insert(entry.name.clone()); + changed = true; + continue; + } + }; + let object_oid = blob_oid(&bytes); + let mode = mode_from_stat(&stat); + let stat_matches = stat_matches_index(entry, &stat); + if !stat_matches || object_oid != entry.hash || mode != normalize_index_mode(entry.mode) + { + changed = true; + } + files.insert( + path, + ScannedFile { + bytes, + mode, + object_oid, + tracked: true, + }, + ); + } + + let mut untracked = BTreeSet::new(); + let mut ignored = BTreeSet::new(); + let mut pending = vec![PathBuf::new()]; + let layers = crate::internal::layer::ExclusionSnapshot::for_scope(&self.scope.scope); + let mut visited = 0usize; + while let Some(directory) = pending.pop() { + if visited >= self.max_files { + partial = true; + changed = true; + partial_paths.insert(directory.to_string_lossy().to_string()); + break; + } + let (mut entries, hit_cap) = self.read_dir(&root, &directory)?; + if hit_cap { + partial = true; + changed = true; + partial_paths.insert(directory.to_string_lossy().to_string()); + } + entries.sort_by(|left, right| left.name.cmp(&right.name)); + for dirent in entries { + visited = visited.saturating_add(1); + let name = crate::internal::worktree_io::protocol::dirent_os(&dirent.name); + if name == std::ffi::OsStr::new(crate::utils::util::ROOT_DIR) + || name == std::ffi::OsStr::new(crate::utils::util::GIT_DIR) + { + continue; + } + let relative = directory.join(name); + let relative = validate_relative_path(&relative)?; + let path = relative + .to_str() + .ok_or_else(|| ScanError::InvalidPath(relative.display().to_string()))? + .to_string(); + let absolute = self.scope.worktree_root.join(&relative); + let is_dir = dirent.is_dir && !dirent.is_symlink; + if crate::utils::util::check_gitignore_with_layers_as_dir( + &self.scope.worktree_root, + &absolute, + &layers, + is_dir, + ) { + ignored.insert(path); + continue; + } + if is_dir { + pending.push(relative); + continue; + } + if !dirent.is_file && !dirent.is_symlink { + return Err(ScanError::Io(format!( + "unsupported worktree entry '{path}'" + ))); + } + if tracked_names.contains(&path) { + continue; + } + untracked.insert(path.clone()); + changed = true; + if self.capture_policy == CapturePolicy::FailClosed { + return Err(ScanError::UntrackedDisallowed); + } + if self.capture_policy != CapturePolicy::TrackedAndUntracked { + continue; + } + let stat = match self.stat(&root, &relative)? { + Some(stat) => stat, + None => { + partial = true; + partial_paths.insert(path); + continue; + } + }; + if stat.len() > self.max_file_bytes { + partial = true; + partial_paths.insert(path); + continue; + } + let Some(bytes) = self.read_file(&root, &relative)? else { + partial = true; + partial_paths.insert(path); + continue; + }; + let object_oid = blob_oid(&bytes); + files.insert( + path, + ScannedFile { + bytes, + mode: mode_from_stat(&stat), + object_oid, + tracked: false, + }, + ); + changed = true; + } + } + + Ok(ScanResult { + files, + index_entries, + raw_index, + untracked, + ignored, + deleted_tracked, + partial, + partial_paths, + changed, + }) + } + + async fn capture_objects( + &self, + operation_id: &str, + scan: &ScanResult, + ) -> Result { + if self.store.repo_id().trim().is_empty() { + return Err(SnapshotError::Store(StoreError::Validation( + "snapshot operation store repository id cannot be empty".to_string(), + ))); + } + let storage = self.store.storage(); + for file in scan.files.values() { + put_blob(storage, &file.object_oid, &file.bytes)?; + } + let index_tree_oid = write_tree( + storage, + scan.index_entries.iter().map(|entry| { + ( + PathBuf::from(&entry.path), + tree_mode_from_index(entry.mode), + entry.object_oid, + ) + }), + )?; + let raw_index_blob_oid = put_blob(storage, &blob_oid(&scan.raw_index), &scan.raw_index)?; + let working_copy_tree_oid = write_tree( + storage, + scan.files.iter().map(|(path, file)| { + ( + PathBuf::from(path), + tree_mode_from_index(file.mode), + file.object_oid, + ) + }), + )?; + let untracked_manifest = UntrackedManifest { + schema_version: UNTRACKED_MANIFEST_SCHEMA_VERSION, + capture_policy: self.capture_policy, + untracked: scan.untracked.iter().cloned().collect(), + ignored: scan.ignored.iter().cloned().collect(), + deleted_tracked: scan.deleted_tracked.iter().cloned().collect(), + partial_paths: scan.partial_paths.iter().cloned().collect(), + files: scan + .files + .iter() + .map(|(path, file)| FileManifestEntry { + path: path.clone(), + object_oid: file.object_oid, + mode: file.mode, + }) + .collect(), + }; + let untracked_bytes = serde_json::to_vec(&untracked_manifest) + .map_err(|error| SnapshotError::Manifest(error.to_string()))?; + let untracked_manifest_oid = + put_blob(storage, &blob_oid(&untracked_bytes), &untracked_bytes)?; + let head = capture_head(self.store.db()).await?; + + let mut captures = Vec::new(); + let mut facet_partial = false; + let facet_ctx = FacetCaptureCtx { + repo_id: Some(self.store.repo_id().to_string()), + workspace_id: Some(self.workspace_id()), + }; + for name in self.registry.names() { + match self.registry.capture(&name, &facet_ctx) { + Ok(capture) => captures.push(capture), + Err(error) => { + facet_partial = true; + tracing::warn!(facet = %name, error = %error, "workspace snapshot facet capture failed"); + } + } + } + let sparse_facet_oid = captures + .iter() + .find(|capture| capture.facet.as_str() == "sparse") + .and_then(|capture| capture.payload_oid); + let sequencer_facet_oid = captures + .iter() + .find(|capture| capture.facet.as_str() == "sequencer") + .and_then(|capture| capture.payload_oid); + let facet_restore_policies = self.registry.policies(&captures); + let complete = !scan.partial + && !facet_partial + && captures.len() == self.registry.len() + && self.registry.is_fully_restorable(&captures); + let snapshot = WorkspaceSnapshotV2 { + schema_version: WORKSPACE_SNAPSHOT_SCHEMA_VERSION, + workspace_id: self.workspace_id(), + head: head.clone(), + index_tree_oid, + raw_index_blob_oid, + working_copy_tree_oid, + untracked_manifest_oid, + sparse_facet_oid, + sequencer_facet_oid, + worktree_generation: self.next_generation().await?, + capture_policy: self.capture_policy, + completeness: if complete { + Completeness::Full + } else { + Completeness::Partial + }, + facet_restore_policies, + }; + let snapshot_bytes = snapshot + .to_canonical_bytes() + .map_err(|error| SnapshotError::Manifest(error.to_string()))?; + let snapshot_oid = put_blob(storage, &blob_oid(&snapshot_bytes), &snapshot_bytes)?; + + let refs_payload = RefsFacetPayload { head }; + let refs_bytes = serde_json::to_vec(&refs_payload) + .map_err(|error| SnapshotError::Manifest(error.to_string()))?; + let refs_facet_oid = put_blob(storage, &blob_oid(&refs_bytes), &refs_bytes)?; + let post_view = super::RepoViewV2 { + schema_version: super::view::REPO_VIEW_SCHEMA_VERSION, + repo_id: self.store.repo_id().to_string(), + refs_facet_oid, + workspaces: BTreeMap::from([(snapshot.workspace_id.clone(), snapshot_oid)]), + change_roots: Vec::new(), + extension_facets: BTreeMap::new(), + }; + let post_view_oid = self.store.write_view_manifest(&post_view).await?; + let parent_heads = self + .store + .read_heads(self.store.repo_id(), self.scope.scope.storage_key()) + .await?; + let operation = OperationV2 { + op_id: operation_id.to_string(), + parent_op_ids: parent_heads.clone(), + pre_view_oid: post_view_oid, + post_view_oid, + kind: OperationKind::ExternalSnapshot, + status: if complete { + OperationStatusV2::Success + } else { + OperationStatusV2::Partial + }, + metadata: OperationMetaV2 { + command_name: Some("workspace snapshot".to_string()), + description: Some("automatic working-copy snapshot".to_string()), + actor: Some("libra-snapshotter".to_string()), + ..Default::default() + }, + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + }; + self.store.write_operation(&operation).await?; + if let Err(error) = self + .store + .cas_update_op_heads( + self.store.repo_id(), + self.scope.scope.storage_key(), + &parent_heads, + &[operation_id.to_string()], + ) + .await + { + self.record_diagnostic(operation_id, error.to_string()) + .await; + return Err(SnapshotError::Store(error)); + } + let pointer = WorkspaceStatePointer { + last_op_id: operation_id.to_string(), + last_snapshot_oid: snapshot_oid, + generation: snapshot.worktree_generation, + }; + if let Err(error) = pointer.save(&self.scope).await { + self.record_diagnostic(operation_id, error.to_string()) + .await; + return Err(SnapshotError::Pointer(error.to_string())); + } + Ok(SnapshotOutcome::Captured { + operation_id: operation_id.to_string(), + snapshot_oid, + snapshot: Box::new(snapshot), + }) + } + + async fn state_matches_last_snapshot(&self, scan: &ScanResult) -> Result { + let pointer = match WorkspaceStatePointer::load(&self.scope).await { + Ok(pointer) => pointer, + Err(error) if error_is_missing_pointer(&error) => return Ok(false), + Err(error) => return Err(SnapshotError::Pointer(error.to_string())), + }; + let snapshot_bytes = self + .store + .storage() + .get(&pointer.last_snapshot_oid) + .map_err(|error| SnapshotError::Object(error.to_string()))?; + let snapshot = WorkspaceSnapshotV2::from_canonical_bytes(&snapshot_bytes) + .map_err(|error| SnapshotError::Object(error.to_string()))?; + if snapshot.capture_policy != self.capture_policy { + return Ok(false); + } + let manifest_bytes = self + .store + .storage() + .get(&snapshot.untracked_manifest_oid) + .map_err(|error| SnapshotError::Object(error.to_string()))?; + let manifest = serde_json::from_slice::(&manifest_bytes) + .map_err(|error| SnapshotError::Manifest(error.to_string()))?; + let files = scan + .files + .iter() + .map(|(path, file)| FileManifestEntry { + path: path.clone(), + object_oid: file.object_oid, + mode: file.mode, + }) + .collect::>(); + Ok(manifest.capture_policy == self.capture_policy + && manifest.untracked == scan.untracked.iter().cloned().collect::>() + && manifest.deleted_tracked == scan.deleted_tracked.iter().cloned().collect::>() + && manifest.partial_paths == scan.partial_paths.iter().cloned().collect::>() + && manifest.files == files) + } + + async fn next_generation(&self) -> Result { + match WorkspaceStatePointer::load(&self.scope).await { + Ok(pointer) => Ok(pointer.generation.saturating_add(1)), + Err(error) if error_is_missing_pointer(&error) => Ok(1), + Err(error) => Err(SnapshotError::Pointer(error.to_string())), + } + } + + async fn record_diagnostic(&self, operation_id: &str, message: String) { + let _ = self + .store + .append_journal(&super::JournalEntry { + journal_id: format!("snapshot-{operation_id}"), + op_id: operation_id.to_string(), + phase: super::JournalPhase::Reserved, + pre_view_oid: None, + target_view_oid: None, + owner: "workspace-snapshotter".to_string(), + updated_at: chrono::Utc::now().timestamp_millis(), + recovery_payload: Some(message), + }) + .await; + } + + fn workspace_id(&self) -> String { + match &self.scope.scope { + crate::internal::worktree_scope::WorktreeScope::Main => "main".to_string(), + crate::internal::worktree_scope::WorktreeScope::Linked(id) => id.clone(), + } + } + + fn stat(&self, root: &[u8], relative: &Path) -> Result, ScanError> { + let events = self + .io + .submit( + IoRequest::SymlinkMetadata { + path: path_to_bytes(relative), + root: root.to_vec(), + }, + path_to_bytes(relative), + IO_TIMEOUT, + ) + .map_err(scan_executor_error)?; + for event in events { + if let IoEvent::DoneStat { result } = event { + return match unwrap_wire(result) { + Ok(stat) => Ok(Some(stat)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(ScanError::Io(error.to_string())), + }; + } + } + Err(ScanError::Io( + "worktree stat returned no terminal event".to_string(), + )) + } + + fn read_file(&self, root: &[u8], relative: &Path) -> Result>, ScanError> { + let events = self + .io + .submit( + IoRequest::ReadFile { + path: path_to_bytes(relative), + root: root.to_vec(), + byte_limit: self.max_file_bytes, + }, + path_to_bytes(relative), + IO_TIMEOUT, + ) + .map_err(scan_executor_error)?; + for event in events { + if let IoEvent::DoneObjectBlob { status, bytes } = event { + return match status { + ObjectBlobStatus::Ok => Ok(bytes), + ObjectBlobStatus::Missing => Ok(None), + ObjectBlobStatus::TooLarge => Ok(None), + other => Err(ScanError::Io(format!( + "bounded file read failed: {other:?}" + ))), + }; + } + } + Err(ScanError::Io( + "worktree file read returned no terminal event".to_string(), + )) + } + + fn read_dir(&self, root: &[u8], relative: &Path) -> Result<(Vec, bool), ScanError> { + let path = self.scope.worktree_root.join(relative); + let relative = relative_worktree_path(root, &path, true) + .map_err(|error| ScanError::InvalidPath(error.to_string()))?; + let events = self + .io + .submit( + IoRequest::ReadDir { + path: path_to_bytes(&relative), + root: root.to_vec(), + remaining: self.max_files, + checkpoint_every: 32, + }, + path_to_bytes(&relative), + IO_TIMEOUT, + ) + .map_err(scan_executor_error)?; + let mut entries = Vec::new(); + for event in events { + match event { + IoEvent::RecordDirent(dirent) => entries.push(dirent), + IoEvent::RecordError { kind, raw_os } => { + return Err(ScanError::Io(io_from_wire(kind, raw_os).to_string())); + } + IoEvent::DoneReadDir { listing } => { + let hit_cap = listing.hit_cap; + if let Some((kind, raw_os)) = listing.error_kinds.first().copied() { + return Err(ScanError::Io(io_from_wire(kind, raw_os).to_string())); + } + return Ok((entries, hit_cap)); + } + _ => {} + } + } + Ok((entries, false)) + } +} + +#[derive(Debug, Serialize)] +struct RefsFacetPayload { + head: HeadState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct FileManifestEntry { + path: String, + object_oid: ObjectHash, + mode: u32, +} + +#[derive(Debug, Deserialize, Serialize)] +struct UntrackedManifest { + schema_version: u32, + capture_policy: CapturePolicy, + untracked: Vec, + ignored: Vec, + deleted_tracked: Vec, + partial_paths: Vec, + files: Vec, +} + +async fn capture_head(db: &C) -> Result { + match Head::current_result_with_conn(db).await { + Ok(Head::Branch(name)) => Ok(HeadState::Symbolic { + reference: if name.starts_with("refs/") { + name + } else { + format!("refs/heads/{name}") + }, + }), + Ok(Head::Detached(oid)) => Ok(HeadState::Detached { oid }), + Err(error) if error.to_string().contains("HEAD reference is missing") => { + Ok(HeadState::Symbolic { + reference: "refs/heads/main".to_string(), + }) + } + Err(error) => Err(SnapshotError::Head(error.to_string())), + } +} + +fn put_blob( + storage: &ClientStorage, + oid: &ObjectHash, + bytes: &[u8], +) -> Result { + storage + .put(oid, bytes, ObjectType::Blob) + .map_err(|error| SnapshotError::Object(error.to_string()))?; + Ok(*oid) +} + +fn blob_oid(bytes: &[u8]) -> ObjectHash { + ObjectHash::from_type_and_data(ObjectType::Blob, bytes) +} + +fn write_tree( + storage: &ClientStorage, + leaves: impl IntoIterator, +) -> Result { + let mut entries: BTreeMap> = BTreeMap::new(); + for (path, mode, oid) in leaves { + validate_relative_path(&path).map_err(|error| SnapshotError::Object(error.to_string()))?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + SnapshotError::Object(format!("tree path is not UTF-8: {}", path.display())) + })?; + let parent = path.parent().unwrap_or_else(|| Path::new("")); + ensure_tree_ancestors(&mut entries, parent); + entries + .entry(parent.to_path_buf()) + .or_default() + .push(TreeItem::new(mode, oid, name.to_string())); + } + write_tree_recursively(storage, Path::new(""), &mut entries) +} + +fn ensure_tree_ancestors(entries: &mut BTreeMap>, path: &Path) { + let mut current = Some(path); + while let Some(current_path) = current { + if current_path.as_os_str().is_empty() { + break; + } + entries.entry(current_path.to_path_buf()).or_default(); + current = current_path.parent(); + } +} + +fn write_tree_recursively( + storage: &ClientStorage, + current: &Path, + entries: &mut BTreeMap>, +) -> Result { + let mut items = entries.remove(current).unwrap_or_default(); + let mut children: Vec = entries + .keys() + .filter(|path| path.parent() == Some(current)) + .cloned() + .collect(); + children.sort(); + for child in children { + let name = child + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + SnapshotError::Object(format!("tree path is not UTF-8: {}", child.display())) + })?; + let oid = write_tree_recursively(storage, &child, entries)?; + items.push(TreeItem::new(TreeItemMode::Tree, oid, name.to_string())); + } + sort_tree_items_for_git(&mut items); + let tree = if items.is_empty() { + Tree::from_bytes(&[], ObjectHash::from_type_and_data(ObjectType::Tree, &[])) + .map_err(|error| SnapshotError::Object(error.to_string()))? + } else { + Tree::from_tree_items(items).map_err(|error| SnapshotError::Object(error.to_string()))? + }; + let data = tree + .to_data() + .map_err(|error| SnapshotError::Object(error.to_string()))?; + storage + .put(&tree.id, &data, ObjectType::Tree) + .map_err(|error| SnapshotError::Object(error.to_string()))?; + Ok(tree.id) +} + +fn collect_tree_files( + storage: &ClientStorage, + tree_oid: &ObjectHash, + prefix: &Path, + output: &mut Vec<(PathBuf, u32, Vec)>, +) -> Result<(), String> { + let bytes = storage.get(tree_oid).map_err(|error| error.to_string())?; + let tree = Tree::from_bytes(&bytes, *tree_oid).map_err(|error| error.to_string())?; + for item in &tree.tree_items { + let path = prefix.join(&item.name); + if item.mode == TreeItemMode::Tree { + collect_tree_files(storage, &item.id, &path, output)?; + } else { + let bytes = storage.get(&item.id).map_err(|error| error.to_string())?; + output.push((path, tree_mode_to_raw(item.mode), bytes)); + } + } + Ok(()) +} + +fn validate_tree_path(path: &Path) -> Result { + validate_relative_path(path).map_err(|error| error.to_string()) +} + +fn validate_relative_path(path: &Path) -> Result { + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(ScanError::InvalidPath(path.display().to_string())); + } + if path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(ScanError::InvalidPath(path.display().to_string())); + } + Ok(path.to_path_buf()) +} + +fn mode_from_stat(stat: &CapturedStat) -> u32 { + if stat.is_symlink() { + 0o120000 + } else if stat.mode & 0o111 != 0 { + 0o100755 + } else { + 0o100644 + } +} + +fn normalize_index_mode(mode: u32) -> u32 { + match mode & 0o170000 { + 0o120000 => 0o120000, + _ if mode & 0o111 != 0 => 0o100755, + _ => 0o100644, + } +} + +fn tree_mode_from_index(mode: u32) -> TreeItemMode { + match normalize_index_mode(mode) { + 0o120000 => TreeItemMode::Link, + 0o100755 => TreeItemMode::BlobExecutable, + _ => TreeItemMode::Blob, + } +} + +fn tree_mode_to_raw(mode: TreeItemMode) -> u32 { + match mode { + TreeItemMode::Link => 0o120000, + TreeItemMode::BlobExecutable => 0o100755, + _ => 0o100644, + } +} + +fn stat_matches_index( + entry: &git_internal::internal::index::IndexEntry, + stat: &CapturedStat, +) -> bool { + entry.size as u64 == stat.len() + && entry.ctime.to_string() == format!("{}:{}", stat.ctime_sec, stat.ctime_nsec) + && entry.mtime.to_string() == format!("{}:{}", stat.mtime_sec, stat.mtime_nsec) + && normalize_index_mode(entry.mode) == mode_from_stat(stat) +} + +fn scan_executor_error(error: ExecutorError) -> ScanError { + if matches!(error, ExecutorError::DeadlineExpired) { + ScanError::Timeout + } else { + ScanError::Io(error.to_string()) + } +} + +fn error_is_missing_pointer(error: &super::PointerError) -> bool { + matches!(error, super::PointerError::Missing(_)) +} + +struct WorktreeLock { + _file: fs::File, +} + +impl WorktreeLock { + fn acquire(scope: &PinnedRequestScope) -> Result { + let path = scope.gitdir.join(SNAPSHOT_LOCK_NAME); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .map_err(|error| error.to_string())?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SAFETY: flock is applied to the owned lock descriptor and the + // descriptor remains alive until WorktreeLock is dropped. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result != 0 { + return Err(io::Error::last_os_error().to_string()); + } + } + Ok(Self { _file: file }) + } +} + +impl Drop for WorktreeLock { + fn drop(&mut self) { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SAFETY: the descriptor belongs to this guard and is still open. + unsafe { libc::flock(self._file.as_raw_fd(), libc::LOCK_UN) }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modes_round_trip_to_git_tree_modes() { + assert_eq!(tree_mode_from_index(0o100644), TreeItemMode::Blob); + assert_eq!(tree_mode_from_index(0o100755), TreeItemMode::BlobExecutable); + assert_eq!(tree_mode_from_index(0o120000), TreeItemMode::Link); + } + + #[test] + fn empty_capture_tree_is_canonical_empty_tree() { + let dir = tempfile::tempdir().expect("temporary object store"); + let storage = ClientStorage::init_local(dir.path().join("objects")); + let oid = write_tree(&storage, std::iter::empty()).expect("write empty tree"); + assert_eq!(oid, ObjectHash::from_type_and_data(ObjectType::Tree, &[])); + } +} diff --git a/src/internal/operation/store.rs b/src/internal/operation/store.rs new file mode 100644 index 000000000..a70cb8b58 --- /dev/null +++ b/src/internal/operation/store.rs @@ -0,0 +1,740 @@ +//! 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 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, +} + +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 + } + + /// The configured content-addressed object store used by operation + /// manifests and workspace snapshots. + pub fn storage(&self) -> &ClientStorage { + &self.storage + } + + /// Repository identity carried by this store. Snapshot publication uses + /// the same identity for its RepoView and operation rows. + pub fn repo_id(&self) -> &str { + &self.repo_id + } + + pub async fn write_view_manifest(&self, view: &RepoViewV2) -> Result { + let bytes = view.to_canonical_bytes()?; + let oid = ObjectHash::from_type_and_data(ObjectType::Blob, &bytes); + let storage = self.storage.clone(); + tokio::task::spawn_blocking(move || { + storage + .put(&oid, &bytes, ObjectType::Blob) + .map_err(|error| StoreError::Object(error.to_string())) + }) + .await + .map_err(|error| StoreError::Object(error.to_string()))??; + Ok(oid) + } + + pub async fn load_view(&self, oid: &ObjectHash) -> Result { + let storage = self.storage.clone(); + let expected_oid = *oid; + let bytes = tokio::task::spawn_blocking(move || { + storage + .get(&expected_oid) + .map_err(|error| StoreError::Object(error.to_string())) + }) + .await + .map_err(|error| StoreError::Object(error.to_string()))??; + let actual_oid = ObjectHash::from_type_and_data(ObjectType::Blob, &bytes); + if actual_oid != *oid { + return Err(StoreError::InvalidObjectHash(format!( + "manifest {oid} contains object {actual_oid}" + ))); + } + 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() { + return Err(StoreError::Validation( + "operation id cannot be empty".to_string(), + )); + } + validate_parent_ids(&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', ?, ?, ?, ?, ?, ?, ?, ?)", + [ + 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(), + match operation.status { + OperationStatusV2::Running => None, + OperationStatusV2::Success + | OperationStatusV2::Failed + | OperationStatusV2::Partial + | OperationStatusV2::Aborted => Some(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<(), StoreError> { + 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(); + if current_heads != expected { + let _ = txn.rollback().await; + return Err(StoreError::CasConflict { current_heads }); + } + + let generation = current_rows + .iter() + .map(|(_, generation)| *generation) + .max() + .unwrap_or(0) + .saturating_add(1); + if let Err(error) = txn + .execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM operation_head WHERE repo_id = ? AND scope_key = ?", + [repo_id.into(), scope_key.into()], + )) + .await + { + let _ = txn.rollback().await; + return Err(StoreError::Database(error)); + } + for op_id in replacement { + if let Err(error) = txn + .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.into(), + generation.into(), + ], + )) + .await + { + let _ = txn.rollback().await; + return Err(StoreError::Database(error)); + } + } + txn.commit().await?; + Ok(()) + } + + 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_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, + "WITH RECURSIVE reachable(op_id) AS ( \ + SELECT op_id FROM operation_head WHERE repo_id = ? AND scope_key = ? \ + UNION \ + SELECT parent_op_id FROM operation_parent \ + JOIN reachable ON operation_parent.op_id = reachable.op_id \ + ) SELECT operation_parent.op_id, operation_parent.parent_op_id \ + FROM operation_parent JOIN reachable \ + ON operation_parent.op_id = reachable.op_id \ + ORDER BY operation_parent.op_id, operation_parent.ordinal", + [repo_id.into(), scope_key.into()], + )) + .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(), + )); + } + self.db + .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?; + 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 = ? \ + ORDER BY op_id", + [repo_id.into(), scope_key.into()], + )) + .await?; + rows.into_iter() + .map(|row| { + let generation = row.try_get_by_index::(1)?; + if generation < 0 { + return Err(StoreError::Validation(format!( + "operation head generation cannot be negative: {generation}" + ))); + } + Ok((row.try_get_by_index::(0)?, generation as u64)) + }) + .collect() +} + +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(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 !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() { + 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..e26c5a570 --- /dev/null +++ b/src/internal/operation/view.rs @@ -0,0 +1,299 @@ +//! Canonical, content-addressed v2 repository and workspace manifests. + +use std::collections::BTreeMap; + +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), +} + +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) + } +} + +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)) + ); + } +} diff --git a/src/internal/operation/working_copy.rs b/src/internal/operation/working_copy.rs new file mode 100644 index 000000000..ce08e8c10 --- /dev/null +++ b/src/internal/operation/working_copy.rs @@ -0,0 +1,188 @@ +//! Workspace state pointer and operation-head freshness checks. +//! +//! The pointer is a tiny, crash-safe sidecar in the resolved gitdir. It is +//! not the source of truth for operation history: operation_head remains +//! authoritative, and this file only records which published snapshot the +//! working copy last materialized. + +use std::path::PathBuf; + +use git_internal::hash::ObjectHash; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::store::OpHeadsView; + +/// The request-scoped path carrier used by pointer I/O. +pub type PinnedRequestScope = crate::internal::worktree_scope::RequestScope; + +const POINTER_FILE_NAME: &str = "workspace-state-pointer.json"; + +/// The last operation/snapshot materialized into one workspace. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceStatePointer { + pub last_op_id: String, + pub last_snapshot_oid: ObjectHash, + pub generation: u64, +} + +#[derive(Debug, Error)] +pub enum PointerError { + #[error("workspace state pointer is missing at {0}")] + Missing(PathBuf), + #[error("failed to read workspace state pointer: {0}")] + Io(#[from] std::io::Error), + #[error("workspace state pointer JSON is invalid: {0}")] + Json(#[from] serde_json::Error), + #[error("workspace state pointer has an empty operation id")] + EmptyOperationId, + #[error("workspace state pointer write task failed: {0}")] + WriteTask(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Staleness { + Fresh, + Stale, + Sibling, +} + +impl WorkspaceStatePointer { + pub fn path(scope: &PinnedRequestScope) -> PathBuf { + scope.gitdir.join(POINTER_FILE_NAME) + } + + pub async fn load(scope: &PinnedRequestScope) -> Result { + let path = Self::path(scope); + let bytes = tokio::fs::read(&path).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + PointerError::Missing(path.clone()) + } else { + PointerError::Io(error) + } + })?; + let pointer = serde_json::from_slice::(&bytes)?; + pointer.validate()?; + Ok(pointer) + } + + pub async fn save(&self, scope: &PinnedRequestScope) -> Result<(), PointerError> { + self.validate()?; + let bytes = serde_json::to_vec(self)?; + let path = Self::path(scope); + tokio::task::spawn_blocking(move || { + crate::utils::atomic_write::write_atomic(&path, &bytes, true) + }) + .await + .map_err(|error| PointerError::WriteTask(error.to_string()))??; + Ok(()) + } + + pub fn staleness(&self, heads: &OpHeadsView) -> Staleness { + let Some(pointer_generation) = heads.generation(&self.last_op_id) else { + return if heads.heads.keys().any(|head| { + heads.is_ancestor(head, &self.last_op_id) + || heads.is_ancestor(&self.last_op_id, head) + }) { + Staleness::Stale + } else { + Staleness::Sibling + }; + }; + + if pointer_generation == self.generation { + return Staleness::Fresh; + } + + Staleness::Stale + } + + fn validate(&self) -> Result<(), PointerError> { + if self.last_op_id.is_empty() { + return Err(PointerError::EmptyOperationId); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::internal::worktree_scope::{RequestScope, WorktreeScope}; + + fn pointer(op_id: &str, generation: u64) -> WorkspaceStatePointer { + WorkspaceStatePointer { + last_op_id: op_id.to_string(), + last_snapshot_oid: ObjectHash::from_type_and_data( + git_internal::internal::object::types::ObjectType::Blob, + op_id.as_bytes(), + ), + generation, + } + } + + #[test] + fn matching_head_and_generation_is_fresh() { + let heads = + OpHeadsView::with_generations(vec![("op-1".to_string(), 3)]).expect("valid heads"); + assert_eq!(pointer("op-1", 3).staleness(&heads), Staleness::Fresh); + } + + #[test] + fn generation_mismatch_is_stale() { + let heads = + OpHeadsView::with_generations(vec![("op-1".to_string(), 4)]).expect("valid heads"); + assert_eq!(pointer("op-1", 3).staleness(&heads), Staleness::Stale); + } + + #[test] + fn unrelated_head_is_sibling() { + let heads = OpHeadsView::new(vec!["op-2".to_string()]).expect("valid heads"); + assert_eq!(pointer("op-1", 1).staleness(&heads), Staleness::Sibling); + } + + #[test] + fn descendant_head_is_stale() { + let mut heads = OpHeadsView::new(vec!["op-2".to_string()]).expect("valid heads"); + heads.add_ancestor("op-2", "op-1").expect("valid edge"); + assert_eq!(pointer("op-1", 0).staleness(&heads), Staleness::Stale); + } + + #[tokio::test] + async fn pointer_round_trip_uses_atomic_scope_path() { + let dir = TempDir::new().expect("temporary pointer directory"); + let scope = RequestScope { + scope: WorktreeScope::Main, + workdir: dir.path().to_path_buf(), + gitdir: dir.path().to_path_buf(), + storage: dir.path().to_path_buf(), + worktree_root: dir.path().to_path_buf(), + }; + let expected = pointer("op-1", 2); + expected.save(&scope).await.expect("pointer saves"); + assert_eq!( + WorkspaceStatePointer::load(&scope) + .await + .expect("pointer loads"), + expected + ); + assert!(WorkspaceStatePointer::path(&scope).is_file()); + } + + #[tokio::test] + async fn missing_pointer_is_reported_without_fallback() { + let dir = TempDir::new().expect("temporary pointer directory"); + let scope = RequestScope { + scope: WorktreeScope::Main, + workdir: dir.path().to_path_buf(), + gitdir: dir.path().join("gitdir"), + storage: dir.path().to_path_buf(), + worktree_root: dir.path().to_path_buf(), + }; + let result = WorkspaceStatePointer::load(&scope).await; + assert!(matches!(result, Err(PointerError::Missing(_)))); + } +} diff --git a/src/internal/sequencer/mod.rs b/src/internal/sequencer/mod.rs index d0a188890..aa4cb2cea 100644 --- a/src/internal/sequencer/mod.rs +++ b/src/internal/sequencer/mod.rs @@ -226,16 +226,33 @@ pub async fn load_for_scope( let Some(stored) = load_stored_in_scope(scope.storage_key()).await? else { return Ok(None); }; + Ok(Some(sequence_state_from_stored(stored)?)) +} + +/// Load sequence state through an already-opened repository connection. The +/// operation facet uses this form so a pinned request cannot accidentally +/// resolve a different repository while capturing state. +pub(crate) async fn load_for_scope_with_conn( + db: &C, + scope: &crate::internal::worktree_scope::WorktreeScope, +) -> Result, String> { + let Some(stored) = load_stored_with_conn(db, scope.storage_key()).await? else { + return Ok(None); + }; + Ok(Some(sequence_state_from_stored(stored)?)) +} + +fn sequence_state_from_stored(stored: StoredSequenceState) -> Result { let kind = SequenceKind::from_token(&stored.kind) .ok_or_else(|| format!("unknown sequence kind '{}'", stored.kind))?; - Ok(Some(SequenceState { + Ok(SequenceState { kind, head_name: stored.head_name, head_orig: stored.head_orig, current_oid: stored.current_oid, todo: stored.todo, payload: stored.payload, - })) + }) } pub(crate) async fn load_am() -> Result, String> { @@ -554,6 +571,31 @@ where { save_fields( db, + ¤t_scope_key(), + state.kind.as_str(), + &state.head_name, + &state.head_orig, + &state.current_oid, + &state.todo, + &state.payload, + ) + .await +} + +/// Restore sequence state for an already-resolved worktree using an existing +/// connection. This is intentionally crate-visible: OL-07 owns the facet +/// envelope, while this module remains the single owner of sequencer SQL. +pub(crate) async fn save_for_scope_with_conn( + db: &C, + scope: &crate::internal::worktree_scope::WorktreeScope, + state: &SequenceState, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait, +{ + save_fields( + db, + scope.storage_key(), state.kind.as_str(), &state.head_name, &state.head_orig, @@ -603,6 +645,7 @@ where { save_fields( db, + ¤t_scope_key(), "am", &state.head_name, &state.head_orig, @@ -613,8 +656,12 @@ where .await } +// The SQL row is deliberately expanded at this single ownership boundary; +// the extra scope key prevents a facet restore from falling back to cwd. +#[allow(clippy::too_many_arguments)] async fn save_fields( db: &C, + scope_key: &str, kind: &str, head_name: &str, head_orig: &str, @@ -625,14 +672,10 @@ async fn save_fields( where C: ConnectionTrait, { - // Part C W1 (§C.4.2): replace only THIS worktree's row. An unscoped - // `DELETE FROM sequence_state` would wipe every other worktree's - // in-progress sequence. - let scope_key = current_scope_key(); db.execute_raw(Statement::from_sql_and_values( DbBackend::Sqlite, "DELETE FROM sequence_state WHERE worktree_id = ?", - [scope_key.clone().into()], + [scope_key.to_string().into()], )) .await?; let todo = todo.join("\n"); @@ -713,10 +756,46 @@ where { // Part C W1 (§C.4.2): scoped by worktree AND kind — a mis-routed abort can // erase neither a different consumer's row nor another worktree's sequence. + clear_for_scope_with_conn( + db, + &crate::internal::worktree_scope::WorktreeScope::for_request(), + kind, + ) + .await +} + +/// Clear one worktree's sequence row through an already-resolved scope. +pub(crate) async fn clear_for_scope_with_conn( + db: &C, + scope: &crate::internal::worktree_scope::WorktreeScope, + kind: SequenceKind, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait, +{ db.execute_raw(Statement::from_sql_and_values( DbBackend::Sqlite, "DELETE FROM sequence_state WHERE kind = ? AND worktree_id = ?", - [kind.as_str().into(), current_scope_key().into()], + [kind.as_str().into(), scope.storage_key().into()], + )) + .await?; + Ok(()) +} + +/// Clear any active sequence for one already-resolved worktree. This is used +/// when restoring an empty sequencer facet because the captured state does +/// not carry a kind with which to scope the delete. +pub(crate) async fn clear_for_scope_all_with_conn( + db: &C, + scope: &crate::internal::worktree_scope::WorktreeScope, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait, +{ + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM sequence_state WHERE worktree_id = ?", + [scope.storage_key().into()], )) .await?; Ok(()) diff --git a/src/internal/sparse/mod.rs b/src/internal/sparse/mod.rs index 59d04d476..4f07081aa 100644 --- a/src/internal/sparse/mod.rs +++ b/src/internal/sparse/mod.rs @@ -129,6 +129,83 @@ impl SparseViewStore { Self::set_enabled(scope, false).await } + /// Read both sparse-view tables through an already-opened connection. + /// Operation facets use this form to preserve the request-pinned + /// repository and to keep this module the single owner of sparse SQL. + pub(crate) async fn state_for_scope_with_conn( + db: &C, + scope: &WorktreeScope, + ) -> Result<(bool, Vec), String> { + let enabled = db + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT enabled FROM sparse_view_meta WHERE worktree_id = ?", + [scope.storage_key().into()], + )) + .await + .map_err(|error| format!("failed to read the sparse view toggle: {error}"))? + .map(|row| row.try_get_by_index::(0).map(|value| value != 0)) + .transpose() + .map_err(|error| error.to_string())? + .unwrap_or(false); + let rows = db + .query_all_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT pattern FROM sparse_view WHERE worktree_id = ? \ + ORDER BY ordinal ASC, id ASC", + [scope.storage_key().into()], + )) + .await + .map_err(|error| format!("failed to read the sparse view patterns: {error}"))?; + rows.into_iter() + .map(|row| row.try_get_by_index(0).map_err(|error| error.to_string())) + .collect::, _>>() + .map(|patterns| (enabled, patterns)) + } + + /// Restore both sparse-view tables using the caller's transaction. The + /// adapter owns the facet envelope; this store owns the SQL and scope + /// key, so another worktree cannot be overwritten accidentally. + pub(crate) async fn restore_for_scope_with_conn( + db: &C, + scope: &WorktreeScope, + enabled: bool, + patterns: &[String], + ) -> Result<(), String> { + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM sparse_view WHERE worktree_id = ?", + [scope.storage_key().into()], + )) + .await + .map_err(|error| format!("failed to clear the sparse view: {error}"))?; + for (ordinal, pattern) in patterns.iter().enumerate() { + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO sparse_view (worktree_id, pattern, ordinal) VALUES (?, ?, ?)", + [ + scope.storage_key().into(), + pattern.as_str().into(), + (ordinal as i64).into(), + ], + )) + .await + .map_err(|error| format!("failed to restore a sparse pattern: {error}"))?; + } + db.execute_raw(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO sparse_view_meta (worktree_id, enabled) VALUES (?, ?) \ + ON CONFLICT(worktree_id) DO UPDATE SET enabled = excluded.enabled", + [ + scope.storage_key().into(), + (if enabled { 1 } else { 0 }).into(), + ], + )) + .await + .map_err(|error| format!("failed to restore the sparse view toggle: {error}"))?; + Ok(()) + } + async fn rewrite(scope: &WorktreeScope, patterns: &[String]) -> Result<(), String> { let db = crate::internal::sequencer::request_db_checked().await?; let txn = db.begin().await.map_err(|e| e.to_string())?; diff --git a/src/internal/worktree_io/handler.rs b/src/internal/worktree_io/handler.rs index ad373eebb..9c601f516 100644 --- a/src/internal/worktree_io/handler.rs +++ b/src/internal/worktree_io/handler.rs @@ -41,6 +41,7 @@ fn seal_worktree_capability(request: &IoRequest) -> io::Result root, IoRequest::ReadObjectBlob { .. } | IoRequest::Shutdown => return Ok(None), }; @@ -239,6 +240,17 @@ pub(crate) fn handle_request(request: IoRequest, stdout: &mut impl Write) -> io: }; write_frame(stdout, &IoEvent::DoneHash { hex: result })?; } + IoRequest::ReadFile { + path, byte_limit, .. + } => { + write_frame(stdout, &IoEvent::Begin)?; + let path = bytes_to_path(&path); + let Some(capability) = worktree_capability.as_ref() else { + return Err(io::Error::other("missing worktree capability")); + }; + let outcome = read_worktree_file_beneath(capability, &path, byte_limit); + write_object_blob_outcome(stdout, outcome)?; + } IoRequest::ReadObjectBlob { oid, byte_limit, @@ -274,6 +286,35 @@ pub(crate) fn handle_request(request: IoRequest, stdout: &mut impl Write) -> io: Ok(true) } +fn read_worktree_file_beneath( + capability: &WorktreeRootCapability, + path: &Path, + byte_limit: u64, +) -> Result, ObjectBlobStatus> { + let relative = capability + .relative(path) + .map_err(|_| ObjectBlobStatus::Failed)?; + let root = crate::utils::beneath::open_root(capability.root()) + .map_err(|_| ObjectBlobStatus::Failed)?; + let stat = crate::utils::beneath::lstat_beneath(&root, &relative) + .map_err(|_| ObjectBlobStatus::Failed)?; + if stat.len > byte_limit { + return Err(ObjectBlobStatus::TooLarge); + } + let bytes = if stat.is_symlink { + crate::utils::beneath::read_symlink_beneath(&root, &relative) + } else if stat.is_file { + crate::utils::beneath::read_file_beneath(&root, &relative) + } else { + Err(io::Error::other("worktree path is not a regular file")) + } + .map_err(|_| ObjectBlobStatus::Failed)?; + if bytes.len() as u64 > byte_limit { + return Err(ObjectBlobStatus::TooLarge); + } + Ok(bytes) +} + fn lstat_request(path: &Path, capability: &WorktreeRootCapability) -> WireResult { let rel = match capability.relative_or_root(path) { Ok(rel) => rel, diff --git a/src/internal/worktree_io/protocol.rs b/src/internal/worktree_io/protocol.rs index 7f0ccb346..4e8db040a 100644 --- a/src/internal/worktree_io/protocol.rs +++ b/src/internal/worktree_io/protocol.rs @@ -412,6 +412,14 @@ pub(crate) enum IoRequest { #[serde(default)] root_session: u64, }, + ReadFile { + /// A canonical relative path beneath `root`. + path: Vec, + /// An absolute, sealed worktree root. + root: Vec, + /// Maximum number of bytes returned by the bounded read. + byte_limit: u64, + }, ReadObjectBlob { oid: String, objects_root: Vec, @@ -443,7 +451,7 @@ impl IoRequest { validate_worktree_path(root, left, true)?; validate_worktree_path(root, right, true).map(|_| ()) } - Self::FileBlobHash { path, root, .. } => { + Self::FileBlobHash { path, root, .. } | Self::ReadFile { path, root, .. } => { validate_worktree_path(root, path, false).map(|_| ()) } Self::MarkerProbe { dir, root } => validate_worktree_path(root, dir, true).map(|_| ()), diff --git a/tests/INDEX.md b/tests/INDEX.md index ce275044c..d2bc029f4 100644 --- a/tests/INDEX.md +++ b/tests/INDEX.md @@ -18,6 +18,11 @@ | target | wave | one-line purpose | relevant src | |---|---|---|---| | `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` | +| `operation_schema_v2` | 1 | OL-02 fresh/legacy SQLite operation schema convergence and guarded upgrade/rollback | `src/internal/db.rs`, `sql/migrations/2026090101_operation_v2.sql`, `src/internal/model/` | +| `operation_dag` | 1 | OL-04 operation object, journal, and operation-head CAS integration | `src/internal/operation/store.rs`, `sql/migrations/2026090101_operation_v2.sql` | +| `workspace_snapshot_roundtrip` | 1 | OL-06 bounded working-copy snapshot capture and tracked/untracked tree roundtrip | `src/internal/operation/snapshot.rs`, `src/internal/worktree_io/` | +| `index_snapshot_roundtrip` | 1 | OL-07 byte-exact raw Git index facet restoration | `src/internal/operation/facet.rs`, `src/internal/operation/` | +| `sequencer_snapshot_roundtrip` | 1 | OL-07 sequencer and sparse-view facet restoration | `src/internal/operation/facet.rs`, `src/internal/sequencer/`, `src/internal/sparse/` | | `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` | | `compat_bisect_subcommand_surface` | 1 | Guards `libra bisect` subcommand surface | `src/command/bisect.rs` | diff --git a/tests/compat/agent_bridge_schema_test.rs b/tests/compat/agent_bridge_schema_test.rs index 4f2a53556..36373dc4a 100644 --- a/tests/compat/agent_bridge_schema_test.rs +++ b/tests/compat/agent_bridge_schema_test.rs @@ -14,6 +14,7 @@ const BRIDGE_MIGRATION_VERSION: i64 = 2026081801; /// one mutation result can carry its operation, workspace, parent-session and /// evidence associations (LB-04/LB-05 VCS wiring). const BRIDGE_LINK_RELATIONS_VERSION: i64 = 2026082401; +const OPERATION_V2_VERSION: i64 = 2026090101; #[test] fn bridge_migrations_are_registered_and_link_relations_is_the_latest() { @@ -24,10 +25,16 @@ fn bridge_migrations_are_registered_and_link_relations_is_the_latest() { .any(|migration| migration.version == BRIDGE_MIGRATION_VERSION), "2026081801_agent_bridge_capture must stay registered" ); + assert!( + builtin_migrations() + .iter() + .any(|migration| migration.version == BRIDGE_LINK_RELATIONS_VERSION), + "2026082401_agent_bridge_link_relations must stay registered" + ); assert_eq!( runner.max_registered_version(), - Some(BRIDGE_LINK_RELATIONS_VERSION), - "2026082401_agent_bridge_link_relations must be the latest registered migration" + Some(OPERATION_V2_VERSION), + "2026090101_operation_v2 must be the latest registered migration" ); } diff --git a/tests/db_migration_test.rs b/tests/db_migration_test.rs index 4f22fb4e1..b1596dee8 100644 --- a/tests/db_migration_test.rs +++ b/tests/db_migration_test.rs @@ -55,7 +55,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 +118,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"); 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)); } // --------------------------------------------------------------------------- @@ -1208,7 +1209,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); @@ -1402,7 +1403,7 @@ async fn agent_subagent_content_up_down_up_and_nonempty_guard() { 2026071407, 2026071901, 2026072101, 2026072201, 2026072301, 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, - 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401 + 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); conn.execute_raw(Statement::from_string( @@ -1467,7 +1468,8 @@ async fn agent_subagent_content_up_down_up_and_nonempty_guard() { 2026071406, 2026071407, 2026071901, 2026072101, 2026072201, 2026072301, 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, - 2026073101, 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401 + 2026073101, 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401, + 2026090101 ] ); assert!(table_exists(&conn, "agent_subagent_content_claim").await); @@ -1571,7 +1573,7 @@ async fn existing_agent_subagent_1406_schema_upgrades_to_replication() { 2026071407, 2026071901, 2026072101, 2026072201, 2026072301, 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, - 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401 + 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); let claim = conn @@ -1705,7 +1707,7 @@ async fn evolved_agent_subagent_1406_columns_upgrade_idempotently() { 2026071407, 2026071901, 2026072101, 2026072201, 2026072301, 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, - 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401 + 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); let cursor = conn @@ -1749,11 +1751,11 @@ async fn agent_import_identity_tombstone_up_down_up_round_trip() { assert_eq!( rolled, vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, 2026072901, - 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304, 2026072303, - 2026072302, 2026072301, 2026072201, 2026072101, 2026071901, 2026071407, 2026071406, - 2026071405, 2026071404, 2026071403, 2026071402 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, + 2026072901, 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304, + 2026072303, 2026072302, 2026072301, 2026072201, 2026072101, 2026071901, 2026071407, + 2026071406, 2026071405, 2026071404, 2026071403, 2026071402 ] ); assert!(!table_exists(&conn, "agent_import_identity").await); @@ -1772,7 +1774,7 @@ async fn agent_import_identity_tombstone_up_down_up_round_trip() { 2026072101, 2026072201, 2026072301, 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, 2026080401, 2026080402, - 2026080403, 2026081301, 2026081801, 2026082401 + 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); assert!(table_exists(&conn, "agent_import_identity").await); @@ -1803,11 +1805,11 @@ async fn existing_agent_tombstone_1403_schema_upgrades_to_compat_barrier() { assert_eq!( rolled, vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, 2026072901, - 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304, 2026072303, - 2026072302, 2026072301, 2026072201, 2026072101, 2026071901, 2026071407, 2026071406, - 2026071405, 2026071404 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, + 2026072901, 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304, + 2026072303, 2026072302, 2026072301, 2026072201, 2026072101, 2026071901, 2026071407, + 2026071406, 2026071405, 2026071404 ] ); assert!(table_exists(&conn, "agent_import_tombstone").await); @@ -1826,7 +1828,7 @@ async fn existing_agent_tombstone_1403_schema_upgrades_to_compat_barrier() { 2026072301, 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, 2026080401, 2026080402, 2026080403, 2026081301, - 2026081801, 2026082401 + 2026081801, 2026082401, 2026090101 ] ); assert!(trigger_exists(&conn, "agent_tombstone_block_session_insert").await); @@ -2171,14 +2173,14 @@ async fn approved_permission_up_down_up_round_trip() { assert_eq!( rolled, vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, 2026072901, - 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304, 2026072303, - 2026072302, 2026072301, 2026072201, 2026072101, 2026071901, 2026071407, 2026071406, - 2026071405, 2026071404, 2026071403, 2026071402, 2026071401, 2026071301, 2026070803, - 2026070802, 2026070801, 2026070701, 2026070601, 2026070501, 2026070401, 2026070301, - 2026070202, 2026070201, 2026062301, 2026061401, 2026060801, 2026060401, 2026060201, - 2026053101, 2026052301, 2026050801, 2026050601 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, + 2026072901, 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304, + 2026072303, 2026072302, 2026072301, 2026072201, 2026072101, 2026071901, 2026071407, + 2026071406, 2026071405, 2026071404, 2026071403, 2026071402, 2026071401, 2026071301, + 2026070803, 2026070802, 2026070801, 2026070701, 2026070601, 2026070501, 2026070401, + 2026070301, 2026070202, 2026070201, 2026062301, 2026061401, 2026060801, 2026060401, + 2026060201, 2026053101, 2026052301, 2026050801, 2026050601 ] ); assert!( @@ -2209,7 +2211,7 @@ async fn approved_permission_up_down_up_round_trip() { 2026072101, 2026072201, 2026072301, 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, 2026080401, 2026080402, - 2026080403, 2026081301, 2026081801, 2026082401 + 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); assert!(table_exists(&conn, "approved_permission").await); @@ -3081,9 +3083,10 @@ async fn legacy_layer_rows_with_linked_fail_migration() { .await .expect("rollback layer scope"), vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, 2026072901, - 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304, 2026072303 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, + 2026072901, 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304, + 2026072303 ] ); conn.execute_raw(Statement::from_string( @@ -3136,7 +3139,8 @@ async fn legacy_layer_rows_with_linked_fail_migration() { vec![ 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, - 2026073101, 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401 + 2026073101, 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401, + 2026090101 ] ); let row = conn @@ -3413,9 +3417,9 @@ async fn sparse_migration_projects_last_wins_toggle() { .await .expect("rollback sparse scope"), vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, 2026072901, - 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, + 2026072901, 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304 ] ); // Duplicate legacy rows: stale `true` (lower id) then effective `false` @@ -3451,7 +3455,7 @@ async fn sparse_migration_projects_last_wins_toggle() { vec![ 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, - 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401 + 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); let row = conn @@ -3486,9 +3490,9 @@ async fn legacy_sparse_state_with_linked_requires_adopt_or_clear() { .await .expect("rollback sparse scope"), vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, 2026072901, - 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, + 2026072901, 2026072502, 2026072501, 2026072403, 2026072402, 2026072401, 2026072304 ] ); conn.execute_raw(Statement::from_string( @@ -3531,7 +3535,7 @@ async fn legacy_sparse_state_with_linked_requires_adopt_or_clear() { vec![ 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, - 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401 + 2026080401, 2026080402, 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); assert!(column_exists(&conn, "sparse_view", "worktree_id").await); @@ -3752,9 +3756,9 @@ async fn worktree_registry_v2_capability_marker_round_trip() { .await .expect("rollback capability marker"), vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, 2026072901, - 2026072502, 2026072501, 2026072403, 2026072402, 2026072401 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005, 2026073004, 2026073003, 2026073002, 2026073001, 2026072902, + 2026072901, 2026072502, 2026072501, 2026072403, 2026072402, 2026072401 ] ); assert!(!table_exists(&conn, "worktree_registry_capability").await); @@ -3765,7 +3769,7 @@ async fn worktree_registry_v2_capability_marker_round_trip() { vec![ 2026072401, 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, 2026080401, - 2026080402, 2026080403, 2026081301, 2026081801, 2026082401 + 2026080402, 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); assert!(table_exists(&conn, "worktree_registry_capability").await); @@ -3888,7 +3892,7 @@ async fn registry_v2_down_migration_rejects_nonterminal_state() { vec![ 2026072402, 2026072403, 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, 2026080401, 2026080402, - 2026080403, 2026081301, 2026081801, 2026082401 + 2026080403, 2026081301, 2026081801, 2026082401, 2026090101 ] ); } @@ -3971,7 +3975,7 @@ async fn workspace_record_down_migration_rejects_nonterminal_state() { vec![ 2026072501, 2026072502, 2026072901, 2026072902, 2026073001, 2026073002, 2026073003, 2026073004, 2026073005, 2026073101, 2026080401, 2026080402, 2026080403, 2026081301, - 2026081801, 2026082401 + 2026081801, 2026082401, 2026090101 ] ); @@ -4099,9 +4103,9 @@ async fn operation_scope_provenance_backfills_narrowly_and_enforces_its_domain() let conn = connect(&url).await; let runner = builtin_runner().expect("builtin runner"); runner - .run_pending(&conn) + .run_pending_up_to(&conn, 2026072902) .await - .expect("apply current migration registry"); + .expect("apply the provenance migration"); let backend = conn.get_database_backend(); // The domain is enforced by the database, not only by readers: a reader @@ -4192,7 +4196,7 @@ async fn operation_scope_provenance_backfills_narrowly_and_enforces_its_domain() .expect("seed linked-worktree evidence"); runner - .run_pending(&conn) + .run_pending_up_to(&conn, 2026072902) .await .expect("re-apply the provenance migration"); @@ -4463,7 +4467,7 @@ async fn registry_v3_rollback_refuses_live_generations() { .current_version(&conn) .await .expect("current version"), - Some(2026082401), + Some(2026090101), "and the schema is untouched" ); } @@ -4510,8 +4514,8 @@ async fn registry_v3_rollback_allows_absent_generations() { assert_eq!( rolled, vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005 ], "{label}: exactly v3 rolled back" ); @@ -4540,8 +4544,8 @@ async fn registry_v3_rollback_allows_unreadable_registry() { .await .expect("an unparseable registry does not block the rollback"), vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101, - 2026073005 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101, 2026073005 ] ); } @@ -4720,9 +4724,17 @@ async fn operation_provenance_marks_unknown_on_removed_linked_worktree() { )) .await .expect("plant an unscoped historic operation"); + conn.execute_raw(Statement::from_string( + conn.get_database_backend(), + "INSERT INTO reference (name, kind, `commit`, remote, worktree_id) \ + VALUES ('HEAD', 'Head', NULL, NULL, 'wt-linked')" + .to_string(), + )) + .await + .expect("plant linked-worktree reference evidence"); runner - .run_pending(&conn) + .run_pending_up_to(&conn, 2026072902) .await .expect("provenance marking must not block the upgrade"); let row = conn @@ -4837,8 +4849,8 @@ async fn stash_generation_fence_up_down_up_round_trip() { ); assert_eq!( runner.current_version(&conn).await.expect("version"), - Some(2026082401), - "the runtime usage migrations are the newest migrations — retarget this test when a newer one lands" + Some(2026090101), + "operation_v2 is the newest migration — retarget this test when a newer one lands" ); let rolled = runner @@ -4848,7 +4860,8 @@ async fn stash_generation_fence_up_down_up_round_trip() { assert_eq!( rolled, vec![ - 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, 2026073101 + 2026090101, 2026082401, 2026081801, 2026081301, 2026080403, 2026080402, 2026080401, + 2026073101 ], "the runtime attribution, scope, and fence migrations roll back in order" ); @@ -4931,7 +4944,7 @@ async fn approved_permission_provenance_migration() { // Up: empty provenance backfill; project_id unchanged. assert_eq!( runner.run_pending(&conn).await.expect("apply W4-07"), - vec![2026081301, 2026081801, 2026082401] + vec![2026081301, 2026081801, 2026082401, 2026090101] ); assert!(column_exists(&conn, "approved_permission", "source_worktree_id").await); assert!(column_exists(&conn, "approved_permission", "source_session_id").await); @@ -5023,7 +5036,7 @@ async fn approved_permission_provenance_migration() { assert_eq!( runner.run_pending(&conn).await.expect("re-up"), - vec![2026081301, 2026081801, 2026082401] + vec![2026081301, 2026081801, 2026082401, 2026090101] ); let kept_after: i64 = conn .query_one_raw(Statement::from_string( @@ -5058,7 +5071,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/index_snapshot_roundtrip.rs b/tests/index_snapshot_roundtrip.rs new file mode 100644 index 000000000..db94f1c23 --- /dev/null +++ b/tests/index_snapshot_roundtrip.rs @@ -0,0 +1,70 @@ +//! OL-07 focused coverage for byte-exact raw index facet restoration. + +use std::path::Path; + +use git_internal::internal::{ + index::{Index, IndexEntry}, + object::{blob::Blob, types::ObjectType}, +}; +use libra::internal::{ + operation::{FacetCaptureCtx, FacetRegistry, FacetRestoreCtx, RawIndexFacet}, + worktree_scope::{RequestScope, WorktreeScope}, +}; +use tempfile::TempDir; + +fn scope(root: &Path) -> RequestScope { + RequestScope { + scope: WorktreeScope::Main, + workdir: root.to_path_buf(), + gitdir: root.join(".libra"), + storage: root.to_path_buf(), + worktree_root: root.to_path_buf(), + } +} + +#[test] +fn raw_index_facet_restores_exact_index_bytes_and_flags() { + let dir = TempDir::new().expect("temporary repository"); + let root = dir.path().join("worktree"); + let gitdir = root.join(".libra"); + std::fs::create_dir_all(&gitdir).expect("create gitdir"); + let objects = dir.path().join("objects"); + std::fs::create_dir_all(&objects).expect("create object store"); + let storage = libra::utils::client_storage::ClientStorage::init_local(objects); + + let content = b"index payload\n"; + let blob = Blob::from_content_bytes(content.to_vec()); + storage + .put(&blob.id, content, ObjectType::Blob) + .expect("store blob"); + let mut entry = + IndexEntry::new_from_blob("intent.txt".to_string(), blob.id, content.len() as u32); + entry.flags.assume_valid = true; + entry.flags.stage = 2; + let mut index = Index::new(); + index.add(entry); + let index_path = gitdir.join("index"); + index.save(&index_path).expect("write index"); + let original = std::fs::read(&index_path).expect("read original index"); + + let mut registry = FacetRegistry::new(); + registry + .register(Box::new(RawIndexFacet::new(&scope(&root), storage))) + .expect("register raw index facet"); + let name = libra::internal::operation::FacetName::from("index"); + let capture = registry + .capture(&name, &FacetCaptureCtx::default()) + .expect("capture raw index"); + assert!(capture.payload_oid.is_some()); + assert!(registry.is_fully_restorable(std::slice::from_ref(&capture))); + + std::fs::write(&index_path, b"not the original index").expect("mutate index bytes"); + let facet = registry.get(&name).expect("registered facet"); + facet + .restore(&capture, &mut FacetRestoreCtx::default()) + .expect("restore raw index"); + assert_eq!( + std::fs::read(index_path).expect("read restored index"), + original + ); +} diff --git a/tests/operation_dag.rs b/tests/operation_dag.rs new file mode 100644 index 000000000..bb930b1f4 --- /dev/null +++ b/tests/operation_dag.rs @@ -0,0 +1,154 @@ +//! 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 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); + let store = OperationStoreV2::new_for_repo("repo-1", db, storage); + + let view_oid = store + .write_view_manifest(&view()) + .await + .expect("manifest writes"); + assert_eq!( + store.load_view(&view_oid).await.expect("manifest loads"), + view() + ); + + let operation = OperationV2 { + op_id: "op-1".to_string(), + parent_op_ids: Vec::new(), + 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"); + + store + .cas_update_op_heads("repo-1", "main", &[], &["op-1".to_string()]) + .await + .expect("initial head publish"); + assert_eq!( + store + .read_heads("repo-1", "main") + .await + .expect("heads read"), + ["op-1"] + ); + + let conflict = store + .cas_update_op_heads("repo-1", "main", &[], &["op-2".to_string()]) + .await; + assert!(matches!( + conflict, + Err(libra::internal::operation::StoreError::CasConflict { .. }) + )); + + 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"); + assert_eq!( + store + .read_journal("op-1") + .await + .expect("journal reads") + .len(), + 1 + ); + + for (op_id, parent_op_ids) in [ + ("op-2", vec!["op-1".to_string()]), + ("op-3", vec!["op-2".to_string(), "op-1".to_string()]), + ] { + store + .write_operation(&OperationV2 { + op_id: op_id.to_string(), + parent_op_ids, + pre_view_oid: view_oid, + post_view_oid: view_oid, + kind: OperationKind::Reconcile, + status: OperationStatusV2::Success, + metadata: OperationMetaV2::default(), + restores_op_id: None, + reverts_op_id: None, + predecessor_map_oid: None, + }) + .await + .expect("DAG operation writes"); + } + store + .cas_update_op_heads( + "repo-1", + "main", + &["op-1".to_string()], + &["op-2".to_string()], + ) + .await + .expect("DAG head publish"); + store + .cas_update_op_heads( + "repo-1", + "main", + &["op-2".to_string()], + &["op-3".to_string()], + ) + .await + .expect("multi-parent head publish"); + let heads = store + .read_heads_view("repo-1", "main") + .await + .expect("DAG heads read"); + assert_eq!(heads.head_ids(), ["op-3"]); + assert!(heads.is_ancestor("op-1", "op-3")); +} diff --git a/tests/operation_schema_v2.rs b/tests/operation_schema_v2.rs new file mode 100644 index 000000000..c763c9eef --- /dev/null +++ b/tests/operation_schema_v2.rs @@ -0,0 +1,193 @@ +//! 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',\ + 'operation_view', 'operation_view_ref', '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 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)\ + );\ + 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")); + 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; + drop(upgraded); + signature + }; + drop(legacy); + + assert_eq!(fresh_signature, legacy_signature); +} + +#[tokio::test] +async fn operation_v2_migration_is_versioned_and_exposes_v2_shape() { + 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()) + ); +} + +#[tokio::test] +async fn empty_operation_v2_database_can_round_trip_through_guarded_rollback() { + let dir = TempDir::new().expect("temporary schema directory"); + let path = db_path(&dir, "rollback.db"); + let conn = db::create_database(path.to_str().expect("UTF-8 path")) + .await + .expect("create database"); + let runner = libra::internal::db::migration::builtin_runner().expect("builtin runner"); + + runner + .rollback_to(&conn, 2026082401) + .await + .expect("empty v2 database rolls back"); + assert!( + table_columns(&conn, "operation") + .await + .contains(&"view_id".to_string()) + ); + assert!( + !table_columns(&conn, "operation") + .await + .contains(&"post_view_oid".to_string()) + ); + assert!( + table_columns(&conn, "operation_view") + .await + .contains(&"head_target".to_string()) + ); + assert!(table_columns(&conn, "operation_head").await.is_empty()); + + runner.run_pending(&conn).await.expect("v2 re-upgrade"); + assert!( + table_columns(&conn, "operation") + .await + .contains(&"post_view_oid".to_string()) + ); + assert!( + !table_columns(&conn, "operation") + .await + .contains(&"view_id".to_string()) + ); +} diff --git a/tests/operation_service_test.rs b/tests/operation_service_test.rs index d3fd4ae1e..fe54c0b6b 100644 --- a/tests/operation_service_test.rs +++ b/tests/operation_service_test.rs @@ -66,6 +66,35 @@ async fn create_operation_schema(db: &DatabaseConnection) { } } +/// Create the v2 operation tables used to exercise the compatibility facade. +async fn create_operation_v2_schema(db: &DatabaseConnection) { + let ddl = [ + "CREATE TABLE operation(\ + op_id TEXT PRIMARY KEY, repo_id TEXT NOT NULL, format_version INTEGER NOT NULL DEFAULT 2,\ + kind TEXT NOT NULL, status TEXT NOT NULL, command_name TEXT, description TEXT,\ + args_digest TEXT, actor TEXT, worktree_id TEXT, scope_kind TEXT NOT NULL,\ + pre_view_oid TEXT NOT NULL, post_view_oid TEXT NOT NULL, restores_op_id TEXT,\ + reverts_op_id TEXT, predecessor_map_oid TEXT, causal_context_id TEXT,\ + start_ts INTEGER NOT NULL, end_ts INTEGER, scope_provenance TEXT NOT NULL DEFAULT 'declared',\ + restorable INTEGER NOT NULL DEFAULT 1, control_slot TEXT, claim_owner TEXT\ + )", + "CREATE TABLE operation_parent(\ + op_id TEXT NOT NULL, parent_op_id TEXT NOT NULL, ordinal INTEGER NOT NULL,\ + PRIMARY KEY(op_id, parent_op_id)\ + )", + "CREATE TABLE operation_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\ + )", + ]; + for sql in ddl { + db.execute_raw(Statement::from_string(DbBackend::Sqlite, sql.to_string())) + .await + .unwrap(); + } +} + /// Build a minimal operation record for deterministic service-layer tests. fn sample_operation(op_id: &str, repo_id: &str, view_id: &str, end_ts: i64) -> OperationRecord { OperationRecord { @@ -139,6 +168,69 @@ async fn invalid_arguments_are_rejected() { assert!(matches!(error, OperationServiceError::InvalidArgument(_))); } +#[tokio::test] +/// Verifies that the legacy service API uses v2 columns when the v1 view_id is absent. +async fn v2_compatibility_facade_reads_writes_and_closes_claims() { + let db = Database::connect("sqlite::memory:").await.unwrap(); + create_operation_v2_schema(&db).await; + + let record = sample_operation("op_v2", "repo_v2", "legacy-view", 20); + let inserted = OperationService::insert_operation_with_conn(&db, &record) + .await + .unwrap(); + assert_eq!(inserted, record); + + let listed = OperationService::list_operations_by_repo_with_conn(&db, "repo_v2", 10) + .await + .unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].op_id, record.op_id); + assert_ne!(listed[0].view_id, record.view_id); + let page = OperationService::list_operations_by_repo_paginated_with_conn( + &db, + "repo_v2", + OperationQueryPage::default(), + ) + .await + .unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.items[0].op_id, "op_v2"); + + let running = OperationRecord { + op_id: "op_running".to_string(), + status: OperationStatus::Running, + control_slot: Some("control".to_string()), + claim_owner: Some("host/123".to_string()), + start_ts: 30, + end_ts: None, + ..record.clone() + }; + OperationService::insert_operation_with_conn(&db, &running) + .await + .unwrap(); + assert_eq!( + OperationService::running_control_claim_with_conn(&db, "repo_v2", "") + .await + .unwrap(), + Some(( + "op_running".to_string(), + "commit".to_string(), + 30, + Some("host/123".to_string()) + )) + ); + assert!( + OperationService::abandon_claim_with_conn(&db, "op_running", 40) + .await + .unwrap() + ); + assert!( + OperationService::delete_operation_with_conn(&db, "op_running") + .await + .unwrap() + ); +} + #[tokio::test] /// Verifies that unique constraints are enforced for duplicated refs and workspace pointers. async fn duplicate_constraints_are_enforced_for_view_refs_and_workspace() { diff --git a/tests/sequencer_snapshot_roundtrip.rs b/tests/sequencer_snapshot_roundtrip.rs new file mode 100644 index 000000000..6c29bce2c --- /dev/null +++ b/tests/sequencer_snapshot_roundtrip.rs @@ -0,0 +1,101 @@ +//! OL-07 focused coverage for sequencer and sparse-view facet restoration. + +use std::path::Path; + +use libra::internal::{ + db, + operation::{FacetCaptureCtx, FacetRestoreCtx, SequencerFacet, SparseFacet, StateFacet}, + sequencer::{SequenceKind, SequenceState}, + worktree_scope::{RequestScope, WorktreeScope}, +}; +use tempfile::TempDir; + +fn scope(root: &Path) -> RequestScope { + RequestScope { + scope: WorktreeScope::Main, + workdir: root.to_path_buf(), + gitdir: root.join(".libra"), + storage: root.to_path_buf(), + worktree_root: root.to_path_buf(), + } +} + +async fn fixture() -> ( + TempDir, + RequestScope, + sea_orm::DatabaseConnection, + libra::utils::client_storage::ClientStorage, +) { + let dir = TempDir::new().expect("temporary repository"); + let root = dir.path().join("worktree"); + std::fs::create_dir_all(root.join(".libra")).expect("create gitdir"); + let objects = dir.path().join("objects"); + std::fs::create_dir_all(&objects).expect("create object store"); + let db = db::create_database( + dir.path() + .join("repo.db") + .to_str() + .expect("UTF-8 database path"), + ) + .await + .expect("database initializes"); + let storage = libra::utils::client_storage::ClientStorage::init_local(objects); + (dir, scope(&root), db, storage) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sequencer_facet_round_trips_intermediate_state() { + let (_dir, request_scope, db, storage) = fixture().await; + let facet = SequencerFacet::new(&request_scope, db, storage); + let state = SequenceState { + kind: SequenceKind::Rebase, + head_name: "refs/heads/topic".to_string(), + head_orig: "1111111111111111111111111111111111111111".to_string(), + current_oid: "2222222222222222222222222222222222222222".to_string(), + todo: vec![ + "3333333333333333333333333333333333333333".to_string(), + "4444444444444444444444444444444444444444".to_string(), + ], + payload: "rebase-options".to_string(), + }; + facet + .write_state(Some(state)) + .expect("write sequence state"); + let capture = facet + .capture(&FacetCaptureCtx::default()) + .expect("capture sequence state"); + assert!(capture.payload_oid.is_some()); + + facet.write_state(None).expect("clear sequence state"); + facet + .restore(&capture, &mut FacetRestoreCtx::default()) + .expect("restore sequence state"); + let restored = facet + .capture(&FacetCaptureCtx::default()) + .expect("recapture sequence state"); + assert_eq!(restored.payload_oid, capture.payload_oid); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sparse_facet_round_trips_toggle_and_ordered_patterns() { + let (_dir, request_scope, db, storage) = fixture().await; + let facet = SparseFacet::new(&request_scope, db, storage); + let patterns = vec!["src/**".to_string(), "!src/generated/**".to_string()]; + facet + .write_state(true, patterns) + .expect("write sparse view"); + let capture = facet + .capture(&FacetCaptureCtx::default()) + .expect("capture sparse view"); + + facet + .write_state(false, Vec::new()) + .expect("clear sparse view"); + facet + .restore(&capture, &mut FacetRestoreCtx::default()) + .expect("restore sparse view"); + let restored = facet + .capture(&FacetCaptureCtx::default()) + .expect("recapture sparse view"); + assert_eq!(restored.payload_oid, capture.payload_oid); +} diff --git a/tests/workspace_snapshot_roundtrip.rs b/tests/workspace_snapshot_roundtrip.rs new file mode 100644 index 000000000..662b86024 --- /dev/null +++ b/tests/workspace_snapshot_roundtrip.rs @@ -0,0 +1,173 @@ +//! OL-06 focused coverage for bounded working-copy snapshot capture. + +use std::path::PathBuf; + +use git_internal::internal::{ + index::{Index, IndexEntry}, + object::{blob::Blob, types::ObjectType}, +}; +use libra::internal::{ + db, + operation::{ + CapturePolicy, FacetRegistry, OperationStoreV2, SnapshotOutcome, WorkspaceSnapshotter, + }, + worktree_scope::{RequestScope, WorktreeScope}, +}; +use tempfile::TempDir; + +fn scope(root: &std::path::Path) -> RequestScope { + RequestScope { + scope: WorktreeScope::Main, + workdir: root.to_path_buf(), + gitdir: root.join(".libra"), + storage: root.to_path_buf(), + worktree_root: root.to_path_buf(), + } +} + +async fn fixture() -> (TempDir, RequestScope, OperationStoreV2) { + fixture_with_index(true).await +} + +async fn fixture_with_index(with_index: bool) -> (TempDir, RequestScope, OperationStoreV2) { + let dir = TempDir::new().expect("temporary repository"); + let root = dir.path().join("worktree"); + let gitdir = root.join(".libra"); + std::fs::create_dir_all(&gitdir).expect("create gitdir"); + let object_path = dir.path().join("objects"); + std::fs::create_dir_all(&object_path).expect("create object store"); + let db = db::create_database( + dir.path() + .join("repo.db") + .to_str() + .expect("UTF-8 database path"), + ) + .await + .expect("database initializes"); + let storage = libra::utils::client_storage::ClientStorage::init_local(object_path); + std::fs::create_dir_all(&root).expect("create worktree"); + if with_index { + let content = b"tracked baseline\n"; + std::fs::write(root.join("tracked.txt"), content).expect("write tracked file"); + let blob = Blob::from_content_bytes(content.to_vec()); + storage + .put(&blob.id, content, ObjectType::Blob) + .expect("store baseline blob"); + let mut index = Index::new(); + index.add( + IndexEntry::new_from_file(PathBuf::from("tracked.txt").as_path(), blob.id, &root) + .expect("create index entry"), + ); + index.save(gitdir.join("index")).expect("write index"); + } + let request_scope = scope(&root); + let store = OperationStoreV2::new_for_repo("repo-test", db, storage); + (dir, request_scope, store) +} + +#[tokio::test] +async fn snapshot_empty_worktree_without_index_is_unchanged() { + let (_dir, request_scope, store) = fixture_with_index(false).await; + let mut snapshotter = WorkspaceSnapshotter::new( + request_scope, + store, + FacetRegistry::new(), + CapturePolicy::Tracked, + ); + assert_eq!( + snapshotter.capture().await.expect("empty worktree scan"), + SnapshotOutcome::NoChange + ); +} + +#[tokio::test] +async fn snapshot_roundtrip_restores_tracked_and_untracked_content() { + let (_dir, request_scope, store) = fixture().await; + let mut snapshotter = WorkspaceSnapshotter::new( + request_scope.clone(), + store, + FacetRegistry::new(), + CapturePolicy::TrackedAndUntracked, + ); + assert_eq!( + snapshotter.capture().await.expect("clean baseline capture"), + SnapshotOutcome::NoChange + ); + + std::fs::write( + request_scope.worktree_root.join("tracked.txt"), + b"tracked changed\n", + ) + .expect("modify tracked file"); + std::fs::write( + request_scope.worktree_root.join("new.txt"), + b"untracked content\n", + ) + .expect("write untracked file"); + let outcome = snapshotter.capture().await.expect("capture changes"); + let SnapshotOutcome::Captured { snapshot, .. } = outcome else { + panic!("changed files must publish a snapshot") + }; + assert_eq!(snapshot.capture_policy, CapturePolicy::TrackedAndUntracked); + assert_eq!( + snapshot.completeness, + libra::internal::operation::Completeness::Full + ); + assert_eq!( + snapshotter + .capture() + .await + .expect("repeat unchanged capture"), + SnapshotOutcome::NoChange + ); + + std::fs::write( + request_scope.worktree_root.join("tracked.txt"), + b"overwritten\n", + ) + .expect("overwrite tracked file"); + std::fs::write( + request_scope.worktree_root.join("new.txt"), + b"overwritten untracked\n", + ) + .expect("overwrite untracked file"); + snapshotter + .restore_working_copy(&snapshot) + .expect("restore snapshot tree"); + assert_eq!( + std::fs::read(request_scope.worktree_root.join("tracked.txt")).expect("read tracked"), + b"tracked changed\n" + ); + assert_eq!( + std::fs::read(request_scope.worktree_root.join("new.txt")).expect("read untracked"), + b"untracked content\n" + ); +} + +#[tokio::test] +async fn snapshot_marks_size_limited_file_partial() { + let (_dir, request_scope, store) = fixture().await; + std::fs::write( + request_scope.worktree_root.join("tracked.txt"), + b"this no longer fits", + ) + .expect("modify tracked file"); + let mut snapshotter = WorkspaceSnapshotter::new( + request_scope, + store, + FacetRegistry::new(), + CapturePolicy::Tracked, + ) + .with_limits(100, 4); + let outcome = snapshotter + .capture() + .await + .expect("partial capture publishes"); + let SnapshotOutcome::Captured { snapshot, .. } = outcome else { + panic!("size-limited change must publish a partial snapshot") + }; + assert_eq!( + snapshot.completeness, + libra::internal::operation::Completeness::Partial + ); +} diff --git a/web/out/404.html b/web/out/404.html index 4f1d278b7..f970231f4 100644 --- a/web/out/404.html +++ b/web/out/404.html @@ -1 +1 @@ -404: This page could not be found.Libra Code

404

This page could not be found.

\ No newline at end of file +404: This page could not be found.Libra Code

404

This page could not be found.

\ No newline at end of file diff --git a/web/out/404/index.html b/web/out/404/index.html index 4f1d278b7..f970231f4 100644 --- a/web/out/404/index.html +++ b/web/out/404/index.html @@ -1 +1 @@ -404: This page could not be found.Libra Code

404

This page could not be found.

\ No newline at end of file +404: This page could not be found.Libra Code

404

This page could not be found.

\ No newline at end of file diff --git a/web/out/__next.__PAGE__.txt b/web/out/__next.__PAGE__.txt index a4c87f643..584f0686c 100644 --- a/web/out/__next.__PAGE__.txt +++ b/web/out/__next.__PAGE__.txt @@ -3,7 +3,7 @@ 3:I[52683,["/_next/static/chunks/0-d6j10u4ad6s.js","/_next/static/chunks/012s93a.x1q81.js"],"default"] 6:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"OutletBoundary"] 7:"$Sreact.suspense" -0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/012s93a.x1q81.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.5"} +0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/012s93a.x1q81.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.10"} 4:{} 5:"$0:rsc:props:children:0:props:serverProvidedParams:params" 8:null diff --git a/web/out/__next._full.txt b/web/out/__next._full.txt index 19f466fdb..bf09909d6 100644 --- a/web/out/__next._full.txt +++ b/web/out/__next._full.txt @@ -9,7 +9,7 @@ b:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"ViewportBoundary"] d:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"MetadataBoundary"] f:I[69716,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default",1] :HL["/_next/static/chunks/114qnh8dmpxu5.css","style"] -0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@6","$@7"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/012s93a.x1q81.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],null]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"libra-web-0.22.5"} +0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@6","$@7"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/012s93a.x1q81.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],null]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"libra-web-0.22.10"} 6:{} 7:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params" c:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] diff --git a/web/out/__next._head.txt b/web/out/__next._head.txt index 89100469f..b0dc5fafc 100644 --- a/web/out/__next._head.txt +++ b/web/out/__next._head.txt @@ -2,4 +2,4 @@ 2:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"ViewportBoundary"] 3:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"MetadataBoundary"] 4:"$Sreact.suspense" -0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Libra Code"}],["$","meta","1",{"name":"description","content":"Local Libra Code session"}]]}]}]}],null]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.5"} +0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Libra Code"}],["$","meta","1",{"name":"description","content":"Local Libra Code session"}]]}]}]}],null]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.10"} diff --git a/web/out/__next._index.txt b/web/out/__next._index.txt index 2fb4d21e7..f61894418 100644 --- a/web/out/__next._index.txt +++ b/web/out/__next._index.txt @@ -2,4 +2,4 @@ 2:I[71579,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default"] 3:I[3522,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default"] :HL["/_next/static/chunks/114qnh8dmpxu5.css","style"] -0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.5"} +0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.10"} diff --git a/web/out/__next._tree.txt b/web/out/__next._tree.txt index b33af24bd..15eea22c8 100644 --- a/web/out/__next._tree.txt +++ b/web/out/__next._tree.txt @@ -1,2 +1,2 @@ :HL["/_next/static/chunks/114qnh8dmpxu5.css","style"] -0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}},"staleTime":300,"buildId":"libra-web-0.22.5"} +0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}},"staleTime":300,"buildId":"libra-web-0.22.10"} diff --git a/web/out/_next/static/libra-web-0.22.5/_buildManifest.js b/web/out/_next/static/libra-web-0.22.10/_buildManifest.js similarity index 100% rename from web/out/_next/static/libra-web-0.22.5/_buildManifest.js rename to web/out/_next/static/libra-web-0.22.10/_buildManifest.js diff --git a/web/out/_next/static/libra-web-0.22.5/_clientMiddlewareManifest.js b/web/out/_next/static/libra-web-0.22.10/_clientMiddlewareManifest.js similarity index 100% rename from web/out/_next/static/libra-web-0.22.5/_clientMiddlewareManifest.js rename to web/out/_next/static/libra-web-0.22.10/_clientMiddlewareManifest.js diff --git a/web/out/_next/static/libra-web-0.22.5/_ssgManifest.js b/web/out/_next/static/libra-web-0.22.10/_ssgManifest.js similarity index 100% rename from web/out/_next/static/libra-web-0.22.5/_ssgManifest.js rename to web/out/_next/static/libra-web-0.22.10/_ssgManifest.js diff --git a/web/out/_not-found/__next._full.txt b/web/out/_not-found/__next._full.txt index 433287551..24406072a 100644 --- a/web/out/_not-found/__next._full.txt +++ b/web/out/_not-found/__next._full.txt @@ -7,7 +7,7 @@ a:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"MetadataBoundary"] c:I[69716,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default",1] :HL["/_next/static/chunks/114qnh8dmpxu5.css","style"] -0:{"P":null,"c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L4",null,{"children":["$","$5",null,{"name":"Next.MetadataOutlet","children":"$@6"}]}]]}],{},null,false,null]},null,false,"$@7"]},null,false,null],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$5",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],null]}],false]],"m":"$undefined","G":["$c",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"libra-web-0.22.5"} +0:{"P":null,"c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L4",null,{"children":["$","$5",null,{"name":"Next.MetadataOutlet","children":"$@6"}]}]]}],{},null,false,null]},null,false,"$@7"]},null,false,null],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$5",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],null]}],false]],"m":"$undefined","G":["$c",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"libra-web-0.22.10"} d:[] 7:"$Wd" 9:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] diff --git a/web/out/_not-found/__next._head.txt b/web/out/_not-found/__next._head.txt index e9044680d..a75e80a23 100644 --- a/web/out/_not-found/__next._head.txt +++ b/web/out/_not-found/__next._head.txt @@ -2,4 +2,4 @@ 2:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"ViewportBoundary"] 3:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"MetadataBoundary"] 4:"$Sreact.suspense" -0:{"rsc":["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Libra Code"}],["$","meta","1",{"name":"description","content":"Local Libra Code session"}]]}]}]}],null]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.5"} +0:{"rsc":["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Libra Code"}],["$","meta","1",{"name":"description","content":"Local Libra Code session"}]]}]}]}],null]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.10"} diff --git a/web/out/_not-found/__next._index.txt b/web/out/_not-found/__next._index.txt index 2fb4d21e7..f61894418 100644 --- a/web/out/_not-found/__next._index.txt +++ b/web/out/_not-found/__next._index.txt @@ -2,4 +2,4 @@ 2:I[71579,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default"] 3:I[3522,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default"] :HL["/_next/static/chunks/114qnh8dmpxu5.css","style"] -0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.5"} +0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.10"} diff --git a/web/out/_not-found/__next._not-found.__PAGE__.txt b/web/out/_not-found/__next._not-found.__PAGE__.txt index bcfa8108c..7a76c219d 100644 --- a/web/out/_not-found/__next._not-found.__PAGE__.txt +++ b/web/out/_not-found/__next._not-found.__PAGE__.txt @@ -1,5 +1,5 @@ 1:"$Sreact.fragment" 2:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"OutletBoundary"] 3:"$Sreact.suspense" -0:{"rsc":["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L2",null,{"children":["$","$3",null,{"name":"Next.MetadataOutlet","children":"$@4"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.5"} +0:{"rsc":["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L2",null,{"children":["$","$3",null,{"name":"Next.MetadataOutlet","children":"$@4"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"libra-web-0.22.10"} 4:null diff --git a/web/out/_not-found/__next._not-found.txt b/web/out/_not-found/__next._not-found.txt index 470fc3371..891af5645 100644 --- a/web/out/_not-found/__next._not-found.txt +++ b/web/out/_not-found/__next._not-found.txt @@ -2,4 +2,4 @@ 2:I[71579,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default"] 3:I[3522,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default"] 4:[] -0:{"rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"isPartial":false,"staleTime":300,"varyParams":"$W4","buildId":"libra-web-0.22.5"} +0:{"rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"isPartial":false,"staleTime":300,"varyParams":"$W4","buildId":"libra-web-0.22.10"} diff --git a/web/out/_not-found/__next._tree.txt b/web/out/_not-found/__next._tree.txt index 18b4e8248..81da4b4e5 100644 --- a/web/out/_not-found/__next._tree.txt +++ b/web/out/_not-found/__next._tree.txt @@ -1,2 +1,2 @@ :HL["/_next/static/chunks/114qnh8dmpxu5.css","style"] -0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"/_not-found","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"libra-web-0.22.5"} +0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"/_not-found","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"libra-web-0.22.10"} diff --git a/web/out/_not-found/index.html b/web/out/_not-found/index.html index 4f1d278b7..f970231f4 100644 --- a/web/out/_not-found/index.html +++ b/web/out/_not-found/index.html @@ -1 +1 @@ -404: This page could not be found.Libra Code

404

This page could not be found.

\ No newline at end of file +404: This page could not be found.Libra Code

404

This page could not be found.

\ No newline at end of file diff --git a/web/out/_not-found/index.txt b/web/out/_not-found/index.txt index 433287551..24406072a 100644 --- a/web/out/_not-found/index.txt +++ b/web/out/_not-found/index.txt @@ -7,7 +7,7 @@ a:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"MetadataBoundary"] c:I[69716,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default",1] :HL["/_next/static/chunks/114qnh8dmpxu5.css","style"] -0:{"P":null,"c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L4",null,{"children":["$","$5",null,{"name":"Next.MetadataOutlet","children":"$@6"}]}]]}],{},null,false,null]},null,false,"$@7"]},null,false,null],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$5",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],null]}],false]],"m":"$undefined","G":["$c",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"libra-web-0.22.5"} +0:{"P":null,"c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L4",null,{"children":["$","$5",null,{"name":"Next.MetadataOutlet","children":"$@6"}]}]]}],{},null,false,null]},null,false,"$@7"]},null,false,null],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$5",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],null]}],false]],"m":"$undefined","G":["$c",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"libra-web-0.22.10"} d:[] 7:"$Wd" 9:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] diff --git a/web/out/index.html b/web/out/index.html index c9d495353..01af4b0d7 100644 --- a/web/out/index.html +++ b/web/out/index.html @@ -1 +1 @@ -Libra Code

Libra — Agent Workspace

Shared browser foundation is ready.

Loading session…

Threads

Repository-shared list from storage. Process resume is working-directory scoped — use the original session cwd with `libra code --resume`.

No threads found.

    Session lifecycle

    Active thread: none · Phase: Ready

    Selected thread: none

    No live session snapshot is loaded.

    Usage

    Cumulative

    No usage totals loaded.

    Current turn

    No current-turn delta loaded.

    Sub-agent attribution

    Sub-agent attribution unavailable.

    Thread version graph

    No thread is attached to this session yet.

      Execution

      No confirmed plan execution in the current snapshot.

      Repair

      No plan-execution repair state projected.

      SSE resilience

      SSE connected

      No cursor seq observed yet.

      No projected session snapshot is retained yet.

      \ No newline at end of file +Libra Code

      Libra — Agent Workspace

      Shared browser foundation is ready.

      Loading session…

      Threads

      Repository-shared list from storage. Process resume is working-directory scoped — use the original session cwd with `libra code --resume`.

      No threads found.

        Session lifecycle

        Active thread: none · Phase: Ready

        Selected thread: none

        No live session snapshot is loaded.

        Usage

        Cumulative

        No usage totals loaded.

        Current turn

        No current-turn delta loaded.

        Sub-agent attribution

        Sub-agent attribution unavailable.

        Thread version graph

        No thread is attached to this session yet.

          Execution

          No confirmed plan execution in the current snapshot.

          Repair

          No plan-execution repair state projected.

          SSE resilience

          SSE connected

          No cursor seq observed yet.

          No projected session snapshot is retained yet.

          \ No newline at end of file diff --git a/web/out/index.txt b/web/out/index.txt index 19f466fdb..bf09909d6 100644 --- a/web/out/index.txt +++ b/web/out/index.txt @@ -9,7 +9,7 @@ b:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"ViewportBoundary"] d:I[40097,["/_next/static/chunks/0-d6j10u4ad6s.js"],"MetadataBoundary"] f:I[69716,["/_next/static/chunks/0-d6j10u4ad6s.js"],"default",1] :HL["/_next/static/chunks/114qnh8dmpxu5.css","style"] -0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@6","$@7"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/012s93a.x1q81.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],null]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"libra-web-0.22.5"} +0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0-d6j10u4ad6s.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@6","$@7"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/012s93a.x1q81.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],null]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/114qnh8dmpxu5.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"libra-web-0.22.10"} 6:{} 7:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params" c:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]