diff --git a/Cargo.lock b/Cargo.lock index 0efe3c4..47d4c92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -468,6 +468,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bollard" version = "0.19.4" @@ -601,8 +610,20 @@ version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" dependencies = [ - "bytecheck_derive", - "ptr_meta", + "bytecheck_derive 0.6.12", + "ptr_meta 0.1.4", + "simdutf8", +] + +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive 0.8.2", + "ptr_meta 0.3.1", + "rancor", "simdutf8", ] @@ -617,6 +638,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -796,6 +828,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -930,6 +968,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.23.0" @@ -1015,7 +1062,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1069,12 +1116,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -1265,9 +1323,8 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "crc32fast", - "libz-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1504,14 +1561,13 @@ dependencies = [ [[package]] name = "git-internal" -version = "0.4.1" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3373b0719c11eed1df7d80242354816316b44c7f842f519daf60efacec7a861a" +checksum = "12cc02fd83b0fa7dff557b1ed4a6095b027083af2f6d84244fb6813da7caaaae" dependencies = [ "ahash 0.8.12", "async-trait", "axum", - "bincode 2.0.1", "bstr", "byteorder", "bytes", @@ -1532,10 +1588,13 @@ dependencies = [ "num_cpus", "path-absolutize", "rayon", + "ring", + "rkyv 0.8.17", "sea-orm", "serde", - "sha1", - "sha2", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", "similar", "tempfile", "thiserror 2.0.19", @@ -1668,7 +1727,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1725,6 +1784,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -2250,17 +2318,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-sys" -version = "1.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2351,7 +2408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -2431,6 +2488,26 @@ dependencies = [ "uuid", ] +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2769,21 +2846,18 @@ dependencies = [ [[package]] name = "path-absolutize" -version = "3.1.1" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +checksum = "f808742975794703469f67a28dd14b1d1009a1743c18b0353b4b951dbb0068ad" dependencies = [ "path-dedot", ] [[package]] name = "path-dedot" -version = "3.1.1" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" -dependencies = [ - "once_cell", -] +checksum = "03351d0f1c066c114015408dc6a3e101f080fc03ef9d8d799aa58ec760cac5a6" [[package]] name = "pem-rfc7468" @@ -3003,7 +3077,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" dependencies = [ - "ptr_meta_derive", + "ptr_meta_derive 0.1.4", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive 0.3.1", ] [[package]] @@ -3017,6 +3100,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "quinn" version = "0.11.11" @@ -3121,6 +3215,15 @@ dependencies = [ "nibble_vec", ] +[[package]] +name = "rancor" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daff8b7b3ccf5f7ba270b3e7a0a4d4c701c5797e38dec27c7e2c3dbb830fed1c" +dependencies = [ + "ptr_meta 0.3.1", +] + [[package]] name = "rand" version = "0.8.7" @@ -3308,7 +3411,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" dependencies = [ - "bytecheck", + "bytecheck 0.6.12", +] + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck 0.8.2", ] [[package]] @@ -3421,17 +3533,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" dependencies = [ "bitvec", - "bytecheck", + "bytecheck 0.6.12", "bytes", "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", + "ptr_meta 0.1.4", + "rend 0.4.2", + "rkyv_derive 0.7.46", "seahash", "tinyvec", "uuid", ] +[[package]] +name = "rkyv" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" +dependencies = [ + "bytecheck 0.8.2", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "munge", + "ptr_meta 0.3.1", + "rancor", + "rend 0.5.4", + "rkyv_derive 0.8.17", + "tinyvec", + "uuid", +] + [[package]] name = "rkyv_derive" version = "0.7.46" @@ -3443,14 +3574,25 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rkyv_derive" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -3474,7 +3616,7 @@ dependencies = [ "bytes", "num-traits", "rand 0.8.7", - "rkyv", + "rkyv 0.7.46", "serde", "serde_json", "wasm-bindgen", @@ -3669,7 +3811,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scorpiofs" -version = "0.3.0" +version = "0.3.1" dependencies = [ "assert_cmd", "async-recursion", @@ -3988,7 +4130,18 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3999,7 +4152,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4039,7 +4203,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -4067,9 +4231,12 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "similar" -version = "2.7.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16" +dependencies = [ + "bstr", +] [[package]] name = "slab" @@ -4172,7 +4339,7 @@ dependencies = [ "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.19", "time", @@ -4209,7 +4376,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -4232,7 +4399,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -4253,8 +4420,8 @@ dependencies = [ "rsa", "rust_decimal", "serde", - "sha1", - "sha2", + "sha1 0.10.7", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -4297,7 +4464,7 @@ dependencies = [ "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -5708,6 +5875,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 9b21461..178830f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scorpiofs" -version = "0.3.0" +version = "0.3.1" edition = "2021" # `cargo run` (no --bin) runs the main `scorpio` binary; `antares` is the alias. default-run = "scorpio" @@ -25,7 +25,7 @@ path = "src/main.rs" [dependencies] -git-internal = "0.4.1" +git-internal = "0.8.5" reqwest = { version = "0.13.1", features = ["json", "blocking"] } serde = { version = "1.0.228", features = ["derive"] } tokio = { version = "1.49.0", features = ["full"] } diff --git a/docs/antares.md b/docs/antares.md index 134f22c..583ed08 100644 --- a/docs/antares.md +++ b/docs/antares.md @@ -260,6 +260,42 @@ Antares 路由可通过两种方式暴露,**路径前缀不同**: --- +### 4.3. List changed paths + +**Endpoint**: `GET /mounts/{mount_id}/changes` (primary process: +`GET /antares/mounts/{mount_id}/changes`) + +**Description**: Returns the paths represented by the optional CL layer and +the private writable upper layer. The service does not walk the remote Dicfuse +base. Upper-layer entries override CL entries for the same path, character +device whiteouts are reported as `deleted`, and other entries are reported as +`modified`. The `.libra` linked-worktree metadata path is excluded. + +**Response** (200 OK): + +```json +{ + "mount_id": "550e8400-e29b-41d4-a716-446655440000", + "generation": 14695981039346656037, + "changes": [ + { + "kind": "modified", + "path": "src/lib.rs" + }, + { + "kind": "deleted", + "path": "src/obsolete.rs" + } + ] +} +``` + +`generation` is a stable fingerprint of the sorted changed-path set. Clients +must still compare candidate files against their own index and object model; +this endpoint reports overlay candidates, not Git status. + +--- + ### 5. 删除挂载 **端点**: `DELETE /mounts/{mount_id}` diff --git a/docs/worktree-api.md b/docs/worktree-api.md new file mode 100644 index 0000000..0e8e11e --- /dev/null +++ b/docs/worktree-api.md @@ -0,0 +1,127 @@ +# Antares worktree control API + +This API is the ScorpioFS half of a Libra-managed worktree. It exposes mount +lifecycle, writable-upper state, and safe base-switch preflight. It does not +implement Git refs, commits, index updates, merge, rebase, or credentials. + +## Capability discovery + +Check `/health` before using worktree operations. A compatible daemon reports: + +```text +mount.v1 +ready.v1 +changes.v1 +worktree-base.v1 +refresh-plan.v1 +``` + +Clients should negotiate capabilities instead of assuming every ScorpioFS +daemon implements the worktree API. + +## Ownership contract + +Libra owns HEAD, index, refs, object storage, commit construction, remote +transport, and conflict stages. ScorpioFS owns the mount, the Dicfuse lower +projection, and the writable Antares upper layer. + +The mount must contain only a reconstructable `.libra` pointer. Do not create +or persist `.libra` refs or index files inside an Antares upper directory. + +## Attach a Libra worktree + +1. Libra resolves the commit/tree it intends to expose. +2. Create an Antares mount without a `cl` field. +3. Wait for the mount to become ready. +4. Bind the resolved revision before allowing a process to write to the mount. + +```bash +curl -X POST http://127.0.0.1:2725/antares/mounts \ + -H 'Content-Type: application/json' \ + -d '{"job_id":"libra-dev-42","path":"/project/aardvark-dns"}' + +curl http://127.0.0.1:2725/antares/mounts//ready + +curl -X POST http://127.0.0.1:2725/antares/mounts//worktree/base \ + -H 'Content-Type: application/json' \ + -d '{"base_revision":""}' +``` + +Binding is rejected for a mount with a CL layer, a dirty upper layer, or an +existing different base binding. Repeating the same binding is idempotent. + +## Read worktree state + +```bash +curl http://127.0.0.1:2725/antares/mounts//worktree +``` + +Example response: + +```json +{ + "mount_id": "...", + "path": "/project/aardvark-dns", + "base_revision": "abc123", + "mount_state": "ready", + "dirty": true, + "changes": { + "mount_id": "...", + "generation": 123456, + "changes": [ + { "kind": "modified", "path": "src/lib.rs" } + ] + } +} +``` + +`changes` contains private upper-layer edits only. A CL layer is a build +baseline, not an unstaged Git worktree edit. Libra may use `generation` to +avoid reprocessing an unchanged path set, but it remains responsible for blob +hashing when updating its index. + +## Plan a refresh + +`refresh-plan` is a guard, not a mutation. Libra resolves target refs and calls +it before a later checkout, fast-forward, merge, or rebase operation. + +```bash +curl -X POST \ + http://127.0.0.1:2725/antares/mounts//worktree/refresh-plan \ + -H 'Content-Type: application/json' \ + -d '{ + "expected_base_revision":"abc123", + "target_revision":"def456", + "require_clean":true + }' +``` + +The disposition is one of: + +| Value | Meaning | Libra action | +| --- | --- | --- | +| `ready` | Bound base matches and the requested cleanliness rule passes | Proceed to a future transactional base switch | +| `already_at_target` | Target already matches the bound base | No worktree action | +| `unbound` | No Libra base has been registered | Bind a base first | +| `base_mismatch` | Caller has stale worktree metadata | Re-read state and resolve refs again | +| `blocked_dirty` | Upper layer has local edits | Reject, stash, merge, or rebase in Libra | + +## Git operation mapping + +| Libra operation | ScorpioFS action | +| --- | --- | +| `libra fetch origin` | None; only remote refs change | +| `libra status` | Read `/worktree`, then compare candidate paths with Libra index | +| `libra add` | None; Libra updates its host-local index | +| clean `pull --ff-only` | Call `refresh-plan`, then a future base switch/remount | +| dirty pull or checkout | `blocked_dirty`; Libra owns stash/merge/rebase | +| `libra commit` | Future transaction: switch lower to committed tree, then clear committed upper paths | +| `libra push` | None; pushing does not mutate the active mount | + +## Current lower-snapshot boundary + +`base_revision` records the revision selected by Libra. An actual Dicfuse lower +switch is deliberately not implemented until Mega exposes revision-addressable +tree, hash, and blob APIs. A successful refresh plan therefore proves only +that the worktree is safe to switch; it does not claim that the FUSE lower tree +has already moved to the target revision. diff --git a/docs/worktree-state-transitions.md b/docs/worktree-state-transitions.md new file mode 100644 index 0000000..057b30b --- /dev/null +++ b/docs/worktree-state-transitions.md @@ -0,0 +1,145 @@ +# ScorpioFS worktree state transitions + +## Scope and ownership + +ScorpioFS is a projection and FUSE ownership layer. Libra owns Git-compatible +objects, refs, HEAD, index, commits, transport, and credentials. ScorpioFS +must never persist `.libra` state in an Antares upper layer. + +The mapping is deliberately narrow: + +| Version-control concept | Owner | ScorpioFS representation | +| --- | --- | --- | +| `HEAD` commit/tree | Libra | `base_revision` bound to an Antares mount | +| index | Libra | not materialized by ScorpioFS | +| working tree | both | immutable Dicfuse lower plus Antares upper | +| unstaged changes | ScorpioFS | changed paths in the private upper layer | +| remote-tracking refs | Libra | not a filesystem update | + +An optional Antares CL layer is a build-view input. It is not a Libra index and +cannot be used with a Libra worktree-base binding. + +## Invariants + +1. A mounted worktree has at most one immutable `base_revision`. +2. `libra fetch` only updates remote refs and never changes Dicfuse lower files. +3. ScorpioFS never changes a mounted lower tree merely because a remote branch moved. +4. A dirty upper layer blocks a safe fast-forward base switch by default. +5. A base switch is coordinated by Libra and must be an explicit remount/generation + change; the current `refresh-plan` API is intentionally non-mutating. +6. Open handles continue to observe their existing generation until the caller + quiesces the worktree and performs the switch. + +## Mount states + +```text +Provisioning -> Mounted -> Ready + -> Quiescing -> Refreshing -> Ready + -> Quiescing -> Conflict + -> Unmounting -> Unmounted +``` + +`Refreshing` and `Conflict` are target states for the future revision-aware +Dicfuse switch. Existing implementations use `Ready` plus the non-mutating +refresh plan and do not claim to have switched the lower tree. + +## Git-compatible transitions + +### Attach + +1. Libra resolves the exact commit/tree to check out. +2. Libra creates the Antares mount. +3. Before a client writes to the mount, Libra calls + `POST /mounts/{id}/worktree/base` with that immutable revision. +4. ScorpioFS rejects the binding if the mount has a CL layer or an upper delta. + +### Fetch + +`libra fetch origin` updates `refs/remotes/origin/*` only. It must not invoke a +Dicfuse refresh, invalidate an active worktree, or change `base_revision`. +The status command can report ahead/behind using Libra refs while the visible +filesystem remains pinned. + +### Status + +Libra computes staged changes from `HEAD -> index`. It obtains unstaged and +untracked candidates from `GET /mounts/{id}/worktree`, then compares only those +paths against its index. This avoids recursively walking a monorepo mount. + +### Clean fast-forward + +1. Libra resolves target commit `T`. +2. Libra calls `POST /mounts/{id}/worktree/refresh-plan` with the currently + bound revision and `T`. +3. A `ready` plan permits a future transactional base switch. +4. Libra quiesces users, switches Dicfuse to the tree for `T`, remounts, then + updates HEAD and index atomically. + +### Dirty worktree + +The default plan returns `blocked_dirty`. Libra must preserve the upper layer +and either reject the operation with Git-style overwrite protection, create a +stash, or run a later three-way merge/rebase implementation. ScorpioFS must +not silently overwrite upper files. + +### Commit + +After Libra creates commit `C`, it must atomically switch the lower base to +`C` and remove only upper entries represented by the committed tree. If that +switch fails, HEAD/index/upper remain unchanged so no local edit is lost. + +## Refresh-plan API + +```http +GET /mounts/{id}/worktree +POST /mounts/{id}/worktree/base +POST /mounts/{id}/worktree/refresh-plan +``` + +Base binding: + +```json +{ "base_revision": "commit-or-tree-oid" } +``` + +Refresh preflight: + +```json +{ + "expected_base_revision": "current-oid", + "target_revision": "target-oid", + "require_clean": true +} +``` + +The response is one of `ready`, `already_at_target`, `unbound`, +`base_mismatch`, or `blocked_dirty`. It is not an update command. + +## Required server capability for actual switching + +Mega must expose revision-addressable tree and blob reads, for example: + +```text +GET /api/v1/tree?path=&revision= +GET /api/v1/tree/content-hash?path=&revision= +GET /api/v1/blob/ +``` + +Until that exists, a `base_revision` is control-plane provenance only. It must +not be advertised as proof that Dicfuse is reading an immutable historical tree. + +## Future conflict model + +For merge/rebase, Libra owns the three-way comparison and index stages: + +```text +BASE = merge base +OURS = HEAD/index/upper +THEIRS = target revision +``` + +ScorpioFS should later record the base OID at first copy-up/delete, expose it +with each changed path, and preserve upper files. Libra then creates normal +Git conflict stages and decides whether to materialize conflict files. The FUSE +layer must not write conflict markers by default because build tools may read +them as ordinary source. diff --git a/src/daemon/antares.rs b/src/daemon/antares.rs index e96244f..3c3ad30 100644 --- a/src/daemon/antares.rs +++ b/src/daemon/antares.rs @@ -4,10 +4,11 @@ //! AntaresService implementations. Includes graceful shutdown with cleanup. use std::{ - collections::{HashMap, VecDeque}, + collections::{BTreeMap, HashMap, VecDeque}, ffi::CString, net::SocketAddr, os::unix::ffi::OsStrExt, + os::unix::fs::FileTypeExt, path::{Component, Path, PathBuf}, sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -77,6 +78,16 @@ where .route("/mounts/{mount_id}/cl", post(Self::build_cl)) .route("/mounts/{mount_id}/cl", delete(Self::clear_cl)) .route("/mounts/{mount_id}/ready", get(Self::mount_ready)) + .route("/mounts/{mount_id}/changes", get(Self::mount_changes)) + .route("/mounts/{mount_id}/worktree", get(Self::worktree_state)) + .route( + "/mounts/{mount_id}/worktree/base", + post(Self::bind_worktree_base), + ) + .route( + "/mounts/{mount_id}/worktree/refresh-plan", + post(Self::plan_worktree_refresh), + ) .with_state(self.service.clone()) } @@ -300,6 +311,42 @@ where let resp = service.check_mount_ready(mount_id).await?; Ok(Json(resp)) } + + /// Return paths represented in this mount's private writable upper layer. + async fn mount_changes( + State(service): State>, + AxumPath(mount_id): AxumPath, + ) -> Result, ApiError> { + Ok(Json(service.changed_paths(mount_id).await?)) + } + + /// Return the Git-compatible worktree state for an interactive mount. + async fn worktree_state( + State(service): State>, + AxumPath(mount_id): AxumPath, + ) -> Result, ApiError> { + Ok(Json(service.worktree_state(mount_id).await?)) + } + + /// Bind an otherwise clean, CL-free mount to the revision selected by Libra. + async fn bind_worktree_base( + State(service): State>, + AxumPath(mount_id): AxumPath, + Json(request): Json, + ) -> Result, ApiError> { + Ok(Json(service.bind_worktree_base(mount_id, request).await?)) + } + + /// Check whether Libra may safely perform a later base-tree switch. + async fn plan_worktree_refresh( + State(service): State>, + AxumPath(mount_id): AxumPath, + Json(request): Json, + ) -> Result, ApiError> { + Ok(Json( + service.plan_worktree_refresh(mount_id, request).await?, + )) + } } /// Asynchronous service boundary that the HTTP layer depends on. @@ -342,6 +389,38 @@ pub trait AntaresService: Send + Sync { /// Background kernel warmup (Phase 2) is intentionally non-blocking. async fn check_mount_ready(&self, mount_id: Uuid) -> Result; + /// List paths changed in the mount's private writable upper layer. + async fn changed_paths(&self, mount_id: Uuid) -> Result; + + /// Return the lower-base binding and writable upper state used by Libra. + async fn worktree_state(&self, _mount_id: Uuid) -> Result { + Err(ServiceError::Unsupported( + "worktree state is not implemented by this Antares service".into(), + )) + } + + /// Bind a clean mount to the immutable revision selected by the VCS owner. + async fn bind_worktree_base( + &self, + _mount_id: Uuid, + _request: BindWorktreeBaseRequest, + ) -> Result { + Err(ServiceError::Unsupported( + "worktree base binding is not implemented by this Antares service".into(), + )) + } + + /// Produce a non-mutating preflight for a future lower-base switch. + async fn plan_worktree_refresh( + &self, + _mount_id: Uuid, + _request: RefreshPlanRequest, + ) -> Result { + Err(ServiceError::Unsupported( + "worktree refresh planning is not implemented by this Antares service".into(), + )) + } + async fn health_info(&self) -> HealthResponse; async fn shutdown_cleanup(&self) -> Result<(), ServiceError>; } @@ -404,6 +483,9 @@ pub struct MountStatus { pub path: String, /// Optional CL identifier pub cl: Option, + /// Commit/revision selected by Libra for an interactive worktree mount. + #[serde(default)] + pub base_revision: Option, /// The actual filesystem mountpoint pub mountpoint: String, pub layers: MountLayers, @@ -456,9 +538,100 @@ pub struct MountReadyResponse { pub state: MountLifecycle, } +/// Response for the `/mounts/{mount_id}/changes` endpoint. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MountChangesResponse { + pub mount_id: Uuid, + /// Stable fingerprint of the current changed-path set. + pub generation: u64, + pub changes: Vec, +} + +/// Request used by Libra immediately after attaching a clean worktree mount. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BindWorktreeBaseRequest { + /// Immutable commit/revision that Dicfuse is expected to project for this mount. + pub base_revision: String, +} + +/// Current ScorpioFS contribution to a Git-compatible worktree state. +/// +/// HEAD, index, refs, commits, and conflict stages remain owned by Libra. The +/// response only reports the pinned lower-base identity and upper-layer delta. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WorktreeStateResponse { + pub mount_id: Uuid, + pub path: String, + pub base_revision: Option, + pub mount_state: MountLifecycle, + pub dirty: bool, + pub changes: MountChangesResponse, +} + +/// Non-mutating request for deciding whether a VCS owner may switch bases. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RefreshPlanRequest { + /// Optimistic lock: the revision Libra believes is mounted now. + pub expected_base_revision: String, + /// Resolved target revision. ScorpioFS does not resolve refs itself. + pub target_revision: String, + /// Reject dirty worktrees. Defaults to the safe Git-compatible behavior. + #[serde(default = "default_require_clean")] + pub require_clean: bool, +} + +fn default_require_clean() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RefreshPlanDisposition { + Ready, + AlreadyAtTarget, + Unbound, + BaseMismatch, + BlockedDirty, +} + +/// Result of a refresh preflight. This endpoint never switches the FUSE lower layer. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RefreshPlanResponse { + pub mount_id: Uuid, + pub current_base_revision: Option, + pub target_revision: String, + pub disposition: RefreshPlanDisposition, + pub worktree: WorktreeStateResponse, +} + +/// A path represented in the private writable upper layer. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct ChangedPath { + pub kind: ChangeKind, + /// Normalized path relative to the Antares mount root. + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_path: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChangeKind { + Modified, + Deleted, +} + /// Health check response payload. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HealthResponse { + /// Version of the VCS worktree control protocol. + pub protocol_version: u32, + /// Stable service identifier used during capability negotiation. + pub service: String, + /// ScorpioFS package version. + pub service_version: Option, + /// Versioned control-plane capabilities supported by this service. + pub capabilities: Vec, /// Service health status: "healthy" or "degraded" pub status: String, /// Current number of active mounts @@ -487,6 +660,8 @@ pub enum ServiceError { NotFoundTask(String), #[error("failed to interact with fuse stack: {0}")] FuseFailure(String), + #[error("unsupported operation: {0}")] + Unsupported(String), #[error("unexpected error: {0}")] Internal(String), } @@ -521,6 +696,9 @@ impl IntoResponse for ApiError { ApiError::Service(ServiceError::FuseFailure(msg)) => { (StatusCode::INTERNAL_SERVER_ERROR, "FUSE_ERROR", msg.clone()) } + ApiError::Service(ServiceError::Unsupported(msg)) => { + (StatusCode::NOT_IMPLEMENTED, "UNSUPPORTED", msg.clone()) + } ApiError::Service(ServiceError::Internal(msg)) => ( StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", @@ -556,6 +734,8 @@ struct MountEntry { path: String, /// Optional CL identifier cl: Option, + /// Immutable revision selected by Libra for an interactive worktree. + base_revision: Option, /// Auto-generated mountpoint path mountpoint: String, /// Auto-generated upper directory @@ -592,6 +772,7 @@ impl MountEntry { job_id: self.job_id.clone(), path: self.path.clone(), cl: self.cl.clone(), + base_revision: self.base_revision.clone(), mountpoint: self.mountpoint.clone(), layers: MountLayers { upper: self.upper_dir.clone(), @@ -618,6 +799,105 @@ fn current_epoch_ms() -> u64 { .as_millis() as u64 } +fn scan_layer_changes( + layer_dir: &Path, + changes: &mut BTreeMap, +) -> Result<(), ServiceError> { + let mut pending = vec![layer_dir.to_path_buf()]; + + while let Some(directory) = pending.pop() { + let entries = std::fs::read_dir(&directory).map_err(|error| { + ServiceError::Internal(format!( + "failed to read Antares upper directory {:?}: {}", + directory, error + )) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + ServiceError::Internal(format!( + "failed to read an entry from Antares upper directory {:?}: {}", + directory, error + )) + })?; + let path = entry.path(); + let relative = path.strip_prefix(layer_dir).map_err(|error| { + ServiceError::Internal(format!( + "Antares layer entry {:?} escaped layer directory {:?}: {}", + path, layer_dir, error + )) + })?; + if relative + .components() + .next() + .is_some_and(|component| component.as_os_str() == ".libra") + { + continue; + } + + let file_type = entry.file_type().map_err(|error| { + ServiceError::Internal(format!( + "failed to inspect Antares upper entry {:?}: {}", + path, error + )) + })?; + if file_type.is_dir() { + pending.push(path); + continue; + } + + let relative = relative.to_str().ok_or_else(|| { + ServiceError::Internal(format!( + "Antares upper path {:?} is not valid UTF-8", + relative + )) + })?; + let changed = ChangedPath { + kind: if file_type.is_char_device() { + ChangeKind::Deleted + } else { + ChangeKind::Modified + }, + path: relative.to_string(), + source_path: None, + }; + changes.insert(changed.path.clone(), changed); + } + } + Ok(()) +} + +fn scan_mount_changes( + mount_id: Uuid, + upper_dir: &Path, + cl_dir: Option<&Path>, +) -> Result { + let mut by_path = BTreeMap::new(); + if let Some(cl_dir) = cl_dir { + scan_layer_changes(cl_dir, &mut by_path)?; + } + scan_layer_changes(upper_dir, &mut by_path)?; + let changes: Vec<_> = by_path.into_values().collect(); + + let mut generation = 0xcbf29ce484222325_u64; + for change in &changes { + generation ^= match change.kind { + ChangeKind::Modified => 1, + ChangeKind::Deleted => 2, + }; + generation = generation.wrapping_mul(0x100000001b3); + for byte in change.path.as_bytes() { + generation ^= u64::from(*byte); + generation = generation.wrapping_mul(0x100000001b3); + } + } + + Ok(MountChangesResponse { + mount_id, + generation, + changes, + }) +} + /// Type alias for path index: maps (monorepo_path, optional_cl) to mount_id. type PathIndex = Arc), Uuid>>>; /// Type alias for job index: maps a build task id (job_id/build_id) to mount_id. @@ -631,6 +911,8 @@ pub struct PersistedMountState { pub job_id: Option, pub path: String, pub cl: Option, + #[serde(default)] + pub base_revision: Option, pub mountpoint: String, pub upper_dir: String, pub cl_dir: Option, @@ -643,6 +925,18 @@ pub struct PersistedState { pub mounts: Vec, } +/// Selects which process owns durable mount lifecycle state. +/// +/// `ScorpioFs` preserves the standalone daemon behavior. `External` is for an +/// embedding controller such as Libra: ScorpioFS keeps only the live FUSE +/// handles and never writes or recovers a state file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StateOwnership { + #[default] + ScorpioFs, + External, +} + /// Concrete implementation of AntaresService. pub struct AntaresServiceImpl { /// Shared Dicfuse instance for root path (read-only base layer). @@ -660,6 +954,8 @@ pub struct AntaresServiceImpl { start_time: Instant, /// Path to the state file for persistence. state_file: PathBuf, + /// Durable state owner. External controllers must remain the sole writer. + state_ownership: StateOwnership, } impl AntaresServiceImpl { @@ -671,6 +967,19 @@ impl AntaresServiceImpl { /// # Note /// Requires config to be initialized via `config::init_config()` before calling. pub async fn new(dicfuse: Option>) -> Self { + Self::new_with_state_ownership(dicfuse, StateOwnership::ScorpioFs).await + } + + /// Create a service whose durable desired state is owned by the embedding + /// controller. The service never writes or recovers `antares_state_file`. + pub async fn new_external_state(dicfuse: Option>) -> Self { + Self::new_with_state_ownership(dicfuse, StateOwnership::External).await + } + + async fn new_with_state_ownership( + dicfuse: Option>, + state_ownership: StateOwnership, + ) -> Self { let dic = match dicfuse { Some(d) => d, None => DicfuseManager::global().await, @@ -687,6 +996,7 @@ impl AntaresServiceImpl { job_index: Arc::new(RwLock::new(HashMap::new())), start_time: Instant::now(), state_file, + state_ownership, } } @@ -1147,6 +1457,10 @@ impl AntaresServiceImpl { /// Persist current mount state to file. async fn persist_state(&self) { + if self.state_ownership == StateOwnership::External { + return; + } + let mounts = self.mounts.read().await; let state = PersistedState { mounts: mounts @@ -1157,6 +1471,7 @@ impl AntaresServiceImpl { job_id: e.job_id.clone(), path: e.path.clone(), cl: e.cl.clone(), + base_revision: e.base_revision.clone(), mountpoint: e.mountpoint.clone(), upper_dir: e.upper_dir.clone(), cl_dir: e.cl_dir.clone(), @@ -1264,6 +1579,7 @@ impl AntaresServiceImpl { job_id: persisted.job_id.clone(), path: persisted.path.clone(), cl: persisted.cl.clone(), + base_revision: persisted.base_revision.clone(), mountpoint: persisted.mountpoint.clone(), upper_dir: persisted.upper_dir.clone(), cl_dir: persisted.cl_dir.clone(), @@ -1316,6 +1632,16 @@ impl AntaresServiceImpl { pub async fn health_info_impl(&self) -> HealthResponse { let mounts = self.mounts.read().await; HealthResponse { + protocol_version: 1, + service: "scorpiofs".to_string(), + service_version: Some(env!("CARGO_PKG_VERSION").to_string()), + capabilities: vec![ + "mount.v1".to_string(), + "ready.v1".to_string(), + "changes.v1".to_string(), + "worktree-base.v1".to_string(), + "refresh-plan.v1".to_string(), + ], status: "healthy".to_string(), mount_count: mounts.len(), uptime_secs: self.start_time.elapsed().as_secs(), @@ -1558,6 +1884,7 @@ impl AntaresService for AntaresServiceImpl { job_id: task_id.clone(), path: request.path.clone(), cl: request.cl.clone(), + base_revision: None, mountpoint: mountpoint_str.clone(), upper_dir: upper_dir_str.clone(), cl_dir: cl_dir_str.clone(), @@ -1655,6 +1982,161 @@ impl AntaresService for AntaresServiceImpl { Ok(entry.to_status()) } + async fn changed_paths(&self, mount_id: Uuid) -> Result { + let (upper_dir, cl_dir) = { + let mounts = self.mounts.read().await; + let entry = mounts + .get(&mount_id) + .ok_or(ServiceError::NotFound(mount_id))?; + ( + PathBuf::from(&entry.upper_dir), + entry.cl_dir.as_deref().map(PathBuf::from), + ) + }; + tokio::task::spawn_blocking(move || { + scan_mount_changes(mount_id, &upper_dir, cl_dir.as_deref()) + }) + .await + .map_err(|error| { + ServiceError::Internal(format!( + "Antares changed-path scan task failed for mount {}: {}", + mount_id, error + )) + })? + } + + async fn worktree_state(&self, mount_id: Uuid) -> Result { + let (path, base_revision, mount_state, upper_dir) = { + let mounts = self.mounts.read().await; + let entry = mounts + .get(&mount_id) + .ok_or(ServiceError::NotFound(mount_id))?; + ( + entry.path.clone(), + entry.base_revision.clone(), + entry.state.clone(), + PathBuf::from(&entry.upper_dir), + ) + }; + + // A VCS worktree treats an optional CL layer as part of its supplied + // base, not as a user edit. Only the private upper layer is dirty. + let changes = + tokio::task::spawn_blocking(move || scan_mount_changes(mount_id, &upper_dir, None)) + .await + .map_err(|error| { + ServiceError::Internal(format!( + "Antares worktree-state scan task failed for mount {}: {}", + mount_id, error + )) + })??; + + Ok(WorktreeStateResponse { + mount_id, + path, + base_revision, + mount_state, + dirty: !changes.changes.is_empty(), + changes, + }) + } + + async fn bind_worktree_base( + &self, + mount_id: Uuid, + request: BindWorktreeBaseRequest, + ) -> Result { + let base_revision = request.base_revision.trim(); + if base_revision.is_empty() { + return Err(ServiceError::InvalidRequest( + "base_revision cannot be empty".into(), + )); + } + + let existing_base = { + let mounts = self.mounts.read().await; + let entry = mounts + .get(&mount_id) + .ok_or(ServiceError::NotFound(mount_id))?; + entry.base_revision.clone() + }; + if let Some(existing) = existing_base { + if existing != base_revision { + return Err(ServiceError::InvalidRequest(format!( + "mount {} is already bound to base revision {}", + mount_id, existing + ))); + } + return self.worktree_state(mount_id).await; + } + + let state = self.worktree_state(mount_id).await?; + if state.dirty { + return Err(ServiceError::InvalidRequest( + "cannot bind a worktree base after local upper-layer changes exist".into(), + )); + } + + { + let mut mounts = self.mounts.write().await; + let entry = mounts + .get_mut(&mount_id) + .ok_or(ServiceError::NotFound(mount_id))?; + if entry.cl.is_some() { + return Err(ServiceError::InvalidRequest( + "cannot bind a Libra worktree base to a mount with a CL layer".into(), + )); + } + if let Some(existing) = &entry.base_revision { + if existing != base_revision { + return Err(ServiceError::InvalidRequest(format!( + "mount {} is already bound to base revision {}", + mount_id, existing + ))); + } + } else { + entry.base_revision = Some(base_revision.to_string()); + entry.update_last_seen(); + } + } + + self.persist_state().await; + self.worktree_state(mount_id).await + } + + async fn plan_worktree_refresh( + &self, + mount_id: Uuid, + request: RefreshPlanRequest, + ) -> Result { + let expected = request.expected_base_revision.trim(); + let target = request.target_revision.trim(); + if expected.is_empty() || target.is_empty() { + return Err(ServiceError::InvalidRequest( + "expected_base_revision and target_revision cannot be empty".into(), + )); + } + + let worktree = self.worktree_state(mount_id).await?; + let disposition = match worktree.base_revision.as_deref() { + None => RefreshPlanDisposition::Unbound, + Some(current) if current != expected => RefreshPlanDisposition::BaseMismatch, + Some(current) if current == target => RefreshPlanDisposition::AlreadyAtTarget, + Some(_) if request.require_clean && worktree.dirty => { + RefreshPlanDisposition::BlockedDirty + } + Some(_) => RefreshPlanDisposition::Ready, + }; + + Ok(RefreshPlanResponse { + mount_id, + current_base_revision: worktree.base_revision.clone(), + target_revision: target.to_string(), + disposition, + worktree, + }) + } + async fn delete_mount(&self, mount_id: Uuid) -> Result { let start = Instant::now(); // Acquire write locks to update state @@ -1804,6 +2286,11 @@ impl AntaresService for AntaresServiceImpl { mount_id, entry.state ))); } + if entry.base_revision.is_some() { + return Err(ServiceError::InvalidRequest( + "cannot modify a CL layer after binding a Libra worktree base".into(), + )); + } let cl_root = crate::util::config::antares_cl_root(); let cl_dir_str = format!("{}/{}", cl_root, mount_id); @@ -1976,6 +2463,11 @@ impl AntaresService for AntaresServiceImpl { mount_id, entry.state ))); } + if entry.base_revision.is_some() { + return Err(ServiceError::InvalidRequest( + "cannot modify a CL layer after binding a Libra worktree base".into(), + )); + } if entry.cl.is_none() { return Err(ServiceError::InvalidRequest( @@ -2573,6 +3065,7 @@ mod tests { job_id: task_id.clone(), path: request.path, cl: request.cl, + base_revision: None, mountpoint: mountpoint.clone(), layers: MountLayers { upper: upper_dir, @@ -2616,6 +3109,20 @@ mod tests { .ok_or(ServiceError::NotFound(mount_id)) } + async fn changed_paths( + &self, + mount_id: Uuid, + ) -> Result { + if !self.mounts.read().await.contains_key(&mount_id) { + return Err(ServiceError::NotFound(mount_id)); + } + Ok(MountChangesResponse { + mount_id, + generation: 0, + changes: Vec::new(), + }) + } + async fn build_cl( &self, mount_id: Uuid, @@ -2666,6 +3173,14 @@ mod tests { async fn health_info(&self) -> HealthResponse { let mounts = self.mounts.read().await; HealthResponse { + protocol_version: 1, + service: "scorpiofs".to_string(), + service_version: Some(env!("CARGO_PKG_VERSION").to_string()), + capabilities: vec![ + "mount.v1".to_string(), + "ready.v1".to_string(), + "changes.v1".to_string(), + ], status: "healthy".to_string(), mount_count: mounts.len(), uptime_secs: 0, @@ -2699,6 +3214,76 @@ mod tests { daemon.router() } + #[test] + fn changed_path_scan_ignores_libra_metadata_and_sorts_paths() { + let root = tempfile::tempdir().unwrap(); + let cl = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("src")).unwrap(); + std::fs::create_dir_all(cl.path().join("src")).unwrap(); + std::fs::write(root.path().join("src/z.rs"), "z").unwrap(); + std::fs::write(root.path().join("src/a.rs"), "a").unwrap(); + std::fs::write(root.path().join("src/shared.rs"), "upper").unwrap(); + std::fs::write(cl.path().join("src/shared.rs"), "cl").unwrap(); + std::fs::write(cl.path().join("src/cl-only.rs"), "cl").unwrap(); + std::fs::write(root.path().join(".libra"), "gitdir: /tmp/metadata").unwrap(); + + let mount_id = Uuid::new_v4(); + let response = scan_mount_changes(mount_id, root.path(), Some(cl.path())).unwrap(); + + assert_eq!(response.mount_id, mount_id); + assert_eq!( + response + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(), + vec!["src/a.rs", "src/cl-only.rs", "src/shared.rs", "src/z.rs"] + ); + assert!(response + .changes + .iter() + .all(|change| change.kind == ChangeKind::Modified)); + } + + #[tokio::test] + async fn test_mount_changes_route() { + let app = create_test_router(); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/mounts") + .header("content-type", "application/json") + .body(Body::from(r#"{"job_id":"vcs-job","path":"/project"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let created: MountCreated = serde_json::from_slice(&body).unwrap(); + + let response = app + .oneshot( + Request::builder() + .uri(format!("/mounts/{}/changes", created.mount_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let changes: MountChangesResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(changes.mount_id, created.mount_id); + assert!(changes.changes.is_empty()); + } + #[tokio::test] async fn test_healthcheck() { let app = create_test_router(); diff --git a/src/lib.rs b/src/lib.rs index a8d9d83..7714cf0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -214,7 +214,7 @@ pub mod prelude { AntaresDaemon, AntaresService, AntaresServiceImpl, ApiError, BuildClRequest, CreateMountRequest, ErrorBody, HealthResponse, MountCollection, MountCreated, MountLayers, MountLifecycle, MountReadyResponse, MountStatus, PersistedMountState, PersistedState, - ServiceError, + ServiceError, StateOwnership, }; } diff --git a/src/manager/store.rs b/src/manager/store.rs index 3405fb1..eac5387 100644 --- a/src/manager/store.rs +++ b/src/manager/store.rs @@ -13,6 +13,9 @@ use tokio::sync::mpsc::Receiver; use crate::util::GPath; +const TREE_KEY_PREFIX: &str = "tree:v2:"; +const COMMIT_KEY: &str = "commit:v2"; + pub trait TreeStore { fn insert_tree(&self, path: PathBuf, tree: Tree); fn get_bypath(&self, path: &Path) -> Result; @@ -21,20 +24,17 @@ pub trait TreeStore { impl TreeStore for sled::Db { fn insert_tree(&self, path: PathBuf, tree: Tree) { - let config = bincode::config::standard(); - let value = bincode::encode_to_vec(&tree, config).unwrap(); - let key = path.to_str().unwrap(); + let value = serde_json::to_vec(&tree).unwrap(); + let key = format!("{TREE_KEY_PREFIX}{}", path.to_str().unwrap()); self.insert(key, value).unwrap(); } fn get_bypath(&self, path: &Path) -> Result { - let key = path.to_str().unwrap(); - match self.get(key)? { + let key = format!("{TREE_KEY_PREFIX}{}", path.to_str().unwrap()); + match self.get(&key)? { Some(encoded_value) => { - let config = bincode::config::standard(); - let (decoded, _): (Tree, usize) = - bincode::decode_from_slice(&encoded_value, config) - .map_err(|_| std::io::Error::other("Deserialization error"))?; + let decoded = serde_json::from_slice(&encoded_value) + .map_err(|_| std::io::Error::other("Deserialization error"))?; Ok(decoded) } None => { @@ -48,17 +48,19 @@ impl TreeStore for sled::Db { } fn db_tree_list(&self) -> Result> { - self.iter() + self.scan_prefix(TREE_KEY_PREFIX.as_bytes()) .map(|item| match item { // By returning a HashMap, we avoid using a double pointer loop structure in diff.rs. Ok((path, encoded_value)) => { // Convert the IVec to a string and then to a PathBuf let path = std::str::from_utf8(&path) - .map_err(|_| Error::new(ErrorKind::InvalidData, "Invalid UTF8 path"))?; - let config = bincode::config::standard(); - let (decoded_tree, _): (Tree, usize) = - bincode::decode_from_slice(&encoded_value, config) - .map_err(|_| Error::other("Deserialization error"))?; + .map_err(|_| Error::new(ErrorKind::InvalidData, "Invalid UTF8 path"))? + .strip_prefix(TREE_KEY_PREFIX) + .ok_or_else(|| { + Error::new(ErrorKind::InvalidData, "Invalid tree cache key") + })?; + let decoded_tree = serde_json::from_slice(&encoded_value) + .map_err(|_| Error::other("Deserialization error"))?; Ok((PathBuf::from(path), decoded_tree)) } Err(e) => Err(Error::new(ErrorKind::NotFound, e)), @@ -74,23 +76,18 @@ pub trait CommitStore { } impl CommitStore for sled::Db { fn store_commit(&self, commit: Commit) -> Result<()> { - let config = bincode::config::standard(); - let encoded_commit = bincode::encode_to_vec(&commit, config).unwrap(); - let re = self.insert("COMMIT", encoded_commit)?; - if re.is_some() { - Ok(()) - } else { - Err(std::io::Error::other("Failed to store commit")) - } + let encoded_commit = serde_json::to_vec(&commit) + .map_err(|_| std::io::Error::other("Serialization error"))?; + self.insert(COMMIT_KEY, encoded_commit)?; + Ok(()) } fn get_commit(&self) -> Result { - let encoded_value = self.get("COMMIT")?; - let config = bincode::config::standard(); - let (decoded, _): (Commit, usize) = - bincode::decode_from_slice(&encoded_value.unwrap(), config) - .map_err(|_| std::io::Error::other("Deserialization error"))?; - Ok(decoded) + let encoded_value = self + .get(COMMIT_KEY)? + .ok_or_else(|| Error::new(ErrorKind::NotFound, "Commit not found"))?; + serde_json::from_slice(&encoded_value) + .map_err(|_| std::io::Error::other("Deserialization error")) } } pub async fn store_trees(storepath: &str, mut tree_channel: Receiver<(GPath, Tree)>) -> Result<()> { @@ -614,11 +611,7 @@ mod test { .unwrap(); if let Some(encoded_value) = db.get(t.id.as_ref()).unwrap() { - // use bincode to deserialize the value . - let config = bincode::config::standard(); - let decoded: Tree = bincode::decode_from_slice(&encoded_value, config) - .unwrap() - .0; + let decoded: Tree = serde_json::from_slice(&encoded_value).unwrap(); println!(" {decoded}"); }; } @@ -634,9 +627,10 @@ mod test { for result in iter { match result { Ok((key, value)) => { - // Deserialize the value into the original tree structure using bincode - let config = bincode::config::standard(); - let tree: Tree = bincode::decode_from_slice(&value, config).unwrap().0; + if !key.starts_with(super::TREE_KEY_PREFIX.as_bytes()) { + continue; + } + let tree: Tree = serde_json::from_slice(&value).unwrap(); let key_str = std::str::from_utf8(&key).unwrap(); println!("path:{key_str}");