From cf449618ab1e33fcd5d4d362daf8000ab169f1cb Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 5 Oct 2026 17:42:59 -0500 Subject: [PATCH 01/23] feat(contract): add libatomic, the canonical Atomic protobuf contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One crate owns the Atomic contract: the canonical protobuf sources (36 files, package atomic), protox + tonic-build codegen (no system protoc), the generated clients/servers in the atomic module (aliased proto), and check_contract.py — the descriptor contract gate that compiles the tree and enforces the declared invariants: scope, caller, effect and capability annotations on every method; read/write discipline and replay metadata echo; retired fields stay reserved; sandbox callers never receive broad authority; generation/snapshot fencing. PASS today at 97 RPCs across 15 services, contract revision 2. Status: DRAFT for alignment — add-only after review and the first tagged release. Purely additive: no consumer wiring in this change. --- Cargo.lock | 478 +++++++++++++++++- Cargo.toml | 9 + libatomic/Cargo.toml | 18 + libatomic/build.rs | 38 ++ libatomic/proto/README.md | 74 +++ libatomic/proto/atomic/common/change.proto | 77 +++ libatomic/proto/atomic/common/common.proto | 171 +++++++ libatomic/proto/atomic/common/error.proto | 83 +++ libatomic/proto/atomic/common/event.proto | 53 ++ libatomic/proto/atomic/common/options.proto | 70 +++ .../proto/atomic/common/pagination.proto | 32 ++ .../attestation/attestation_messages.proto | 116 +++++ .../attestation/attestation_service.proto | 52 ++ .../services/daemon/daemon_messages.proto | 151 ++++++ .../services/daemon/daemon_service.proto | 74 +++ .../git_interop/git_interop_messages.proto | 62 +++ .../git_interop/git_interop_service.proto | 46 ++ .../knowledge/knowledge_messages.proto | 112 ++++ .../knowledge/knowledge_service.proto | 29 ++ .../maintenance/maintenance_messages.proto | 84 +++ .../maintenance/maintenance_service.proto | 42 ++ .../provenance/provenance_messages.proto | 373 ++++++++++++++ .../provenance/provenance_service.proto | 170 +++++++ .../services/reactor/reactor_messages.proto | 72 +++ .../services/reactor/reactor_service.proto | 54 ++ .../repository_mutation_messages.proto | 177 +++++++ .../repository_mutation_service.proto | 122 +++++ .../repository_query_messages.proto | 169 +++++++ .../repository_query_service.proto | 80 +++ .../services/sandbox/sandbox_messages.proto | 342 +++++++++++++ .../services/sandbox/sandbox_service.proto | 134 +++++ .../atomic/services/sync/sync_messages.proto | 94 ++++ .../atomic/services/sync/sync_service.proto | 60 +++ .../atomic/services/tag/tag_messages.proto | 57 +++ .../atomic/services/tag/tag_service.proto | 45 ++ .../services/triage/triage_messages.proto | 56 ++ .../services/triage/triage_service.proto | 29 ++ .../services/vault/vault_messages.proto | 224 ++++++++ .../atomic/services/vault/vault_service.proto | 122 +++++ .../atomic/services/view/view_messages.proto | 118 +++++ .../atomic/services/view/view_service.proto | 71 +++ libatomic/proto/check_contract.py | 185 +++++++ libatomic/src/lib.rs | 25 + 43 files changed, 4639 insertions(+), 11 deletions(-) create mode 100644 libatomic/Cargo.toml create mode 100644 libatomic/build.rs create mode 100644 libatomic/proto/README.md create mode 100644 libatomic/proto/atomic/common/change.proto create mode 100644 libatomic/proto/atomic/common/common.proto create mode 100644 libatomic/proto/atomic/common/error.proto create mode 100644 libatomic/proto/atomic/common/event.proto create mode 100644 libatomic/proto/atomic/common/options.proto create mode 100644 libatomic/proto/atomic/common/pagination.proto create mode 100644 libatomic/proto/atomic/services/attestation/attestation_messages.proto create mode 100644 libatomic/proto/atomic/services/attestation/attestation_service.proto create mode 100644 libatomic/proto/atomic/services/daemon/daemon_messages.proto create mode 100644 libatomic/proto/atomic/services/daemon/daemon_service.proto create mode 100644 libatomic/proto/atomic/services/git_interop/git_interop_messages.proto create mode 100644 libatomic/proto/atomic/services/git_interop/git_interop_service.proto create mode 100644 libatomic/proto/atomic/services/knowledge/knowledge_messages.proto create mode 100644 libatomic/proto/atomic/services/knowledge/knowledge_service.proto create mode 100644 libatomic/proto/atomic/services/maintenance/maintenance_messages.proto create mode 100644 libatomic/proto/atomic/services/maintenance/maintenance_service.proto create mode 100644 libatomic/proto/atomic/services/provenance/provenance_messages.proto create mode 100644 libatomic/proto/atomic/services/provenance/provenance_service.proto create mode 100644 libatomic/proto/atomic/services/reactor/reactor_messages.proto create mode 100644 libatomic/proto/atomic/services/reactor/reactor_service.proto create mode 100644 libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto create mode 100644 libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto create mode 100644 libatomic/proto/atomic/services/repository_query/repository_query_messages.proto create mode 100644 libatomic/proto/atomic/services/repository_query/repository_query_service.proto create mode 100644 libatomic/proto/atomic/services/sandbox/sandbox_messages.proto create mode 100644 libatomic/proto/atomic/services/sandbox/sandbox_service.proto create mode 100644 libatomic/proto/atomic/services/sync/sync_messages.proto create mode 100644 libatomic/proto/atomic/services/sync/sync_service.proto create mode 100644 libatomic/proto/atomic/services/tag/tag_messages.proto create mode 100644 libatomic/proto/atomic/services/tag/tag_service.proto create mode 100644 libatomic/proto/atomic/services/triage/triage_messages.proto create mode 100644 libatomic/proto/atomic/services/triage/triage_service.proto create mode 100644 libatomic/proto/atomic/services/vault/vault_messages.proto create mode 100644 libatomic/proto/atomic/services/vault/vault_service.proto create mode 100644 libatomic/proto/atomic/services/view/view_messages.proto create mode 100644 libatomic/proto/atomic/services/view/view_service.proto create mode 100644 libatomic/proto/check_contract.py create mode 100644 libatomic/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7c7a9d24..44031dad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,39 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "atomic-agent" version = "0.19.1" @@ -409,6 +442,53 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -421,6 +501,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bitflags" version = "1.3.2" @@ -648,7 +734,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -1030,6 +1116,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1265,7 +1357,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util 0.7.18", @@ -1281,6 +1373,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1364,6 +1462,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -1378,6 +1482,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1401,6 +1506,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1434,7 +1552,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -1585,6 +1703,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1604,7 +1732,7 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "web-time", ] @@ -1648,6 +1776,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1710,6 +1847,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libatomic" +version = "0.19.1" +dependencies = [ + "prost", + "prost-types", + "protox", + "tonic", + "tonic-build", +] + [[package]] name = "libc" version = "0.2.186" @@ -1803,6 +1957,39 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.118", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1815,6 +2002,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.3" @@ -1830,6 +2023,28 @@ dependencies = [ "libc", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "mime" version = "0.3.17" @@ -1857,6 +2072,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "native-tls" version = "0.2.18" @@ -2005,6 +2226,36 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2073,6 +2324,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2082,6 +2343,98 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.118", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost", + "prost-types", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost", + "prost-reflect", + "prost-types", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types", + "thiserror 1.0.69", +] + [[package]] name = "quickcheck" version = "1.1.0" @@ -2117,7 +2470,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -2155,7 +2508,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.61.2", ] @@ -2401,7 +2754,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tokio-util 0.7.18", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -2644,7 +2997,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -2775,6 +3128,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -3024,7 +3387,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -3060,6 +3423,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.6.10" @@ -3116,7 +3490,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -3130,6 +3504,70 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.7", + "slab", + "tokio", + "tokio-util 0.7.18", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -3162,7 +3600,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util 0.7.18", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "url", @@ -3187,9 +3625,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3327,6 +3777,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 230a73c4..660cc271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "atomic-repository", "atomic-semantic", "atomic-teams", + "libatomic", ] [workspace.package] @@ -35,6 +36,14 @@ atomic-remote = { path = "atomic-remote" } atomic-repository = { path = "atomic-repository" } atomic-semantic = { path = "atomic-semantic" } atomic-teams = { path = "atomic-teams" } +libatomic = { path = "libatomic" } + +# gRPC (one contract, many transports) +tonic = "0.12" +prost = "0.13" +prost-types = "0.13" +tonic-build = "0.12" +protox = "0.7" # Serialization serde = { version = "1.0", features = ["derive"] } diff --git a/libatomic/Cargo.toml b/libatomic/Cargo.toml new file mode 100644 index 00000000..25224d5c --- /dev/null +++ b/libatomic/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "libatomic" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "The canonical Atomic protobuf contract: service definitions, tonic/prost codegen, and the descriptor contract gate" + +[dependencies] +tonic = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } + +[build-dependencies] +tonic-build = { workspace = true } +protox = { workspace = true } diff --git a/libatomic/build.rs b/libatomic/build.rs new file mode 100644 index 00000000..7fc244de --- /dev/null +++ b/libatomic/build.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; + +fn collect_protos(dir: &PathBuf, out: &mut Vec) { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .expect("proto dir readable") + .map(|e| e.expect("proto entry").path()) + .collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + collect_protos(&path, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("proto") { + out.push(path); + } + } +} + +fn main() -> Result<(), Box> { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?); + let proto_root = manifest.join("proto"); + let mut files = Vec::new(); + collect_protos(&proto_root, &mut files); + assert!(!files.is_empty(), "no protos found under {}", proto_root.display()); + + // protox is a pure-Rust protoc replacement: no system protoc dependency. + // The include root carries `atomic/...`, matching the imports + // (`atomic/common/common.proto`). + let fds = protox::compile( + &files, + [proto_root.clone()], + )?; + + tonic_build::configure() + .build_client(true) + .build_server(true) + .compile_fds(fds)?; + Ok(()) +} diff --git a/libatomic/proto/README.md b/libatomic/proto/README.md new file mode 100644 index 00000000..24af5fa2 --- /dev/null +++ b/libatomic/proto/README.md @@ -0,0 +1,74 @@ +# libatomic — the canonical Atomic contract + +`libatomic` is the one crate that owns the Atomic service contract: + +- **`proto/atomic/`** — the canonical protobuf sources (package `atomic`): + shared primitives, the stable error model, and one folder per service + (`_service.proto` for the RPCs, `_messages.proto` for that + service's request/response messages). This tree is the contract's single + source of truth; there is no second copy. +- **codegen** — `build.rs` compiles the tree with + [`protox`](https://crates.io/crates/protox) (a pure-Rust `protoc` + replacement — no system protoc install) and `tonic-build`, generating + both clients and servers into the `atomic` module: + + ```rust + use libatomic::atomic::{StatusRequest, repository_query_service_client::RepositoryQueryServiceClient}; + // transport-neutral alias: + use libatomic::proto as pb; + ``` + +- **`proto/check_contract.py`** — the descriptor contract gate. It compiles + the tree and enforces the invariants the contract promises (details + below); run it from any throwaway environment, it never writes into the + source tree. + +## Layout + +``` +libatomic/ + build.rs protox + tonic-build codegen (no system protoc) + src/lib.rs the generated `atomic` module (+ `proto` alias) + proto/ + README.md <- this file + check_contract.py <- protobuf compilation + descriptor contract gate + atomic/ + common/ <- shared primitives + options.proto scope, capabilities, callers, effects (extensions 51001-51004) + common.proto repository/view/snapshot IDs, request/response metadata, hashes + change.proto shared change-domain message shapes + error.proto the stable error model + pagination.proto PageCursor {seq, offset} + event.proto the agent event envelope + services// <- one folder per service + _service.proto the service definition — RPCs only + _messages.proto that service's messages +``` + +## Status: DRAFT for alignment + +Numbers are correctable until review and the first tagged release — +**add-only thereafter**. Field and extension numbers are never reused or +renumbered; removed field numbers are reserved. Older draft clients must +fail version negotiation rather than send an ignored field to a writer. + +## The contract gate + +`check_contract.py` compiles the tree and verifies the declared +invariants: every method declares `execution_scope`, `allowed_caller`, +`effect`, and `required_capability`; reads stay read-only (no write/admin +authority, no mutation replay echo); mutations carry request metadata and +echo it in the response; retired fields stay reserved; workspace effects +stay LOCAL; sandbox callers never receive broad authority; and +generation/snapshot fencing is in place. + +```bash +# TEMP_DIR is an existing temporary directory outside the repository +python3 -m venv "$TEMP_DIR/proto-venv" +"$TEMP_DIR/proto-venv/bin/pip" install grpcio-tools +"$TEMP_DIR/proto-venv/bin/python" proto/check_contract.py --temp-dir "$TEMP_DIR" +``` + +The import root is `proto/`; imports read `atomic/common/common.proto`. +The checker verifies declarations — it does not claim to test any +implementation of them. diff --git a/libatomic/proto/atomic/common/change.proto b/libatomic/proto/atomic/common/change.proto new file mode 100644 index 00000000..358bf62b --- /dev/null +++ b/libatomic/proto/atomic/common/change.proto @@ -0,0 +1,77 @@ +// atomic-protocol — shared change-domain types. +// +// DRAFT — add-only. Shared across RepositoryQueryService, +// RepositoryMutationService, and friends. Shapes are verified against the +// atomic source (2026-10-02) — this is ATOMIC's model (patch theory, a +// semantic change graph), NOT git's: +// * a change has NO commit-parents. Its position in a view is a log +// SEQUENCE together with the Merkle state after applying it (HistoryEntry, +// atomic-repository/src/history/types.rs:79); its graph edges are +// DEPENDENCIES (StoredChangeMeta's dependency hash table: index 0 = +// self hash, 1+ = dependency hashes — redb_change_store/mod.rs:181). +// * the change header carries message/description/timestamp/authors +// (atomic-core/src/change/header.rs:197); authors are name/email +// records, not DIDs. + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; + +// A change author (atomic-core ChangeHeader's Author: name + optional email). +message Author { + string name = 1; + optional string email = 2; +} + +// Log entry — mirrors HistoryEntry: the change's position in a view's log, +// with the MERKLE STATE AFTER applying it (the view's merkle chain — not a +// commit-parent link). +message ChangeLogEntry { + Hash hash = 1; + uint64 sequence = 2; // position in the view's log (the CLI's #N) + Hash state = 3; // view Merkle state after this change + optional string message = 4; // ChangeHeader.message + optional string description = 5; + repeated Author authors = 6; + optional google.protobuf.Timestamp recorded_at = 7; // ChangeHeader.timestamp + repeated Hash dependencies = 8; // change-graph edges (no commit-parents) + optional string view = 9; // which view's log (for --all) + bool is_tagged = 10; +} + +// Change metadata — mirrors StoredChangeMeta: header + dependency hash table +// + section counts/flags. The actual sections (graph/semantic/chunks/ +// signatures) are fetched layer-selectively via ChangeIncludes. Note the +// known CLI issue on draft-view change serialization (invalid section type: +// 0x04) — a CLI/format mismatch, not corruption; reconciled outside the +// contract. +message ChangeInfo { + Hash hash = 1; + optional string message = 2; + optional string description = 3; + repeated Author authors = 4; + optional google.protobuf.Timestamp recorded_at = 5; + repeated Hash dependencies = 6; // dependency hashes (hash_table[1..]) + uint32 graph_section_count = 7; + uint32 semantic_section_count = 8; + uint32 content_chunk_count = 9; + bool has_provenance = 10; + bool has_unhashed = 11; + bool has_signature = 12; +} + +// A working-copy conflict (the CONFLICTS table's model). +message ConflictInfo { + string path = 1; + optional string view = 2; + optional Hash base = 3; + // SHAPE-CONFIRM: conflict-side details from the CONFLICTS table model. + // Render details (add-only): the conflict kind, the marker line, and + // the change-hash sides involved. + optional string kind = 4; + optional uint64 line = 5; + repeated string sides = 6; +} diff --git a/libatomic/proto/atomic/common/common.proto b/libatomic/proto/atomic/common/common.proto new file mode 100644 index 00000000..e0a92617 --- /dev/null +++ b/libatomic/proto/atomic/common/common.proto @@ -0,0 +1,171 @@ +// atomic-protocol — shared primitives. +// +// DRAFT — add-only. Field numbers are never reused or renumbered. +// Conventions carried from the earlier parked draft: +// package `atomic`, RepositoryRef numbering per the RFC. +// +// CONTRACT RULES (descriptor gate now; runtime conformance tests at implementation): +// * Idempotency: every mutation carries RequestMeta.request_id (UUID v4). +// The server echoes it in response metadata and detects replays — a +// replay returns the original outcome; different contents for the same key +// fail with IDEMPOTENCY_MISMATCH. This is the legacy journal protocol's +// request-ID discipline carried into the unified contract. Idempotency +// state is scoped by authority, repository, authenticated principal/grant, +// method and request_id, with a canonical request fingerprint. Authorize +// before opening storage or consulting replay state. The same key in another +// repository is a fresh request (one daemon serves many redb files — +// replay tables must not cross repository boundaries). +// * Caller-supplied time: RequestMeta.observed_at arrives on the wire; +// the server MUST NOT substitute its own clock for observation times +// (matters for replay; matters more when the caller is a sandbox whose +// clock is not trusted). Observation time NEVER controls token expiry, +// authorization, deadlines, or leases; those use the server's trusted clock. +// * Addressing: remote clients NEVER send filesystem paths — persistent +// repository IDs only (RepositoryRef). Local path discovery resolves to +// a persistent ID before forming the request (RFC "unified repository +// addressing"). Local-scoped ops may reference working-copy paths. +// * No REDB tables, raw keys, or transaction handles over RPC. There is no +// BeginTransaction. Each method defines its own server-side transaction +// boundary (RFC D5). +// * Reads run as independent concurrent read transactions; writes queue in +// a bounded per-repository FIFO queue — the contract never assumes a +// global write lock (RFC concurrency model). +// * No direct-storage fallback (RFC D4): a client that cannot reach the +// service fails with a stable error; it never opens storage itself. + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/timestamp.proto"; + +// Shared generated constant for GetCapabilities and x-atomic-contract-version. +// Revision 1's draft behavior is retired, not wire-compatible with these fences. +enum ContractVersion { + CONTRACT_VERSION_UNSPECIFIED = 0; + reserved 1; + CONTRACT_VERSION_2 = 2; +} + +// Globally qualified repository reference. Field numbering matches the RFC. +// authority = the local daemon or a configured hosted authority; routing is +// explicit and preserved in diagnostics, provenance, and correlation. +// +// MULTI-REPOSITORY ROUTING: one daemon multiplexes MANY repository +// databases (RFC D3) — every repo-scoped request names exactly ONE +// repository here, and the daemon resolves repository_id to its +// lazily-opened, idle-evicted repository handle. IDs are persistent: +// assigned at init (or explicit DaemonService.RegisterRepository for +// repositories that predate them) and also recorded in the working copy's +// .atomic so local clients can read them without a round trip. Remote +// clients use registered IDs only. +message RepositoryRef { + string authority = 1; // e.g. "local", or a configured hosted authority + bytes repository_id = 2; // persistent repository ID (never a path) + optional string workspace_id = 3; // required for working-copy operations +} + +// Stable, opaque domain identity, not a redb key. A name is a display hint: +// authorization uses view_id, and deleting/recreating a name never revives a +// grant. Domain handlers must verify any supplied name agrees with the ID. +message ViewRef { + bytes view_id = 1; + optional string name = 2; +} + +// A coherent effective perspective, not merely the view's own ordered log. +// effective_state covers repository/target identity and scope, contributing ancestor +// identities/states, visible dependency closure, and view-scoped vault revision. +// Servers derive a canonical BLAKE3 digest; callers echo it, never construct it. +// It is not a transaction handle. Validate again inside publication's write txn. +message ViewSnapshot { + ViewRef view = 1; + Hash effective_state = 2; + optional Hash own_merkle = 3; // diagnostic; insufficient as an effective fence +} + +enum HashAlgorithm { + HASH_ALGORITHM_UNSPECIFIED = 0; + HASH_ALGORITHM_BLAKE3 = 1; // atomic's content hash (32-byte digest) +} + +// A content or state hash. Today always blake3-32; the algorithm field keeps +// the wire honest if that ever changes. +message Hash { + bytes value = 1; + HashAlgorithm algorithm = 2; +} + +// A verified identity (atomic uses DIDs). +message Identity { + string did = 1; + optional string display_name = 2; +} + +// Common mutation header: idempotency key + caller-supplied observation time. +message RequestMeta { + string request_id = 1; // UUID v4; echoed by the server + optional google.protobuf.Timestamp observed_at = 2; // caller-supplied "now" +} + +// Present on every successful ordinary mutation response (field 100). Errors +// echo via ErrorInfo. Event ingestion instead uses event_id deduplication. +message ResponseMeta { + string request_id = 1; + bool replayed = 2; + optional ViewSnapshot snapshot = 3; +} + +// A (session, turn) pair — used by provenance and agent integration. +message SessionTurn { + string session_id = 1; + uint32 turn_number = 2; +} + +// Schema-tagged opaque bytes — the interchange pattern for payloads whose +// internal encoding is owned elsewhere (V3 change files, provenance graph +// exports, knowledge-graph slices). SubmitChange/Changes carry V3 change +// bytes as versioned-opaque with this schema field, like the event envelope's +// type URL (recorded recommendation). +message VersionedBytes { + string schema = 1; // e.g. "atomic.change.v3", "atomic.prov.graph.v1" + bytes payload = 2; +} + +// Expected-state precondition on a mutation (RFC D5/D7: fencing +// on EVERY mutation, not just provenance). A mismatch fails with +// PRECONDITION_FAILED (or GENERATION_CONFLICT for the provenance fencing +// flavor) instead of silently racing. +message ExpectedState { + oneof kind { + uint64 generation = 1; // provenance/attempt generation fencing + Hash merkle_root = 2; // view state as the caller last saw it + uint64 sequence = 3; // sequence-number fencing + ViewSnapshot view_snapshot = 4; // required for effective-view mutations + } +} + +// How a command names a change: by hash or by log sequence ("#N" — the +// change's position in the current view's log, as the CLI accepts). +message ChangeRef { + oneof kind { + Hash hash = 1; + uint64 sequence = 2; + } +} + +// A versioned change bundle — the wire form of a change interchange object +// (today: V3 change-file bytes; .change files remain import/export +// interchange per the RFC's non-goals). +message ChangeBundle { + Hash hash = 1; + string schema = 2; // e.g. "atomic.change.v3" + bytes payload = 3; +} + +// A signature over some attested target, by an identity. +message ChangeSignature { + string identity_did = 1; + bytes signature = 2; + optional google.protobuf.Timestamp signed_at = 3; +} diff --git a/libatomic/proto/atomic/common/error.proto b/libatomic/proto/atomic/common/error.proto new file mode 100644 index 00000000..f8ca7253 --- /dev/null +++ b/libatomic/proto/atomic/common/error.proto @@ -0,0 +1,83 @@ +// atomic-protocol — the stable error model. +// +// DRAFT — add-only. Enum values 28+ reserved: enum numbers are never reused. +// +// Vocabulary provenance: +// * legacy agent journal protocol (verified in source, 2026-10-01): +// "unsupported-version", "provenance-store", "provenance-checkpoint", +// "provenance-lifecycle". +// * sandbox branch per the remote-sandboxes extension: +// unauthorized, forbidden, view, repository, provenance, changes, submit, +// file-states, materialize, sandbox-token, remote, internal. +// * RFC D7/capability section: CAPABILITY_UNAVAILABLE, WORKSPACE_REQUIRED. +// * standard transport/domain classes, stable and machine-readable. +// +// Errors carry stable machine-readable codes plus actionable context +// Transport-level failures use gRPC status with ErrorInfo as +// the detail payload; domain refusals that must carry recovery payloads +// (e.g. a stale-view skeleton) travel inside the response message as oneof +// outcomes, not as transport errors. + +syntax = "proto3"; + +package atomic; + +enum ErrorCode { + ERROR_CODE_UNSPECIFIED = 0; + + // — capability & negotiation — + ERROR_CODE_CAPABILITY_UNAVAILABLE = 1; // endpoint lacks a declared capability (RFC) + ERROR_CODE_PROTOCOL_VERSION_MISMATCH = 2; // legacy "unsupported-version"; fails hard both directions + ERROR_CODE_WORKSPACE_REQUIRED = 3; // working-copy op without a workspace id (RFC) + + // — authentication & authorization — + ERROR_CODE_UNAUTHORIZED = 4; // no/invalid credentials (sandbox branch) + ERROR_CODE_FORBIDDEN = 5; // authenticated but not allowed (sandbox branch) + ERROR_CODE_SANDBOX_TOKEN = 6; // invalid/expired/revoked sandbox token (sandbox branch) + + // — idempotency & preconditions — + ERROR_CODE_IDEMPOTENCY_MISMATCH = 7; // request-ID echo mismatch (replay detection) + ERROR_CODE_PRECONDITION_FAILED = 8; // expected Merkle/sequence mismatch + ERROR_CODE_GENERATION_CONFLICT = 9; // provenance generation fencing mismatch (legacy "provenance-store" fencing flavor) + + // — resource & request validity — + ERROR_CODE_INVALID_ARGUMENT = 10; + ERROR_CODE_NOT_FOUND = 11; // change/view/tag/entity missing + ERROR_CODE_REPOSITORY = 12; // repository-level failure (sandbox branch code) + ERROR_CODE_REPOSITORY_NOT_FOUND = 13; + ERROR_CODE_RESOURCE_EXHAUSTED = 14; // limits: write queue full, message too large + ERROR_CODE_RATE_LIMITED = 15; + + // — view & change semantics — + ERROR_CODE_VIEW = 16; // view not visible to caller (sandbox branch) + ERROR_CODE_VIEW_STALE = 17; // effective snapshot mismatch (carries skeleton) + ERROR_CODE_CHANGE_REJECTED = 18; // sandbox "submit" refusal + + // — provenance journal (store-domain codes, verified in the legacy surface) — + ERROR_CODE_PROVENANCE_STORE = 19; // journal reserve/append failures + ERROR_CODE_PROVENANCE_CHECKPOINT = 20; // prepare/load/bind/acknowledge failures + ERROR_CODE_PROVENANCE_LIFECYCLE = 21; // stop/resume/abandon/status failures + + // — sandbox data ops (sandbox branch codes) — + ERROR_CODE_PROVENANCE = 22; // sandbox provenance publication + ERROR_CODE_CHANGES = 23; // change fetch/landing + ERROR_CODE_FILE_STATES = 24; // sandbox file-state slice + ERROR_CODE_MATERIALIZE = 25; // materialize failures + + // — remote & transport — + ERROR_CODE_REMOTE = 26; // remote/iroh dialing failures (sandbox branch) + ERROR_CODE_INTERNAL = 27; // legacy journal protocol + sandbox branch; server bug + + reserved 28 to 999; // add-only headroom; numbers are never reused +} + +// The machine-readable error detail attached to a failure. `context` carries +// operation-specific key/values (e.g. {"expected_generation": "3", +// "actual_generation": "4"}); contract tests assert on code + context keys, +// never on message prose. +message ErrorInfo { + ErrorCode code = 1; + string message = 2; + map context = 3; + optional string request_id = 4; // echoed idempotency key when known +} diff --git a/libatomic/proto/atomic/common/event.proto b/libatomic/proto/atomic/common/event.proto new file mode 100644 index 00000000..d109669e --- /dev/null +++ b/libatomic/proto/atomic/common/event.proto @@ -0,0 +1,53 @@ +// atomic-protocol — the Reactor event envelope (RFC "Event envelope" section). +// +// DRAFT — add-only. CONTRACT DRAFT: the ReactorService server lands in +// Phase 3; this envelope is drafted now so repository mutation events +// (transactional outbox, RFC D8) and agent ingestion rhyme with it +// (align with the Reactor event envelope so the two contracts +// rhyme"). +// +// Durability semantics (RFC D9) belong to the server, but the envelope +// carries the identity fields they depend on: +// * append-before-acknowledgement; globally unique event_id (dedup on it); +// * daemon ingestion_sequence assigned at ingest (replay order); +// * repo-local and session-local sequences; +// * causation_id (what caused this event) + correlation_id (what work this +// event belongs to) — preserved across daemon proxying (RFC D7). + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; + +message EventEnvelope { + string event_id = 1; // globally unique (UUID v4) + Identity source = 2; // producing agent/principal + Identity authenticated_principal = 3; // OUTPUT ONLY, derived by middleware; + // inbound assertions are refused. source requires explicit delegation rights. + optional bytes repository_id = 4; + // PublishEventRequest.repository and this ID must agree when both present; + // every frame of PublishStream independently authorizes its repository. + optional string workspace_id = 5; + optional string session_id = 6; + optional uint32 session_turn = 7; + + // Assigned by the daemon at ingest; absent on publish requests. + optional uint64 ingestion_sequence = 8; + optional uint64 repository_sequence = 9; + optional uint64 session_sequence = 10; + + google.protobuf.Timestamp occurred_at = 11; // caller-supplied observation time + optional google.protobuf.Timestamp ingested_at = 12; // daemon clock at ingest + + optional string causation_id = 13; + optional string correlation_id = 14; + + // Schema/type URL identifying the payload family (RFC: opaque extension + // payloads must identify a schema and obey size limits). + string type_url = 15; + oneof payload { + bytes opaque = 16; // stable named families land additively in Phase 3 + } +} diff --git a/libatomic/proto/atomic/common/options.proto b/libatomic/proto/atomic/common/options.proto new file mode 100644 index 00000000..d3f563da --- /dev/null +++ b/libatomic/proto/atomic/common/options.proto @@ -0,0 +1,70 @@ +// atomic-protocol — method options (RFC D7). +// +// DRAFT — numbers are correctable until review and the first tagged +// release; add-only thereafter. Field and extension numbers are NEVER reused +// or renumbered. 51001/51002 are the permanent allocations for Atomic's +// method options (chosen once, per RFC). +// +// THE ONE PROTOCOL: this package is the sole public protobuf contract for +// local daemon and hosted server. There is no second protocol. +// +// Capability tokens use dot style ("repository.read") — the RFC is +// internally inconsistent (dot style in its method-option examples, colon +// style in its authorization section); dot style is carried from the parked +// earlier draft and flagged for ratification. + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/descriptor.proto"; + +// Declared execution scope for an RPC. Scope documents where a method is +// expected to run; negotiated capabilities determine whether a specific +// endpoint can execute it; authorization determines whether the principal +// may invoke it (RFC D7 — three distinct checks). +enum ExecutionScope { + EXECUTION_SCOPE_UNSPECIFIED = 0; + EXECUTION_SCOPE_LOCAL = 1; // local daemon only (working-copy, admin ops) + EXECUTION_SCOPE_REMOTE = 2; // hosted server only (org policy, hosted grants) + EXECUTION_SCOPE_BOTH = 3; // same semantics on local daemon and hosted server +} + +// Caller trust is independent of endpoint placement. Middleware establishes +// this class; no request body may assert it. Socket access is not authorization. +enum CallerClass { + CALLER_CLASS_UNSPECIFIED = 0; + CALLER_CLASS_LOCAL = 1; + CALLER_CLASS_REMOTE_IDENTITY = 2; + CALLER_CLASS_SANDBOX = 3; +} + +// Allowed effects, not permissions. REPOSITORY_READ never acquires a writer, +// even for lazy repair or an aborted projection. WORKSPACE_WRITE requires the +// workspace lease; REPOSITORY_WRITE queues per repository. DAEMON_WRITE uses +// daemon storage, never a global queue serializing all repository writes. +enum RpcEffect { + RPC_EFFECT_UNSPECIFIED = 0; + RPC_EFFECT_REPOSITORY_READ = 1; + RPC_EFFECT_REPOSITORY_WRITE = 2; + RPC_EFFECT_WORKSPACE_READ = 3; + RPC_EFFECT_WORKSPACE_WRITE = 4; + RPC_EFFECT_DAEMON_READ = 5; + RPC_EFFECT_DAEMON_WRITE = 6; + RPC_EFFECT_EXTERNAL_IO = 7; +} + +extend google.protobuf.MethodOptions { + // Intended execution scope. Every method declares one. + ExecutionScope execution_scope = 51001; + // Required endpoint capabilities (RFC D7: "one or more" — with ONE flagged + // carve-out: the negotiation pair DaemonService.Health/GetCapabilities + // declares zero, because a client must discover what an endpoint supports + // before it can know which capabilities it holds; gating the handshake on a + // capability would be circular. Carried from the parked Slice 0 draft.) + repeated string required_capability = 51002; + // Explicit allowlist. Missing/unspecified means deny. A sandbox credential + // cannot become an unrestricted remote identity on another service. + repeated CallerClass allowed_caller = 51003; + repeated RpcEffect effect = 51004; +} diff --git a/libatomic/proto/atomic/common/pagination.proto b/libatomic/proto/atomic/common/pagination.proto new file mode 100644 index 00000000..cbfa3c2d --- /dev/null +++ b/libatomic/proto/atomic/common/pagination.proto @@ -0,0 +1,32 @@ +// atomic-protocol — pagination. +// +// DRAFT — add-only. +// +// The frozen-provenance cursor {seq, offset} (verified against the +// provenance store source) +// is the first concrete instance of this primitive: `seq` indexes the journal +// envelope, `offset` the byte position within it — so one envelope larger +// than a page budget is reassembled client-side from fragments, with strict +// continuity validation (append_frozen_page). Every future paginated read +// (Log, ListAttestations, vault listings, Replay...) uses the same cursor +// message rather than growing per-op cursor types. + +syntax = "proto3"; + +package atomic; + +// Opaque-ish cursor: `seq` selects an item (envelope/log entry/...), `offset` +// a position inside it when items span pages. Servers issue cursors; clients +// treat them as opaque and echo them back for the next page. +message PageCursor { + uint64 seq = 1; + uint64 offset = 2; +} + +// Client-side page budget request. Servers clamp to the negotiated limits +// (GetCapabilities.limits) and always return the next cursor, if more data +// remains. +message PageBudget { + optional uint32 max_items = 1; + optional uint32 max_bytes = 2; +} diff --git a/libatomic/proto/atomic/services/attestation/attestation_messages.proto b/libatomic/proto/atomic/services/attestation/attestation_messages.proto new file mode 100644 index 00000000..68f81037 --- /dev/null +++ b/libatomic/proto/atomic/services/attestation/attestation_messages.proto @@ -0,0 +1,116 @@ +// atomic-protocol — AttestationService: the external-signing flow and +// attestation records. +// +// DRAFT — add-only. +// +// Covers `atomic intent attest --prepare/--signed` (REMOTE-capable — that is +// the point of the feature: a signer holding the key elsewhere — browser, +// hardware token, remote signing service — needs only the public key), the +// direct attest verbs (`intent attest`, `memory attest`), verification +// (`intent verify`, `memory verify`), graph-level attestations +// (`agent attest`), and change signatures. +// +// RecordAttestation verifies the signature against --identity's key AND the +// attested target AS IT IS NOW (not as it was at prepare time) — prepare and +// record are two calls by design; the gap is the human/hardware signing step. + +syntax = "proto3"; + +package atomic; + +// All targets are resolved within the named view's effective perspective. +// Sandbox view must match its grant; graph-stats sessions must be owned by it. +// Vault attestations are view-local versions, never replacements of inherited +// attestations. A sandbox signer must match the grant's acting identity; no +// daemon default-key fallback. Signature verification is not authorization. + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; +import "atomic/common/pagination.proto"; + +// What is being attested. One service, one target shape, no per-kind RPCs. +message AttestationTarget { + oneof kind { + string intent_id = 1; // `intent attest` + string memory_id = 2; // `memory attest` + Hash change_hash = 3; // change signatures + GraphStatsRef graph_stats = 4; // `agent attest` (AI-cost/token attestations) + } +} + +message GraphStatsRef { + optional string session_id = 1; + optional uint32 turn_number = 2; + optional Hash change_hash = 3; +} + + + +message PrepareAttestationRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + AttestationTarget target = 3; + string identity_did = 4; + ViewRef view = 5; +} +message PrepareAttestationResponse { + string document = 1; // canonical document (displayed to the signer) + bytes signing_bytes = 2; // the exact bytes covered by the signature + ViewSnapshot snapshot = 3; +} + +message RecordAttestationRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + AttestationTarget target = 3; + string identity_did = 4; + bytes signature = 5; + ViewRef view = 6; + ViewSnapshot expected_snapshot = 7; + // SHAPE-CONFIRM (Slice N): whether the prepared document hash rides the + // request; today `--signed` verifies against the intent as it is now. +} +message RecordAttestationResponse { + AttestationInfo attestation = 1; + ResponseMeta meta = 100; +} + +message VerifyAttestationRequest { + RepositoryRef repository = 1; + oneof target { + string attestation_id = 2; + AttestationTarget subject = 3; + } + ViewRef view = 4; + // Local key resolution hint: identity-store name (default identity when + // absent) — the public key that verifies the attestation (add-only). + optional string identity_name = 5; +} +message VerifyAttestationResponse { + bool valid = 1; + optional string reason = 2; +} + +message ListAttestationsRequest { + RepositoryRef repository = 1; + optional AttestationTarget filter = 2; + optional PageCursor cursor = 3; + optional PageBudget budget = 4; + ViewRef view = 5; +} +message ListAttestationsResponse { + repeated AttestationInfo attestations = 1; + optional PageCursor next_cursor = 2; +} + +message AttestationInfo { + string id = 1; + AttestationTarget target = 2; + string identity_did = 3; + optional bytes signature = 4; + google.protobuf.Timestamp recorded_at = 5; + // Local-surface helpers (paths of the tracked attestation entry and the + // transition sidecar) — informational, add-only. + optional string vault_path = 6; + optional string sidecar_path = 7; +} diff --git a/libatomic/proto/atomic/services/attestation/attestation_service.proto b/libatomic/proto/atomic/services/attestation/attestation_service.proto new file mode 100644 index 00000000..2d4c90cd --- /dev/null +++ b/libatomic/proto/atomic/services/attestation/attestation_service.proto @@ -0,0 +1,52 @@ +// atomic-protocol — AttestationService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/attestation/attestation_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/attestation/attestation_messages.proto"; + +service AttestationService { + // `--prepare`: returns the canonical document and the exact signing bytes. + // A remote signer can do this over the wire with only a public key. + // EXECUTION CLASS: read — prepare performs no mutation; the record (and + // the write) happens at RecordAttestation. No private keys are used here. + rpc PrepareAttestation(PrepareAttestationRequest) returns (PrepareAttestationResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "attestation.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `--signed` (or direct attest): verifies against the identity's key and + // the target as it is now; records the attestation. + rpc RecordAttestation(RecordAttestationRequest) returns (RecordAttestationResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "attestation.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + rpc VerifyAttestation(VerifyAttestationRequest) returns (VerifyAttestationResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "attestation.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // List/inspect (`agent attest`, attestation listings). + rpc ListAttestations(ListAttestationsRequest) returns (ListAttestationsResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "attestation.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } +} diff --git a/libatomic/proto/atomic/services/daemon/daemon_messages.proto b/libatomic/proto/atomic/services/daemon/daemon_messages.proto new file mode 100644 index 00000000..79bd9f37 --- /dev/null +++ b/libatomic/proto/atomic/services/daemon/daemon_messages.proto @@ -0,0 +1,151 @@ +// atomic-protocol — DaemonService: daemon health, capability negotiation, +// repository registry, lifecycle. +// +// DRAFT — add-only. Extends the earlier parked draft. +// +// The legacy agent journal protocol is being fully dismantled (with the +// `atomic agent database-owner` command that served it); liveness, +// process lifecycle, and storage ownership become daemon concerns here: +// Health carries status + quick-gate feature tokens; the additive +// capability-flag pattern of the legacy surface (old clients ignore, old +// servers omit, clients gate before using newer ops) carries into +// GetCapabilities as the negotiated method matrix. +// +// Daemon lifecycle (RFC D3/D4): one user-level daemon; repository databases +// are opened lazily and evicted when idle; a local client that cannot +// reach the daemon may start/restart it and retry — it may NEVER open redb +// directly. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; // ExecutionScope (MethodDescriptorInfo) +import "atomic/common/common.proto"; + +enum ServingStatus { + SERVING_STATUS_UNSPECIFIED = 0; + SERVING_STATUS_SERVING = 1; + SERVING_STATUS_NOT_SERVING = 2; +} + +enum EndpointKind { + ENDPOINT_KIND_UNSPECIFIED = 0; + ENDPOINT_KIND_LOCAL_DAEMON = 1; + ENDPOINT_KIND_HOSTED_SERVER = 2; +} + +// Negotiated limits. Seeded from the legacy journal transport's proven +// defaults (64 MiB max message; 256 fragments per frozen page; 1 MiB frozen +// pages; 4 MiB client-side append chunking). The daemon may lower these per +// principal; never raise above what clients negotiate. +message Limits { + uint64 max_message_bytes = 1; + uint32 max_frozen_page_fragments = 2; + uint64 frozen_page_bytes = 3; + uint64 append_chunk_budget_bytes = 4; + uint32 max_changes_per_request = 5; + uint32 max_inodes_per_request = 6; + uint32 max_write_queue_depth = 7; +} + +// Auth requirements for remote principals: TLS + authenticated identity + +// audience-bound JWTs (a token leaked from one server must not replay at +// another — the `aud` claim carried from the sandbox extension's auth). +message AuthRequirements { + bool tls_required = 1; + bool audience_binding = 2; + repeated string identity_schemes = 3; + repeated string credential_schemes = 4; // e.g. sandbox-token-bin, bearer-jwt + // TLS requirement refers to TCP; iroh provides its authenticated encrypted + // transport. Both apply the same caller/resource authorization policy. +} + +message MethodDescriptorInfo { + string service = 1; + string method = 2; + ExecutionScope scope = 3; + repeated string required_capabilities = 4; + repeated CallerClass allowed_callers = 5; + repeated RpcEffect effects = 6; +} + + + +message HealthRequest {} + +message HealthResponse { + ServingStatus status = 1; + uint32 pid = 2; + repeated string features = 3; // quick-gate feature tokens; the full matrix + // lives in GetCapabilities +} + +message GetCapabilitiesRequest {} + +message GetCapabilitiesResponse { + uint32 protocol_version = 1; // contract version 2; mismatch fails hard + // BOTH directions (version discipline) + string implementation_version = 2; + EndpointKind endpoint_kind = 3; + repeated MethodDescriptorInfo methods = 4; // the generated method matrix + repeated string features = 5; // e.g. "harness_reflection" (see the + // grpcurl + server reflection is the + // recommended harness flavor) + Limits limits = 6; + AuthRequirements auth = 7; +} + +message ListRepositoriesRequest {} + +message ListRepositoriesResponse { + repeated RepositorySummary repositories = 1; +} + +message ResolveRepositoryRequest { + string path = 1; // working-copy path (LOCAL endpoint only) +} +message ResolveRepositoryResponse { + RepositoryRef repository = 1; // the persistent identity for that path + reserved 2; // registration is an explicit mutation + WorkspaceInfo workspace = 3; +} + +// Returned only to local principals. CLI normalizes cwd-relative arguments to +// root-relative paths before sending working-copy requests. Daemon cwd is never +// consulted. workspace_id is bound to this repository/root and its current view. +message WorkspaceInfo { + string workspace_id = 1; + string root_path = 2; + string relative_cwd = 3; + ViewRef current_view = 4; +} + +// Bootstrap registration has no RepositoryRef yet. Validate filesystem ownership +// and Atomic repository identity; aliases must refer to the same physical store. +// Duplicate IDs at distinct stores are refused, never silently rebound. Assign +// legacy IDs in the registry/.atomic ID record, without opening a repository +// write transaction. Repository schema migration is separate administration. +message RegisterRepositoryRequest { + string path = 1; + RequestMeta meta = 2; +} +message RegisterRepositoryResponse { + RepositoryRef repository = 1; + WorkspaceInfo workspace = 2; + ResponseMeta meta = 100; +} + +message RepositorySummary { + RepositoryRef repository = 1; + bool open = 2; // repository database currently open + optional string local_path = 3; // local daemon only; never sent to remote + // clients as addressing — informational +} + +message ShutdownRequest { RequestMeta meta = 1; } + +message ShutdownResponse { + uint32 pid = 1; // ShuttingDown{pid} + ResponseMeta meta = 100; +} diff --git a/libatomic/proto/atomic/services/daemon/daemon_service.proto b/libatomic/proto/atomic/services/daemon/daemon_service.proto new file mode 100644 index 00000000..62e505bf --- /dev/null +++ b/libatomic/proto/atomic/services/daemon/daemon_service.proto @@ -0,0 +1,74 @@ +// atomic-protocol — DaemonService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/daemon/daemon_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/daemon/daemon_messages.proto"; + +service DaemonService { + // Liveness + quick-gate feature tokens. Zero required capabilities + // (negotiation pair — see options.proto) and BOTH scope: health is served + // by local daemon and hosted server alike. + rpc Health(HealthRequest) returns (HealthResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_DAEMON_READ; + } + + // The capability handshake (RFC D7): versions, endpoint kind, method + // matrix, limits, auth. Zero required capabilities (negotiation pair). + // Clients gate on this BEFORE invoking ops — the additive capability-flag + // pattern of the legacy surface, generalized to the full method matrix. + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_DAEMON_READ; + } + + // Local path discovery → persistent repository identity: the contract + // analog of find_repository_root. LOOKUP ONLY: resolve a registered + // repository/workspace, including the caller's subdirectory. Never register, + // initialize tables or recover a working tree on a read. Unregistered → + // REPOSITORY_NOT_FOUND; local clients explicitly RegisterRepository. + rpc ResolveRepository(ResolveRepositoryRequest) returns (ResolveRepositoryResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "daemon.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_DAEMON_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } + + rpc RegisterRepository(RegisterRepositoryRequest) returns (RegisterRepositoryResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "daemon.admin"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + } + + // Repositories this daemon can serve (with open/idle state). + rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "daemon.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_DAEMON_READ; + } + + // Graceful shutdown (admin, local-only): drain active and queued work, + // close repository databases, release locks. + rpc Shutdown(ShutdownRequest) returns (ShutdownResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "daemon.admin"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } +} diff --git a/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto b/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto new file mode 100644 index 00000000..4836a6a5 --- /dev/null +++ b/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto @@ -0,0 +1,62 @@ +// atomic-protocol — GitInteropService: the git import/push pipelines. +// +// DRAFT — add-only. Git interop is LOCAL-only (working copies and git +// mirrors are local). `git hooks install/uninstall` only writes .git/hooks +// shim files — no RPC (exempt, file-only). Import may create the repository +// (init_with_view) on first run — bootstrap-adjacent, flagged in COVERAGE. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/common.proto"; + + + +message ImportFromGitRequest { + RepositoryRef repository = 1; // absent = create (bootstrap form) + RequestMeta meta = 2; + optional string source_path = 3; // local git checkout (LOCAL scope) + optional string branch = 4; + bool all_branches = 5; + bool incremental = 6; // post-commit hook shims use this + reserved 7; + bool with_crdt = 8; + optional string destination_path = 9; // required only when repository absent; + // an existing repository instead requires its registered workspace_id. + // Bootstrap never guesses a destination from daemon cwd or source_path. +} +message ImportFromGitResponse { + uint64 changes_imported = 1; + repeated string views_created = 2; + reserved 3; + RepositoryRef repository = 4; // includes registered workspace, also on bootstrap + ResponseMeta meta = 100; +} + +message PushToGitRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + optional string message = 3; // -m + bool no_push = 4; // --no-push (shadow commits only) + optional string branch = 5; +} +message PushToGitResponse { + optional string shadow_commit = 1; + bool pushed = 2; + repeated string validation_findings = 3; + ResponseMeta meta = 100; +} + +message PreviewGitImportRequest { + optional RepositoryRef repository = 1; + string source_path = 2; // local principal only; read source, never initialize + optional string branch = 3; + bool all_branches = 4; + bool incremental = 5; + bool with_crdt = 6; +} +message PreviewGitImportResponse { + uint64 changes = 1; + repeated string views = 2; +} diff --git a/libatomic/proto/atomic/services/git_interop/git_interop_service.proto b/libatomic/proto/atomic/services/git_interop/git_interop_service.proto new file mode 100644 index 00000000..d89e717b --- /dev/null +++ b/libatomic/proto/atomic/services/git_interop/git_interop_service.proto @@ -0,0 +1,46 @@ +// atomic-protocol — GitInteropService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/git_interop/git_interop_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/git_interop/git_interop_messages.proto"; + +service GitInteropService { + // `atomic git import [--branch|--all|--incremental|--with-crdt]` + // — import git history as Atomic changes/views; bootstrap also registers the + // repository/workspace with the same ownership/identity checks as registration. + rpc ImportFromGit(ImportFromGitRequest) returns (ImportFromGitResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "gitinterop.write"; + option (atomic.required_capability) = "repository.write"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + } + rpc PreviewGitImport(PreviewGitImportRequest) returns (PreviewGitImportResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "gitinterop.read"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } + // `atomic git push [-m|--no-push|--branch]` — shadow-commit pipeline + + // validator, push Atomic state to git. + rpc PushToGit(PushToGitRequest) returns (PushToGitResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "gitinterop.write"; + option (atomic.required_capability) = "repository.read"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + option (atomic.effect) = RPC_EFFECT_EXTERNAL_IO; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } +} diff --git a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto new file mode 100644 index 00000000..0a79006f --- /dev/null +++ b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto @@ -0,0 +1,112 @@ +// atomic-protocol — KnowledgeService: knowledge-graph queries and +// maintenance. +// +// DRAFT — add-only. The CLI's eleven `query` verbs collapse into TWO RPCs +// (the headline combine of this draft): +// * QueryGraph — every read: content/code search, kg search, entity, +// neighbors, callers, graph slice, plan execution, RAG ask; +// * MaintainKnowledgeGraph — embed/enrich/reindex (the write/rebuild side). +// Local file-index construction (`query index`) remains client-side. QueryGraph +// reads a view-scoped indexed snapshot; it must never build/refresh an index. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/common.proto"; + + + +message QueryGraphRequest { + RepositoryRef repository = 1; + uint32 limit = 2; // default 10-20 per kind today + oneof query { + ContentSearchQuery content_search = 3; // `query code` (syntext index) + KgSearchQuery kg_search = 4; // `query search` + EntityQuery entity = 5; // `query entities` + NeighborsQuery neighbors = 6; // `query neighbors` + CallersQuery callers = 7; // `query callers` + GraphSliceQuery graph_slice = 8; // `query graph` + PlanExecutionQuery plan = 9; // `query plan` (JSON plan steps) + RagAskQuery rag_ask = 10; // `query ask` (KG + LLM answer) + } + ViewRef view = 11; // required; sandbox must match grant + // All nodes, edges, snippets, opaque payloads and every plan step are filtered + // BEFORE evaluation/provider calls by this effective perspective. Plans are + // read-only; no embed/enrich/reindex or arbitrary filesystem/provider action. +} + +message ContentSearchQuery { + string query = 1; + repeated string paths = 2; + // SHAPE-CONFIRM: filters from query/mod.rs content search. +} +message KgSearchQuery { + string query = 1; +} +message EntityQuery { + string node_id = 1; +} +message NeighborsQuery { + string node_id = 1; + optional bool incoming = 2; +} +message CallersQuery { + string node_id = 1; +} +message GraphSliceQuery { + repeated string node_ids = 1; + optional int32 max_depth = 2; +} +message PlanExecutionQuery { + bytes plan_json = 1; // structured multi-step plan (parse_plan shape) +} +message RagAskQuery { + string question = 1; + uint32 top_k = 2; +} + +message QueryGraphResponse { + repeated KGNode nodes = 1; + repeated KGEdge edges = 2; + repeated ContentHit hits = 3; // content/code search results + optional string answer = 4; // rag_ask + optional bytes plan_result = 5; // plan step outputs (JSON) + ViewSnapshot snapshot = 6; +} + +message KGNode { + string id = 1; + string node_type = 2; + optional string name = 3; + optional bytes data = 4; // node payload (SHAPE-CONFIRM) + optional double score = 5; // search ranking when applicable +} +message KGEdge { + string source = 1; + string target = 2; + string relation = 3; +} +message ContentHit { + string path = 1; + string snippet = 2; + optional uint64 line = 3; + optional double score = 4; +} + +enum KnowledgeMaintainAction { + KNOWLEDGE_MAINTAIN_ACTION_UNSPECIFIED = 0; + KNOWLEDGE_MAINTAIN_ACTION_EMBED = 1; // `query embed` + KNOWLEDGE_MAINTAIN_ACTION_ENRICH = 2; // `query enrich` (index build) + KNOWLEDGE_MAINTAIN_ACTION_REINDEX = 3; // `query reindex` +} +message MaintainKnowledgeGraphRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + KnowledgeMaintainAction action = 3; + optional string scope = 4; // e.g. entity-type filter +} +message MaintainKnowledgeGraphResponse { + uint64 processed = 1; + ResponseMeta meta = 100; +} diff --git a/libatomic/proto/atomic/services/knowledge/knowledge_service.proto b/libatomic/proto/atomic/services/knowledge/knowledge_service.proto new file mode 100644 index 00000000..f86ccb07 --- /dev/null +++ b/libatomic/proto/atomic/services/knowledge/knowledge_service.proto @@ -0,0 +1,29 @@ +// atomic-protocol — KnowledgeService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/knowledge/knowledge_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/knowledge/knowledge_messages.proto"; + +service KnowledgeService { + rpc QueryGraph(QueryGraphRequest) returns (QueryGraphResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "knowledge.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_EXTERNAL_IO; // only ask/vector plan steps + } + rpc MaintainKnowledgeGraph(MaintainKnowledgeGraphRequest) returns (MaintainKnowledgeGraphResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "knowledge.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_EXTERNAL_IO; + } +} diff --git a/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto b/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto new file mode 100644 index 00000000..70e8fc41 --- /dev/null +++ b/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto @@ -0,0 +1,84 @@ +// atomic-protocol — MaintenanceService: repository repair and consistency. +// +// DRAFT — add-only. `atomic doctor` folds into Repair (the +// mutating actions) and CheckRepository (the read-only consistency check, +// split out per the read/write principle — a check never gates on a +// mutation or enters the write queue; see COVERAGE §9). ReindexWorkspace is +// the separate explicit file-index mutation formerly hidden in Status. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/common.proto"; + + + +enum RepairAction { + REPAIR_ACTION_UNSPECIFIED = 0; + REPAIR_ACTION_REBUILD_DEPENDENCY_INDEX = 1; // CHANGE_DEPS rebuild + REPAIR_ACTION_MATERIALIZE_CRDT = 2; // CRDT tables from FileOps + reserved 3; // was CONSISTENCY_CHECK — + // split to CheckRepository +} +message RepairRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + RepairAction action = 3; + // --force on the CLI verb: re-index / re-apply even when rows exist. + bool force = 4; + // View for MATERIALIZE_CRDT (defaults to the current view server-side). + string view = 5; +} +// Why FileOps were not materialized (mirrors CrdtMaterializeSkipStats). +message CrdtSkipStats { + uint64 non_create_trunk = 1; + uint64 unresolved_path = 2; + uint64 unresolved_line = 3; + uint64 missing_content_range = 4; + uint64 non_insert_branch = 5; + uint64 non_insert_leaf = 6; +} +message RepairResponse { + repeated string findings = 1; + // The headline count: changes indexed (dep-index repair) or changes + // applied (CRDT materialize). + uint64 repaired = 2; + // REBUILD_DEPENDENCY_INDEX render data. + uint64 indexed = 3; + uint64 skipped = 4; + uint64 failed = 5; + // MATERIALIZE_CRDT render data (mirrors CrdtMaterializeOutcome). + uint64 changes_scanned = 6; + uint64 changes_applied = 7; + uint64 file_ops_applied = 8; + uint64 file_ops_already_materialized = 9; + uint64 file_ops_skipped = 10; + uint64 elapsed_ms = 11; + uint64 trunks_created = 12; + uint64 branches_created = 13; + uint64 leaves_created = 14; + CrdtSkipStats skip_stats = 15; + repeated string skip_samples = 16; + ResponseMeta meta = 100; +} +message CheckRepositoryRequest { + RepositoryRef repository = 1; +} +message CheckRepositoryResponse { + repeated string findings = 1; + bool consistent = 2; + // Render data mirroring VerifyReport. + uint64 clean_files_checked = 3; + uint64 uncommitted_skipped = 4; + uint64 conflicted_files = 5; +} + +message ReindexWorkspaceRequest { + RepositoryRef repository = 1; // workspace_id required + RequestMeta meta = 2; +} +message ReindexWorkspaceResponse { + uint64 indexed = 1; + ResponseMeta meta = 100; +} diff --git a/libatomic/proto/atomic/services/maintenance/maintenance_service.proto b/libatomic/proto/atomic/services/maintenance/maintenance_service.proto new file mode 100644 index 00000000..4600e3fe --- /dev/null +++ b/libatomic/proto/atomic/services/maintenance/maintenance_service.proto @@ -0,0 +1,42 @@ +// atomic-protocol — MaintenanceService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/maintenance/maintenance_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/maintenance/maintenance_messages.proto"; + +service MaintenanceService { + // `atomic doctor repair-dependency-index | materialize-crdt` — mutating + // repairs only; the read-only consistency check lives in CheckRepository. + rpc Repair(RepairRequest) returns (RepairResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "maintenance.admin"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic doctor check` — the consistency check is doc'd read-only in + // source ("Read-only… Mutates nothing", doctor.rs:36-46): read authorization + // and execution, never repair authority. EXECUTES AS A READ TRANSACTION + // — concurrent, never queued behind writers. + rpc CheckRepository(CheckRepositoryRequest) returns (CheckRepositoryResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "maintenance.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // Lease the workspace while deriving/validating its file-index baseline. + // Writes derived repository metadata, never workspace file contents. + rpc ReindexWorkspace(ReindexWorkspaceRequest) returns (ReindexWorkspaceResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "maintenance.admin"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } +} diff --git a/libatomic/proto/atomic/services/provenance/provenance_messages.proto b/libatomic/proto/atomic/services/provenance/provenance_messages.proto new file mode 100644 index 00000000..f1f76b7a --- /dev/null +++ b/libatomic/proto/atomic/services/provenance/provenance_messages.proto @@ -0,0 +1,373 @@ +// atomic-protocol — ProvenanceService: the provenance tracking contract — +// session/turn journal, generation fencing, checkpoint phases, frozen- +// envelope storage, session ledger, provenance projection. +// +// DRAFT — add-only. Context: the legacy agent journal protocol and the +// `atomic agent database-owner` command that served it are being FULLY +// DISMANTLED; atomicd becomes the only storage owner. What carries forward +// is the provenance tracking semantics below — verified against the +// provenance store source (atomic-repository/src/redb_change_store/ +// provenance.rs, 2026-10-01). The legacy process machinery (fs2 election, +// JSON frames, per-request re-opens, reconnect-retry, version-in-frame) is +// deliberately NOT carried. +// +// The semantics, semantics-for-semantics: +// * Generation fencing on every mutation: ReserveTurn grants the +// generation; every subsequent op carries expected_generation; bind/ +// acknowledge advance to attempt_generation. A mismatch is +// GENERATION_CONFLICT — never a silent race. +// * Request-ID discipline: RequestMeta.request_id (UUID v4) on every +// mutation, echoed, mismatch = IDEMPOTENCY_MISMATCH. +// * Caller-supplied `now` (RequestMeta.observed_at) — observation time +// arrives on the wire, never from server clocks. +// * Separate RPCs at the checkpoint phase boundaries — the named +// crash-injection failpoints (before/after-envelope-commit, +// after-checkpoint-prepare, before-frozen-page-continuation, +// frozen-page-unavailable, after-checkpoint-bind) sit EXACTLY at +// reserve→append→prepare→load→bind→acknowledge seams. Collapsing +// prepare/bind/acknowledge destroys the crash-safety test story. +// * Frozen-envelope paging: fragment shape {cursor{seq,offset}, bytes, +// complete}, page fragment budget (256), client-side reassembly with +// strict continuity validation. Frozen loads are PAGED-ONLY: cursor +// absent = first page; a complete journal may arrive as a single page. +// (A legacy unpaged form existed as compat with the dismantled +// protocol — deliberately not carried.) +// * Turn lifecycle: stop/resume/abandon combined into one UpdateTurn with +// an action enum (no crash-injection seams live there); status is the +// read-side GetTurn. +// * Authorization (sandbox matrix): the first ReserveTurn +// claims the session for its view; later ops from another view → +// FORBIDDEN. After a successful remote reserve, envelope/checkpoint ops +// on that turn from another view → FORBIDDEN. Remote callers present the +// sandbox token; local callers ride the transport principal. +// +// The legacy 13-op journal surface folds into 8 journal RPCs below (its +// liveness op is daemon Health; its process-shutdown op is daemon +// lifecycle, not a protocol op). Session ledger ops (`atomic session`) +// and provenance projection reads (`provenance trace/show`) join this +// service as the same domain. + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; +import "atomic/common/pagination.proto"; + +// ————— turn & checkpoint state (source: redb_change_store/provenance.rs) ————— + +enum StopCause { + STOP_CAUSE_UNSPECIFIED = 0; + STOP_CAUSE_USER_REQUESTED = 1; + STOP_CAUSE_LEASE_EXPIRED = 2; + STOP_CAUSE_PROCESS_EXITED = 3; + STOP_CAUSE_HOOK_FAILURE = 4; + STOP_CAUSE_SYSTEM_SHUTDOWN = 5; + STOP_CAUSE_ABANDONED = 6; +} + +enum ProvenanceTurnState { + PROVENANCE_TURN_STATE_UNSPECIFIED = 0; + PROVENANCE_TURN_STATE_RUNNING = 1; + PROVENANCE_TURN_STATE_STOPPED = 2; + PROVENANCE_TURN_STATE_CHECKPOINTING = 3; + PROVENANCE_TURN_STATE_COMPLETED = 4; + PROVENANCE_TURN_STATE_ABANDONED = 5; +} + +enum CheckpointPhase { + CHECKPOINT_PHASE_UNSPECIFIED = 0; + CHECKPOINT_PHASE_PREPARED = 1; + CHECKPOINT_PHASE_HASH_BOUND = 2; + CHECKPOINT_PHASE_PUBLISHED = 3; +} + +message StopState { + StopCause cause = 1; + google.protobuf.Timestamp observed_at = 2; + optional uint64 last_event_seq = 3; + bool resumable = 4; +} + +message StoredProvenanceTurn { + uint32 schema_version = 1; + uint64 provenance_id = 2; // ProvenanceId(u64) + string session_id = 3; + uint32 turn_number = 4; + ProvenanceTurnState state = 5; + uint64 generation = 6; // the granted generation (fencing) + uint64 next_event_seq = 7; + google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp updated_at = 9; + optional google.protobuf.Timestamp completed_at = 10; + optional Hash final_hash = 11; + optional ProvenanceCheckpointAttempt checkpoint_attempt = 12; + optional StopState stop_state = 13; // present when STOPPED or ABANDONED +} + +// Checkpoint source — exact fields from ProvenanceCheckpointSource. +message CheckpointSource { + string agent_name = 1; + string agent_display_name = 2; + string agent_vendor = 3; + repeated Hash change_hashes = 4; + optional Hash previous_provenance = 5; + optional string plan_id = 6; + uint32 ledger_turn_number = 7; +} + +message ProvenanceCheckpointAttempt { + uint64 attempt_generation = 1; // bind/acknowledge advance to this + uint64 frozen_event_count = 2; // exclusive journal-seq cutoff + CheckpointSource source = 3; + CheckpointPhase phase = 4; + optional Hash provenance_hash = 5; + optional SessionTurn session_turn = 6; + optional Hash manifest_hash = 7; + google.protobuf.Timestamp prepared_at = 8; + google.protobuf.Timestamp updated_at = 9; +} + +// ————— wire envelopes ————— + +// Legacy {event_id, bytes} journal envelope + the schema/type URL the unified +// contract requires (align with the Reactor event envelope so +// the two contracts rhyme). +message WireEnvelope { + string event_id = 1; + string type_url = 2; + bytes envelope = 3; +} + +// JournalAppendAck {event_id, sequence} — exact shape. +message EnvelopeAck { + string event_id = 1; + uint64 sequence = 2; +} + +// Frozen page fragments (source: FrozenProvenanceFragment/Page/Cursor). +// One envelope larger than a page budget spans fragments; clients reassemble +// with strict continuity validation (cursor must continue the previous page). +message FrozenFragment { + PageCursor cursor = 1; // {seq, offset} — the pagination primitive's seed + bytes bytes = 2; + bool complete = 3; // marks envelope end for reassembly +} + +message FrozenPage { + repeated FrozenFragment fragments = 1; + PageCursor next = 2; // next page cursor; absent field = end +} + +// ————— service ————— + + + +// ————— journal ops ————— + +message ReserveTurnRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string session_id = 3; + uint32 turn_number = 4; + ViewRef view = 5; // required; sandbox must match grant; claim only unused sessions +} +message ReserveTurnResponse { + StoredProvenanceTurn turn = 1; // carries the granted generation + bool committed = 2; + ResponseMeta meta = 100; +} + +message AppendEnvelopesRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + uint64 provenance_id = 3; + uint64 expected_generation = 4; // fencing on every mutation + repeated WireEnvelope envelopes = 5; +} +message AppendEnvelopesResponse { + repeated EnvelopeAck acknowledgements = 1; + ResponseMeta meta = 100; +} + +message PrepareCheckpointRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + uint64 provenance_id = 3; + uint64 expected_generation = 4; + CheckpointSource source = 5; + bool reuse_frozen_changes = 6; +} +message PrepareCheckpointResponse { + ProvenanceCheckpointAttempt attempt = 1; // CheckpointPrepared{attempt} + ResponseMeta meta = 100; +} + +message LoadFrozenEnvelopesRequest { + RepositoryRef repository = 1; + uint64 provenance_id = 2; + uint64 attempt_generation = 3; // from the PrepareCheckpoint response + uint64 frozen_event_count = 4; // exclusive journal-seq cutoff + optional PageCursor cursor = 5; // absent = first page + optional PageBudget budget = 6; +} +message LoadFrozenEnvelopesResponse { + FrozenPage page = 1; // fragments + next cursor; a complete + // journal may arrive as a single page +} + +message BindCheckpointHashRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + uint64 provenance_id = 3; + uint64 expected_generation = 4; + Hash hash = 5; // manifest hash + SessionTurn session_turn = 6; +} +message BindCheckpointHashResponse { + ProvenanceCheckpointAttempt attempt = 1; + ResponseMeta meta = 100; +} + +message AcknowledgeCheckpointRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + uint64 provenance_id = 3; + uint64 expected_generation = 4; + Hash manifest_hash = 5; + google.protobuf.Timestamp completed_at = 6; // caller-supplied +} +message AcknowledgeCheckpointResponse { ResponseMeta meta = 100; } + +// ————— turn lifecycle ————— + +enum TurnAction { + TURN_ACTION_UNSPECIFIED = 0; + TURN_ACTION_STOP = 1; + TURN_ACTION_RESUME = 2; + TURN_ACTION_ABANDON = 3; +} + +message UpdateTurnRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; // observed_at = the lifecycle observation time + string session_id = 3; + uint32 turn_number = 4; + TurnAction action = 5; + optional StopCause cause = 6; // STOP + optional bool resumable = 7; // STOP + uint64 expected_generation = 8; // required, nonzero; lookup never grants a generation +} +message UpdateTurnResponse { + StoredProvenanceTurn turn = 1; // TurnLifecycle{turn} + ResponseMeta meta = 100; +} + +message GetTurnRequest { + RepositoryRef repository = 1; + string session_id = 2; + uint32 turn_number = 3; +} +message GetTurnResponse { + optional StoredProvenanceTurn turn = 1; +} + +// ————— session ledger ————— + +message GetSessionRequest { + RepositoryRef repository = 1; + string session_id = 2; + optional uint32 from_turn = 3; + optional uint32 to_turn = 4; +} +message GetSessionResponse { + repeated StoredProvenanceTurn turns = 1; + // SHAPE-CONFIRM (Slice 2): session-stamp fields beyond turns live in the + // session ledger; finalize against commands/session.rs. +} + +message ForkSessionRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string session_id = 3; + string forked_session_id = 4; +} +message ForkSessionResponse { + string session_id = 1; + ResponseMeta meta = 100; +} + +message RebuildSessionIndexRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; +} +message RebuildSessionIndexResponse { ResponseMeta meta = 100; } + +// ————— provenance projection ————— + +message GetProvenanceRequest { + RepositoryRef repository = 1; + Hash change = 2; + ViewRef view = 3; // must match sandbox grant; change must be visible +} +message GetProvenanceResponse { + VersionedBytes provenance = 1; // captured per-turn provenance for the change +} + +message ExportProvenanceRequest { + RepositoryRef repository = 1; + reserved 2, 4; // mutation header and daemon-held signer selection removed + Hash change = 3; + ViewRef view = 5; +} +message ExportProvenanceResponse { + bytes prov_jsonld = 1; // W3C PROV projection + reserved 2; // caller signs; no daemon private-key operation under a read cap +} + +// — typed turn dispatch (transition seam; see provenance_service.proto) — + +message DispatchTurnEventRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string agent_id = 3; // agent registry key (e.g. "opencode") + optional string agent_display_name = 4; + optional string agent_identity = 5; // resolved client-side (selection chain) + TurnEventBody event = 6; // the typed agent event +} +message TurnEventBody { + string session_id = 1; + string event_type = 2; // session_start|turn_start|turn_end|session_end|pre_tool_use|post_tool_use + optional string prompt = 3; + optional string tool_name = 4; + optional string tool_use_id = 5; + google.protobuf.Timestamp timestamp = 6; + optional bytes raw_json = 7; // opaque agent context (never parsed here) +} +message DispatchTurnEventResponse { + string session_id = 1; + bool recorded = 2; + optional Hash change_hash = 3; + optional string view = 4; + repeated string files = 5; + repeated string warnings = 6; + ResponseMeta meta = 100; +} + +// — recent session ledgers (the `session show` no-arg listing) — + +message ListSessionsRequest { + RepositoryRef repository = 1; + uint32 limit = 2; // most recent N (CLI default 10) +} +message ListSessionsResponse { + repeated SessionSummary sessions = 1; +} +message SessionSummary { + string session_id = 1; + optional string view = 2; // view_name + uint32 turn_count = 3; + optional google.protobuf.Timestamp started_at = 4; + optional google.protobuf.Timestamp ended_at = 5; +} diff --git a/libatomic/proto/atomic/services/provenance/provenance_service.proto b/libatomic/proto/atomic/services/provenance/provenance_service.proto new file mode 100644 index 00000000..92051ee3 --- /dev/null +++ b/libatomic/proto/atomic/services/provenance/provenance_service.proto @@ -0,0 +1,170 @@ +// atomic-protocol — ProvenanceService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/provenance/provenance_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/provenance/provenance_messages.proto"; + +service ProvenanceService { + // Grants the generation for this session/turn. The first reserve claims + // the session for the caller's view (authorization matrix). + rpc ReserveTurn(ReserveTurnRequest) returns (ReserveTurnResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "agent.checkpoint"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + + // Appends journal envelopes — chunked client-side under + // limits.append_chunk_budget_bytes (4 MiB budget preserved); acks carry + // per-event sequences. + rpc AppendEnvelopes(AppendEnvelopesRequest) returns (AppendEnvelopesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + + // Freezes envelopes for checkpointing. CRASH-INJECTION SEAM: + // before-envelope-commit / after-envelope-commit / + // after-checkpoint-prepare. + rpc PrepareCheckpoint(PrepareCheckpointRequest) returns (PrepareCheckpointResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "agent.checkpoint"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + + // Loads frozen envelopes as pages: cursor absent = first page; a complete + // journal may arrive as a single page. (A legacy unpaged form existed as + // compat with the dismantled protocol — not carried.) CRASH-INJECTION + // SEAM: before-frozen-page-continuation / frozen-page-unavailable. + rpc LoadFrozenEnvelopes(LoadFrozenEnvelopesRequest) returns (LoadFrozenEnvelopesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + + // Binds the manifest hash to the checkpoint attempt (advances the + // generation to attempt_generation). CRASH-INJECTION SEAM: + // after-checkpoint-bind. + rpc BindCheckpointHash(BindCheckpointHashRequest) returns (BindCheckpointHashResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "agent.checkpoint"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + + // Completes the turn: the checkpoint is acknowledged (caller-supplied + // completion time). + rpc AcknowledgeCheckpoint(AcknowledgeCheckpointRequest) returns (AcknowledgeCheckpointResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "agent.checkpoint"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + + // Stop/resume/abandon in one RPC (action enum); no crash-injection seams + // live on these transitions. + rpc UpdateTurn(UpdateTurnRequest) returns (UpdateTurnResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + + // Turn status (read). + rpc GetTurn(GetTurnRequest) returns (GetTurnResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + + // — session ledger (`atomic session show/fork/rebuild`) — + rpc GetSession(GetSessionRequest) returns (GetSessionResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + rpc ForkSession(ForkSessionRequest) returns (ForkSessionResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + rpc RebuildSessionIndex(RebuildSessionIndexRequest) returns (RebuildSessionIndexResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "provenance.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + + // — provenance projection (`atomic provenance show/trace`) — + rpc GetProvenance(GetProvenanceRequest) returns (GetProvenanceResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // Unsigned projection/signable bytes. Private-key signing stays with caller. + rpc ExportProvenance(ExportProvenanceRequest) returns (ExportProvenanceResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + +// TRANSITION (full-turn slice, 2026-10-02): typed turn dispatch. The Reactor +// bridge delivers verified TurnEvents here; the daemon hosts the turn +// orchestrator (session view, recording, journal checkpoint) in-process while +// the aggregate-client decomposition lands. Target state (COVERAGE §4): the +// hook surface decomposes into ProvenanceService + RepositoryMutationService +// + AttestationService + KnowledgeService composed by the client; this RPC +// is the transitional seam and stays add-only after this draft pass. +rpc DispatchTurnEvent(DispatchTurnEventRequest) returns (DispatchTurnEventResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "provenance.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; +} + + // Recent session ledgers, newest activity first (`atomic session show` with + // no id / the bare `session` listing). Add-only sibling of GetSession. + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } +} diff --git a/libatomic/proto/atomic/services/reactor/reactor_messages.proto b/libatomic/proto/atomic/services/reactor/reactor_messages.proto new file mode 100644 index 00000000..c7e4e020 --- /dev/null +++ b/libatomic/proto/atomic/services/reactor/reactor_messages.proto @@ -0,0 +1,72 @@ +// atomic-protocol — ReactorService: durable cross-project event ingestion. +// +// DRAFT — add-only. CONTRACT DRAFT ONLY: the server lands in Phase 3 with +// atomicd; the contract is drafted now because the Reactor is its own first +// consumer and the repository-outbox events (RFC D8) must rhyme with this +// envelope (event.proto). +// +// Durability semantics (RFC D9) that the contract names explicitly: +// * append-before-acknowledgement (an acked event is durable); +// * globally unique event_id — duplicate ingestion is deduped, not failed; +// * daemon ingestion_sequence = replay order; repo/session sequences are +// per-stream ordering only — no semantically meaningful total order +// across unrelated repositories; +// * causation_id/correlation_id preserved across daemon proxying; +// * durable consumer checkpoints; at-least-once delivery — consumers are +// idempotent or land in dead-letter state. +// +// The Reactor prototype in this repo (durable inbox: append-before-ack, +// dedup, accepted≠published, per-session ordering, replay) is the design +// spec for the server that implements this service. + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; +import "atomic/common/event.proto"; +import "atomic/common/pagination.proto"; + + + +message PublishEventRequest { + optional RepositoryRef repository = 1; // repository-scoped publishes carry + // their ref; cross-project events + // omit it + EventEnvelope event = 2; +} +message PublishEventResponse { + uint64 ingestion_sequence = 1; // assigned at durable append + optional google.protobuf.Timestamp ingested_at = 2; +} + +message PublishStreamResponse { + uint64 accepted = 1; + uint64 duplicates = 2; // deduped event_ids, not failures + optional uint64 last_ingestion_sequence = 3; +} + +message SubscribeRequest { + string consumer_id = 1; // owned by authenticated principal; never ambient global ID + repeated string families = 2; // type_url prefixes; empty = all authorized + optional bytes repository_id = 3; + optional string session_id = 4; +} +message ReplayRequest { + string consumer_id = 1; // same ownership and event-family/repository filtering + oneof from { + uint64 ingestion_sequence = 2; // daemon replay order + PageCursor checkpoint = 3; // last delivered checkpoint + } + optional uint64 limit = 4; + repeated string families = 5; +} + +message CheckpointConsumerRequest { + string consumer_id = 1; // owner only; monotonic, no advancing beyond delivered state + uint64 ingestion_sequence = 2; + optional uint64 session_sequence = 3; + RequestMeta meta = 4; +} +message CheckpointConsumerResponse { ResponseMeta meta = 100; } diff --git a/libatomic/proto/atomic/services/reactor/reactor_service.proto b/libatomic/proto/atomic/services/reactor/reactor_service.proto new file mode 100644 index 00000000..85f5b69f --- /dev/null +++ b/libatomic/proto/atomic/services/reactor/reactor_service.proto @@ -0,0 +1,54 @@ +// atomic-protocol — ReactorService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/reactor/reactor_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/reactor/reactor_messages.proto"; +import "atomic/common/event.proto"; + +service ReactorService { + // Single-event publish; ack means durably appended + sequenced. + rpc Publish(PublishEventRequest) returns (PublishEventResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "reactor.publish"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + // Streaming publish — one EventEnvelope per frame, one ack summary. + rpc PublishStream(stream EventEnvelope) returns (PublishStreamResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "reactor.publish"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + // Live subscription (at-least-once; per-consumer checkpoints). + rpc Subscribe(SubscribeRequest) returns (stream EventEnvelope) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "reactor.subscribe"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_DAEMON_READ; + } + // Replay from a checkpoint or sequence with the same envelope shape. + rpc Replay(ReplayRequest) returns (stream EventEnvelope) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "reactor.subscribe"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_DAEMON_READ; + } + // Persist a consumer's delivery checkpoint. + rpc CheckpointConsumer(CheckpointConsumerRequest) returns (CheckpointConsumerResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "reactor.checkpoint"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } +} diff --git a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto new file mode 100644 index 00000000..8d9ab620 --- /dev/null +++ b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto @@ -0,0 +1,177 @@ +// atomic-protocol — RepositoryMutationService: working-copy and change-graph +// mutations. +// +// DRAFT — add-only. Every mutation carries RequestMeta (idempotency key) and +// an ExpectedState precondition where the caller knows prior state (RFC +// 2.2/2.3: expected-Merkle/sequence preconditions on mutating operations — +// the fencing pattern generalized from provenance to ALL mutations). +// Mutations commit their repository event to the outbox in the SAME write +// transaction (RFC D8). Writes queue per repository (bounded FIFO); the +// working-copy mutations additionally hold the workspace mutation lease +// (lock order: workspace lease → write-queue permit → redb write txn). + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; +import "atomic/common/change.proto"; // ChangeInfo, ConflictInfo + + + +message AddFilesRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + repeated string paths = 3; // working-copy paths + optional ExpectedState expected = 4; +} +message AddFilesResponse { + uint32 tracked = 1; + ResponseMeta meta = 100; +} + +message RemoveFilesRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + repeated string paths = 3; + bool keep_working_copy = 4; // `--keep` +} +message RemoveFilesResponse { + uint32 removed = 1; + ResponseMeta meta = 100; +} + +message MoveFileRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string source = 3; + string destination = 4; +} +message MoveFileResponse { ResponseMeta meta = 100; } + +message RestoreRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + repeated string paths = 3; // empty = whole tree + bool force = 4; + reserved 5; // preview is read-only RepositoryQueryService.PreviewRestore +} +message RestoreResponse { + repeated string restored = 1; + ResponseMeta meta = 100; +} + +message RecordRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string message = 3; + repeated string paths = 4; // empty = tracked-files snapshot + optional ExpectedState expected = 5; // required matching view_snapshot; + // snapshot.view must equal this workspace's current view when publishing. +} +message RecordResponse { + ChangeInfo change = 1; + optional Hash view_merkle = 2; // post-record state for the next + // expected-state precondition + optional string view = 3; // view the change landed on (add-only) + ResponseMeta meta = 100; +} + +message ReviseRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + ChangeRef target = 3; + optional string reword_message = 4; // `--reword` + optional ExpectedState expected = 5; // required matching view_snapshot +} +message ReviseResponse { + ChangeInfo change = 1; + ResponseMeta meta = 100; +} + +message InsertChangesRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + // Bare CLI promotion resolves source and parent; the daemon has no caller cwd. + reserved 3, 8, 10, 11; + oneof source { + Hash single_change = 4; + string from_view = 5; + string up_to_tag = 6; + } + optional string target_view = 7; // display hint; must agree with target.view + bool allow_conflicts = 9; + ViewRef target = 12; + ViewSnapshot expected_snapshot = 13; + optional ViewSnapshot source_snapshot = 14; // required for from_view/tag + // Dependency closure is mandatory; a confirmation flag cannot bypass policy. +} +message InsertChangesResponse { + repeated ChangeInfo inserted = 1; + reserved 2; + repeated ConflictInfo conflicts = 3; // when allowed & occurred + ResponseMeta meta = 100; +} + +message UnrecordRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + reserved 3; + optional ExpectedState expected = 4; + ViewRef view = 5; // required; expected.kind must be matching view_snapshot + optional ChangeRef target = 6; // absent means last reference in this view +} +message UnrecordResponse { + optional ChangeInfo removed = 1; + reserved 2; + ResponseMeta meta = 100; +} + +message CreateStashRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + optional string message = 3; // uncommitted work stashed onto an + // orphan view + // `atomic stash push -u` (add-only): include untracked files. + bool include_untracked = 4; + // `atomic stash push -k` (add-only): keep the working copy dirty. + bool keep = 5; +} +message CreateStashResponse { + string stash_id = 1; + ResponseMeta meta = 100; +} + +message ApplyStashRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string stash_id = 3; + reserved 4; // CLI pop composes ApplyStash then explicit DropStash +} +message ApplyStashResponse { + repeated string applied = 1; + ResponseMeta meta = 100; +} + +message ListStashesRequest { + RepositoryRef repository = 1; +} +message ListStashesResponse { + repeated StashInfo stashes = 1; +} +message StashInfo { + string id = 1; + optional string message = 2; + optional google.protobuf.Timestamp created_at = 3; + optional Hash tree = 4; + // SHAPE-CONFIRM: orphan-view stash details from commands/stash.rs. +} + +message DropStashRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + optional string stash_id = 3; // absent + all = `stash clear` + bool all = 4; +} +message DropStashResponse { ResponseMeta meta = 100; } diff --git a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto new file mode 100644 index 00000000..8057000f --- /dev/null +++ b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto @@ -0,0 +1,122 @@ +// atomic-protocol — RepositoryMutationService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/repository_mutation/repository_mutation_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/repository_mutation/repository_mutation_messages.proto"; + +service RepositoryMutationService { + // `atomic add ...` — track files in the internal tree. + rpc AddFiles(AddFilesRequest) returns (AddFilesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; // mutable tracking state/lease + } + // `atomic remove [--keep]` + rpc RemoveFiles(RemoveFilesRequest) returns (RemoveFilesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + } + // `atomic move ` + rpc MoveFile(MoveFileRequest) returns (MoveFileResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + } + // `atomic restore [--force]` (alias `reset`) — reset the + // working copy toward pristine state. + rpc Restore(RestoreRequest) returns (RestoreResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; // replay/outcome ledger + } + + // `atomic record -m ...` — change publication from tracked files; the + // store+apply+view-advance+outbox event commit in ONE write transaction + // (RFC 1.3). Expensive preparation happens before the writer is acquired; + // state is revalidated before commit. + rpc Record(RecordRequest) returns (RecordResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.write"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + } + // `atomic revise [--reword]` — replace a recorded patch with a new hash; + // never mutate an immutable content-addressed change in place. + rpc Revise(ReviseRequest) returns (ReviseResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.write"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } + // Insert refs + dependency closure into an explicit target. Bare CLI promotion + // resolves its source/parent first. No working-copy materialization or preview. + rpc InsertChanges(InsertChangesRequest) returns (InsertChangesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // Remove a reference from an explicit view; do not delete shared graph data + // or touch host files. The client separately restores its workspace if needed. + rpc Unrecord(UnrecordRequest) returns (UnrecordResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + + // `atomic stash push|pop|apply|list|show|drop|clear` → 4 RPCs (combine). + rpc CreateStash(CreateStashRequest) returns (CreateStashResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.write"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + } + rpc ApplyStash(ApplyStashRequest) returns (ApplyStashResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + rpc ListStashes(ListStashesRequest) returns (ListStashesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } + rpc DropStash(DropStashRequest) returns (DropStashResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.write"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + } +} diff --git a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto new file mode 100644 index 00000000..adcd005b --- /dev/null +++ b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto @@ -0,0 +1,169 @@ +// atomic-protocol — RepositoryQueryService: read-only repository operations. +// +// DRAFT — add-only. Reads execute as independent concurrent read +// transactions (RFC concurrency model); no global write lock is ever assumed. +// LOCAL-scoped reads are working-copy-aware (status/DiffWorkingCopy against the +// tree); BOTH reads (log/change/Diff/PreviewMutation) are semantically identical on +// the local daemon and the hosted server (D6/D7 — the CLI-vs-RPC parity +// targets). + +syntax = "proto3"; + +package atomic; + +import "atomic/common/common.proto"; +import "atomic/common/change.proto"; +import "atomic/common/pagination.proto"; + + + +message StatusRequest { + RepositoryRef repository = 1; + reserved 2; // allow_reindex violated read-only execution +} +// Mirrors RepositoryStatus (atomic-repository/src/status.rs:390): view, +// merkle state, per-file entries, and the stale-index count that drives the +// --reindex hint (unindexed tracked files are conservatively reported +// Modified; explicit ReindexWorkspace resolves them). +message StatusResponse { + string view = 1; + optional Hash view_merkle = 2; + repeated FileStatusEntry files = 3; + uint32 modified_count = 4; + uint32 deleted_count = 5; + uint32 untracked_count = 6; + uint32 added_count = 7; + uint32 conflicted_count = 8; + uint32 stale_index_count = 9; // non-zero → `status --reindex` would + // resolve false positives + ViewSnapshot snapshot = 10; +} +// atomic's file-status vocabulary, source-verified (status.rs:136): +// Modified, Deleted, Untracked, Added, Conflicted — no git-shaped states. +enum FileStatus { + FILE_STATUS_UNSPECIFIED = 0; + FILE_STATUS_MODIFIED = 1; + FILE_STATUS_DELETED = 2; + FILE_STATUS_UNTRACKED = 3; + FILE_STATUS_ADDED = 4; + FILE_STATUS_CONFLICTED = 5; +} +// Mirrors FileStatusEntry (status.rs:263). +message FileStatusEntry { + string path = 1; + FileStatus status = 2; + optional uint64 inode = 3; // opaque handle; present for tracked files + optional Hash recorded_hash = 4; // recorded graph content + optional Hash current_hash = 5; // on-disk content when hashed + optional string details = 6; // conflict type, permissions, etc. +} + +message LogRequest { + RepositoryRef repository = 1; + bool all_views = 2; // `--all` + optional string view = 3; // required unless all_views; no daemon current-view default + optional PageCursor cursor = 4; + optional PageBudget budget = 5; +} +message LogResponse { + repeated ChangeLogEntry entries = 1; + optional PageCursor next_cursor = 2; +} +// ChangeLogEntry lives in atomic/common/change.proto (shared change-domain). + +message GetChangeRequest { + RepositoryRef repository = 1; + ChangeRef ref = 2; + ChangeIncludes includes = 3; + optional ViewRef view = 4; // required when ref is a view-log sequence +} +// Sections fetched by flag instead of by verb — the combine that keeps +// GetChange one RPC. +message ChangeIncludes { + bool metadata = 1; + bool graph_section = 2; + bool semantic_section = 3; + bool signatures = 4; + bool content_chunks = 5; +} +message GetChangeResponse { + ChangeInfo change = 1; + optional VersionedBytes graph_section = 2; + optional VersionedBytes semantic_section = 3; + repeated ChangeSignature signatures = 4; + optional bytes content_chunks = 5; +} +// ChangeInfo lives in atomic/common/change.proto (shared change-domain). + +message DiffRequest { + RepositoryRef repository = 1; + reserved 2; // working_copy moved to a LOCAL, workspace-aware method + oneof scope { + RefPairScope refs = 3; // view/tag/change-pair diff + } + bool stat_only = 4; +} +message DiffWorkingCopyRequest { + RepositoryRef repository = 1; // workspace_id required + WorkingCopyScope working_copy = 2; + bool stat_only = 3; +} +message WorkingCopyScope { + bool include_untracked = 1; + repeated string paths = 2; // working-copy paths (LOCAL semantics) +} +message RefPairScope { + string from_view = 1; + string to_view = 2; +} +message DiffChunk { + string path = 1; + optional bytes patch = 2; // unified diff hunks (absent when stat-only) + uint64 additions = 3; + uint64 deletions = 4; + bool binary = 5; +} + +message ListConflictsRequest { + RepositoryRef repository = 1; +} +message ListConflictsResponse { + repeated ConflictInfo conflicts = 1; +} + +message PreviewMutationRequest { + RepositoryRef repository = 1; + reserved 2; // filesystem restore has its own LOCAL preview + oneof operation { + InsertPreview insert = 3; + UnrecordPreview unrecord = 4; + } +} +message PreviewRestoreRequest { + RepositoryRef repository = 1; // workspace_id required + repeated string paths = 2; // workspace-root-relative + bool force = 3; +} +message InsertPreview { + oneof source { + Hash single_change = 1; + string from_view = 2; + string up_to_tag = 3; + } + string target_view = 4; // required; dependency closure is always enforced + bool allow_conflicts = 5; +} +message UnrecordPreview { + ViewRef view = 1; + optional ChangeRef target = 2; // absent means last change in this view +} +message PreviewMutationResponse { + repeated string affected_paths = 1; + repeated ChangeInfo changes = 2; + repeated ConflictInfo conflicts = 3; + ViewSnapshot snapshot = 4; + // The restore preview's would-untrack list (add-only): Added files + // that a restore would untrack but keep on disk. + repeated string untracked = 5; +} +// ConflictInfo lives in atomic/common/change.proto (shared change-domain). diff --git a/libatomic/proto/atomic/services/repository_query/repository_query_service.proto b/libatomic/proto/atomic/services/repository_query/repository_query_service.proto new file mode 100644 index 00000000..78f0689e --- /dev/null +++ b/libatomic/proto/atomic/services/repository_query/repository_query_service.proto @@ -0,0 +1,80 @@ +// atomic-protocol — RepositoryQueryService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/repository_query/repository_query_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/repository_query/repository_query_messages.proto"; + +service RepositoryQueryService { + // Working-copy status is strictly read-only. --reindex is an explicit + // MaintenanceService.ReindexWorkspace followed by this read. + rpc Status(StatusRequest) returns (StatusResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.read"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } + // `atomic log [--all]` — paged change history. + rpc Log(LogRequest) returns (LogResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `atomic change ` — one change, sections requested by includes + // (one RPC instead of per-section verbs). + rpc GetChange(GetChangeRequest) returns (GetChangeResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // Repository-level ref-pair diff. No server filesystem access. + rpc Diff(DiffRequest) returns (stream DiffChunk) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + rpc DiffWorkingCopy(DiffWorkingCopyRequest) returns (stream DiffChunk) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.read"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } + // One planning surface, no apply flag and no RequestMeta/replay writes. + rpc PreviewMutation(PreviewMutationRequest) returns (PreviewMutationResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + rpc PreviewRestore(PreviewRestoreRequest) returns (PreviewMutationResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.read"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } + // `atomic conflicts` — files carrying conflict markers. + rpc ListConflicts(ListConflictsRequest) returns (ListConflictsResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } +} diff --git a/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto b/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto new file mode 100644 index 00000000..7b179979 --- /dev/null +++ b/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto @@ -0,0 +1,342 @@ +// atomic-protocol — SandboxService: sandbox grants, local sandbox working +// trees, and the remote-sandbox data ops (the remote-sandboxes extension +// — the handoff is the spec here). +// +// DRAFT — add-only. +// +// "A remote sandbox is a machine holding only a pointer file .atomic-sandbox +// = {iroh EndpointAddr of the serving daemon, repository, view, token}. +// Materialize writes the view's tree into the sandbox root; record/status/ +// log/diff then work locally and land changes through the serving +// repository." +// +// Authorization matrix for remote callers (preserved semantics): +// * sandbox credentials travel in authenticated transport metadata (README); +// data ops must target the token's view — naming another view → FORBIDDEN; +// * session ownership: first ReserveTurn claims the session for its view; +// * provenance ownership: post-reserve, envelope/checkpoint ops on that +// turn from another view → FORBIDDEN; +// * PrepareCheckpoint additionally requires the checkpoint's change_hashes +// to be visible on the caller's view; +// * grant admin (Open/Renew/Close) is LOCAL-only. +// +// Inode keying (recorded recommendation): FileStates inodes +// are OPAQUE SERVER-ISSUED HANDLES — they originate from the server's own +// skeleton export (like cursors). They are not permissions: every use checks +// repository, snapshot and effective-view visibility; guesses are refused. +// +// Materialize path safety (client-side checks that MUST survive): paths off +// the wire must be plain relative components (no "..", no root, no empty or +// "."), reserved bookkeeping names refused (.atomic, the pointer file, the +// cache dir — writing the pointer would re-point the sandbox at an +// attacker's node), parents canonicalized and checked inside the tree +// (symlinked-parent escape refused). + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; +import "atomic/common/error.proto"; +import "atomic/common/change.proto"; + +// ————— sandbox cache exports (client-side; only transport changes with the +// daemon — "SandboxSkeleton"/"SandboxSlice") ————— + +message SandboxViewRow { + string path = 1; + uint64 inode = 2; // opaque server-issued handle + optional Hash content = 3; + // SHAPE-CONFIRM (Slice 3): full row fields. +} + +message SandboxAncestorView { + string view = 1; + optional Hash head = 2; // its own log + optional Hash real_state = 3; // real state + optional string parent = 4; // real parent + ViewSnapshot snapshot = 5; + repeated ChangeLogEntry log = 6; // this ancestor's OWN log, not the union + // draft ancestors + nearest shared ancestor, each with its own log, + // real state, real parent. +} + +message SandboxSkeleton { + repeated SandboxViewRow rows = 1; + repeated SandboxAncestorView ancestors = 2; + VersionedBytes graph_slice = 3; // serialized graph slice + ViewSnapshot snapshot = 4; + repeated ChangeLogEntry own_log = 5; + VersionedBytes vault = 6; // target's effective vault, including its overlay + // SHAPE-CONFIRM (Slice 3): skeleton takes its view's vault; draft views + // render as themselves. +} + +message SandboxContentSpan { + uint64 inode = 1; + uint64 offset = 2; + uint64 length = 3; + Hash change = 4; + bytes content = 5; // length must equal content size; never arbitrary CAS access +} + +message SandboxSlice { + repeated SandboxViewRow rows = 1; // the rows record reads + repeated SandboxContentSpan content_spans = 2; // the content spans + ViewSnapshot snapshot = 3; +} + +// ————— grant admin (LOCAL-only, mint/renew/revoke) ————— + + + +// ————— grant admin messages ————— + +message OpenSandboxRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string view = 3; + optional string acting_as_did = 4; + optional uint64 ttl_secs = 5; // bounds 1..31536000; default 7200 + repeated string capabilities = 6; // explicit nonempty subset of sandbox, + // provenance, vault and attestation permissions; never repository.write, + // working-copy.*, daemon.*, sandbox.admin or view lifecycle permissions. + ViewRef target = 7; // required; view string is a compatibility/display hint +} +message OpenSandboxResponse { + SandboxOpened opened = 1; // {endpoint_addr, view, token, expires_at} + ResponseMeta meta = 100; +} + +message RenewSandboxRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string view = 3; + uint64 ttl_secs = 4; + ViewRef target = 5; +} +message RenewSandboxResponse { + google.protobuf.Timestamp expires_at = 1; + ResponseMeta meta = 100; +} + +message CloseSandboxRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string view = 3; + ViewRef target = 4; +} +message CloseSandboxResponse { + bool revoked = 1; + ResponseMeta meta = 100; +} + +// ————— local working trees ————— + +message CreateSandboxTreeRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string name = 3; + optional string from_view = 4; +} +message CreateSandboxTreeResponse { + string sandbox_path = 1; // local working-tree path (LOCAL scope) + ResponseMeta meta = 100; +} + +message StageSandboxImageRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string name = 3; + // SHAPE-CONFIRM (Slice 3): OCI staging options from commands/sandbox.rs. +} +message StageSandboxImageResponse { + string staged_ref = 1; + ResponseMeta meta = 100; +} + +message SealSandboxImageRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string name = 3; +} +message SealSandboxImageResponse { + string sealed_ref = 1; + ResponseMeta meta = 100; +} + +// ————— data ops ————— + +message MaterializeRequest { + RepositoryRef repository = 1; + reserved 2, 3; // credentials moved to middleware; mutable view names removed + optional ViewRef target = 4; // sandbox: inferred from grant; others: required +} +message MaterializeFrame { + oneof frame { + TreeEntry entry = 1; // streamed first + MaterializeSummary done = 2; // terminal frame + } +} +message TreeEntry { + string path = 1; // plain relative components — receivers + // validate (path-safety rules above) + TreeEntryKind kind = 2; + optional Hash content = 3; + optional bytes inline = 4; // small blobs may ride the entry +} +enum TreeEntryKind { + TREE_ENTRY_KIND_UNSPECIFIED = 0; + TREE_ENTRY_KIND_FILE = 1; + TREE_ENTRY_KIND_DIRECTORY = 2; + TREE_ENTRY_KIND_SYMLINK = 3; +} +message MaterializeSummary { + Hash snapshot = 1; + uint64 entries = 2; + SandboxSkeleton skeleton = 3; + // Entries, snapshot and skeleton MUST be from one coherent effective state. +} + +message GetFileStatesRequest { + RepositoryRef repository = 1; + reserved 2, 3; + repeated uint64 inodes = 4; // opaque server-issued handles (skeleton- + // issued, like cursors — §10.1) + optional ViewRef target = 5; + ViewSnapshot expected_snapshot = 6; // required; bind handles to this state +} +message GetFileStatesResponse { + SandboxSlice slice = 1; +} + +message GetChangesRequest { + RepositoryRef repository = 1; + reserved 2, 3; + repeated Hash hashes = 4; + optional ViewRef target = 5; + ViewSnapshot expected_snapshot = 6; + // Every hash must belong to the authorized effective filter. Fail the whole + // request if any hash is foreign; knowledge of a hash grants no access. +} +message GetChangesResponse { + oneof outcome { + ChangesPayload changes = 1; + ErrorInfo refused = 2; // ChangeRefused + } +} +message ChangesPayload { + repeated ChangeBundle changes = 1; // versioned-opaque V3 bytes +} + +message PublishProvenanceRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + reserved 3, 4, 6; // token/name and caller-supplied StoredProvenanceTurn removed + VersionedBytes graph = 5; // serialized provenance graph + SessionTurn turn = 7; + uint64 expected_generation = 8; // required; derive stored turn server-side + optional ViewRef target = 9; + // Turn ownership, graph identity and explained-change visibility are checked + // again at publication. A payload cannot replace another session's state. +} +message PublishProvenanceResponse { + oneof outcome { + ProvenancePublished publication = 1; + ErrorInfo refused = 2; + } + ResponseMeta meta = 100; +} +message ProvenancePublished { + Hash provenance_hash = 1; + google.protobuf.Timestamp published_at = 2; + // SHAPE-CONFIRM (Slice 3): publication record shape. +} + +message SubmitChangeRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + reserved 3, 4, 5; // credentials/name/own-log-only base_state removed + ChangeBundle change = 6; // versioned-opaque V3 change bytes + ViewSnapshot expected_snapshot = 7; // required; effective view incl. ancestors + optional ViewRef target = 8; + // Parse/hash/schema-check before queueing; in publication validate all + // dependencies, referenced nodes, paths and metadata against the granted + // effective filter. Refusal writes nothing. Reserved/traversal paths are + // refused on SUBMISSION as well as client-side materialization. + // .vault edits are allowed, but require vault.write in addition to + // sandbox.submit and update only this view's vault overlay atomically. +} +message SubmitChangeResponse { + oneof outcome { + ChangeSubmitted submitted = 1; + ChangeRefused refused = 2; // stale-view carries a live skeleton + } + ResponseMeta meta = 100; +} +message ChangeSubmitted { + bool submitted = 1; + SandboxSkeleton skeleton = 2; +} +message ChangeRefused { + ErrorInfo rejection = 1; // e.g. {code: VIEW_STALE, context:{...}} + optional SandboxSkeleton skeleton = 2; // present on stale-view refusals +} + + +// ————— grants and sandbox tokens (sandbox-domain; formerly grant.proto) ————— + +// atomic-protocol — grants and sandbox tokens. +// +// DRAFT — add-only. +// +// Model carried from the remote-sandbox extension: a grant is +// view-scoped, optionally identity-bound, and time-boxed. A remote caller is +// trusted ONLY as far as its token: exactly one view, optionally one acting +// identity, until expiry. +// +// CONTRACT BEHAVIOR (recorded recommendation): +// the grant registry is EPHEMERAL — only blake3 hashes of tokens are stored, +// grant-admin issuance replay is memory-only and invalidated with its grant; +// no durable response/outbox/event payload may contain the token, +// the registry dies with the daemon process, and a restart invalidates all +// sandbox tokens (fail-closed). Local admin reissues via OpenSandbox to recover; +// this is documented contract behavior, not an outage. +// +// TTL bounds: 1s..31536000s (365d) with checked arithmetic — one absurd +// --ttl (or an unrelated poisoned lock) must never take the registry down. +// One live token per (repository, stable view ID): minting replaces. Default +// TTL 7200s. Deleting a view revokes its grants; name reuse cannot revive them. + + + + +// A live grant as tracked by the registry (informational; tokens themselves +// are opaque bytes on the wire — the server stores only blake3(token)). +message GrantInfo { + string view = 1; + optional string acting_as_did = 2; + google.protobuf.Timestamp expires_at = 3; + RepositoryRef repository = 4; + ViewRef target = 5; + repeated string capabilities = 6; +} + +// Result of minting a sandbox token (OpenSandbox). `endpoint_addr` is the +// iroh EndpointAddr of the serving daemon under the iroh transport kind; +// under UDS/named-pipe/TLS it is the local endpoint identifier. +message SandboxOpened { + string endpoint_addr = 1; + string view = 2; + bytes token = 3; + google.protobuf.Timestamp expires_at = 4; + RepositoryRef repository = 5; // the grant's repository identity — the + // sandbox echoes it in every data request + // and the pointer file carries it (a + // sandbox has no local .atomic to read) + // workspace_id is absent: this ref grants no access to a host working tree. + ViewRef target = 6; + repeated string capabilities = 7; + ViewSnapshot snapshot = 8; // allows vault/API work before tree materialization +} diff --git a/libatomic/proto/atomic/services/sandbox/sandbox_service.proto b/libatomic/proto/atomic/services/sandbox/sandbox_service.proto new file mode 100644 index 00000000..fc5e7910 --- /dev/null +++ b/libatomic/proto/atomic/services/sandbox/sandbox_service.proto @@ -0,0 +1,134 @@ +// atomic-protocol — SandboxService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/sandbox/sandbox_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/sandbox/sandbox_messages.proto"; + +service SandboxService { + // Mint a sandbox grant: one live token per repository + stable view ID + // (minting replaces the + // prior). TTL bounds 1s..365d, checked arithmetic; default 2h. Tokens are + // ephemeral — restart invalidates them; local administration reissues them + // (contract behavior, sandbox_messages.proto). + rpc OpenSandbox(OpenSandboxRequest) returns (OpenSandboxResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "sandbox.admin"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + rpc RenewSandbox(RenewSandboxRequest) returns (RenewSandboxResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "sandbox.admin"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + rpc CloseSandbox(CloseSandboxRequest) returns (CloseSandboxResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "sandbox.admin"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + + // Local copy-on-write sandbox working trees (`atomic sandbox + // create/stage/seal` — OCI image stage/seal). Local-only paths are fine + // here (LOCAL scope): these are working-copy-adjacent operations. + // SHAPE-CONFIRM (Slice 3): refine against commands/sandbox.rs. + rpc CreateSandboxTree(CreateSandboxTreeRequest) returns (CreateSandboxTreeResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "sandbox.create"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + rpc StageSandboxImage(StageSandboxImageRequest) returns (StageSandboxImageResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "sandbox.create"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + option (atomic.effect) = RPC_EFFECT_EXTERNAL_IO; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + rpc SealSandboxImage(SealSandboxImageRequest) returns (SealSandboxImageResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "sandbox.create"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + option (atomic.effect) = RPC_EFFECT_EXTERNAL_IO; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; + } + + // ————— data ops (BOTH deployments; sandbox = token's view only) ————— + // Sandbox callers take repository + stable view from their pointer file (which + // carries the RepositoryRef returned by OpenSandbox — a sandbox has no + // local .atomic to read it from). The server cross-checks the sandbox + // token's grant binding: a token bound to a different repository or view + // → FORBIDDEN. The repository named by the request and the repository the + // token was minted for must be the same. + + // The legacy surface's only multi-frame response → server streaming. + // Entries stream first; the summary frame carries snapshot hash, entry + // count, and the skeleton so the sandbox can cache without a second + // round trip. + rpc Materialize(MaterializeRequest) returns (stream MaterializeFrame) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "sandbox.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + rpc GetFileStates(GetFileStatesRequest) returns (GetFileStatesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "sandbox.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + rpc GetChanges(GetChangesRequest) returns (GetChangesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "sandbox.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + rpc PublishProvenance(PublishProvenanceRequest) returns (PublishProvenanceResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "provenance.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // Publishes only into the authorized view and its vault overlay. No host + // materialization, current-view switch, sibling/ancestor vault replacement, + // or implicit promotion. expected_snapshot fences the effective perspective. A + // VIEW_STALE refusal CARRIES A LIVE SKELETON — recovery costs the round + // trip already being made; never make the sandbox ask again for a state it + // was just told is stale. + rpc SubmitChange(SubmitChangeRequest) returns (SubmitChangeResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "sandbox.submit"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } +} diff --git a/libatomic/proto/atomic/services/sync/sync_messages.proto b/libatomic/proto/atomic/services/sync/sync_messages.proto new file mode 100644 index 00000000..791b0a1f --- /dev/null +++ b/libatomic/proto/atomic/services/sync/sync_messages.proto @@ -0,0 +1,94 @@ +// atomic-protocol — SyncService: remote synchronization and the remote +// registry. +// +// DRAFT — add-only. The existing HTTP `/code` + SyncPack behavior must be +// preserved behind — or deliberately migrated into — ONE SyncService domain +// implementation (RFC "one synchronization protocol"): local repo sync, +// atomicd-to-server sync, and future server replication use the same +// object, dependency, and compare-and-swap semantics. The hosted server +// implements the receiving side of the SAME contract (view manifests, +// CAS-on-prev), so `view list --remote` is just ViewService.ListViews with +// a hosted RepositoryRef — routing, not a second protocol. +// +// Remotes are persisted IN the repository database — so remote registry +// CRUD is service-layer work, combined into one ManageRemotes RPC. +// `clone`/`init` are bootstrap (exempt from service coverage per RFC) — +// clone's fetch/apply/sidecar-import phases are covered by PullChanges. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/common.proto"; + + + +message PushChangesRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string remote = 3; + repeated string views = 4; // empty = current view manifest chain + optional ExpectedState expected = 5; // CAS-on-prev precondition +} +message PushChangesResponse { + uint64 pushed_changes = 1; + repeated string manifests = 2; + ResponseMeta meta = 100; +} + +message PullChangesRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string remote = 3; + bool import_sidecars = 4; // provenance/attestation ingestion on + // apply (default true) + // Imports update repository/view state only. Host materialization is a + // separately authorized working-copy operation, not a hidden pull effect. +} +message PullChangesResponse { + uint64 applied_changes = 1; + repeated string updated_views = 2; + ResponseMeta meta = 100; +} + +enum RemoteAction { + REMOTE_ACTION_UNSPECIFIED = 0; + REMOTE_ACTION_ADD = 1; + REMOTE_ACTION_REMOVE = 2; + REMOTE_ACTION_RENAME = 3; + REMOTE_ACTION_SET_URL = 4; + REMOTE_ACTION_SET_DEFAULT = 5; + reserved 6; // was LIST — split to ListRemotes so a read never gates on + // repository.write (COVERAGE §9) +} +message ListRemotesRequest { + RepositoryRef repository = 1; +} +message ListRemotesResponse { + repeated RemoteInfo remotes = 1; +} +message ManageRemotesRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + RemoteAction action = 3; + optional string name = 4; + optional string url = 5; + optional string new_name = 6; +} +message ManageRemotesResponse { + repeated RemoteInfo remotes = 1; + ResponseMeta meta = 100; +} +message RemoteInfo { + string name = 1; + string url = 2; + bool default = 3; +} + +message BindRemoteProjectRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string server = 3; // server profile name (config.toml) + string project_id = 4; +} +message BindRemoteProjectResponse { ResponseMeta meta = 100; } diff --git a/libatomic/proto/atomic/services/sync/sync_service.proto b/libatomic/proto/atomic/services/sync/sync_service.proto new file mode 100644 index 00000000..b6e9c560 --- /dev/null +++ b/libatomic/proto/atomic/services/sync/sync_service.proto @@ -0,0 +1,60 @@ +// atomic-protocol — SyncService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/sync/sync_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/sync/sync_messages.proto"; + +service SyncService { + // `atomic push [--remote]` — upload view manifests + CAS objects to the + // remote; the hosted server implements the receiving counterpart. + rpc PushChanges(PushChangesRequest) returns (PushChangesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "sync.write"; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_EXTERNAL_IO; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; // replay/outcome ledger + } + // `atomic pull [--remote]` — download + apply; imports provenance and + // attestation sidecars through ProvenanceService/AttestationService on + // apply (the sidecar path becomes service calls, not direct writes). + rpc PullChanges(PullChangesRequest) returns (PullChangesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "sync.read"; + option (atomic.required_capability) = "repository.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_EXTERNAL_IO; + } + // `atomic remote list` (+ bare `atomic remote`) — read-only listing; + // its own RPC so a list never gates on a write capability and never + // enters the write queue (remotes persist in the repository DB, but + // reading them is a concurrent read transaction under the daemon). + rpc ListRemotes(ListRemotesRequest) returns (ListRemotesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `atomic remote add|remove|rename|set-url|default` — one RPC, action + // enum, MUTATING actions only (list lives in ListRemotes). + rpc ManageRemotes(ManageRemotesRequest) returns (ManageRemotesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic project init` — bind the local repository to a remote project. + rpc BindRemoteProject(BindRemoteProjectRequest) returns (BindRemoteProjectResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } +} diff --git a/libatomic/proto/atomic/services/tag/tag_messages.proto b/libatomic/proto/atomic/services/tag/tag_messages.proto new file mode 100644 index 00000000..6243ef34 --- /dev/null +++ b/libatomic/proto/atomic/services/tag/tag_messages.proto @@ -0,0 +1,57 @@ +// atomic-protocol — TagService: named Merkle-state snapshots. +// +// DRAFT — add-only. Tags are repository metadata: reads are BOTH (hosted +// servers serve them), writes BOTH with repository.write (a hosted +// repository can be tagged remotely). The RFC names tags in its BOTH scope +// list. + +syntax = "proto3"; + +package atomic; + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; + + + +message CreateTagRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string name = 3; + optional Hash state = 4; // merkle state to snapshot (default: + // explicitly named view's own Merkle) + optional string message = 5; + ViewRef view = 6; + ViewSnapshot expected_snapshot = 7; +} +message CreateTagResponse { ResponseMeta meta = 100; } + +message DeleteTagRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string name = 3; + Hash expected_state = 4; // required; refuse replacing/deleting a raced tag +} +message DeleteTagResponse { ResponseMeta meta = 100; } + +message ListTagsRequest { + RepositoryRef repository = 1; +} +message ListTagsResponse { + repeated TagInfo tags = 1; +} + +message GetTagRequest { + RepositoryRef repository = 1; + string name = 2; +} +message GetTagResponse { + TagInfo tag = 1; +} + +message TagInfo { + string name = 1; + Hash state = 2; + optional string message = 3; + optional google.protobuf.Timestamp created_at = 4; +} diff --git a/libatomic/proto/atomic/services/tag/tag_service.proto b/libatomic/proto/atomic/services/tag/tag_service.proto new file mode 100644 index 00000000..539e5479 --- /dev/null +++ b/libatomic/proto/atomic/services/tag/tag_service.proto @@ -0,0 +1,45 @@ +// atomic-protocol — TagService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/tag/tag_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/tag/tag_messages.proto"; + +service TagService { + // `atomic tag create ` + rpc CreateTag(CreateTagRequest) returns (CreateTagResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic tag delete ` + rpc DeleteTag(DeleteTagRequest) returns (DeleteTagResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic tag list` + rpc ListTags(ListTagsRequest) returns (ListTagsResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `atomic tag show ` (list-with-detail covers `-v`) + rpc GetTag(GetTagRequest) returns (GetTagResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } +} diff --git a/libatomic/proto/atomic/services/triage/triage_messages.proto b/libatomic/proto/atomic/services/triage/triage_messages.proto new file mode 100644 index 00000000..5aa8bcc1 --- /dev/null +++ b/libatomic/proto/atomic/services/triage/triage_messages.proto @@ -0,0 +1,56 @@ +// atomic-protocol — TriageService: promotion review. +// +// DRAFT — add-only. Triage review is a paid-surface workflow (the +// intent→change pipeline's review gate): candidates are the change-sets +// pending promotion between views; the review is the canonical report +// (ordered semantic layers, provenance-backed) that `atomic triage review +// --json` emits today. Structured here (not JSON strings) so the hosted +// Outpost UI can render it without a parser divergence. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/common.proto"; + + + +message ListTriageCandidatesRequest { + RepositoryRef repository = 1; + optional string from_view = 2; + optional string to_view = 3; +} +message ListTriageCandidatesResponse { + repeated TriageCandidate candidates = 1; +} +message TriageCandidate { + Hash change = 1; + optional string intent_id = 2; // linked vault provenance + repeated string views = 3; // views carrying the change + optional string summary = 4; +} + +message GenerateTriageReviewRequest { + RepositoryRef repository = 1; + string from_view = 2; + string to_view = 3; +} +message GenerateTriageReviewResponse { + repeated TriageLayer layers = 1; // ordered semantic walkthrough layers + repeated TriageRisk risks = 2; + // SHAPE-CONFIRM: layer/risk vocabulary from triage/model+output.rs. +} +message TriageLayer { + string name = 1; // e.g. storage, service, protocol, docs + repeated TriageChangeRef changes = 2; + string narration = 3; +} +message TriageChangeRef { + Hash change = 1; + repeated string files = 2; +} +message TriageRisk { + string severity = 1; // blocker | warning | note + string finding = 2; + optional Hash change = 3; +} diff --git a/libatomic/proto/atomic/services/triage/triage_service.proto b/libatomic/proto/atomic/services/triage/triage_service.proto new file mode 100644 index 00000000..43e7bc72 --- /dev/null +++ b/libatomic/proto/atomic/services/triage/triage_service.proto @@ -0,0 +1,29 @@ +// atomic-protocol — TriageService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/triage/triage_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/triage/triage_messages.proto"; + +service TriageService { + // `atomic triage candidates` + rpc ListTriageCandidates(ListTriageCandidatesRequest) returns (ListTriageCandidatesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "triage.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `atomic triage review [--json]` + rpc GenerateTriageReview(GenerateTriageReviewRequest) returns (GenerateTriageReviewResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "triage.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } +} diff --git a/libatomic/proto/atomic/services/vault/vault_messages.proto b/libatomic/proto/atomic/services/vault/vault_messages.proto new file mode 100644 index 00000000..bb36e0b5 --- /dev/null +++ b/libatomic/proto/atomic/services/vault/vault_messages.proto @@ -0,0 +1,224 @@ +// atomic-protocol — VaultService: the `.vault/` knowledge store — intents, +// memories, goals, vault sync, context. +// +// DRAFT — add-only. This is the agent-facing product surface (the +// intent→change workflow with triage + knowledge graph), so the combine +// discipline matters most here: intents/memories/goals are one entity model +// with a kind enum, not three parallel RPC families. `vault intent`/ +// `vault memory` shims in the CLI print redirects — they map onto the same +// methods below. +// +// Combine decisions (see COVERAGE.md): +// * goal start/stop/resume → UpdateVaultEntity(kind=GOAL, action); +// * goal list/show → GetVaultEntry/ListVaultEntries(kind filter); +// * intent new / memory new → CreateVaultEntity; +// * intent update / memory write → UpdateVaultEntity; +// * vault list/summaries → ListVaultEntries modes; +// * attest verbs → AttestationService (intent/memory are just targets). + +syntax = "proto3"; + +package atomic; + +// VIEW ISOLATION: every request names its view (field 20), including reads. +// A sandbox target must equal its grant's stable view ID. Reads see that view's +// effective vault. Edits to inherited entities create target-local versions; +// deletion creates a target-local tombstone; links refer only to visible +// entities. No repository mutation overwrites sibling/ancestor state or writes +// host files. ExportVault is the separate local filesystem operation. +// Changes to the view's vault revision participate in ViewSnapshot fencing. +// Remote sandboxes may write through these RPCs or submit recorded .vault edits; +// both paths have identical view-local indexing/authorization semantics. + +import "google/protobuf/timestamp.proto"; +import "atomic/common/common.proto"; +import "atomic/common/pagination.proto"; + +enum VaultEntityKind { + VAULT_ENTITY_KIND_UNSPECIFIED = 0; + VAULT_ENTITY_KIND_INTENT = 1; + VAULT_ENTITY_KIND_MEMORY = 2; + VAULT_ENTITY_KIND_GOAL = 3; +} + +enum VaultEntityStatus { + VAULT_ENTITY_STATUS_UNSPECIFIED = 0; + VAULT_ENTITY_STATUS_BACKLOG = 1; + VAULT_ENTITY_STATUS_IN_PROGRESS = 2; + VAULT_ENTITY_STATUS_NEEDS_REVIEW = 3; + VAULT_ENTITY_STATUS_PLANNED = 4; + VAULT_ENTITY_STATUS_DONE = 5; + VAULT_ENTITY_STATUS_ACTIVE = 6; + VAULT_ENTITY_STATUS_SUSPENDED = 7; + VAULT_ENTITY_STATUS_COMPLETED = 8; + VAULT_ENTITY_STATUS_SUPERSEDED = 9; + VAULT_ENTITY_STATUS_RETRACTED = 10; + // Validate per-kind transitions: intent lifecycle vs goal/memory lifecycle. +} + + + +message InitVaultRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + ViewRef view = 20; + ViewSnapshot expected_snapshot = 21; +} +message InitVaultResponse { ResponseMeta meta = 100; } + +message SyncVaultRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + ViewRef view = 20; + ViewSnapshot expected_snapshot = 21; +} +message SyncVaultResponse { + uint32 entities_synced = 1; + ResponseMeta meta = 100; +} + +message GetVaultEntryRequest { + RepositoryRef repository = 1; + VaultEntityKind kind = 2; + string id = 3; + ViewRef view = 20; +} +message GetVaultEntryResponse { + VaultEntry entry = 1; + ViewSnapshot snapshot = 20; +} + +message ListVaultEntriesRequest { + RepositoryRef repository = 1; + optional VaultEntityKind kind = 2; + optional VaultEntityStatus status = 3; + bool summaries_only = 4; // `vault summaries` + optional PageCursor cursor = 5; + optional PageBudget budget = 6; + ViewRef view = 20; +} +message ListVaultEntriesResponse { + repeated VaultEntry entries = 1; // summaries_only → title lines only + optional PageCursor next_cursor = 2; + ViewSnapshot snapshot = 20; // continuation cursors bind to this perspective +} + +message GetVaultContextRequest { + RepositoryRef repository = 1; + optional uint32 max_entries = 2; + repeated VaultEntityKind kinds = 3; + ViewRef view = 20; +} +message GetVaultContextResponse { + repeated VaultEntry entries = 1; + ViewSnapshot snapshot = 20; + // SHAPE-CONFIRM: context-pack composition from vault/context.rs. +} + +message ExportVaultRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + repeated VaultEntityKind kinds = 3; + optional string destination = 4; // local path (LOCAL scope) + ViewRef view = 20; +} +message ExportVaultResponse { + uint32 exported = 1; + ResponseMeta meta = 100; +} + +message CreateVaultEntityRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + VaultEntityKind kind = 3; + string title = 4; + optional string body = 5; // initial directive/content text + // Memory-specific metadata (kind INTENT ignores these): + // constraint|preference|lesson|context|decision (add-only). + optional string memory_kind = 6; + // urn:atomic:... the memory derives from (add-only). + repeated string derived_from = 7; + // One-line summaries for goals (add-only). + repeated string about = 8; + ViewRef view = 20; + ViewSnapshot expected_snapshot = 21; +} +message CreateVaultEntityResponse { + VaultEntry entry = 1; + ResponseMeta meta = 100; +} + +message UpdateVaultEntityRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + VaultEntityKind kind = 3; + string id = 4; + oneof update { + string body = 5; // rewrite content (memory write) + VaultEntityStatus status = 6; // status transition (intent lifecycle, + // goal start/stop/resume) + } + optional ExpectedState expected = 7; + ViewRef view = 20; // expected.kind must be view_snapshot matching this view +} +message UpdateVaultEntityResponse { + VaultEntry entry = 1; + ResponseMeta meta = 100; +} + +message DeleteVaultEntityRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + VaultEntityKind kind = 3; + string id = 4; + ViewRef view = 20; + ViewSnapshot expected_snapshot = 21; +} +message DeleteVaultEntityResponse { ResponseMeta meta = 100; } + +message ValidateVaultEntityRequest { + RepositoryRef repository = 1; + VaultEntityKind kind = 2; + oneof subject { + string id = 3; // validate stored entity + bytes document = 4; // validate candidate bytes (path-arg form) + } + optional Hash content_hash = 5; + ViewRef view = 20; +} +message ValidateVaultEntityResponse { + bool valid = 1; + repeated string issues = 2; +} + +message LinkVaultEntitiesRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + VaultEntityRef source = 3; + VaultEntityRef target = 4; + ViewRef view = 20; + ViewSnapshot expected_snapshot = 21; +} +message LinkVaultEntitiesResponse { ResponseMeta meta = 100; } + +message VaultEntityRef { + VaultEntityKind kind = 1; + string id = 2; +} + +message VaultEntry { + VaultEntityKind kind = 1; + string id = 2; + string title = 3; + optional VaultEntityStatus status = 4; + optional string body = 5; // present unless summaries_only + optional google.protobuf.Timestamp created_at = 6; + optional google.protobuf.Timestamp updated_at = 7; + repeated string linked = 8; // linked entity ids + // Local-surface render helpers (add-only): the vault file path and the + // intent/memory priority label. + optional string vault_path = 9; + optional string priority = 10; + // SHAPE-CONFIRM: attestation/verification state surfacing (attestations + // live in AttestationService; entries reference their latest attestation). +} diff --git a/libatomic/proto/atomic/services/vault/vault_service.proto b/libatomic/proto/atomic/services/vault/vault_service.proto new file mode 100644 index 00000000..55d3804e --- /dev/null +++ b/libatomic/proto/atomic/services/vault/vault_service.proto @@ -0,0 +1,122 @@ +// atomic-protocol — VaultService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/vault/vault_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/vault/vault_messages.proto"; + +service VaultService { + // Initialize the authorized view's vault namespace, not host files. + rpc InitVault(InitVaultRequest) returns (InitVaultResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic vault sync` — deflate on-disk `.vault/` edits into the vault + // database (the file→db bridge; hooks handle the reverse direction). + rpc SyncVault(SyncVaultRequest) returns (SyncVaultResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "vault.write"; + option (atomic.required_capability) = "working-copy.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_READ; + } + // `atomic vault show ` / `intent show` / `memory show` / `goal show` + rpc GetVaultEntry(GetVaultEntryRequest) returns (GetVaultEntryResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `atomic vault list|summaries` / `intent list` / `memory list` / `goal list` + rpc ListVaultEntries(ListVaultEntriesRequest) returns (ListVaultEntriesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `atomic vault context` — the context pack surfaced to agents. + rpc GetVaultContext(GetVaultContextRequest) returns (GetVaultContextResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `atomic vault materialize` — export vault content to files (LOCAL). + // EXECUTION CLASS: redb-read — materialize only reads the vault + // repository database and writes workspace FILES under .vault/ (no repository + // DB writes; daemon recovery ledger is separate). working-copy.write gates + // that filesystem effect. + rpc ExportVault(ExportVaultRequest) returns (ExportVaultResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "vault.read"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; // replay/outcome ledger + } + // `atomic intent new` / `memory new` + rpc CreateVaultEntity(CreateVaultEntityRequest) returns (CreateVaultEntityResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `intent update` / `memory write` / goal transitions / status flips — + // one RPC, oneof update kind (the intent lifecycle's status vocabulary + // is honored by the status field). + rpc UpdateVaultEntity(UpdateVaultEntityRequest) returns (UpdateVaultEntityResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic intent delete` + rpc DeleteVaultEntity(DeleteVaultEntityRequest) returns (DeleteVaultEntityResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic intent validate [--hash]` / `memory validate` — id-arg form + // validates against the db; document-arg form validates the bytes alone + // (no db state required, still routed through the service for parity). + rpc ValidateVaultEntity(ValidateVaultEntityRequest) returns (ValidateVaultEntityResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // `atomic intent link` — link entities (e.g. intent ↔ intent provenance). + rpc LinkVaultEntities(LinkVaultEntitiesRequest) returns (LinkVaultEntitiesResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "vault.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.allowed_caller) = CALLER_CLASS_SANDBOX; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } +} diff --git a/libatomic/proto/atomic/services/view/view_messages.proto b/libatomic/proto/atomic/services/view/view_messages.proto new file mode 100644 index 00000000..83cf7754 --- /dev/null +++ b/libatomic/proto/atomic/services/view/view_messages.proto @@ -0,0 +1,118 @@ +// atomic-protocol — ViewService: view lifecycle and scope. +// +// DRAFT — add-only. Views are the workflow spine (draft → shared). View +// reads and repository-only lifecycle writes are BOTH. Only switch follows a +// host working copy and stays LOCAL. Sandboxes have no lifecycle authority. +// +// Mappings (see COVERAGE.md): `atomic split` = CreateView with +// explicit from_view, then optional SwitchView; `view promote` = SetViewScope +// (a scope change to Shared root — DISTINCT from bare `atomic insert`, +// which lands the current view's changes into its parent; verified +// against insert.rs and view/promote.rs before mapping them apart). + +syntax = "proto3"; + +package atomic; + +import "atomic/common/common.proto"; + +enum ViewScope { + VIEW_SCOPE_UNSPECIFIED = 0; + VIEW_SCOPE_DRAFT = 1; + VIEW_SCOPE_SHARED = 2; +} + + + +message ListViewsRequest { + RepositoryRef repository = 1; + bool include_details = 2; // `-v` +} +message ListViewsResponse { + repeated ViewInfo views = 1; +} +message ViewInfo { + string name = 1; + ViewScope scope = 2; + optional string parent = 3; + optional Hash head = 4; // head merkle state + uint64 change_count = 5; + bool current = 6; + ViewRef ref = 7; + ViewSnapshot snapshot = 8; +} + +message CreateViewRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string name = 3; + reserved 5, 7; // from_current/switch_after: client resolves/composes explicitly + oneof base { + string from_view = 4; // `view create --from` / `split` from another view + bool empty = 6; // `--empty` + } + ViewScope scope = 8; + optional ViewSnapshot base_snapshot = 9; // required for from_view +} +message CreateViewResponse { + ViewInfo view = 1; + ResponseMeta meta = 100; +} + +message SwitchViewRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string view = 3; +} +message SwitchViewResponse { + uint64 files_written = 1; // materialized files (add-only render) + ResponseMeta meta = 100; +} + +message DeleteViewRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string view = 3; + optional ExpectedState expected = 4; // required matching view_snapshot; + // refuse views checked out by an active workspace; do not switch it implicitly +} +message DeleteViewResponse { ResponseMeta meta = 100; } + +message SetViewScopeRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string view = 3; + ViewScope scope = 4; // `view promote` sets SHARED (parent cleared) + ViewSnapshot expected_snapshot = 5; +} +message SetViewScopeResponse { ResponseMeta meta = 100; } + +// `atomic view split` (add-only): fork a draft and remove the chosen +// changes from the source, atomically, under the reverse-dependency +// guard. Mirrors the domain SplitOptions/SplitOutcome exactly. +message SplitViewRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string name = 3; // the new draft view + string from_view = 4; // source (empty = current view) + repeated Hash changes = 5; // the changes to split out + bool cascade = 6; // move dependents instead of refusing + bool materialize = 7; // reconcile the source's working copy +} +// One change involved in a split, with its pre-split source sequence. +message SplitChangeInfo { + optional Hash hash = 1; + uint64 sequence = 2; +} +message SplitViewResponse { + repeated SplitChangeInfo requested = 1; // in source-sequence order + repeated SplitChangeInfo dependents = 2; // the blocking/moved closure + repeated SplitChangeInfo moved = 3; // requested + dependents + uint64 source_change_count = 4; // source's own count after + uint64 target_change_count = 5; // draft's own count after + bool working_copy_updated = 6; + uint64 files_written = 7; + uint64 files_removed = 8; + ViewInfo view = 9; + ResponseMeta meta = 100; +} diff --git a/libatomic/proto/atomic/services/view/view_service.proto b/libatomic/proto/atomic/services/view/view_service.proto new file mode 100644 index 00000000..9a5d859a --- /dev/null +++ b/libatomic/proto/atomic/services/view/view_service.proto @@ -0,0 +1,71 @@ +// atomic-protocol — ViewService definition (the RPC contract). +// DRAFT — add-only. Messages for this service live in +// atomic/services/view/view_messages.proto · shared primitives in atomic/common/. + +syntax = "proto3"; + +package atomic; + +import "atomic/common/options.proto"; +import "atomic/services/view/view_messages.proto"; + +service ViewService { + // `atomic view list [-v]` (+ remote listing via RepositoryRef.authority — + // the SAME method serves local daemon and hosted server; routing is + // explicit in the ref, per RFC D7). + rpc ListViews(ListViewsRequest) returns (ListViewsResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "repository.read"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + } + // Repository-only creation; CLI split --switch composes SwitchView. + rpc CreateView(CreateViewRequest) returns (CreateViewResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "view.create"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic view switch` — the working copy follows. + rpc SwitchView(SwitchViewRequest) returns (SwitchViewResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "view.switch"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + } + // `atomic view delete` + rpc DeleteView(DeleteViewRequest) returns (DeleteViewResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "view.delete"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_DAEMON_WRITE; // revoke bound sandbox grants + } + // `atomic view promote` — set a view's scope to Shared with no parent + // (RFC's `view:promote` capability). Not a change-insertion op. + rpc SetViewScope(SetViewScopeRequest) returns (SetViewScopeResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; + option (atomic.required_capability) = "view.promote"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.allowed_caller) = CALLER_CLASS_REMOTE_IDENTITY; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } + // `atomic view split` — fork a draft AND remove the chosen changes from + // the source under a reverse-dependency guard, atomically (add-only; + // supersedes the old "split = CreateView composition" mapping, which + // could not remove from the source or enforce the guard). + rpc SplitView(SplitViewRequest) returns (SplitViewResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "view.create"; + option (atomic.required_capability) = "view.write"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; // materialize reconcile + } +} diff --git a/libatomic/proto/check_contract.py b/libatomic/proto/check_contract.py new file mode 100644 index 00000000..cb3bf4d5 --- /dev/null +++ b/libatomic/proto/check_contract.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Compile the draft and check its security-relevant descriptor invariants. + +Run with Python from a throwaway venv containing grpcio-tools. Generated modules +and descriptors live only in a temporary directory, never in the source tree. +This checks the wire contract, not runtime authorization or transaction behavior. +""" + +import argparse +import importlib +from pathlib import Path +import sys +import tempfile + +from google.protobuf import descriptor_pb2 +import grpc_tools +from grpc_tools import protoc + + +SANDBOX_METHODS = { + "DaemonService": {"Health", "GetCapabilities"}, + "SandboxService": { + "Materialize", "GetFileStates", "GetChanges", "SubmitChange", "PublishProvenance", + }, + "ProvenanceService": { + "ReserveTurn", "AppendEnvelopes", "PrepareCheckpoint", "LoadFrozenEnvelopes", + "BindCheckpointHash", "AcknowledgeCheckpoint", "UpdateTurn", "GetTurn", + "GetSession", "GetProvenance", "ExportProvenance", + }, + "VaultService": { + "InitVault", "GetVaultEntry", "ListVaultEntries", "GetVaultContext", + "CreateVaultEntity", "UpdateVaultEntity", "DeleteVaultEntity", + "ValidateVaultEntity", "LinkVaultEntities", + }, + "AttestationService": { + "PrepareAttestation", "RecordAttestation", "VerifyAttestation", "ListAttestations", + }, + "KnowledgeService": {"QueryGraph"}, +} + +RETIRED_FIELDS = { + "StatusRequest": {2}, "ResolveRepositoryResponse": {2}, + "MaterializeRequest": {2, 3}, "GetFileStatesRequest": {2, 3}, + "GetChangesRequest": {2, 3}, "SubmitChangeRequest": {3, 4, 5}, + "PublishProvenanceRequest": {3, 4, 6}, "DiffRequest": {2}, + "RestoreRequest": {5}, "InsertChangesRequest": {3, 8, 10, 11}, + "InsertChangesResponse": {2}, "UnrecordRequest": {3}, + "UnrecordResponse": {2}, "ApplyStashRequest": {4}, + "CreateViewRequest": {5, 7}, "ImportFromGitRequest": {7}, + "ImportFromGitResponse": {3}, "ExportProvenanceRequest": {2, 4}, + "ExportProvenanceResponse": {2}, +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--temp-dir", help="Existing parent for temporary compiler output") + args = parser.parse_args() + root = Path(__file__).resolve().parent + files = sorted(p.relative_to(root).as_posix() for p in (root / "atomic").rglob("*.proto")) + failures = [] + + def check(condition, description): + if not condition: + failures.append(description) + + with tempfile.TemporaryDirectory(prefix="atomic-proto-", dir=args.temp_dir) as output: + descriptor_path = Path(output) / "atomic.pb" + result = protoc.main([ + "protoc", f"-I{root}", + f"-I{Path(grpc_tools.__file__).parent / '_proto'}", + f"--descriptor_set_out={descriptor_path}", "--include_imports", + f"--python_out={output}", *files, + ]) + if result: + return result + sys.path.insert(0, output) + options = importlib.import_module("atomic.common.options_pb2") + common = importlib.import_module("atomic.common.common_pb2") + check(common.CONTRACT_VERSION_2 == 2, "Contract revision 2 constant changed") + descriptors = descriptor_pb2.FileDescriptorSet.FromString(descriptor_path.read_bytes()) + messages = { + f".atomic.{message.name}": message + for file in descriptors.file if file.package == "atomic" + for message in file.message_type + } + methods = { + (service.name, method.name): method + for file in descriptors.file if file.package == "atomic" + for service in file.service for method in service.method + } + writes = { + options.RPC_EFFECT_REPOSITORY_WRITE, + options.RPC_EFFECT_WORKSPACE_WRITE, + options.RPC_EFFECT_DAEMON_WRITE, + } + workspace = {options.RPC_EFFECT_WORKSPACE_READ, options.RPC_EFFECT_WORKSPACE_WRITE} + event_publish = {("ReactorService", "Publish"), ("ReactorService", "PublishStream")} + negotiation = {("DaemonService", "Health"), ("DaemonService", "GetCapabilities")} + mutation_permissions = { + "sandbox.submit", "agent.checkpoint", "view.create", "view.switch", + "view.delete", "view.promote", "reactor.publish", "reactor.checkpoint", + } + observed_sandbox = {} + for key, method in methods.items(): + label = ".".join(key) + opt = method.options + callers = set(opt.Extensions[options.allowed_caller]) + effects = set(opt.Extensions[options.effect]) + capabilities = set(opt.Extensions[options.required_capability]) + scope = opt.Extensions[options.execution_scope] + check(scope != options.EXECUTION_SCOPE_UNSPECIFIED, f"{label}: missing scope") + check(callers and options.CALLER_CLASS_UNSPECIFIED not in callers, f"{label}: missing caller allowlist") + check(effects and options.RPC_EFFECT_UNSPECIFIED not in effects, f"{label}: missing effects") + check(bool(capabilities) == (key not in negotiation), f"{label}: incorrect negotiation capability exception") + if scope == options.EXECUTION_SCOPE_LOCAL: + check(callers == {options.CALLER_CLASS_LOCAL}, f"{label}: LOCAL operation allows network callers") + if effects & workspace: + check(scope == options.EXECUTION_SCOPE_LOCAL, f"{label}: workspace effects outside LOCAL") + check(options.CALLER_CLASS_SANDBOX not in callers, f"{label}: sandbox host filesystem access") + if not effects & writes: + check(not any(c.endswith((".write", ".admin")) for c in capabilities), f"{label}: read requires write/admin authority") + check(not any(f.type_name == ".atomic.ResponseMeta" for f in messages[method.output_type].field), f"{label}: read has mutation replay response") + elif key not in event_publish: + check(any(c.endswith((".write", ".admin")) for c in capabilities) or bool(capabilities & mutation_permissions), f"{label}: mutation authorized by read-only capabilities") + check(any(f.type_name == ".atomic.RequestMeta" for f in messages[method.input_type].field), f"{label}: mutation missing request metadata") + check(any(f.number == 100 and f.type_name == ".atomic.ResponseMeta" for f in messages[method.output_type].field), f"{label}: mutation missing response echo") + if options.CALLER_CLASS_SANDBOX in callers: + observed_sandbox.setdefault(key[0], set()).add(key[1]) + check(not capabilities & {"repository.write", "repository.read", "sandbox.admin", "daemon.admin", "view.create", "view.promote"}, f"{label}: sandbox receives broad authority") + check(not effects & (workspace | {options.RPC_EFFECT_DAEMON_WRITE}), f"{label}: sandbox mutates host/daemon state") + if options.RPC_EFFECT_WORKSPACE_WRITE in effects: + check("working-copy.write" in capabilities, f"{label}: workspace write lacks workspace permission") + check(observed_sandbox == SANDBOX_METHODS, "Sandbox method allowlist differs from reviewed contract") + + def field(message, name, kind): + fields = messages[f".atomic.{message}"].field + check(any(f.name == name and f.type_name == f".atomic.{kind}" for f in fields), f"{message}.{name}: missing {kind}") + + for name, numbers in RETIRED_FIELDS.items(): + msg = messages[f".atomic.{name}"] + active = {f.number for f in msg.field} + for number in numbers: + check(number not in active and any(r.start <= number < r.end for r in msg.reserved_range), f"{name}: retired field {number} not reserved") + field("SubmitChangeRequest", "expected_snapshot", "ViewSnapshot") + field("InsertChangesRequest", "expected_snapshot", "ViewSnapshot") + field("PublishProvenanceRequest", "turn", "SessionTurn") + field("ResolveRepositoryResponse", "workspace", "WorkspaceInfo") + field("SandboxOpened", "repository", "RepositoryRef") + field("SandboxOpened", "target", "ViewRef") + field("SandboxOpened", "snapshot", "ViewSnapshot") + field("SandboxSkeleton", "snapshot", "ViewSnapshot") + field("SandboxSlice", "snapshot", "ViewSnapshot") + check(any(f.name == "expected_generation" for f in messages[".atomic.UpdateTurnRequest"].field), "UpdateTurn: missing generation fence") + check(any(f.name == "expected_generation" for f in messages[".atomic.PublishProvenanceRequest"].field), "PublishProvenance: missing generation fence") + check(any(f.name == "destination_path" for f in messages[".atomic.ImportFromGitRequest"].field), "Git import: missing bootstrap destination") + for key in methods: + if key[0] == "VaultService": + field(messages[methods[key].input_type].name, "view", "ViewRef") + for name in ["InitVaultRequest", "SyncVaultRequest", "CreateVaultEntityRequest", "DeleteVaultEntityRequest", "LinkVaultEntitiesRequest"]: + field(name, "expected_snapshot", "ViewSnapshot") + field("UpdateVaultEntityRequest", "expected", "ExpectedState") + for service, names in { + "ViewService": {"CreateView", "DeleteView", "SetViewScope"}, + "RepositoryMutationService": {"InsertChanges", "Unrecord"}, + "ProvenanceService": {"ForkSession"}, + "TriageService": {"ListTriageCandidates", "GenerateTriageReview"}, + "MaintenanceService": {"CheckRepository"}, + }.items(): + for name in names: + check(methods[(service, name)].options.Extensions[options.execution_scope] == options.EXECUTION_SCOPE_BOTH, f"{service}.{name}: repository-only operation is not BOTH") + check(options.execution_scope.number == 51001 and options.required_capability.number == 51002, "Original extension numbers changed") + if failures: + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + return 1 + scopes = {name: sum(m.options.Extensions[options.execution_scope] == value for m in methods.values()) for name, value in [("LOCAL", 1), ("REMOTE", 2), ("BOTH", 3)]} + print(f"Compiled {len(files)} protobuf files; checked {len(methods)} RPCs across {len(set(s for s, _ in methods))} services.") + print(f"Scopes: {scopes}; sandbox allowlist: {sum(map(len, observed_sandbox.values()))} methods.") + print("Caller/effect annotations, read/write capabilities, replay metadata, retired fields, routing and fencing: PASS.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/libatomic/src/lib.rs b/libatomic/src/lib.rs new file mode 100644 index 00000000..a0e8662f --- /dev/null +++ b/libatomic/src/lib.rs @@ -0,0 +1,25 @@ +//! libatomic — the canonical Atomic contract. +//! +//! This crate owns the one Atomic protobuf contract: the canonical +//! sources live under `proto/` (package `atomic`), `build.rs` compiles +//! them with protox (a pure-Rust protoc replacement — no system protoc) +//! and tonic-build, and `proto/check_contract.py` gates the declared +//! descriptor invariants (add-only; scopes, callers, effects and +//! capabilities on every method; read/write discipline; retired fields; +//! fencing). +//! +//! The generated module is [`atomic`] — every file in the contract +//! declares `package atomic`, so all messages and the generated service +//! clients/servers land in that single module — with a transport-neutral +//! [`proto`] alias for callers that prefer it. +//! +//! The contract is transport-neutral by design: any process or tooling +//! that speaks Atomic imports this crate for the types. This crate ships +//! the contract and the codegen only — implementations live elsewhere. + +pub mod atomic { + tonic::include_proto!("atomic"); +} + +/// The generated contract module under its transport-neutral name. +pub use crate::atomic as proto; From 193d924a0d64c93f6de25a5dfe0fdb199066fdca Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 5 Oct 2026 17:50:54 -0500 Subject: [PATCH 02/23] docs(contract): drop draft framing; state the add-only discipline --- libatomic/proto/README.md | 10 +++++----- libatomic/proto/atomic/common/change.proto | 2 +- libatomic/proto/atomic/common/common.proto | 2 +- libatomic/proto/atomic/common/error.proto | 2 +- libatomic/proto/atomic/common/event.proto | 3 +-- libatomic/proto/atomic/common/options.proto | 5 ++--- libatomic/proto/atomic/common/pagination.proto | 2 +- .../services/attestation/attestation_messages.proto | 2 +- .../services/attestation/attestation_service.proto | 2 +- .../proto/atomic/services/daemon/daemon_messages.proto | 2 +- .../proto/atomic/services/daemon/daemon_service.proto | 2 +- .../services/git_interop/git_interop_messages.proto | 2 +- .../services/git_interop/git_interop_service.proto | 2 +- .../atomic/services/knowledge/knowledge_messages.proto | 2 +- .../atomic/services/knowledge/knowledge_service.proto | 2 +- .../services/maintenance/maintenance_messages.proto | 2 +- .../services/maintenance/maintenance_service.proto | 2 +- .../services/provenance/provenance_messages.proto | 2 +- .../services/provenance/provenance_service.proto | 2 +- .../atomic/services/reactor/reactor_messages.proto | 6 ++---- .../atomic/services/reactor/reactor_service.proto | 2 +- .../repository_mutation_messages.proto | 2 +- .../repository_mutation_service.proto | 2 +- .../repository_query/repository_query_messages.proto | 2 +- .../repository_query/repository_query_service.proto | 2 +- .../atomic/services/sandbox/sandbox_messages.proto | 4 ++-- .../atomic/services/sandbox/sandbox_service.proto | 2 +- .../proto/atomic/services/sync/sync_messages.proto | 2 +- .../proto/atomic/services/sync/sync_service.proto | 2 +- libatomic/proto/atomic/services/tag/tag_messages.proto | 2 +- libatomic/proto/atomic/services/tag/tag_service.proto | 2 +- .../proto/atomic/services/triage/triage_messages.proto | 2 +- .../proto/atomic/services/triage/triage_service.proto | 2 +- .../proto/atomic/services/vault/vault_messages.proto | 2 +- .../proto/atomic/services/vault/vault_service.proto | 2 +- .../proto/atomic/services/view/view_messages.proto | 2 +- .../proto/atomic/services/view/view_service.proto | 2 +- 37 files changed, 44 insertions(+), 48 deletions(-) diff --git a/libatomic/proto/README.md b/libatomic/proto/README.md index 24af5fa2..395988bb 100644 --- a/libatomic/proto/README.md +++ b/libatomic/proto/README.md @@ -45,12 +45,12 @@ libatomic/ _messages.proto that service's messages ``` -## Status: DRAFT for alignment +## Add-only discipline -Numbers are correctable until review and the first tagged release — -**add-only thereafter**. Field and extension numbers are never reused or -renumbered; removed field numbers are reserved. Older draft clients must -fail version negotiation rather than send an ignored field to a writer. +Field and extension numbers are never reused or renumbered; removed field +numbers are reserved. A client that does not match the negotiated contract +version must fail version negotiation rather than send an ignored field to +a writer. ## The contract gate diff --git a/libatomic/proto/atomic/common/change.proto b/libatomic/proto/atomic/common/change.proto index 358bf62b..1abe5e85 100644 --- a/libatomic/proto/atomic/common/change.proto +++ b/libatomic/proto/atomic/common/change.proto @@ -1,6 +1,6 @@ // atomic-protocol — shared change-domain types. // -// DRAFT — add-only. Shared across RepositoryQueryService, +// Add-only. Shared across RepositoryQueryService, // RepositoryMutationService, and friends. Shapes are verified against the // atomic source (2026-10-02) — this is ATOMIC's model (patch theory, a // semantic change graph), NOT git's: diff --git a/libatomic/proto/atomic/common/common.proto b/libatomic/proto/atomic/common/common.proto index e0a92617..139b29a6 100644 --- a/libatomic/proto/atomic/common/common.proto +++ b/libatomic/proto/atomic/common/common.proto @@ -1,6 +1,6 @@ // atomic-protocol — shared primitives. // -// DRAFT — add-only. Field numbers are never reused or renumbered. +// Add-only. Field numbers are never reused or renumbered. // Conventions carried from the earlier parked draft: // package `atomic`, RepositoryRef numbering per the RFC. // diff --git a/libatomic/proto/atomic/common/error.proto b/libatomic/proto/atomic/common/error.proto index f8ca7253..cbe1461c 100644 --- a/libatomic/proto/atomic/common/error.proto +++ b/libatomic/proto/atomic/common/error.proto @@ -1,6 +1,6 @@ // atomic-protocol — the stable error model. // -// DRAFT — add-only. Enum values 28+ reserved: enum numbers are never reused. +// Add-only. Enum values 28+ reserved: enum numbers are never reused. // // Vocabulary provenance: // * legacy agent journal protocol (verified in source, 2026-10-01): diff --git a/libatomic/proto/atomic/common/event.proto b/libatomic/proto/atomic/common/event.proto index d109669e..05cc35da 100644 --- a/libatomic/proto/atomic/common/event.proto +++ b/libatomic/proto/atomic/common/event.proto @@ -1,7 +1,6 @@ // atomic-protocol — the Reactor event envelope (RFC "Event envelope" section). // -// DRAFT — add-only. CONTRACT DRAFT: the ReactorService server lands in -// Phase 3; this envelope is drafted now so repository mutation events +// Add-only. The envelope is defined now so repository mutation events // (transactional outbox, RFC D8) and agent ingestion rhyme with it // (align with the Reactor event envelope so the two contracts // rhyme"). diff --git a/libatomic/proto/atomic/common/options.proto b/libatomic/proto/atomic/common/options.proto index d3f563da..67b3e9b5 100644 --- a/libatomic/proto/atomic/common/options.proto +++ b/libatomic/proto/atomic/common/options.proto @@ -1,8 +1,7 @@ // atomic-protocol — method options (RFC D7). // -// DRAFT — numbers are correctable until review and the first tagged -// release; add-only thereafter. Field and extension numbers are NEVER reused -// or renumbered. 51001/51002 are the permanent allocations for Atomic's +// Numbers are add-only after the first tagged release. Field and +// extension numbers are NEVER reused or renumbered. 51001/51002 are the permanent allocations for Atomic's // method options (chosen once, per RFC). // // THE ONE PROTOCOL: this package is the sole public protobuf contract for diff --git a/libatomic/proto/atomic/common/pagination.proto b/libatomic/proto/atomic/common/pagination.proto index cbfa3c2d..5e327e21 100644 --- a/libatomic/proto/atomic/common/pagination.proto +++ b/libatomic/proto/atomic/common/pagination.proto @@ -1,6 +1,6 @@ // atomic-protocol — pagination. // -// DRAFT — add-only. +// Add-only. // // The frozen-provenance cursor {seq, offset} (verified against the // provenance store source) diff --git a/libatomic/proto/atomic/services/attestation/attestation_messages.proto b/libatomic/proto/atomic/services/attestation/attestation_messages.proto index 68f81037..065da8e6 100644 --- a/libatomic/proto/atomic/services/attestation/attestation_messages.proto +++ b/libatomic/proto/atomic/services/attestation/attestation_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — AttestationService: the external-signing flow and // attestation records. // -// DRAFT — add-only. +// Add-only. // // Covers `atomic intent attest --prepare/--signed` (REMOTE-capable — that is // the point of the feature: a signer holding the key elsewhere — browser, diff --git a/libatomic/proto/atomic/services/attestation/attestation_service.proto b/libatomic/proto/atomic/services/attestation/attestation_service.proto index 2d4c90cd..0b5ac482 100644 --- a/libatomic/proto/atomic/services/attestation/attestation_service.proto +++ b/libatomic/proto/atomic/services/attestation/attestation_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — AttestationService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/attestation/attestation_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/daemon/daemon_messages.proto b/libatomic/proto/atomic/services/daemon/daemon_messages.proto index 79bd9f37..d796f012 100644 --- a/libatomic/proto/atomic/services/daemon/daemon_messages.proto +++ b/libatomic/proto/atomic/services/daemon/daemon_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — DaemonService: daemon health, capability negotiation, // repository registry, lifecycle. // -// DRAFT — add-only. Extends the earlier parked draft. +// Add-only. Extends the earlier parked draft. // // The legacy agent journal protocol is being fully dismantled (with the // `atomic agent database-owner` command that served it); liveness, diff --git a/libatomic/proto/atomic/services/daemon/daemon_service.proto b/libatomic/proto/atomic/services/daemon/daemon_service.proto index 62e505bf..66e7fa27 100644 --- a/libatomic/proto/atomic/services/daemon/daemon_service.proto +++ b/libatomic/proto/atomic/services/daemon/daemon_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — DaemonService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/daemon/daemon_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto b/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto index 4836a6a5..a8712048 100644 --- a/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto +++ b/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — GitInteropService: the git import/push pipelines. // -// DRAFT — add-only. Git interop is LOCAL-only (working copies and git +// Add-only. Git interop is LOCAL-only (working copies and git // mirrors are local). `git hooks install/uninstall` only writes .git/hooks // shim files — no RPC (exempt, file-only). Import may create the repository // (init_with_view) on first run — bootstrap-adjacent, flagged in COVERAGE. diff --git a/libatomic/proto/atomic/services/git_interop/git_interop_service.proto b/libatomic/proto/atomic/services/git_interop/git_interop_service.proto index d89e717b..75b011c6 100644 --- a/libatomic/proto/atomic/services/git_interop/git_interop_service.proto +++ b/libatomic/proto/atomic/services/git_interop/git_interop_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — GitInteropService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/git_interop/git_interop_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto index 0a79006f..94567802 100644 --- a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto +++ b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — KnowledgeService: knowledge-graph queries and // maintenance. // -// DRAFT — add-only. The CLI's eleven `query` verbs collapse into TWO RPCs +// Add-only. The CLI's eleven `query` verbs collapse into TWO RPCs // (the headline combine of this draft): // * QueryGraph — every read: content/code search, kg search, entity, // neighbors, callers, graph slice, plan execution, RAG ask; diff --git a/libatomic/proto/atomic/services/knowledge/knowledge_service.proto b/libatomic/proto/atomic/services/knowledge/knowledge_service.proto index f86ccb07..226697cb 100644 --- a/libatomic/proto/atomic/services/knowledge/knowledge_service.proto +++ b/libatomic/proto/atomic/services/knowledge/knowledge_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — KnowledgeService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/knowledge/knowledge_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto b/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto index 70e8fc41..49a2347e 100644 --- a/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto +++ b/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — MaintenanceService: repository repair and consistency. // -// DRAFT — add-only. `atomic doctor` folds into Repair (the +// Add-only. `atomic doctor` folds into Repair (the // mutating actions) and CheckRepository (the read-only consistency check, // split out per the read/write principle — a check never gates on a // mutation or enters the write queue; see COVERAGE §9). ReindexWorkspace is diff --git a/libatomic/proto/atomic/services/maintenance/maintenance_service.proto b/libatomic/proto/atomic/services/maintenance/maintenance_service.proto index 4600e3fe..bc9ae051 100644 --- a/libatomic/proto/atomic/services/maintenance/maintenance_service.proto +++ b/libatomic/proto/atomic/services/maintenance/maintenance_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — MaintenanceService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/maintenance/maintenance_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/provenance/provenance_messages.proto b/libatomic/proto/atomic/services/provenance/provenance_messages.proto index f1f76b7a..0ce43131 100644 --- a/libatomic/proto/atomic/services/provenance/provenance_messages.proto +++ b/libatomic/proto/atomic/services/provenance/provenance_messages.proto @@ -2,7 +2,7 @@ // session/turn journal, generation fencing, checkpoint phases, frozen- // envelope storage, session ledger, provenance projection. // -// DRAFT — add-only. Context: the legacy agent journal protocol and the +// Add-only. Context: the legacy agent journal protocol and the // `atomic agent database-owner` command that served it are being FULLY // DISMANTLED; atomicd becomes the only storage owner. What carries forward // is the provenance tracking semantics below — verified against the diff --git a/libatomic/proto/atomic/services/provenance/provenance_service.proto b/libatomic/proto/atomic/services/provenance/provenance_service.proto index 92051ee3..539c5d77 100644 --- a/libatomic/proto/atomic/services/provenance/provenance_service.proto +++ b/libatomic/proto/atomic/services/provenance/provenance_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — ProvenanceService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/provenance/provenance_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/reactor/reactor_messages.proto b/libatomic/proto/atomic/services/reactor/reactor_messages.proto index c7e4e020..fb4ab5a5 100644 --- a/libatomic/proto/atomic/services/reactor/reactor_messages.proto +++ b/libatomic/proto/atomic/services/reactor/reactor_messages.proto @@ -1,9 +1,7 @@ // atomic-protocol — ReactorService: durable cross-project event ingestion. // -// DRAFT — add-only. CONTRACT DRAFT ONLY: the server lands in Phase 3 with -// atomicd; the contract is drafted now because the Reactor is its own first -// consumer and the repository-outbox events (RFC D8) must rhyme with this -// envelope (event.proto). +// Add-only. The ReactorService server lands with atomicd; the +// repository-outbox events (RFC D8) rhyme with this envelope (event.proto). // // Durability semantics (RFC D9) that the contract names explicitly: // * append-before-acknowledgement (an acked event is durable); diff --git a/libatomic/proto/atomic/services/reactor/reactor_service.proto b/libatomic/proto/atomic/services/reactor/reactor_service.proto index 85f5b69f..5d7a3c31 100644 --- a/libatomic/proto/atomic/services/reactor/reactor_service.proto +++ b/libatomic/proto/atomic/services/reactor/reactor_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — ReactorService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/reactor/reactor_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto index 8d9ab620..7838ac9b 100644 --- a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto +++ b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — RepositoryMutationService: working-copy and change-graph // mutations. // -// DRAFT — add-only. Every mutation carries RequestMeta (idempotency key) and +// Add-only. Every mutation carries RequestMeta (idempotency key) and // an ExpectedState precondition where the caller knows prior state (RFC // 2.2/2.3: expected-Merkle/sequence preconditions on mutating operations — // the fencing pattern generalized from provenance to ALL mutations). diff --git a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto index 8057000f..85da200e 100644 --- a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto +++ b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — RepositoryMutationService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/repository_mutation/repository_mutation_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto index adcd005b..f0fbe24b 100644 --- a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto +++ b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — RepositoryQueryService: read-only repository operations. // -// DRAFT — add-only. Reads execute as independent concurrent read +// Add-only. Reads execute as independent concurrent read // transactions (RFC concurrency model); no global write lock is ever assumed. // LOCAL-scoped reads are working-copy-aware (status/DiffWorkingCopy against the // tree); BOTH reads (log/change/Diff/PreviewMutation) are semantically identical on diff --git a/libatomic/proto/atomic/services/repository_query/repository_query_service.proto b/libatomic/proto/atomic/services/repository_query/repository_query_service.proto index 78f0689e..7624be01 100644 --- a/libatomic/proto/atomic/services/repository_query/repository_query_service.proto +++ b/libatomic/proto/atomic/services/repository_query/repository_query_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — RepositoryQueryService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/repository_query/repository_query_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto b/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto index 7b179979..6b3de868 100644 --- a/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto +++ b/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto @@ -2,7 +2,7 @@ // trees, and the remote-sandbox data ops (the remote-sandboxes extension // — the handoff is the spec here). // -// DRAFT — add-only. +// Add-only. // // "A remote sandbox is a machine holding only a pointer file .atomic-sandbox // = {iroh EndpointAddr of the serving daemon, repository, view, token}. @@ -289,7 +289,7 @@ message ChangeRefused { // atomic-protocol — grants and sandbox tokens. // -// DRAFT — add-only. +// Add-only. // // Model carried from the remote-sandbox extension: a grant is // view-scoped, optionally identity-bound, and time-boxed. A remote caller is diff --git a/libatomic/proto/atomic/services/sandbox/sandbox_service.proto b/libatomic/proto/atomic/services/sandbox/sandbox_service.proto index fc5e7910..0e89e313 100644 --- a/libatomic/proto/atomic/services/sandbox/sandbox_service.proto +++ b/libatomic/proto/atomic/services/sandbox/sandbox_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — SandboxService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/sandbox/sandbox_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/sync/sync_messages.proto b/libatomic/proto/atomic/services/sync/sync_messages.proto index 791b0a1f..d814dfcc 100644 --- a/libatomic/proto/atomic/services/sync/sync_messages.proto +++ b/libatomic/proto/atomic/services/sync/sync_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — SyncService: remote synchronization and the remote // registry. // -// DRAFT — add-only. The existing HTTP `/code` + SyncPack behavior must be +// Add-only. The existing HTTP `/code` + SyncPack behavior must be // preserved behind — or deliberately migrated into — ONE SyncService domain // implementation (RFC "one synchronization protocol"): local repo sync, // atomicd-to-server sync, and future server replication use the same diff --git a/libatomic/proto/atomic/services/sync/sync_service.proto b/libatomic/proto/atomic/services/sync/sync_service.proto index b6e9c560..d4226c17 100644 --- a/libatomic/proto/atomic/services/sync/sync_service.proto +++ b/libatomic/proto/atomic/services/sync/sync_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — SyncService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/sync/sync_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/tag/tag_messages.proto b/libatomic/proto/atomic/services/tag/tag_messages.proto index 6243ef34..e995617b 100644 --- a/libatomic/proto/atomic/services/tag/tag_messages.proto +++ b/libatomic/proto/atomic/services/tag/tag_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — TagService: named Merkle-state snapshots. // -// DRAFT — add-only. Tags are repository metadata: reads are BOTH (hosted +// Add-only. Tags are repository metadata: reads are BOTH (hosted // servers serve them), writes BOTH with repository.write (a hosted // repository can be tagged remotely). The RFC names tags in its BOTH scope // list. diff --git a/libatomic/proto/atomic/services/tag/tag_service.proto b/libatomic/proto/atomic/services/tag/tag_service.proto index 539e5479..1e342162 100644 --- a/libatomic/proto/atomic/services/tag/tag_service.proto +++ b/libatomic/proto/atomic/services/tag/tag_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — TagService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/tag/tag_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/triage/triage_messages.proto b/libatomic/proto/atomic/services/triage/triage_messages.proto index 5aa8bcc1..5b3fae93 100644 --- a/libatomic/proto/atomic/services/triage/triage_messages.proto +++ b/libatomic/proto/atomic/services/triage/triage_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — TriageService: promotion review. // -// DRAFT — add-only. Triage review is a paid-surface workflow (the +// Add-only. Triage review is a paid-surface workflow (the // intent→change pipeline's review gate): candidates are the change-sets // pending promotion between views; the review is the canonical report // (ordered semantic layers, provenance-backed) that `atomic triage review diff --git a/libatomic/proto/atomic/services/triage/triage_service.proto b/libatomic/proto/atomic/services/triage/triage_service.proto index 43e7bc72..8ef50efc 100644 --- a/libatomic/proto/atomic/services/triage/triage_service.proto +++ b/libatomic/proto/atomic/services/triage/triage_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — TriageService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/triage/triage_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/vault/vault_messages.proto b/libatomic/proto/atomic/services/vault/vault_messages.proto index bb36e0b5..0d4fed8b 100644 --- a/libatomic/proto/atomic/services/vault/vault_messages.proto +++ b/libatomic/proto/atomic/services/vault/vault_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — VaultService: the `.vault/` knowledge store — intents, // memories, goals, vault sync, context. // -// DRAFT — add-only. This is the agent-facing product surface (the +// Add-only. This is the agent-facing product surface (the // intent→change workflow with triage + knowledge graph), so the combine // discipline matters most here: intents/memories/goals are one entity model // with a kind enum, not three parallel RPC families. `vault intent`/ diff --git a/libatomic/proto/atomic/services/vault/vault_service.proto b/libatomic/proto/atomic/services/vault/vault_service.proto index 55d3804e..921e6878 100644 --- a/libatomic/proto/atomic/services/vault/vault_service.proto +++ b/libatomic/proto/atomic/services/vault/vault_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — VaultService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/vault/vault_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/view/view_messages.proto b/libatomic/proto/atomic/services/view/view_messages.proto index 83cf7754..ccd2cf5b 100644 --- a/libatomic/proto/atomic/services/view/view_messages.proto +++ b/libatomic/proto/atomic/services/view/view_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — ViewService: view lifecycle and scope. // -// DRAFT — add-only. Views are the workflow spine (draft → shared). View +// Add-only. Views are the workflow spine (draft → shared). View // reads and repository-only lifecycle writes are BOTH. Only switch follows a // host working copy and stays LOCAL. Sandboxes have no lifecycle authority. // diff --git a/libatomic/proto/atomic/services/view/view_service.proto b/libatomic/proto/atomic/services/view/view_service.proto index 9a5d859a..4bdc3fe6 100644 --- a/libatomic/proto/atomic/services/view/view_service.proto +++ b/libatomic/proto/atomic/services/view/view_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — ViewService definition (the RPC contract). -// DRAFT — add-only. Messages for this service live in +// Add-only. Messages for this service live in // atomic/services/view/view_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; From 615ba29189712fbd6b894934880f11c0ca360735 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 5 Oct 2026 17:58:02 -0500 Subject: [PATCH 03/23] style(contract): rustfmt build.rs; clippy-allow the generated module --- libatomic/build.rs | 11 ++++++----- libatomic/src/lib.rs | 2 ++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/libatomic/build.rs b/libatomic/build.rs index 7fc244de..9c83abe3 100644 --- a/libatomic/build.rs +++ b/libatomic/build.rs @@ -20,15 +20,16 @@ fn main() -> Result<(), Box> { let proto_root = manifest.join("proto"); let mut files = Vec::new(); collect_protos(&proto_root, &mut files); - assert!(!files.is_empty(), "no protos found under {}", proto_root.display()); + assert!( + !files.is_empty(), + "no protos found under {}", + proto_root.display() + ); // protox is a pure-Rust protoc replacement: no system protoc dependency. // The include root carries `atomic/...`, matching the imports // (`atomic/common/common.proto`). - let fds = protox::compile( - &files, - [proto_root.clone()], - )?; + let fds = protox::compile(&files, [proto_root.clone()])?; tonic_build::configure() .build_client(true) diff --git a/libatomic/src/lib.rs b/libatomic/src/lib.rs index a0e8662f..255dce1f 100644 --- a/libatomic/src/lib.rs +++ b/libatomic/src/lib.rs @@ -18,6 +18,8 @@ //! the contract and the codegen only — implementations live elsewhere. pub mod atomic { + // The generated module is machine-written; lints do not apply to it. + #![allow(clippy::all)] tonic::include_proto!("atomic"); } From 37f8b7b07e3d803938fb939cbc3827e4be954bb5 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 5 Oct 2026 18:54:45 -0500 Subject: [PATCH 04/23] =?UTF-8?q?feat(libatomic):=20add=20the=20reference?= =?UTF-8?q?=20handlers=20=E2=80=94=20the=20service=20layer=20over=20the=20?= =?UTF-8?q?contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon module implements the contract's service surface: the repository registry with per-repository serialization gates (one writable database handle at a time; read-only opens with a bounded wait so concurrent readers coexist with short-lived writers), the tonic service impls and their plain inner functions (query, mutation, vault, attestation, knowledge, the provenance journal port, maintenance, view), the provenance journal core and its in-process sink, and the domain/protobuf converters. The handlers are transport-neutral: a serving process wires them onto any transport through the generated servers, and any Atomic-speaking consumer can call the same handlers in-process — one implementation. Carries two small atomic-repository additions the handlers require: in-place revise (reword) and stash support, plus the vault entity scaffolds for CreateVaultEntity. --- Cargo.lock | 15 + Cargo.toml | 2 + atomic-repository/src/repository/mod.rs | 5 + atomic-repository/src/repository/revise.rs | 135 ++ atomic-repository/src/repository/stash.rs | 321 ++++ .../src/repository/vault_intent.rs | 76 + libatomic/Cargo.toml | 24 +- libatomic/src/daemon/convert.rs | 84 + libatomic/src/daemon/journal_sink.rs | 355 ++++ libatomic/src/daemon/mod.rs | 42 + libatomic/src/daemon/provenance_core.rs | 454 +++++ libatomic/src/daemon/services.rs | 1614 ++++++++++++++++ libatomic/src/daemon/services_agent.rs | 1675 +++++++++++++++++ libatomic/src/daemon/services_maintenance.rs | 166 ++ libatomic/src/daemon/services_provenance.rs | 598 ++++++ libatomic/src/daemon/services_query.rs | 762 ++++++++ libatomic/src/daemon/state.rs | 302 +++ libatomic/src/lib.rs | 36 +- 18 files changed, 6650 insertions(+), 16 deletions(-) create mode 100644 atomic-repository/src/repository/revise.rs create mode 100644 atomic-repository/src/repository/stash.rs create mode 100644 libatomic/src/daemon/convert.rs create mode 100644 libatomic/src/daemon/journal_sink.rs create mode 100644 libatomic/src/daemon/mod.rs create mode 100644 libatomic/src/daemon/provenance_core.rs create mode 100644 libatomic/src/daemon/services.rs create mode 100644 libatomic/src/daemon/services_agent.rs create mode 100644 libatomic/src/daemon/services_maintenance.rs create mode 100644 libatomic/src/daemon/services_provenance.rs create mode 100644 libatomic/src/daemon/services_query.rs create mode 100644 libatomic/src/daemon/state.rs diff --git a/Cargo.lock b/Cargo.lock index 44031dad..de0a6941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1857,11 +1857,26 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" name = "libatomic" version = "0.19.1" dependencies = [ + "atomic-agent", + "atomic-canonical", + "atomic-core", + "atomic-identity", + "atomic-repository", + "atomic-semantic", + "blake3", + "chrono", + "data-encoding", + "futures-core", "prost", "prost-types", "protox", + "serde_json", + "similar", + "tokio", + "tokio-stream", "tonic", "tonic-build", + "ulid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 660cc271..fd2ece5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,8 @@ prost = "0.13" prost-types = "0.13" tonic-build = "0.12" protox = "0.7" +tokio-stream = "0.1" +futures-core = "0.3" # Serialization serde = { version = "1.0", features = ["derive"] } diff --git a/atomic-repository/src/repository/mod.rs b/atomic-repository/src/repository/mod.rs index a5b494e0..796e916d 100644 --- a/atomic-repository/src/repository/mod.rs +++ b/atomic-repository/src/repository/mod.rs @@ -81,9 +81,11 @@ mod database; mod deferred_tree; mod filter; mod materialize; +mod revise; mod sandbox; mod semantic_materialize; mod split; +mod stash; mod switch; mod views; @@ -97,8 +99,10 @@ pub use filter::{ collect_view_change_ids, collect_visible_change_ids, collect_visible_change_ids_with_deps, expand_indexed_dependency_closure, view_set_id, }; +pub use revise::RewordOutcome; pub use sandbox::{SealOptions, SealResult, StageOptions, StageResult, SANDBOX_POINTER}; pub use split::{SplitChange, SplitOptions, SplitOutcome}; +pub use stash::{StashEntry, StashPushOptions, DEFAULT_STASH_MESSAGE, STASH_PREFIX}; pub use views::{ManifestApplyOutcome, ViewInfo}; // Re-import workspace helpers from `switch` so they are available to @@ -144,6 +148,7 @@ pub use vault_identity::VaultIdentity; pub use vault_intent::{ IntentCreateOptions, IntentCreateResult, IntentDeleteResult, IntentInfo, IntentUpdateOptions, }; +pub use vault_intent::{FEATURE_SCAFFOLD, REVIEW_SCAFFOLD}; pub use vault_kg_enrich::KgEnrichStats; pub use vault_names::{derive_intent_prefix, generate_goal_name}; pub use verify::{VerifyProblem, VerifyReport}; diff --git a/atomic-repository/src/repository/revise.rs b/atomic-repository/src/repository/revise.rs new file mode 100644 index 00000000..b9c80176 --- /dev/null +++ b/atomic-repository/src/repository/revise.rs @@ -0,0 +1,135 @@ +//! Revise: reword a recorded change in place on the view stack. +//! +//! The reword form of `atomic revise` is a stack surgery: unrecord from +//! the top of the current view down to (and including) the target, +//! rebuild the change with a new header (message, optional author) while +//! keeping the EXACT original hunks/file-ops/contents/deps, sign it with +//! the default identity (reword bypasses the record pipeline, so the +//! signature is the revised change's provenance), insert it, and re-apply +//! the pending changes above the target in source order. +//! +//! This module is the ONE code path for the reword flow: the CLI command +//! and the daemon's Revise RPC both call it (the shared-core pattern from +//! `provenance_core`). The content-modification form of revise — +//! re-capturing the working copy through an interactive editor — is +//! client-side orchestration and stays out of the domain. + +use atomic_core::change::{Author, Change}; +use atomic_core::types::{Base32, Hash}; + +use crate::Repository; + +/// The outcome of a reword. +#[derive(Debug, Clone)] +pub struct RewordOutcome { + /// The new change hash (new header ⇒ new hash). + pub new_hash: Hash, + /// The changes re-applied above the target, in source order. + pub reinserted: Vec, +} + +impl Repository { + /// Reword one recorded change on the current view: same hunks, new + /// header (message, optional author), signed with the default + /// identity, with the pending changes above it re-applied. + pub fn reword_change( + &mut self, + target: &Hash, + message: &str, + author: Option, + ) -> Result { + // Resolve the target's sequence and the pending changes above it + // on the current view. + let history = self.log(crate::HistoryOptions::default())?; + let Some(entry) = history.iter().find(|e| &e.hash == target) else { + return Err(crate::RepositoryError::ChangeNotFound { + hash: target.to_base32(), + }); + }; + let sequence = entry.sequence; + + let stack_info = self.get_view_info(&self.current_view)?; + let changes_to_unrecord = stack_info.change_count as usize - sequence as usize; + + // Pending changes (above the target), oldest first for re-apply. + let mut pending: Vec<(u64, Hash)> = history + .into_iter() + .filter(|e| e.sequence > sequence) + .map(|e| (e.sequence, e.hash)) + .collect(); + pending.sort_by_key(|(sequence, _)| *sequence); + let pending = pending + .into_iter() + .map(|(_, hash)| hash) + .collect::>(); + + // Step 1: unrecord from the top down to (and including) the target. + for _ in 0..changes_to_unrecord { + self.unrecord_last(crate::UnrecordOptions::default())?; + } + + // Step 2: rebuild the change — same hunks/file-ops/contents/deps, + // new header. + let original = self.load_change(target)?; + let mut new_header = original.hashed.header.clone(); + new_header.message = message.to_string(); + if let Some(author) = author { + new_header.authors = vec![author]; + } + let mut new_change = Change::with_file_ops( + new_header, + original.hashed.hunks.clone(), + original.hashed.file_ops.clone(), + original.contents.clone(), + original.hashed.dependencies.clone(), + ); + + // Sign with the default identity — the CLI's exact block: a + // revised change is signed like any new record. + if let Ok(store) = atomic_identity::IdentityStore::open_default() { + let signing_identity = store.get_default().ok().flatten(); + if let Some(identity) = signing_identity { + if let Ok(keypair) = store.load_keypair(&identity.id, None) { + let _ = new_change.sign_with( + &atomic_canonical::did::did_for_public_key(&identity.public_key), + keypair.secret.as_bytes(), + chrono::Utc::now().timestamp(), + ); + } + } + } + + // Step 3: save and insert the reworded change. + let new_hash = match self + .save_change(&new_change) + .and_then(|hash| self.insert_change(&hash, Default::default()).map(|_| hash)) + { + Ok(hash) => hash, + Err(error) => { + // Rollback: re-apply everything unrecorded, newest first, + // and surface the error (never a silent partial stack). + for hash in pending.iter().rev() { + let _ = self.reinsert_change(hash, None); + } + return Err(error); + } + }; + + // Step 4: re-apply the pending changes in source order. + let mut reinserted = Vec::with_capacity(pending.len()); + for hash in &pending { + match self.reinsert_change(hash, None) { + Ok(_) => reinserted.push(*hash), + Err(error) => { + // Surface the failure; the stack below is intact. + return Err(error); + } + } + } + + Ok(RewordOutcome { + new_hash, + reinserted, + }) + } +} diff --git a/atomic-repository/src/repository/stash.rs b/atomic-repository/src/repository/stash.rs new file mode 100644 index 00000000..042beeef --- /dev/null +++ b/atomic-repository/src/repository/stash.rs @@ -0,0 +1,321 @@ +//! Stash: temporary storage of uncommitted working-copy changes. +//! +//! Stashes are **orphan views** paired with a raw-bytes sidecar under +//! `.atomic/stashes//`. The sidecar holds the exact bytes of +//! every stashed file plus a `MANIFEST` listing them; applying a stash is +//! a pure filesystem copy back onto the working copy — completely immune +//! to graph mutations between push and pop. +//! +//! This module is the ONE code path for the stash flow: the CLI command +//! and the daemon's stash RPCs both call it (the shared-core pattern from +//! `provenance_core`). It never goes through the graph/record/apply +//! machinery. +//! +//! Stash view names encode their metadata: +//! `stash/{safe_source}_{timestamp_ms}_{safe_message}` — `list` parses +//! them back out. + +use chrono::{DateTime, Utc}; + +use crate::Repository; + +/// Prefix for stash view names. +pub const STASH_PREFIX: &str = "stash/"; + +/// Default message for stashes without a custom message. +pub const DEFAULT_STASH_MESSAGE: &str = "WIP"; + +/// One stash: an orphan view plus its parsed metadata. +#[derive(Debug, Clone)] +pub struct StashEntry { + /// Index in the newest-first listing (`stash@{index}`). + pub index: usize, + /// The full stash view name (the wire's stash id). + pub view_name: String, + /// The view the stash was pushed from. + pub source_view: String, + /// The stash message. + pub message: String, + /// Creation time (parsed from the name, UTC). + pub created_at: DateTime, +} + +impl StashEntry { + /// The display reference: `stash@{index}`. + pub fn reference(&self) -> String { + format!("stash@{{{}}}", self.index) + } + + /// Format the stash entry for display: + /// `stash@{0}: On dev: Fix authentication bug`. + pub fn display(&self) -> String { + format!( + "{}: On {}: {}", + self.reference(), + self.source_view, + self.message + ) + } +} + +impl std::fmt::Display for StashEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.display()) + } +} + +/// Options for a stash push. +#[derive(Debug, Clone, Default)] +pub struct StashPushOptions { + /// Message describing the stashed changes. + pub message: Option, + /// Include untracked files in the stash. + pub include_untracked: bool, + /// Keep the changes in the working copy after stashing. + pub keep: bool, +} + +impl Repository { + /// Push a new stash from the dirty working copy. Returns `Ok(None)` + /// when the working copy is clean (never an error). + pub fn stash_push(&mut self, options: StashPushOptions) -> Result, String> { + use crate::status::StatusOptions; + let status = self + .status(StatusOptions::default()) + .map_err(|e| e.to_string())?; + if status.is_clean() { + return Ok(None); + } + + let source_view = self.current_view().to_string(); + let stash_message = options + .message + .unwrap_or_else(|| DEFAULT_STASH_MESSAGE.to_string()); + + // Stash view name with embedded metadata (verbatim from the CLI). + let timestamp = Utc::now().timestamp_millis(); + let safe_source = source_view.replace('/', "-"); + let safe_message = stash_message + .chars() + .take(30) + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == ' ') + .collect::() + .replace(' ', "-"); + let stash_name = format!( + "{}{}_{}_{}", + STASH_PREFIX, safe_source, timestamp, safe_message + ); + + self.create_view(&stash_name).map_err(|e| e.to_string())?; + + // Save the exact bytes of every dirty file to the sidecar dir — + // a pure working-copy snapshot, no graph interaction. + let stash_dir = self.dot_dir().join("stashes").join(&stash_name); + std::fs::create_dir_all(&stash_dir) + .map_err(|e| format!("failed to create stash dir: {e}"))?; + + let mut stashed_paths: Vec = Vec::new(); + let snapshot_path = |p: String| -> bool { + let abs = self.root().join(&p); + if abs.is_file() { + let rel_dir = stash_dir.join( + std::path::Path::new(&p) + .parent() + .unwrap_or(std::path::Path::new("")), + ); + let _ = std::fs::create_dir_all(&rel_dir); + let _ = std::fs::copy(&abs, stash_dir.join(&p)); + true + } else { + false + } + }; + + for entry in status.modified() { + let p = entry.path().to_string_lossy().to_string(); + if snapshot_path(p) { + stashed_paths.push(entry.path().to_string_lossy().to_string()); + } + } + for entry in status.added() { + let p = entry.path().to_string_lossy().to_string(); + if snapshot_path(p) { + stashed_paths.push(entry.path().to_string_lossy().to_string()); + } + } + if options.include_untracked { + for entry in status.untracked() { + let p = entry.path().to_string_lossy().to_string(); + if snapshot_path(p) { + stashed_paths.push(entry.path().to_string_lossy().to_string()); + } + } + } + + // The manifest records which files were stashed. + std::fs::write(stash_dir.join("MANIFEST"), stashed_paths.join("\n")) + .map_err(|e| format!("failed to write stash manifest: {e}"))?; + + // Restore the working copy to a clean state (unless keep). + if !options.keep { + self.materialize().map_err(|e| e.to_string())?; + } + + Ok(Some(StashEntry { + index: 0, + view_name: stash_name, + source_view, + message: stash_message, + created_at: Utc::now(), + })) + } + + /// List all stashes, newest first, with display indices assigned. + pub fn stash_list(&self) -> Result, String> { + let views = self.list_views().map_err(|e| e.to_string())?; + + let mut stashes: Vec = views + .into_iter() + .filter(|name| name.starts_with(STASH_PREFIX)) + .map(|name| { + // Parse stash metadata from the view name (verbatim from + // the CLI): stash/{source}_{timestamp}_{message}. + let parts: Vec<&str> = name + .trim_start_matches(STASH_PREFIX) + .splitn(3, '_') + .collect(); + + let (source_view, message, timestamp) = if parts.len() >= 2 { + let ts: i64 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); + let src = if parts[0] == "auto" { + "unknown".to_string() + } else { + parts[0].to_string() + }; + let msg = parts + .get(2) + .map(|s| s.to_string()) + .unwrap_or_else(|| DEFAULT_STASH_MESSAGE.to_string()); + (src, msg, ts) + } else { + ("unknown".to_string(), DEFAULT_STASH_MESSAGE.to_string(), 0) + }; + + let created_at = + DateTime::from_timestamp_millis(timestamp).unwrap_or_else(Utc::now); + + StashEntry { + index: 0, + view_name: name, + source_view, + message, + created_at, + } + }) + .collect(); + + // Sort by creation time, newest first, and assign indices. + stashes.sort_by_key(|s| std::cmp::Reverse(s.created_at)); + for (i, stash) in stashes.iter_mut().enumerate() { + stash.index = i; + } + Ok(stashes) + } + + /// Resolve a stash reference (`stash@{N}`, `N`, or a view name) to + /// its entry. `None` resolves to the most recent stash. + pub fn stash_resolve(&self, reference: Option<&str>) -> Result { + let stashes = self.stash_list()?; + if stashes.is_empty() { + return Err("No stashes found".to_string()); + } + + let reference = reference.unwrap_or("0"); + let index = if reference.starts_with("stash@{") && reference.ends_with('}') { + reference[7..reference.len() - 1].parse::().ok() + } else { + reference.parse::().ok() + }; + if let Some(idx) = index { + return stashes + .into_iter() + .find(|s| s.index == idx) + .ok_or_else(|| format!("stash@{{{idx}}} does not exist")); + } + + let full_name = if reference.starts_with(STASH_PREFIX) { + reference.to_string() + } else { + format!("{STASH_PREFIX}{reference}") + }; + stashes + .into_iter() + .find(|s| s.view_name == full_name) + .ok_or_else(|| format!("stash '{reference}' not found")) + } + + /// Apply a stash to the working copy: copy the sidecar bytes back. + /// Pure filesystem operation — no graph interaction. Returns the + /// applied paths. + pub fn stash_apply( + &self, + reference: Option<&str>, + ) -> Result<(StashEntry, Vec), String> { + let stash = self.stash_resolve(reference)?; + let stash_dir = self.dot_dir().join("stashes").join(&stash.view_name); + let manifest_path = stash_dir.join("MANIFEST"); + if !manifest_path.exists() { + return Err(format!( + "stash sidecar not found: {}", + manifest_path.display() + )); + } + let manifest = std::fs::read_to_string(&manifest_path) + .map_err(|e| format!("failed to read stash manifest: {e}"))?; + + let mut applied: Vec = Vec::new(); + for line in manifest.lines() { + let path = line.trim(); + if path.is_empty() { + continue; + } + let src = stash_dir.join(path); + let dst = self.root().join(path); + if src.is_file() { + if let Some(parent) = dst.parent() { + let _ = std::fs::create_dir_all(parent); + } + if std::fs::copy(&src, &dst).is_ok() { + applied.push(path.to_string()); + } + } + } + Ok((stash, applied)) + } + + /// Drop one stash (sidecar + view). Returns the dropped entry. + pub fn stash_drop(&mut self, reference: Option<&str>) -> Result { + let stash = self.stash_resolve(reference)?; + let stash_dir = self.dot_dir().join("stashes").join(&stash.view_name); + if stash_dir.exists() { + let _ = std::fs::remove_dir_all(&stash_dir); + } + self.delete_view(&stash.view_name) + .map_err(|e| e.to_string())?; + Ok(stash) + } + + /// Drop every stash. Returns the dropped count. + pub fn stash_clear(&mut self) -> Result { + let stashes = self.stash_list()?; + for stash in &stashes { + let stash_dir = self.dot_dir().join("stashes").join(&stash.view_name); + if stash_dir.exists() { + let _ = std::fs::remove_dir_all(&stash_dir); + } + self.delete_view(&stash.view_name) + .map_err(|e| e.to_string())?; + } + Ok(stashes.len()) + } +} diff --git a/atomic-repository/src/repository/vault_intent.rs b/atomic-repository/src/repository/vault_intent.rs index dc6abf54..47a1bf9c 100644 --- a/atomic-repository/src/repository/vault_intent.rs +++ b/atomic-repository/src/repository/vault_intent.rs @@ -1335,6 +1335,82 @@ fn remove_empty_dirs_up_to( Ok(()) } +// --------------------------------------------------------------------------- +// Directive scaffolds (shared with the daemon's CreateVaultEntity path) +// --------------------------------------------------------------------------- + +pub const FEATURE_SCAFFOLD: &str = "\ +:::why + +::: + +:::acceptance-criterion{#{id}-ac-1 status=unmet} + +::: + +:::task{#{id}-1 status=open criteria={id}-ac-1} + +::file-ref{path=path/to/file} +::: + +:::scope-in + +::: + +:::scope-out + +::: + +:::constraint + +::: +"; + +/// The directive scaffold emitted for a `--review ` intent. +/// +/// Identical in shape to [`FEATURE_SCAFFOLD`] but carries a +/// `:::ref{to= edge=reviews}` leaf. The gate's `ReviewShape` couples the +/// `review` kind to the `reviews` edge (kind==review ⟺ exactly-a-reviews-edge), +/// so seeding the ref here makes a `--review` intent conform out of the box. The +/// acceptance-criterion stub is phrased as a review verdict. +/// +/// `{id}` is replaced with the intent's ULID (child-id namespacing) and +/// `{target}` with the reviewed intent reference. +pub const REVIEW_SCAFFOLD: &str = "\ +:::why + +::: + +:::acceptance-criterion{#{id}-ac-1 status=unmet} + +::: + +:::task{#{id}-1 status=open criteria={id}-ac-1} + +::file-ref{path=path/to/file} +::: + +:::ref{to={target} edge=reviews} +::: + +:::scope-in + +::: + +:::scope-out + +::: + +:::constraint + +::: +"; + #[cfg(test)] mod tests { use super::*; diff --git a/libatomic/Cargo.toml b/libatomic/Cargo.toml index 25224d5c..fdf86b8e 100644 --- a/libatomic/Cargo.toml +++ b/libatomic/Cargo.toml @@ -6,13 +6,35 @@ authors.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true -description = "The canonical Atomic protobuf contract: service definitions, tonic/prost codegen, and the descriptor contract gate" +description = "The canonical Atomic contract: protobuf service definitions, tonic/prost codegen, the descriptor contract gate, and the reference implementation of the contract" [dependencies] +# Domain crates — the handlers execute on these. +atomic-agent = { workspace = true } +atomic-canonical = { workspace = true } +atomic-core = { workspace = true } +atomic-identity = { workspace = true } +atomic-repository = { workspace = true } +atomic-semantic = { workspace = true } + +# gRPC: the generated contract (this crate owns the codegen). tonic = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } +# Async: the handlers are async and offload database work to the blocking pool. +tokio = { workspace = true } +tokio-stream = { workspace = true } +futures-core = { workspace = true } + +# Domain support. +serde_json = { workspace = true } +chrono = { workspace = true } +blake3 = { workspace = true } +data-encoding = { workspace = true } +similar = { workspace = true } +ulid = { workspace = true } + [build-dependencies] tonic-build = { workspace = true } protox = { workspace = true } diff --git a/libatomic/src/daemon/convert.rs b/libatomic/src/daemon/convert.rs new file mode 100644 index 00000000..7254b08a --- /dev/null +++ b/libatomic/src/daemon/convert.rs @@ -0,0 +1,84 @@ +//! Domain ↔ protobuf conversions shared by the daemon handlers. + +use crate::atomic as pb; +use atomic_core::change::Author; +use atomic_core::types::Merkle; + +pub fn hash_proto(hash: &Merkle) -> pb::Hash { + pb::Hash { + value: hash.0.to_vec(), + algorithm: pb::HashAlgorithm::Blake3 as i32, + } +} + +pub fn author_proto(author: &Author) -> pb::Author { + pb::Author { + name: author.name.clone(), + email: author.email.clone(), + } +} + +pub fn timestamp_proto(time: chrono::DateTime) -> prost_types::Timestamp { + prost_types::Timestamp { + seconds: time.timestamp(), + nanos: time.timestamp_subsec_nanos() as i32, + } +} + +pub fn timestamp_domain(stamp: &prost_types::Timestamp) -> chrono::DateTime { + chrono::DateTime::from_timestamp(stamp.seconds, stamp.nanos.max(0) as u32) + .unwrap_or_else(chrono::Utc::now) +} + +pub fn file_status_proto(status: atomic_repository::status::FileStatus) -> pb::FileStatus { + use atomic_repository::status::FileStatus as Domain; + match status { + Domain::Modified => pb::FileStatus::Modified, + Domain::Deleted => pb::FileStatus::Deleted, + Domain::Untracked => pb::FileStatus::Untracked, + Domain::Added => pb::FileStatus::Added, + Domain::Conflicted => pb::FileStatus::Conflicted, + Domain::Clean | Domain::TypeChanged | Domain::PermissionsChanged => { + pb::FileStatus::Unspecified + } + } +} + +/// Intent lifecycle strings (vault_intent_*) ↔ the proto entity status. +pub fn intent_status_to_proto(status: &str) -> Option { + match status { + "backlog" => Some(pb::VaultEntityStatus::Backlog), + "in_progress" => Some(pb::VaultEntityStatus::InProgress), + "needs-review" => Some(pb::VaultEntityStatus::NeedsReview), + "done" | "completed" => Some(pb::VaultEntityStatus::Done), + "planned" => Some(pb::VaultEntityStatus::Planned), + "icebox" => Some(pb::VaultEntityStatus::Suspended), + _ => None, + } +} + +pub fn intent_status_from_proto(status: pb::VaultEntityStatus) -> Option<&'static str> { + match status { + pb::VaultEntityStatus::Backlog => Some("backlog"), + pb::VaultEntityStatus::InProgress => Some("in_progress"), + pb::VaultEntityStatus::NeedsReview => Some("needs-review"), + pb::VaultEntityStatus::Done => Some("done"), + pb::VaultEntityStatus::Planned => Some("planned"), + pb::VaultEntityStatus::Suspended => Some("icebox"), + _ => None, + } +} + +/// The typed agent event (snake_case HookType serde names) → HookType. +pub fn hook_type(name: &str) -> Option { + use atomic_agent::event::HookType; + match name { + "session_start" => Some(HookType::SessionStart), + "session_end" => Some(HookType::SessionEnd), + "turn_start" => Some(HookType::TurnStart), + "turn_end" => Some(HookType::TurnEnd), + "pre_tool_use" => Some(HookType::PreToolUse), + "post_tool_use" => Some(HookType::PostToolUse), + _ => None, + } +} diff --git a/libatomic/src/daemon/journal_sink.rs b/libatomic/src/daemon/journal_sink.rs new file mode 100644 index 00000000..d4d9cc62 --- /dev/null +++ b/libatomic/src/daemon/journal_sink.rs @@ -0,0 +1,355 @@ +//! The daemon's in-process provenance journal sink. +//! +//! The turn orchestrator (hosted by `DispatchTurnEvent`) journals through +//! the [`ProvenanceJournalSink`] trait. In the daemon the changes.redb +//! store is already open in THIS process — the sink writes to it directly +//! instead of speaking the dismantled legacy owner protocol over a socket +//! to itself. The semantics are exactly the legacy owner's request +//! handlers: generation fencing, request-ID echo and caller-supplied `now` +//! live in the store layer itself. + +use std::sync::Arc; + +use atomic_agent::{ + JournalAppendAck, JournalCheckpointAttempt, JournalCheckpointSource, JournalStopCause, + JournalTurnLifecycle, JournalTurnReservation, JournalTurnStatus, ProvenanceJournalEnvelope, + ProvenanceJournalSink, +}; +use atomic_core::change::session::SessionTurn; +use atomic_core::types::Hash; +use atomic_repository::redb_change_store::{ + FrozenProvenanceCursor, FrozenProvenancePage, ProvenanceCheckpointAttempt as StoreAttempt, + ProvenanceCheckpointSource, ProvenanceId, StopCause, StopState, StoredProvenanceTurn, +}; +use atomic_repository::Repository; + +pub struct DirectJournalSink { + store: Arc, +} + +impl DirectJournalSink { + pub fn open(root: &std::path::Path) -> Result { + let path = + Repository::canonical_change_store_path(root).map_err(|error| error.to_string())?; + let store = atomic_repository::redb_change_store::RedbChangeStore::open(&path) + .map_err(|error| format!("failed to open {}: {error}", path.display()))?; + Ok(Self { + store: Arc::new(store), + }) + } +} + +fn to_store_stop_cause(cause: JournalStopCause) -> StopCause { + match cause { + JournalStopCause::UserRequested => StopCause::UserRequested, + JournalStopCause::LeaseExpired => StopCause::LeaseExpired, + JournalStopCause::ProcessExited => StopCause::ProcessExited, + JournalStopCause::HookFailure => StopCause::HookFailure, + JournalStopCause::SystemShutdown => StopCause::SystemShutdown, + JournalStopCause::Abandoned => StopCause::Abandoned, + } +} + +fn to_journal_stop_cause(cause: StopCause) -> JournalStopCause { + match cause { + StopCause::UserRequested => JournalStopCause::UserRequested, + StopCause::LeaseExpired => JournalStopCause::LeaseExpired, + StopCause::ProcessExited => JournalStopCause::ProcessExited, + StopCause::HookFailure => JournalStopCause::HookFailure, + StopCause::SystemShutdown => JournalStopCause::SystemShutdown, + StopCause::Abandoned => JournalStopCause::Abandoned, + } +} + +fn to_journal_turn_status(turn: StoredProvenanceTurn) -> JournalTurnStatus { + let lifecycle = match turn.state { + atomic_repository::redb_change_store::ProvenanceTurnState::Running => { + JournalTurnLifecycle::Running + } + atomic_repository::redb_change_store::ProvenanceTurnState::Stopped(stop) => { + JournalTurnLifecycle::Stopped { + cause: to_journal_stop_cause(stop.cause), + observed_at: stop.observed_at, + last_event_seq: stop.last_event_seq, + resumable: stop.resumable, + } + } + atomic_repository::redb_change_store::ProvenanceTurnState::Checkpointing => { + JournalTurnLifecycle::Checkpointing + } + atomic_repository::redb_change_store::ProvenanceTurnState::Completed => { + JournalTurnLifecycle::Completed + } + atomic_repository::redb_change_store::ProvenanceTurnState::Abandoned(stop) => { + JournalTurnLifecycle::Abandoned { + observed_at: stop.observed_at, + last_event_seq: stop.last_event_seq, + } + } + }; + JournalTurnStatus { + provenance_id: turn.provenance_id.get(), + generation: turn.generation, + lifecycle, + } +} + +fn to_store_checkpoint_source(source: JournalCheckpointSource) -> ProvenanceCheckpointSource { + ProvenanceCheckpointSource { + agent_name: source.agent_name, + agent_display_name: source.agent_display_name, + agent_vendor: source.agent_vendor, + change_hashes: source.change_hashes, + previous_provenance: source.previous_provenance, + plan_id: source.plan_id, + ledger_turn_number: source.ledger_turn_number, + } +} + +fn to_journal_checkpoint_attempt( + provenance_id: u64, + attempt: StoreAttempt, +) -> JournalCheckpointAttempt { + JournalCheckpointAttempt { + provenance_id, + attempt_generation: attempt.attempt_generation, + frozen_event_count: attempt.frozen_event_count, + source: JournalCheckpointSource { + agent_name: attempt.source.agent_name, + agent_display_name: attempt.source.agent_display_name, + agent_vendor: attempt.source.agent_vendor, + change_hashes: attempt.source.change_hashes, + previous_provenance: attempt.source.previous_provenance, + plan_id: attempt.source.plan_id, + ledger_turn_number: attempt.source.ledger_turn_number, + }, + provenance_hash: attempt.provenance_hash, + session_turn: attempt.session_turn, + manifest_hash: attempt.manifest_hash, + } +} + +impl ProvenanceJournalSink for DirectJournalSink { + fn reserve_turn( + &self, + session_id: &str, + turn_number: u32, + now: i64, + ) -> Result { + let turn = self + .store + .reserve_provenance_turn(session_id, turn_number, now) + .map_err(|error| error.to_string())?; + Ok(JournalTurnReservation { + provenance_id: turn.provenance_id.get(), + generation: turn.generation, + }) + } + + fn append( + &self, + reservation: JournalTurnReservation, + envelopes: Vec, + now: i64, + ) -> Result, String> { + let batch: Vec<(String, Vec)> = envelopes + .iter() + .map(|envelope| { + Ok(( + envelope.event_id.clone(), + envelope.to_json_bytes().map_err(|e| e.to_string())?, + )) + }) + .collect::>()?; + let refs: Vec<(&str, &[u8])> = batch + .iter() + .map(|(id, bytes)| (id.as_str(), bytes.as_slice())) + .collect(); + let stored = self + .store + .append_provenance_envelopes( + ProvenanceId::new(reservation.provenance_id), + reservation.generation, + &refs, + now, + ) + .map_err(|error| error.to_string())?; + Ok(stored + .into_iter() + .map(|event| JournalAppendAck { + event_id: event.event_id, + sequence: event.seq, + }) + .collect()) + } + + fn prepare_checkpoint( + &self, + reservation: JournalTurnReservation, + source: JournalCheckpointSource, + now: i64, + ) -> Result { + let attempt = self + .store + .prepare_provenance_checkpoint( + ProvenanceId::new(reservation.provenance_id), + reservation.generation, + to_store_checkpoint_source(source), + now, + ) + .map_err(|error| error.to_string())?; + Ok(to_journal_checkpoint_attempt( + reservation.provenance_id, + attempt, + )) + } + + fn load_frozen_envelopes( + &self, + checkpoint: &JournalCheckpointAttempt, + ) -> Result>, String> { + // The daemon is in-process with the store: no frame budgets, no + // paging — one direct read. + let stored = self + .store + .load_frozen_provenance_envelopes(ProvenanceId::new(checkpoint.provenance_id)) + .map_err(|error| error.to_string())?; + Ok(stored.into_iter().map(|entry| entry.envelope).collect()) + } + + fn bind_checkpoint_hash( + &self, + checkpoint: &JournalCheckpointAttempt, + hash: Hash, + session_turn: SessionTurn, + now: i64, + ) -> Result { + let attempt = self + .store + .bind_provenance_checkpoint_hash( + ProvenanceId::new(checkpoint.provenance_id), + checkpoint.attempt_generation, + hash, + session_turn, + now, + ) + .map_err(|error| error.to_string())?; + Ok(to_journal_checkpoint_attempt( + checkpoint.provenance_id, + attempt, + )) + } + + fn acknowledge_checkpoint( + &self, + checkpoint: &JournalCheckpointAttempt, + manifest_hash: Hash, + completed_at: i64, + ) -> Result<(), String> { + self.store + .acknowledge_provenance_checkpoint( + ProvenanceId::new(checkpoint.provenance_id), + checkpoint.attempt_generation, + manifest_hash, + completed_at, + ) + .map_err(|error| error.to_string())?; + Ok(()) + } + + fn stop_turn( + &self, + session_id: &str, + turn_number: u32, + cause: JournalStopCause, + resumable: bool, + observed_at: i64, + ) -> Result, String> { + let turn = self + .store + .get_provenance_turn_for(session_id, turn_number) + .map_err(|error| error.to_string())?; + let Some(turn) = turn else { return Ok(None) }; + let stopped = self + .store + .stop_provenance_turn( + turn.provenance_id, + turn.generation, + StopState { + cause: to_store_stop_cause(cause), + observed_at, + last_event_seq: None, + resumable, + }, + ) + .map_err(|error| error.to_string())?; + Ok(Some(to_journal_turn_status(stopped))) + } + + fn resume_turn( + &self, + session_id: &str, + turn_number: u32, + now: i64, + ) -> Result, String> { + let turn = self + .store + .get_provenance_turn_for(session_id, turn_number) + .map_err(|error| error.to_string())?; + let Some(turn) = turn else { return Ok(None) }; + let resumed = self + .store + .resume_provenance_turn(turn.provenance_id, turn.generation, now) + .map_err(|error| error.to_string())?; + Ok(Some(to_journal_turn_status(resumed))) + } + + fn abandon_turn( + &self, + session_id: &str, + turn_number: u32, + observed_at: i64, + ) -> Result, String> { + let turn = self + .store + .get_provenance_turn_for(session_id, turn_number) + .map_err(|error| error.to_string())?; + let Some(turn) = turn else { return Ok(None) }; + let abandoned = self + .store + .abandon_provenance_turn( + turn.provenance_id, + turn.generation, + StopState { + cause: StopCause::Abandoned, + observed_at, + last_event_seq: None, + resumable: false, + }, + ) + .map_err(|error| error.to_string())?; + Ok(Some(to_journal_turn_status(abandoned))) + } + + fn turn_status( + &self, + session_id: &str, + turn_number: u32, + ) -> Result, String> { + let turn = self + .store + .get_provenance_turn_for(session_id, turn_number) + .map_err(|error| error.to_string())?; + Ok(turn.map(to_journal_turn_status)) + } +} + +// The frozen-page machinery stays ported (unused in-process today, but the +// daemon keeps the seam for the hosted/remote surfaces). +#[allow(dead_code)] +fn _frozen_page_seam(page: FrozenProvenancePage) -> (Vec, usize) { + ( + page.fragments.iter().map(|f| f.cursor).collect(), + page.fragments.len(), + ) +} diff --git a/libatomic/src/daemon/mod.rs b/libatomic/src/daemon/mod.rs new file mode 100644 index 00000000..3d68cc80 --- /dev/null +++ b/libatomic/src/daemon/mod.rs @@ -0,0 +1,42 @@ +//! The service-layer handlers: the domain half of the daemon. +//! +//! ONE implementation, any number of callers: a transport process +//! (e.g. `atomicd`) serves these handlers over a socket through the +//! generated tonic servers, and any consumer may call the very same +//! handlers in-process — there is no second copy of service-layer +//! logic anywhere. +//! +//! One state ([`state::DaemonState`]) multiplexes many repositories +//! (RFC D3): each resolved root is registered under its persistent ID +//! and opened lazily, exactly once per request. redb permits one +//! writable process per database file; each RPC acquires the +//! repository's serialization gate, opens the database, runs, and drops +//! the handle (the in-process local mode relies on the same +//! single-writer invariant: no concurrent daemon on the same machine). +//! +//! Module layout: +//! - [`state`] — the repository registry, per-repo gates, error mapping. +//! - [`services`] — DaemonService + RepositoryQuery/Mutation handlers. +//! - [`services_agent`] — Vault / Attestation / Knowledge / Provenance service impls. +//! - [`services_provenance`] — the ProvenanceService journal-port wire handlers. +//! - [`services_query`] — the heavy query handlers + ViewService. +//! - [`services_maintenance`] — MaintenanceService (doctor). +//! - [`provenance_core`] — the shared provenance journal core. +//! - [`journal_sink`] — the in-process provenance sink (DirectJournalSink). +//! - [`convert`] — domain ↔ protobuf converters. + +// The handlers use tonic's `Status` as their error type — the tonic +// server-trait convention. `Status` sits above clippy's default +// large-Err budget; boxing it through every call site would churn the +// handlers for no gain (per-request handling makes the size a non-issue). +#![allow(clippy::result_large_err)] + +pub mod convert; +pub mod journal_sink; +pub mod provenance_core; +pub mod services; +pub mod services_agent; +pub mod services_maintenance; +pub mod services_provenance; +pub mod services_query; +pub mod state; diff --git a/libatomic/src/daemon/provenance_core.rs b/libatomic/src/daemon/provenance_core.rs new file mode 100644 index 00000000..18391079 --- /dev/null +++ b/libatomic/src/daemon/provenance_core.rs @@ -0,0 +1,454 @@ +//! The provenance journal core — one code path for the in-process dispatch +//! and the wire. +//! +//! Every store operation the legacy owner protocol served lives here +//! exactly once: the RPC handlers in +//! `services_provenance.rs` and the in-process `DirectJournalSink` are thin +//! wrappers over these functions, so the two surfaces cannot drift. +//! +//! # Legacy semantics preserved verbatim +//! +//! * Generation fencing on every mutation (`expected_generation`); the +//! store's fencing error text ("generation is stale") surfaces verbatim. +//! * Caller-supplied `now` — observation times arrive on the wire, never +//! from server clocks. +//! * Append idempotency by event ID — a retried batch re-acknowledges the +//! originally stored sequences instead of duplicating events. +//! * PrepareCheckpoint's `reuse_frozen_changes` recovery: a previous Stop +//! may have recorded the change before its journal read failed; the retry +//! sees a clean working copy. Only the frozen hashes are recovered, then +//! the store's own source validation still applies — a new nonempty +//! change set must conflict. +//! * Frozen-envelope paging with strict client-side continuity checks +//! (`append_frozen_page`): page cursors must strictly advance, fragment +//! cursors must continue the previous page exactly, and a retried RPC +//! can never append its bytes twice. +//! * The page budget math from the legacy frame budget: reserve the +//! worst-case metadata and (for the transport encoding) four payload +//! bytes per budget byte, then clamp to the 1 MiB page size. The +//! "frame" here is the encoded protobuf message plus the gRPC length +//! prefix; the empty-response overhead is measured, not guessed. +//! +//! # Crash-injection failpoints +//! +//! The five named failpoints sit at EXACTLY the same seams as the legacy +//! owner: before-envelope-commit, after-envelope-commit, +//! after-checkpoint-prepare, before-frozen-page-continuation, +//! after-checkpoint-bind — plus the frozen-page-unavailable injected-error +//! variant in the page loader. The environment names +//! (`ATOMIC_OWNER_FAILPOINT`, `ATOMIC_OWNER_FAILPOINT_MARKER`) and the +//! abort semantics are kept IDENTICAL to the dismantled owner so the +//! existing crash-injection test suite keeps working against the daemon; +//! renaming them would break parity with the legacy tests that spawn +//! owners with these env vars. + +use std::fs::OpenOptions; + +use crate::atomic::ErrorCode; +use atomic_repository::redb_change_store::{ + FrozenProvenanceCursor, FrozenProvenancePage, ProvenanceCheckpointAttempt, + ProvenanceCheckpointSource, ProvenanceId, ProvenanceTurnState, RedbChangeStore, StopCause, + StopState, StoredProvenanceEnvelope, StoredProvenanceTurn, MAX_FROZEN_PAGE_FRAGMENTS, +}; +use prost::Message; + +/// The ceiling a complete frozen page must stay under once encoded. Kept +/// from the legacy owner's frame limit (the journal contract's +/// `Limits.max_message_bytes`); the budget math below divides by four to +/// reserve worst-case transport expansion. +pub const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; + +/// The frozen page payload budget (the journal contract's +/// `Limits.frozen_page_bytes`). +pub const FROZEN_PAGE_BYTES: usize = 1024 * 1024; + +/// Worst-case per-fragment metadata reserve (including 64-bit cursors), +/// plus the next cursor. Kept from the legacy owner even though protobuf +/// fragments are cheaper to encode — parity over penny-shaving. +pub const FROZEN_PAGE_METADATA_BYTES: usize = 256 * MAX_FROZEN_PAGE_FRAGMENTS + 256; + +/// A store failure classified with the journal contract's error code. +pub struct JournalError { + pub code: ErrorCode, + pub message: String, +} + +impl JournalError { + fn store(code: ErrorCode, error: impl std::fmt::Display) -> Self { + Self { + code, + message: error.to_string(), + } + } +} + +/// The crash-injection seam, ported VERBATIM from the legacy owner +/// (`owner_failpoint`). The env names are load-bearing: the +/// legacy test suite spawns owners (now daemons) with them and asserts +/// the process aborts and the retry converges. +pub fn owner_failpoint(name: &str) { + if std::env::var("ATOMIC_OWNER_FAILPOINT").ok().as_deref() != Some(name) { + return; + } + let should_trip = match std::env::var_os("ATOMIC_OWNER_FAILPOINT_MARKER") { + Some(path) => OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .is_ok(), + None => true, + }; + if should_trip { + std::process::abort(); + } +} + +/// Reserve or retrieve the stable identity for a session turn. The first +/// reserve claims the turn; a committed reservation returns only after the +/// store's transaction commits. +pub fn reserve_turn( + store: &RedbChangeStore, + session_id: &str, + turn_number: u32, + now: i64, +) -> Result { + store + .reserve_provenance_turn(session_id, turn_number, now) + .map_err(|error| JournalError::store(ErrorCode::ProvenanceStore, error)) +} + +/// Append one durable envelope batch with generation fencing. Duplicate +/// event IDs re-acknowledge their original sequences; a committed batch +/// returns only after the transaction commits. +pub fn append_envelopes( + store: &RedbChangeStore, + id: ProvenanceId, + expected_generation: u64, + batch: &[(&str, &[u8])], + now: i64, +) -> Result, JournalError> { + owner_failpoint("before-envelope-commit"); + let stored = store + .append_provenance_envelopes(id, expected_generation, batch, now) + .map_err(|error| JournalError::store(ErrorCode::ProvenanceStore, error))?; + owner_failpoint("after-envelope-commit"); + Ok(stored) +} + +/// Freeze the journal frontier and persist one recoverable checkpoint +/// attempt, with the legacy `reuse_frozen_changes` recovery: a previous +/// Stop may have recorded the change before its journal read failed. The +/// retry sees a clean working copy; recover only those frozen hashes, then +/// retain the store's source validation — a new nonempty change set must +/// still conflict. +pub fn prepare_checkpoint( + store: &RedbChangeStore, + id: ProvenanceId, + expected_generation: u64, + mut source: ProvenanceCheckpointSource, + reuse_frozen_changes: bool, + now: i64, +) -> Result { + let attempt = (|| { + if reuse_frozen_changes && source.change_hashes.is_empty() { + if let Some(turn) = store.get_provenance_turn(id)? { + if turn.generation == expected_generation + && matches!(turn.state, ProvenanceTurnState::Checkpointing) + { + if let Some(attempt) = turn.checkpoint_attempt { + source.change_hashes = attempt.source.change_hashes; + } + } + } + } + store.prepare_provenance_checkpoint(id, expected_generation, source, now) + })() + .map_err(|error| JournalError::store(ErrorCode::ProvenanceCheckpoint, error))?; + owner_failpoint("after-checkpoint-prepare"); + Ok(attempt) +} + +/// The encoded size of one empty frozen-page response frame: the protobuf +/// message plus the 5-byte gRPC length-prefix. The budget reserves this +/// overhead before dividing, exactly like the legacy JSON frame budget. +fn empty_page_frame_overhead() -> usize { + let empty = crate::atomic::LoadFrozenEnvelopesResponse { + page: Some(crate::atomic::FrozenPage { + fragments: Vec::new(), + next: Some(crate::atomic::PageCursor::default()), + }), + }; + empty.encode_to_vec().len() + 5 +} + +/// The legacy budget math: reserve the worst-case metadata and four +/// payload bytes per budget byte, then clamp to the page size. +pub fn frozen_page_budget() -> usize { + (MAX_FRAME_BYTES.saturating_sub(empty_page_frame_overhead() + FROZEN_PAGE_METADATA_BYTES) / 4) + .min(FROZEN_PAGE_BYTES) +} + +/// Read one bounded part of the frozen journal. Continuation requests +/// (cursor past the first page) trip the before-frozen-page-continuation +/// seam; a successful read under the injected frozen-page-unavailable +/// failpoint surfaces the legacy injected error text instead. +pub fn load_frozen_page( + store: &RedbChangeStore, + id: ProvenanceId, + attempt_generation: u64, + frozen_event_count: u64, + cursor: FrozenProvenanceCursor, + requested_budget: Option, +) -> Result { + if cursor != FrozenProvenanceCursor::default() { + owner_failpoint("before-frozen-page-continuation"); + } + let budget = requested_budget + .map(|requested| requested.min(frozen_page_budget())) + .unwrap_or_else(frozen_page_budget); + match store.load_frozen_provenance_page( + id, + attempt_generation, + frozen_event_count, + cursor, + budget, + ) { + Ok(page) => { + if std::env::var("ATOMIC_OWNER_FAILPOINT").ok().as_deref() + == Some("frozen-page-unavailable") + { + return Err(JournalError { + code: ErrorCode::ProvenanceCheckpoint, + message: "injected frozen page read failure".to_string(), + }); + } + Ok(page) + } + Err(error) => Err(JournalError::store(ErrorCode::ProvenanceCheckpoint, error)), + } +} + +/// Reassemble only complete envelopes, advancing the cursor after a +/// successful page. Retrying an RPC cannot append its bytes twice. +/// Ported verbatim from the legacy owner client +/// (`append_frozen_page`). +pub fn append_frozen_page( + envelopes: &mut Vec>, + partial: &mut Vec, + cursor: &mut FrozenProvenanceCursor, + cutoff: u64, + page: FrozenProvenancePage, +) -> Result<(), String> { + let end = FrozenProvenanceCursor { + seq: cutoff, + offset: 0, + }; + if page.next <= *cursor || page.next > end { + return Err("invalid frozen journal page continuation".to_string()); + } + let mut position = *cursor; + for fragment in page.fragments { + // Legacy journal records can leave sequence gaps, but never inside an envelope. + if fragment.cursor.seq >= cutoff + || fragment.cursor < position + || (fragment.cursor != position + && (position.offset != 0 || fragment.cursor.offset != 0)) + || (!fragment.complete && fragment.bytes.is_empty()) + { + return Err("invalid frozen journal fragment cursor".to_string()); + } + partial.extend_from_slice(&fragment.bytes); + position = if fragment.complete { + envelopes.push(std::mem::take(partial)); + FrozenProvenanceCursor { + seq: fragment.cursor.seq + 1, + offset: 0, + } + } else { + FrozenProvenanceCursor { + seq: fragment.cursor.seq, + offset: partial.len(), + } + }; + } + if page.next < position + || (page.next != position && (position.offset != 0 || page.next.offset != 0)) + || (page.next == end && !partial.is_empty()) + { + return Err("incomplete frozen journal page".to_string()); + } + *cursor = page.next; + Ok(()) +} + +/// Load the whole frozen journal by paging through it with strict +/// continuity validation. This is the in-process counterpart of the wire +/// sink's paged loop (the daemon's journal sink reuses it directly). +pub fn load_frozen_envelopes( + store: &RedbChangeStore, + id: ProvenanceId, + attempt_generation: u64, + frozen_event_count: u64, +) -> Result>, JournalError> { + let mut cursor = FrozenProvenanceCursor::default(); + let mut envelopes = Vec::new(); + let mut partial = Vec::new(); + while cursor.seq < frozen_event_count { + let page = load_frozen_page( + store, + id, + attempt_generation, + frozen_event_count, + cursor, + None, + )?; + append_frozen_page( + &mut envelopes, + &mut partial, + &mut cursor, + frozen_event_count, + page, + ) + .map_err(|message| JournalError { + code: ErrorCode::ProvenanceCheckpoint, + message, + })?; + } + Ok(envelopes) +} + +/// Bind the prepared graph hash and exact immutable session turn once. +pub fn bind_checkpoint_hash( + store: &RedbChangeStore, + id: ProvenanceId, + expected_generation: u64, + hash: atomic_core::types::Hash, + session_turn: atomic_core::change::session::SessionTurn, + now: i64, +) -> Result { + let attempt = store + .bind_provenance_checkpoint_hash(id, expected_generation, hash, session_turn, now) + .map_err(|error| JournalError::store(ErrorCode::ProvenanceCheckpoint, error))?; + owner_failpoint("after-checkpoint-bind"); + Ok(attempt) +} + +/// Acknowledge publication and complete the turn (caller-supplied time). +pub fn acknowledge_checkpoint( + store: &RedbChangeStore, + id: ProvenanceId, + expected_generation: u64, + manifest_hash: atomic_core::types::Hash, + completed_at: i64, +) -> Result<(), JournalError> { + store + .acknowledge_provenance_checkpoint(id, expected_generation, manifest_hash, completed_at) + .map_err(|error| JournalError::store(ErrorCode::ProvenanceCheckpoint, error))?; + Ok(()) +} + +fn lifecycle_lookup( + store: &RedbChangeStore, + session_id: &str, + turn_number: u32, +) -> Result, JournalError> { + store + .get_provenance_turn_for(session_id, turn_number) + .map_err(|error| JournalError::store(ErrorCode::ProvenanceLifecycle, error)) +} + +/// Stop a running turn. Like the legacy owner, the looked-up turn's own +/// current generation performs the transition — a lookup never grants a +/// generation, so the request's expected_generation is shape discipline +/// only (it must be present and nonzero). +pub fn stop_turn( + store: &RedbChangeStore, + session_id: &str, + turn_number: u32, + cause: StopCause, + resumable: bool, + observed_at: i64, +) -> Result, JournalError> { + let Some(turn) = lifecycle_lookup(store, session_id, turn_number)? else { + return Ok(None); + }; + let stopped = store + .stop_provenance_turn( + turn.provenance_id, + turn.generation, + StopState { + cause, + observed_at, + last_event_seq: None, + resumable, + }, + ) + .map_err(|error| JournalError::store(ErrorCode::ProvenanceLifecycle, error))?; + Ok(Some(stopped)) +} + +/// Resume a stopped turn with a new fencing generation and the same ID. +pub fn resume_turn( + store: &RedbChangeStore, + session_id: &str, + turn_number: u32, + now: i64, +) -> Result, JournalError> { + let Some(turn) = lifecycle_lookup(store, session_id, turn_number)? else { + return Ok(None); + }; + let resumed = store + .resume_provenance_turn(turn.provenance_id, turn.generation, now) + .map_err(|error| JournalError::store(ErrorCode::ProvenanceLifecycle, error))?; + Ok(Some(resumed)) +} + +/// Explicitly seal a pending turn without publishing a checkpoint. +pub fn abandon_turn( + store: &RedbChangeStore, + session_id: &str, + turn_number: u32, + observed_at: i64, +) -> Result, JournalError> { + let Some(turn) = lifecycle_lookup(store, session_id, turn_number)? else { + return Ok(None); + }; + let abandoned = store + .abandon_provenance_turn( + turn.provenance_id, + turn.generation, + StopState { + cause: StopCause::Abandoned, + observed_at, + last_event_seq: None, + resumable: false, + }, + ) + .map_err(|error| JournalError::store(ErrorCode::ProvenanceLifecycle, error))?; + Ok(Some(abandoned)) +} + +/// Turn status (the read side of the lifecycle). +pub fn get_turn( + store: &RedbChangeStore, + session_id: &str, + turn_number: u32, +) -> Result, JournalError> { + lifecycle_lookup(store, session_id, turn_number) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frozen_page_budget_is_bounded_by_the_page_size() { + assert_eq!(frozen_page_budget(), FROZEN_PAGE_BYTES); + } + + #[test] + fn metadata_reserve_uses_the_legacy_constants() { + assert_eq!(FROZEN_PAGE_METADATA_BYTES, 256 * 256 + 256); + assert_eq!(MAX_FROZEN_PAGE_FRAGMENTS, 256); + } +} diff --git a/libatomic/src/daemon/services.rs b/libatomic/src/daemon/services.rs new file mode 100644 index 00000000..f11e664e --- /dev/null +++ b/libatomic/src/daemon/services.rs @@ -0,0 +1,1614 @@ +//! The full-turn service handlers: the domain half of the service layer. +//! +//! Each RPC is a complete domain operation (RFC D5): it opens its own +//! server-side transaction through the repository handle, never exposes +//! tables or transactions over the wire, and keeps presentation out of the +//! daemon (D6). Handlers run the domain logic on the blocking pool so redb +//! work never stalls the async transport. + +use std::sync::Arc; + +use crate::atomic::ErrorCode; +use crate::atomic::*; +use atomic_core::change::Author; +use atomic_core::change::ChangeHeader; +use atomic_core::pristine::ViewTxnT; +use atomic_identity::IdentityStore; +use atomic_repository::apply::{CrossViewInsertOptions, InsertOptions}; +use atomic_repository::record::RecordOptions; +use atomic_repository::status::StatusOptions; +use atomic_repository::tracking::TrackingOptions; +use atomic_repository::{HistoryOptions, Repository}; +use tonic::{Request, Response, Status}; + +use super::convert::*; +use super::state::{domain_status, repository_error, DaemonState}; + +// --------------------------------------------------------------------------- +// DaemonService +// --------------------------------------------------------------------------- + +pub struct DaemonImpl { + pub state: Arc, +} + +const METHOD_MATRIX: &[(&str, &str)] = &[ + ("DaemonService", "Health"), + ("DaemonService", "GetCapabilities"), + ("DaemonService", "ResolveRepository"), + ("DaemonService", "RegisterRepository"), + ("DaemonService", "ListRepositories"), + ("DaemonService", "Shutdown"), + ("RepositoryQueryService", "Status"), + ("RepositoryQueryService", "Log"), + ("RepositoryQueryService", "ListConflicts"), + ("RepositoryQueryService", "PreviewMutation"), + ("RepositoryQueryService", "PreviewRestore"), + ("RepositoryQueryService", "Diff"), + ("RepositoryMutationService", "AddFiles"), + ("RepositoryMutationService", "Record"), + ("RepositoryMutationService", "RemoveFiles"), + ("RepositoryMutationService", "MoveFile"), + ("RepositoryMutationService", "Unrecord"), + ("RepositoryMutationService", "Restore"), + ("RepositoryMutationService", "CreateStash"), + ("RepositoryMutationService", "ApplyStash"), + ("RepositoryMutationService", "ListStashes"), + ("RepositoryMutationService", "DropStash"), + ("RepositoryMutationService", "InsertChanges"), + ("RepositoryMutationService", "Revise"), + ("VaultService", "CreateVaultEntity"), + ("VaultService", "UpdateVaultEntity"), + ("VaultService", "GetVaultEntry"), + ("VaultService", "ListVaultEntries"), + ("VaultService", "ValidateVaultEntity"), + ("VaultService", "SyncVault"), + ("AttestationService", "RecordAttestation"), + ("AttestationService", "VerifyAttestation"), + ("KnowledgeService", "QueryGraph"), + ("ProvenanceService", "DispatchTurnEvent"), + ("ProvenanceService", "ReserveTurn"), + ("ProvenanceService", "AppendEnvelopes"), + ("ProvenanceService", "PrepareCheckpoint"), + ("ProvenanceService", "LoadFrozenEnvelopes"), + ("ProvenanceService", "BindCheckpointHash"), + ("ProvenanceService", "AcknowledgeCheckpoint"), + ("ProvenanceService", "UpdateTurn"), + ("ProvenanceService", "GetTurn"), + ("ProvenanceService", "GetSession"), + ("ProvenanceService", "ListSessions"), + ("MaintenanceService", "Repair"), + ("MaintenanceService", "CheckRepository"), + ("ViewService", "ListViews"), + ("ViewService", "CreateView"), + ("ViewService", "SwitchView"), + ("ViewService", "DeleteView"), + ("ViewService", "SetViewScope"), + ("ViewService", "SplitView"), +]; + +#[tonic::async_trait] +impl daemon_service_server::DaemonService for DaemonImpl { + async fn health( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(HealthResponse { + status: ServingStatus::Serving as i32, + pid: std::process::id(), + features: vec![ + "full-turn-slice".to_string(), + "repository-registry".to_string(), + ], + })) + } + + async fn get_capabilities( + &self, + _request: Request, + ) -> Result, Status> { + let methods = METHOD_MATRIX + .iter() + .map(|(service, method)| MethodDescriptorInfo { + service: service.to_string(), + method: method.to_string(), + scope: ExecutionScope::Both as i32, + required_capabilities: Vec::new(), + allowed_callers: Vec::new(), + effects: Vec::new(), + }) + .collect(); + Ok(Response::new(GetCapabilitiesResponse { + protocol_version: 2u32, + implementation_version: env!("CARGO_PKG_VERSION").to_string(), + endpoint_kind: EndpointKind::LocalDaemon as i32, + methods, + features: vec!["full-turn-slice".to_string()], + limits: Some(Limits { + max_message_bytes: 64 * 1024 * 1024, + max_frozen_page_fragments: 256, + frozen_page_bytes: 1024 * 1024, + append_chunk_budget_bytes: 4 * 1024 * 1024, + max_changes_per_request: 64, + max_inodes_per_request: 1024, + max_write_queue_depth: 64, + }), + auth: Some(AuthRequirements { + tls_required: false, + audience_binding: false, + identity_schemes: vec!["did:atomic".to_string()], + credential_schemes: vec!["local-socket-uid".to_string()], + }), + })) + } + + async fn resolve_repository( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let root = Repository::find_root(std::path::Path::new(&request.path)) + .map_err(|error| domain_status(ErrorCode::RepositoryNotFound, error.to_string()))?; + let handle = self.state.register(root.clone()); + self.state.log_rpc("ResolveRepository", Some(&handle)); + // Resolve is a pure path walk + registry entry — it never opens a + // repository database, so it must NOT queue behind the per-repo + // serialization gate (a CLI command resolving while a turn records + // would otherwise block for the record's whole duration). + let relative_cwd = std::path::Path::new(&request.path) + .strip_prefix(&handle.root) + .map(|p| p.display().to_string()) + .unwrap_or_default(); + Ok(Response::new(ResolveRepositoryResponse { + repository: Some(handle.repository_ref()), + workspace: Some(WorkspaceInfo { + workspace_id: "default".to_string(), + root_path: handle.root.display().to_string(), + relative_cwd, + current_view: Some(ViewRef { + view_id: blake3::hash(handle.current_view().as_bytes()) + .as_bytes() + .to_vec(), + name: Some(handle.current_view()), + }), + }), + })) + } + + async fn register_repository( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let meta = request.meta.unwrap_or_default(); + let root = Repository::find_root(std::path::Path::new(&request.path)) + .map_err(|error| domain_status(ErrorCode::RepositoryNotFound, error.to_string()))?; + let handle = self.state.register(root); + self.state.log_rpc("RegisterRepository", Some(&handle)); + // Registry-only: no repository database is opened (contract: + // "without opening a repository write transaction"). + Ok(Response::new(RegisterRepositoryResponse { + repository: Some(handle.repository_ref()), + workspace: None, + meta: Some(ResponseMeta { + request_id: meta.request_id, + replayed: false, + snapshot: None, + }), + })) + } + + async fn list_repositories( + &self, + _request: Request, + ) -> Result, Status> { + let repositories = self + .state + .list() + .into_iter() + .map(|handle| RepositorySummary { + repository: Some(handle.repository_ref()), + open: true, + local_path: Some(handle.root.display().to_string()), + }) + .collect(); + Ok(Response::new(ListRepositoriesResponse { repositories })) + } + + async fn shutdown( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let meta = request.meta.unwrap_or_default(); + let state = self.state.clone(); + state.shutdown.notify_one(); + Ok(Response::new(ShutdownResponse { + pid: std::process::id(), + meta: Some(ResponseMeta { + request_id: meta.request_id, + replayed: false, + snapshot: None, + }), + })) + } +} + +// --------------------------------------------------------------------------- +// RepositoryQueryService +// --------------------------------------------------------------------------- + +pub struct QueryImpl { + pub state: Arc, +} + +#[tonic::async_trait] +impl repository_query_service_server::RepositoryQueryService for QueryImpl { + async fn status( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("Status", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || { + // A query: the read-only open (with the read-concurrency wait) + // keeps Status working under concurrent writers. + let repo = handle.repository_readonly()?; + let status = repo + .status(StatusOptions::all()) + .map_err(repository_error)?; + let files = status + .entries() + .iter() + .map(|entry| FileStatusEntry { + path: entry.path().display().to_string(), + status: file_status_proto(entry.status()) as i32, + inode: None, + recorded_hash: entry.recorded_hash().map(hash_proto), + current_hash: entry.current_hash().map(hash_proto), + details: entry.details().map(str::to_string), + }) + .collect(); + Ok::<_, Status>(StatusResponse { + view: status.view().to_string(), + view_merkle: status.state().map(hash_proto), + files, + modified_count: status.modified_count() as u32, + deleted_count: status.deleted_count() as u32, + untracked_count: status.untracked_count() as u32, + added_count: status.added_count() as u32, + conflicted_count: status.conflicted_count() as u32, + stale_index_count: status.stale_index_count() as u32, + snapshot: None, + }) + }) + .await + .map_err(|error| Status::internal(format!("status task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn log(&self, request: Request) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("Log", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let view = if request.all_views { + None + } else { + Some( + request + .view + .clone() + .unwrap_or_else(|| handle.current_view()), + ) + }; + let from_sequence = request.cursor.as_ref().map_or(0, |c| c.seq); + let limit = request + .budget + .as_ref() + .and_then(|b| b.max_items) + .map(|max| max as usize); + let result = tokio::task::spawn_blocking(move || { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let mut options = HistoryOptions::with_headers().from_sequence(from_sequence); + if let Some(view) = &view { + options = options.view(view.clone()); + } + if let Some(limit) = limit { + options = options.limit(limit); + } + let entries = repo.reverse_log(options).map_err(repository_error)?; + let entries = entries + .into_iter() + .map(|entry| ChangeLogEntry { + hash: Some(hash_proto(&entry.hash)), + sequence: entry.sequence, + state: Some(hash_proto(&entry.state)), + message: entry.message().map(str::to_string), + description: entry.description().map(str::to_string), + authors: entry + .authors() + .map(|authors| authors.iter().map(author_proto).collect()) + .unwrap_or_default(), + recorded_at: entry.timestamp().map(timestamp_proto), + dependencies: Vec::new(), + view: view.clone(), + is_tagged: entry.is_tagged, + }) + .collect(); + Ok::<_, Status>(LogResponse { + entries, + next_cursor: None, + }) + }) + .await + .map_err(|error| Status::internal(format!("log task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn get_change( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_query::get_change_impl(&self.state, request).await + } + + type DiffStream = tokio_stream::wrappers::ReceiverStream>; + async fn diff( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("Diff", Some(&handle)); + let refs = match request.scope { + Some(diff_request::Scope::Refs(refs)) => refs, + _ => { + return Err(Status::invalid_argument( + "diff scope required (from/to view pair)", + )) + } + }; + let stat_only = request.stat_only; + let from_view = refs.from_view; + let to_view = refs.to_view; + + // Streaming read transaction: read-only open, no gate — never + // queued behind writers (the preview pattern). + let (sender, receiver) = tokio::sync::mpsc::channel::>(16); + let root = handle.root.clone(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + compute_ref_pair_diff(&root, &from_view, &to_view, stat_only) + }) + .await + .unwrap_or_else(|error| Err(Status::internal(error.to_string()))); + match result { + Ok(chunks) => { + for chunk in chunks { + if sender.send(Ok(chunk)).await.is_err() { + break; // client went away + } + } + } + Err(error) => { + let _ = sender.send(Err(error)).await; + } + } + }); + Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new( + receiver, + ))) + } + + type DiffWorkingCopyStream = + std::pin::Pin> + Send>>; + async fn diff_working_copy( + &self, + request: Request, + ) -> Result, Status> { + let response = + crate::daemon::services_query::diff_working_copy_impl(&self.state, request).await?; + Ok(Response::new(response.into_inner())) + } + + async fn list_conflicts( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("ListConflicts", Some(&handle)); + // Read transaction: read-only open (with the read-concurrency + // wait), never queued behind writers on the gate. + let result = + tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository_readonly()?; + let conflicts = repo.list_conflicts().map_err(repository_error)?; + let mut infos = Vec::new(); + for (path, records) in &conflicts { + for record in records { + infos.push(ConflictInfo { + path: path.clone(), + view: None, + base: None, + kind: Some(format!("{:?}", record.kind).to_lowercase()), + line: record.line.map(|line| line as u64), + sides: record.sides.clone(), + }); + } + } + Ok(ListConflictsResponse { conflicts: infos }) + }) + .await + .map_err(|error| Status::internal(format!("conflicts task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn preview_mutation( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("PreviewMutation", Some(&handle)); + // Read transaction: read-only open, never queued behind writers. + let root = handle.root.clone(); + let current_view = handle.current_view(); + let result = + tokio::task::spawn_blocking(move || -> Result { + let repo = Repository::open_readonly(&root).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("failed to open repository read-only: {error}"), + ) + })?; + match request.operation { + Some(preview_mutation_request::Operation::Insert(preview)) => { + // The would-insert listing: missing changes between + // the source and the target (tag cutoff through the + // domain dry-run). + let target = if preview.target_view.is_empty() { + current_view + } else { + preview.target_view.clone() + }; + let missing = match preview.source { + Some(insert_preview::Source::FromView(source)) => repo + .get_missing_changes_between(&source, Some(&target)) + .map_err(repository_error)?, + Some(insert_preview::Source::UpToTag(tag)) => { + let options = CrossViewInsertOptions::new(&tag, &target) + .up_to_tag(&tag) + .dry_run(true); + repo.insert_from_view(options) + .map_err(repository_error)? + .applied_hashes + } + Some(insert_preview::Source::SingleChange(_)) | None => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "insert preview takes a from-view or up-to-tag", + )) + } + }; + let mut changes = Vec::with_capacity(missing.len()); + for hash in &missing { + let change = repo.load_change(hash).map_err(|error| { + domain_status(ErrorCode::Repository, error.to_string()) + })?; + changes.push(change_info(&change, hash)); + } + Ok(PreviewMutationResponse { + affected_paths: Vec::new(), + changes, + conflicts: Vec::new(), + snapshot: None, + untracked: Vec::new(), + }) + } + Some(preview_mutation_request::Operation::Unrecord(preview)) => { + // The would-unrecord listing: the domain dry-run + // resolves the target (or the view's last change) + // and its cascade without mutating anything. + let view = preview + .view + .and_then(|v| v.name) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| current_view.clone()); + let target_hash = match preview.target.and_then(|target| target.kind) { + Some(change_ref::Kind::Hash(hash)) => { + let mut bytes = [0u8; 32]; + if hash.value.len() != 32 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "hash must be 32 bytes", + )); + } + bytes.copy_from_slice(&hash.value); + Some(atomic_core::types::Merkle(bytes)) + } + Some(change_ref::Kind::Sequence(sequence)) => { + let txn = repo.pristine().read_txn().map_err(|error| { + domain_status(ErrorCode::Repository, error.to_string()) + })?; + let view_state = txn + .get_view(&view) + .map_err(|error| { + domain_status(ErrorCode::View, error.to_string()) + })? + .ok_or_else(|| { + domain_status( + ErrorCode::View, + format!("view '{view}' not found"), + ) + })?; + Some( + atomic_repository::history::get_change_at_sequence( + &txn, + &view_state, + sequence, + ) + .map_err(|error| { + domain_status(ErrorCode::NotFound, error.to_string()) + })? + .hash, + ) + } + None => None, + }; + // The would-unrecord listing: preview_unrecord is + // the READ path (the mutating unrecord's dry_run + // still opens a write transaction). + let txn = repo.pristine().read_txn().map_err(|error| { + domain_status(ErrorCode::Repository, error.to_string()) + })?; + let view_state = txn + .get_view(&view) + .map_err(|error| domain_status(ErrorCode::View, error.to_string()))? + .ok_or_else(|| { + domain_status(ErrorCode::View, format!("view '{view}' not found")) + })?; + let target_hash = match target_hash { + Some(hash) => hash, + None => atomic_repository::unrecord::get_last_change(&txn, &view_state) + .map_err(|error| { + domain_status(ErrorCode::PreconditionFailed, error.to_string()) + })? + .ok_or_else(|| { + domain_status( + ErrorCode::PreconditionFailed, + format!("view '{view}' is empty — nothing to unrecord"), + ) + })?, + }; + let outcome = atomic_repository::unrecord::preview_unrecord( + &txn, + &view_state, + &[target_hash], + &atomic_repository::UnrecordOptions::dry_run().view(view), + ) + .map_err(|error| { + domain_status(ErrorCode::PreconditionFailed, error.to_string()) + })?; + let mut changes = Vec::with_capacity(outcome.unrecorded.len()); + for hash in &outcome.unrecorded { + let change = repo.load_change(hash).map_err(|error| { + domain_status(ErrorCode::Repository, error.to_string()) + })?; + changes.push(change_info(&change, hash)); + } + Ok(PreviewMutationResponse { + affected_paths: Vec::new(), + changes, + conflicts: Vec::new(), + snapshot: None, + untracked: Vec::new(), + }) + } + None => Err(domain_status( + ErrorCode::InvalidArgument, + "preview operation required (insert or unrecord)", + )), + } + }) + .await + .map_err(|error| Status::internal(format!("preview task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn preview_restore( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("PreviewRestore", Some(&handle)); + // Read transaction: read-only open, never queued behind writers. + let root = handle.root.clone(); + let result = + tokio::task::spawn_blocking(move || -> Result { + let repo = Repository::open_readonly(&root).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("failed to open repository read-only: {error}"), + ) + })?; + // The Restore classification WITHOUT mutation: tracked-only + // status pass; Added files would be untracked (kept on + // disk), Modified/Deleted would be restored. + let status = repo + .status(StatusOptions { + include_untracked: false, + ..StatusOptions::default() + }) + .map_err(repository_error)?; + let mut affected: Vec = Vec::new(); + let mut untracked: Vec = Vec::new(); + for entry in status.entries() { + let path = entry.path().to_string_lossy().to_string(); + if !request.paths.is_empty() + && !request.paths.iter().any(|p| path.starts_with(p.as_str())) + { + continue; + } + match entry.status() { + atomic_repository::status::FileStatus::Added => untracked.push(path), + atomic_repository::status::FileStatus::Modified + | atomic_repository::status::FileStatus::Deleted => affected.push(path), + _ => {} + } + } + Ok(PreviewMutationResponse { + affected_paths: affected, + changes: Vec::new(), + conflicts: Vec::new(), + snapshot: None, + untracked, + }) + }) + .await + .map_err(|error| Status::internal(format!("preview task failed: {error}")))??; + Ok(Response::new(result)) + } +} + +/// The view-to-view diff: a pure read (read-only repository open) over +/// each side's visible file set, unified-diffed with `similar` — the +/// same hunk computation the working-copy diff performs. +fn compute_ref_pair_diff( + root: &std::path::Path, + from_view: &str, + to_view: &str, + stat_only: bool, +) -> Result, Status> { + let repo = + Repository::open_readonly_wait(root, super::state::READ_OPEN_WAIT).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("failed to open repository read-only: {error}"), + ) + })?; + for view in [from_view, to_view] { + if !repo.view_exists(view).map_err(repository_error)? { + return Err(domain_status( + ErrorCode::View, + format!("view '{view}' not found"), + )); + } + } + let from_files = repo + .visible_file_paths(from_view) + .map_err(repository_error)?; + let to_files = repo.visible_file_paths(to_view).map_err(repository_error)?; + // Symmetric difference (present on exactly one side) plus the common + // paths whose content actually differs. + let mut all_paths: Vec = from_files + .symmetric_difference(&to_files) + .cloned() + .collect(); + for path in from_files.intersection(&to_files) { + let differs = match ( + repo.get_file_content_on_view(path, from_view), + repo.get_file_content_on_view(path, to_view), + ) { + (Ok(Some(a)), Ok(Some(b))) => a != b, + _ => true, + }; + if differs { + all_paths.push(path.clone()); + } + } + all_paths.sort(); + + let mut chunks = Vec::new(); + for path in all_paths { + let old = repo + .get_file_content_on_view(&path, from_view) + .map_err(repository_error)? + .unwrap_or_default(); + let new = repo + .get_file_content_on_view(&path, to_view) + .map_err(repository_error)?; + let old_binary = old.contains(&0); + let new_binary = new + .as_ref() + .map(|c| c.contains(&0)) + .unwrap_or(false); + if old_binary || new_binary { + chunks.push(DiffChunk { + path: path.clone(), + patch: None, + additions: 0, + deletions: 0, + binary: true, + }); + continue; + } + let old_text = String::from_utf8_lossy(&old).into_owned(); + let new_text = new + .as_ref() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) + .unwrap_or_default(); + let diff = similar::TextDiff::from_lines(&old_text, &new_text); + let mut additions = 0u64; + let mut deletions = 0u64; + for change in diff.iter_all_changes() { + match change.tag() { + similar::ChangeTag::Insert => additions += 1, + similar::ChangeTag::Delete => deletions += 1, + similar::ChangeTag::Equal => {} + } + } + let patch = if stat_only { + None + } else { + Some( + similar::TextDiff::from_lines(&old_text, &new_text) + .unified_diff() + .context_radius(3) + .to_string() + .into_bytes(), + ) + }; + chunks.push(DiffChunk { + path, + patch, + additions, + deletions, + binary: false, + }); + } + Ok(chunks) +} + +/// Minimal wire render of a stored change (hash, message, authors, +/// timestamp) — the record-response shape. +fn change_info( + change: &atomic_core::change::Change, + hash: &atomic_core::types::Merkle, +) -> ChangeInfo { + ChangeInfo { + hash: Some(hash_proto(hash)), + message: Some(change.hashed.header.message.clone()), + description: change.hashed.header.description.clone(), + authors: change + .hashed + .header + .authors + .iter() + .map(author_proto) + .collect(), + recorded_at: Some(timestamp_proto(change.hashed.header.timestamp)), + dependencies: Vec::new(), + graph_section_count: 0, + semantic_section_count: 0, + content_chunk_count: 0, + has_provenance: change.has_provenance(), + has_unhashed: change.unhashed.is_some(), + has_signature: change.signature.is_some(), + } +} + +pub(crate) fn default_ref() -> RepositoryRef { + RepositoryRef { + authority: String::new(), + repository_id: Vec::new(), + workspace_id: None, + } +} + +// --------------------------------------------------------------------------- +// RepositoryMutationService +// --------------------------------------------------------------------------- + +pub struct MutationImpl { + pub state: Arc, +} + +fn resolve_author() -> Result, String> { + let store = IdentityStore::open_default().map_err(|e| e.to_string())?; + let Some(identity) = store.get_default().map_err(|e| e.to_string())? else { + return Ok(None); + }; + Ok(Some(Author::with_identity( + identity.name.clone(), + identity.email.clone(), + identity.public_key_base32(), + ))) +} + +#[tonic::async_trait] +impl repository_mutation_service_server::RepositoryMutationService for MutationImpl { + async fn add_files( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("AddFiles", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let paths: Vec = request.paths; + let meta = request.meta.unwrap_or_default(); + let result = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let mut tracked = 0usize; + for path in &paths { + let stats = repo + .add(path, TrackingOptions::default()) + .map_err(repository_error)?; + tracked += stats.files_added; + } + Ok::<_, Status>(AddFilesResponse { + tracked: tracked as u32, + meta: Some(ResponseMeta { + request_id: meta.request_id, + replayed: false, + snapshot: None, + }), + }) + }) + .await + .map_err(|error| Status::internal(format!("add task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn record( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("Record", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let message = request.message; + let paths = request.paths; + let meta = request.meta.unwrap_or_default(); + let view = handle.current_view(); + let result = tokio::task::spawn_blocking(move || { + if message.trim().is_empty() { + return Err(domain_status( + ErrorCode::InvalidArgument, + "record message is empty", + )); + } + let author = resolve_author().map_err(|error| { + domain_status( + ErrorCode::Internal, + format!("identity resolution failed: {error}"), + ) + })?; + let mut header = ChangeHeader::builder().message(message.clone()); + if let Some(author) = author { + header = header.author(author); + } + let header = header.build(); + let mut options = RecordOptions::new().message(message.clone()); + if !paths.is_empty() { + options = options.paths(paths.clone()); + } + let repo = handle.repository()?; + let outcome = repo + .record(header, options) + .map_err(|error| domain_status(ErrorCode::ChangeRejected, error.to_string()))?; + let change = outcome.change(); + Ok::<_, Status>(RecordResponse { + change: Some(ChangeInfo { + hash: Some(hash_proto(outcome.hash())), + message: Some(change.hashed.header.message.clone()), + description: change.hashed.header.description.clone(), + authors: change + .hashed + .header + .authors + .iter() + .map(author_proto) + .collect(), + recorded_at: Some(timestamp_proto(change.hashed.header.timestamp)), + dependencies: Vec::new(), + graph_section_count: 0, + semantic_section_count: 0, + content_chunk_count: 0, + has_provenance: change.has_provenance(), + has_unhashed: change.unhashed.is_some(), + has_signature: change.signature.is_some(), + }), + view_merkle: outcome.new_state().map(|state| hash_proto(&state)), + view: Some(view), + meta: Some(ResponseMeta { + request_id: meta.request_id, + replayed: false, + snapshot: None, + }), + }) + }) + .await + .map_err(|error| Status::internal(format!("record task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn remove_files( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("RemoveFiles", Some(&handle)); + let keep = request.keep_working_copy; + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository()?; + let options = TrackingOptions::default(); + let mut removed: u32 = 0; + let mut to_delete: Vec = Vec::new(); + for path in &request.paths { + // Normalize exactly like the CLI: absolute paths must be + // inside the repository root and are made relative. + let normalized = if std::path::Path::new(path).is_absolute() { + match std::path::Path::new(path).strip_prefix(&handle.root) { + Ok(rel) => rel.display().to_string(), + Err(_) => { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!("path outside repository: {path}"), + )) + } + } + } else { + path.clone() + }; + let stats = repo + .remove(&normalized, options.clone()) + .map_err(repository_error)?; + removed += stats.files_removed as u32; + if !keep { + to_delete.push(handle.root.join(&normalized)); + } + } + // The CLI deletes from disk only after every tracking removal + // succeeded; the daemon mirrors that ordering. + for path in to_delete { + if path.is_file() { + let _ = std::fs::remove_file(&path); + } + } + Ok(RemoveFilesResponse { + removed, + meta: None, + }) + }) + .await + .map_err(|error| Status::internal(format!("remove task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn move_file( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("MoveFile", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository()?; + // The CLI's exact validation chain, with the domain's own + // refusal vocabulary riding the stable error codes. + if !repo.is_tracked(&request.source).map_err(repository_error)? { + return Err(domain_status( + ErrorCode::NotFound, + format!("file not tracked: {}", request.source), + )); + } + let source_path = handle.root.join(&request.source); + if !source_path.exists() { + return Err(domain_status( + ErrorCode::NotFound, + format!("file not found: {}", source_path.display()), + )); + } + if repo + .is_tracked(&request.destination) + .map_err(repository_error)? + { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!("destination already tracked: {}", request.destination), + )); + } + let destination_path = handle.root.join(&request.destination); + if let Some(parent) = destination_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("failed to create destination directory: {error}"), + ) + })?; + } + // Disk move ONLY — the raw-rename working-copy shape the CLI + // leaves behind on purpose: record's move detection pairs the + // tracked-missing source with the untracked identical-content + // destination into a single FileOp::FileMove (inode preserved). + // An eager TREE rewrite here would destroy that. + std::fs::rename(&source_path, &destination_path).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!( + "failed to move {} -> {}: {error}", + source_path.display(), + destination_path.display() + ), + ) + })?; + Ok(MoveFileResponse { meta: None }) + }) + .await + .map_err(|error| Status::internal(format!("move task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn restore( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("Restore", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository()?; + // Restore only ever touches TRACKED dirty files — the + // untracked scan is skipped (the CLI's status_options). + // One status pass classifies every path; only tracked + // dirty states (Modified/Deleted/Added) are considered. + let status = repo + .status(StatusOptions { + include_untracked: false, + ..StatusOptions::default() + }) + .map_err(repository_error)?; + let dirty: Vec<(String, atomic_repository::status::FileStatus)> = status + .entries() + .iter() + .map(|e| (e.path().to_string_lossy().to_string(), e.status())) + .filter(|(path, file_status)| { + matches!( + file_status, + atomic_repository::status::FileStatus::Modified + | atomic_repository::status::FileStatus::Deleted + | atomic_repository::status::FileStatus::Added + ) && (request.paths.is_empty() + || request.paths.iter().any(|p| path.starts_with(p.as_str()))) + }) + .collect(); + // The CLI's safety gate: only a WHOLE-copy restore (no + // paths) requires force; naming paths is explicit consent. + if request.paths.is_empty() && !dirty.is_empty() && !request.force { + return Err(domain_status( + ErrorCode::PreconditionFailed, + "restore: working copy has unrecorded changes — \ + name paths explicitly or pass force", + )); + } + let mut restored: Vec = Vec::new(); + for (path, file_status) in dirty { + match file_status { + // Added: undo the add — untrack, keep on disk. + atomic_repository::status::FileStatus::Added => { + repo.remove(&path, TrackingOptions::default().with_recursive(false)) + .map_err(repository_error)?; + restored.push(path); + } + // Modified/Deleted: pristine content back to disk. + _ => { + let content = repo + .get_file_content(std::path::Path::new(&path)) + .map_err(repository_error)?; + if let Some(bytes) = content { + let full = handle.root.join(&path); + if let Some(parent) = full.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("failed to create directory: {error}"), + ) + })?; + } + std::fs::write(&full, bytes).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("failed to write {}: {error}", full.display()), + ) + })?; + restored.push(path); + } + // No pristine content: skip (nothing safe to do). + } + } + } + Ok(RestoreResponse { + restored, + meta: None, + }) + }) + .await + .map_err(|error| Status::internal(format!("restore task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn revise( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("Revise", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let mut repo = handle.repository()?; + let target = match request + .target + .as_ref() + .and_then(|target| target.kind.clone()) + { + Some(change_ref::Kind::Hash(hash)) => { + let mut bytes = [0u8; 32]; + if hash.value.len() != 32 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "hash must be 32 bytes", + )); + } + bytes.copy_from_slice(&hash.value); + atomic_core::types::Merkle(bytes) + } + Some(change_ref::Kind::Sequence(_)) | None => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "revise target must be a change hash (the client resolves @/@~N)", + )) + } + }; + let message = request.reword_message.clone().ok_or_else(|| { + domain_status( + ErrorCode::InvalidArgument, + "reword_message is required (the interactive editor is client-side)", + ) + })?; + let outcome = + repo.reword_change(&target, &message, None) + .map_err(|error| match error { + atomic_repository::RepositoryError::ChangeNotFound { hash } => { + domain_status( + ErrorCode::NotFound, + format!("change {hash} not found on the current view"), + ) + } + other => domain_status(ErrorCode::ChangeRejected, other.to_string()), + })?; + let change = repo + .load_change(&outcome.new_hash) + .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; + Ok(ReviseResponse { + change: Some(change_info(&change, &outcome.new_hash)), + meta: None, + }) + }) + .await + .map_err(|error| Status::internal(format!("revise task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn insert_changes( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("InsertChanges", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = + tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository()?; + // The target defaults to the repository's current view (the + // wire's target_view is a display hint that must agree). + let target = request + .target_view + .clone() + .unwrap_or_else(|| handle.current_view()); + // Dependency closure is mandatory on the wire: the CLI's + // --deps=false escape hatch stays client-side. + let (applied, has_conflicts) = match request.source { + Some(insert_changes_request::Source::SingleChange(hash)) => { + let mut bytes = [0u8; 32]; + if hash.value.len() != 32 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "hash must be 32 bytes", + )); + } + bytes.copy_from_slice(&hash.value); + let options = InsertOptions::default() + .apply_deps(true) + .allow_conflict(request.allow_conflicts) + .view(target.clone()); + let outcome = repo + .insert_change_rec(&atomic_core::types::Merkle(bytes), options) + .map_err(|error| { + domain_status(ErrorCode::ChangeRejected, error.to_string()) + })?; + (outcome.stats.applied_hashes.clone(), outcome.has_conflicts) + } + Some(insert_changes_request::Source::FromView(source)) => { + let options = CrossViewInsertOptions::new(&source, &target) + .with_dependencies(true) + .allow_conflicts(request.allow_conflicts); + let outcome = repo.insert_from_view(options).map_err(|error| { + domain_status(ErrorCode::ChangeRejected, error.to_string()) + })?; + (outcome.applied_hashes.clone(), outcome.has_conflicts) + } + Some(insert_changes_request::Source::UpToTag(tag)) => { + // The wire carries only the tag; the source view is + // the repository's current view — exactly the CLI + // default (`insert tag` without --from-view). + let from_view = handle.current_view(); + let options = CrossViewInsertOptions::new(&from_view, &target) + .up_to_tag(&tag) + .with_dependencies(true) + .allow_conflicts(request.allow_conflicts); + let outcome = repo.insert_from_view(options).map_err(|error| { + domain_status(ErrorCode::ChangeRejected, error.to_string()) + })?; + (outcome.applied_hashes.clone(), outcome.has_conflicts) + } + None => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "insert source required (change hash, from-view, or up-to-tag)", + )) + } + }; + // Render the inserted changes; surface conflicts when + // they occurred (never swallowed). + let mut inserted = Vec::with_capacity(applied.len()); + let mut affected_paths: std::collections::HashSet = + std::collections::HashSet::new(); + for hash in &applied { + let change = repo + .load_change(hash) + .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; + // Collect the paths the inserted changes touch so the + // working copy can be refreshed surgically (below). + for op in change.hunks() { + if let Some(path) = op.path() { + affected_paths.insert(path.to_string()); + } + } + inserted.push(change_info(&change, hash)); + } + // Refresh the working copy when the insert landed on the + // repository's CURRENT view: the graph insert alone never + // touches the working tree, and a CLI standing on the + // target view expects the inserted files on disk (the + // local insert's materialization, now owned here so both + // transports behave identically). Affected paths only; + // a full materialize is the fallback when the inserted + // changes carry no path info. + if target == handle.current_view() && !applied.is_empty() { + if affected_paths.is_empty() { + repo.materialize().map_err(|error| { + domain_status(ErrorCode::Materialize, error.to_string()) + })?; + } else { + repo.materialize_paths(affected_paths).map_err(|error| { + domain_status(ErrorCode::Materialize, error.to_string()) + })?; + } + } + let conflicts = if has_conflicts { + repo.list_conflicts() + .map_err(repository_error)? + .into_iter() + .map(|(path, _)| ConflictInfo { + path, + view: None, + base: None, + kind: None, + line: None, + sides: Vec::new(), + }) + .collect() + } else { + Vec::new() + }; + Ok(InsertChangesResponse { + inserted, + conflicts, + meta: None, + }) + }) + .await + .map_err(|error| Status::internal(format!("insert task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn unrecord( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("Unrecord", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository()?; + let options = atomic_repository::UnrecordOptions::new(); + let outcome = match request + .target + .as_ref() + .and_then(|target| target.kind.clone()) + { + Some(change_ref::Kind::Hash(hash)) => { + let mut bytes = [0u8; 32]; + if hash.value.len() != 32 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "hash must be 32 bytes", + )); + } + bytes.copy_from_slice(&hash.value); + repo.unrecord(&atomic_core::types::Merkle(bytes), options) + .map_err(repository_error)? + } + Some(change_ref::Kind::Sequence(_)) => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "unrecord takes a hash target (or none for the last change)", + )) + } + // No target: the most recent change on the view. An empty + // view refuses with the domain's own message. + None => repo.unrecord_last(options).map_err(|error| { + if matches!(error, atomic_repository::RepositoryError::Unrecord(_)) { + domain_status(ErrorCode::PreconditionFailed, error.to_string()) + } else { + repository_error(error) + } + })?, + }; + // The primary unrecorded change rides the response; cascades + // (when UnrecordOptions ever allows them) list through Log. + let removed_hash = + outcome.unrecorded.first().copied().ok_or_else(|| { + domain_status(ErrorCode::Internal, "unrecord returned no change") + })?; + let change = repo + .load_change(&removed_hash) + .map_err(|error| domain_status(ErrorCode::NotFound, error.to_string()))?; + Ok(UnrecordResponse { + removed: Some(ChangeInfo { + hash: Some(hash_proto(&removed_hash)), + message: Some(change.hashed.header.message.clone()), + description: change.hashed.header.description.clone(), + authors: change + .hashed + .header + .authors + .iter() + .map(author_proto) + .collect(), + recorded_at: Some(timestamp_proto(change.hashed.header.timestamp)), + dependencies: Vec::new(), + graph_section_count: 0, + semantic_section_count: 0, + content_chunk_count: 0, + has_provenance: change.has_provenance(), + has_unhashed: change.unhashed.is_some(), + has_signature: change.signature.is_some(), + }), + meta: None, + }) + }) + .await + .map_err(|error| Status::internal(format!("unrecord task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn create_stash( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("CreateStash", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let mut repo = handle.repository()?; + match repo + .stash_push(atomic_repository::StashPushOptions { + message: request.message, + include_untracked: request.include_untracked, + keep: request.keep, + }) + .map_err(|message| domain_status(ErrorCode::Repository, message))? + { + // A clean working copy is Ok(None), never an error — + // the empty stash_id says "nothing to save". + None => Ok(CreateStashResponse { + stash_id: String::new(), + meta: None, + }), + Some(entry) => Ok(CreateStashResponse { + stash_id: entry.view_name, + meta: None, + }), + } + }) + .await + .map_err(|error| Status::internal(format!("stash task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn apply_stash( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("ApplyStash", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository()?; + let (_stash, applied) = repo + .stash_apply(if request.stash_id.is_empty() { + None + } else { + Some(request.stash_id.as_str()) + }) + .map_err(|message| domain_status(ErrorCode::Repository, message))?; + Ok(ApplyStashResponse { + applied, + meta: None, + }) + }) + .await + .map_err(|error| Status::internal(format!("stash task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn list_stashes( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("ListStashes", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository()?; + let stashes = repo + .stash_list() + .map_err(|message| domain_status(ErrorCode::Repository, message))?; + Ok(ListStashesResponse { + stashes: stashes + .into_iter() + .map(|stash| StashInfo { + id: stash.view_name, + message: Some(stash.message), + created_at: Some(prost_types::Timestamp { + seconds: stash.created_at.timestamp(), + nanos: stash.created_at.timestamp_subsec_nanos() as i32, + }), + tree: None, + }) + .collect(), + }) + }) + .await + .map_err(|error| Status::internal(format!("stash task failed: {error}")))??; + Ok(Response::new(result)) + } + + async fn drop_stash( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("DropStash", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + tokio::task::spawn_blocking(move || -> Result<(), Status> { + let mut repo = handle.repository()?; + if request.all { + repo.stash_clear() + .map_err(|message| domain_status(ErrorCode::Repository, message))?; + return Ok(()); + } + let stash_id = request.stash_id.unwrap_or_default(); + repo.stash_drop(if stash_id.is_empty() { + None + } else { + Some(stash_id.as_str()) + }) + .map_err(|message| domain_status(ErrorCode::Repository, message))?; + Ok(()) + }) + .await + .map_err(|error| Status::internal(format!("stash task failed: {error}")))??; + Ok(Response::new(DropStashResponse { meta: None })) + } +} diff --git a/libatomic/src/daemon/services_agent.rs b/libatomic/src/daemon/services_agent.rs new file mode 100644 index 00000000..290cd4f1 --- /dev/null +++ b/libatomic/src/daemon/services_agent.rs @@ -0,0 +1,1675 @@ +//! Vault, attestation, knowledge, and turn-dispatch handlers — the +//! agent-facing product surface of the full-turn slice. + +use std::sync::Arc; + +use crate::atomic::attestation_target::Kind as TargetKind; +use crate::atomic::query_graph_request::Query as QueryOneof; +use crate::atomic::update_vault_entity_request::Update as UpdateKind; +use crate::atomic::verify_attestation_request; +use crate::atomic::ErrorCode; +use crate::atomic::VaultEntityKind as Kind; +use crate::atomic::*; +use atomic_agent::event::TurnEvent; +use atomic_agent::turn::orchestrator::TurnOrchestrator; +use atomic_canonical::gate::{validate_intent, validate_memory}; +use atomic_canonical::lift::lift_intent; +use atomic_canonical::memory::lift_memory; +use atomic_canonical::{ + lift_and_attest, lift_and_attest_memory, verify, verify_memory, CanonicalNode, MemoryNode, +}; +use atomic_core::pristine::VaultEntryType; +use atomic_identity::{Identity, IdentityStore}; +use atomic_repository::IntentCreateOptions; +use atomic_repository::IntentUpdateOptions; +use atomic_repository::Repository; +use serde_json::{Map, Value}; +use tonic::{Request, Response, Status}; + +use super::convert::*; +use super::journal_sink::DirectJournalSink; +use super::state::{domain_status, repository_error, DaemonState}; + +// --------------------------------------------------------------------------- +// VaultService +// --------------------------------------------------------------------------- + +pub struct VaultImpl { + pub state: Arc, +} + +fn parse_frontmatter( + entry: &atomic_core::pristine::VaultEntry, +) -> Result, Status> { + serde_json::from_str(&entry.frontmatter_json).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("vault frontmatter is not valid JSON: {error}"), + ) + }) +} + +fn body_of(entry: &atomic_core::pristine::VaultEntry) -> String { + String::from_utf8_lossy(&entry.content_bytes).into_owned() +} + +/// The sanitized id used for sidecar/vault attestation paths — mirrors the +/// CLI bridge's sanitizer (ASCII alphanumerics, `-`, `_`; everything else +/// becomes `_`). +fn sanitize_id(id: &str) -> String { + id.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +/// The normalized intent key (repo resolver first, uppercased fallback) — +/// mirrors the CLI bridge's `normalized_id`. +fn normalized_id(repo: &Repository, id: &str) -> String { + repo.resolve_intent_key(id) + .unwrap_or_else(|_| id.to_uppercase()) +} + +/// The fresh attested intent node, when a current attestation exists. +fn fresh_attested_intent( + repo: &Repository, + id: &str, + frontmatter: &Map, + body: &str, +) -> Option { + let vault_path = format!( + "attestations/{}/attested.md", + sanitize_id(&normalized_id(repo, id)) + ); + let entry = repo.vault_retrieve(&vault_path).ok()??; + let node: CanonicalNode = serde_json::from_str(body_of(&entry).trim_end()).ok()?; + let fresh = serde_json::from_str::(&entry.frontmatter_json) + .ok() + .and_then(|v| { + v.get("sourceContentHash") + .and_then(Value::as_str) + .map(str::to_string) + }); + if fresh.as_deref() == Some(source_content_hash(frontmatter, body).as_str()) { + Some(node) + } else { + None + } +} + +/// The fresh attested memory node, when a current attestation exists. +fn fresh_attested_memory( + repo: &Repository, + id: &str, + frontmatter: &Map, + body: &str, +) -> Option { + let vault_path = format!("attestations/memory/{}/attested.md", sanitize_id(id)); + let entry = repo.vault_retrieve(&vault_path).ok()??; + let node: MemoryNode = serde_json::from_str(body_of(&entry).trim_end()).ok()?; + let fresh = serde_json::from_str::(&entry.frontmatter_json) + .ok() + .and_then(|v| { + v.get("sourceContentHash") + .and_then(Value::as_str) + .map(str::to_string) + }); + if fresh.as_deref() == Some(source_content_hash(frontmatter, body).as_str()) { + Some(node) + } else { + None + } +} + +fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +pub(crate) fn response_meta(meta: &Option) -> Option { + meta.as_ref().map(|meta| ResponseMeta { + request_id: meta.request_id.clone(), + replayed: false, + snapshot: None, + }) +} + +#[tonic::async_trait] +impl vault_service_server::VaultService for VaultImpl { + async fn create_vault_entity( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let kind = Kind::try_from(request.kind) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown vault entity kind"))?; + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("CreateVaultEntity", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let response = match kind { + Kind::Intent => { + let title = request.title.clone(); + let handle_for_task = handle.clone(); + let result = tokio::task::spawn_blocking({ + let handle = handle_for_task; + move || { + let repo = handle.repository()?; + let created = repo + .vault_intent_create(IntentCreateOptions { + title: title.clone(), + priority: None, + assignee: None, + labels: Vec::new(), + session_id: None, + turn_id: None, + kind: None, + }) + .map_err(repository_error)?; + // The CLI's `intent new` overwrites the legacy + // positional scaffold with the directive scaffold + // through the same update path — the daemon must + // produce the identical artifact. + let scaffold = + atomic_repository::FEATURE_SCAFFOLD.replace("{id}", &created.uid); + repo.vault_intent_update( + &created.id, + IntentUpdateOptions { + content: Some(scaffold), + force: true, + ..Default::default() + }, + ) + .map_err(repository_error)?; + Ok::<_, Status>(created) + } + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + CreateVaultEntityResponse { + entry: Some(VaultEntry { + kind: Kind::Intent as i32, + id: result.id.clone(), + title: request.title.clone(), + status: Some(VaultEntityStatus::Backlog as i32), + body: request.body.clone(), + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(format!(".vault/{}", result.intent_file)), + priority: Some("medium".to_string()), + }), + meta: response_meta(&meta), + } + } + Kind::Memory => { + let memory_kind = request + .memory_kind + .clone() + .unwrap_or_else(|| "context".to_string()); + let text = request.body.clone().unwrap_or_default(); + if text.trim().is_empty() { + return Err(domain_status( + ErrorCode::InvalidArgument, + "memory text must not be empty", + )); + } + let derived_from = request.derived_from.clone(); + let about = request.about.clone(); + let id = ulid::Ulid::new().to_string().to_lowercase(); + let vault_path = format!("memory/{id}.md"); + let body = format!("{}\n", text.trim()); + let body_for_store = body.clone(); + let handle_for_task = handle.clone(); + let response = tokio::task::spawn_blocking({ + let handle = handle_for_task; + let vault_path = vault_path.clone(); + let id = id.clone(); + let body = body_for_store; + move || { + let repo = handle.repository()?; + let mut frontmatter = Map::new(); + frontmatter.insert("uid".into(), Value::String(id)); + frontmatter.insert("memoryKind".into(), Value::String(memory_kind)); + frontmatter.insert("status".into(), Value::String("active".into())); + frontmatter.insert("createdAt".into(), Value::String(now_rfc3339())); + if !about.is_empty() { + frontmatter.insert( + "about".into(), + Value::Array( + about.iter().map(|a| Value::String(a.clone())).collect(), + ), + ); + } + if !derived_from.is_empty() { + frontmatter.insert( + "derivedFrom".into(), + Value::Array( + derived_from + .iter() + .map(|d| Value::String(d.clone())) + .collect(), + ), + ); + } + let frontmatter_json = serde_json::to_string(&frontmatter) + .map_err(|e| Status::internal(e.to_string()))?; + repo.vault_store( + &vault_path, + VaultEntryType::Memory, + body.into_bytes(), + frontmatter_json, + ) + .map_err(repository_error)?; + repo.vault_materialize(&vault_path) + .map_err(repository_error)?; + Ok::<_, Status>(()) + } + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + let _ = response; + CreateVaultEntityResponse { + entry: Some(VaultEntry { + kind: Kind::Memory as i32, + id: id.clone(), + title: id.clone(), + status: Some(VaultEntityStatus::Active as i32), + body: Some(body), + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(format!(".vault/{vault_path}")), + priority: None, + }), + meta: response_meta(&meta), + } + } + other => { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!("vault entity kind {other:?} lands with its slice (unimplemented)"), + )) + } + }; + Ok(Response::new(response)) + } + + async fn update_vault_entity( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let kind = Kind::try_from(request.kind) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown vault entity kind"))?; + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("UpdateVaultEntity", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let update = request + .update + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "update kind is required"))?; + let id = request.id.clone(); + let response = tokio::task::spawn_blocking(move || match kind { + Kind::Intent => { + let repo = handle.repository()?; + let mut options = IntentUpdateOptions::default(); + match update { + UpdateKind::Status(status) => { + let proto = VaultEntityStatus::try_from(status).map_err(|_| { + domain_status(ErrorCode::InvalidArgument, "unknown entity status") + })?; + let status = intent_status_from_proto(proto).ok_or_else(|| { + domain_status( + ErrorCode::InvalidArgument, + "status is not part of the intent lifecycle", + ) + })?; + options.status = Some(status.to_string()); + } + UpdateKind::Body(body) => { + options.content = Some(body); + } + } + let info = repo + .vault_intent_update(&id, options) + .map_err(repository_error)?; + Ok::<_, Status>(UpdateVaultEntityResponse { + entry: Some(VaultEntry { + kind: Kind::Intent as i32, + id: info.id.clone(), + title: info.title.clone(), + status: intent_status_to_proto(&info.status).map(|s| s as i32), + body: None, + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: Some(info.priority.clone()), + }), + meta: response_meta(&meta), + }) + } + Kind::Memory => match update { + UpdateKind::Body(body) => { + let repo = handle.repository()?; + let vault_path = format!("memory/{id}.md"); + let entry = repo + .vault_retrieve(&vault_path) + .map_err(repository_error)? + .ok_or_else(|| domain_status(ErrorCode::NotFound, "memory not found"))?; + let mut frontmatter = parse_frontmatter(&entry)?; + frontmatter.insert("updatedAt".into(), Value::String(now_rfc3339())); + let frontmatter_json = serde_json::to_string(&frontmatter) + .map_err(|e| Status::internal(e.to_string()))?; + repo.vault_store( + &vault_path, + VaultEntryType::Memory, + body.into_bytes(), + frontmatter_json, + ) + .map_err(repository_error)?; + repo.vault_materialize(&vault_path) + .map_err(repository_error)?; + Ok(UpdateVaultEntityResponse { + entry: Some(VaultEntry { + kind: Kind::Memory as i32, + id: id.clone(), + title: id.clone(), + status: None, + body: None, + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(format!(".vault/{vault_path}")), + priority: None, + }), + meta: response_meta(&meta), + }) + } + UpdateKind::Status(_) => Err(domain_status( + ErrorCode::InvalidArgument, + "memory status transitions are new revisions, not updates", + )), + }, + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("vault entity kind {other:?} lands with its slice (unimplemented)"), + )), + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } + + async fn get_vault_entry( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let kind = Kind::try_from(request.kind) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown vault entity kind"))?; + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("GetVaultEntry", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let id = request.id.clone(); + let response = tokio::task::spawn_blocking(move || match kind { + Kind::Intent => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let entry = repo.vault_intent_show(&id).map_err(repository_error)?; + let frontmatter = parse_frontmatter(&entry)?; + let title = frontmatter + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let status = frontmatter + .get("status") + .and_then(Value::as_str) + .and_then(intent_status_to_proto) + .map(|s| s as i32); + Ok::<_, Status>(GetVaultEntryResponse { + entry: Some(VaultEntry { + kind: Kind::Intent as i32, + id: normalized_id(&repo, &id), + title, + status, + body: Some(body_of(&entry)), + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: None, + }), + snapshot: None, + }) + } + Kind::Memory => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let vault_path = format!("memory/{id}.md"); + let entry = repo + .vault_retrieve(&vault_path) + .map_err(repository_error)? + .ok_or_else(|| domain_status(ErrorCode::NotFound, "memory not found"))?; + Ok(GetVaultEntryResponse { + entry: Some(VaultEntry { + kind: Kind::Memory as i32, + id: id.clone(), + title: id.clone(), + status: None, + body: Some(body_of(&entry)), + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(format!(".vault/{vault_path}")), + priority: None, + }), + snapshot: None, + }) + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("vault entity kind {other:?} lands with its slice (unimplemented)"), + )), + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } + + async fn list_vault_entries( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let kind = request + .kind + .and_then(|k| Kind::try_from(k).ok()) + .unwrap_or(Kind::Intent); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ListVaultEntries", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let response = tokio::task::spawn_blocking(move || match kind { + Kind::Intent => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let infos = repo.vault_intent_list(None).map_err(repository_error)?; + let entries = infos + .into_iter() + .map(|info| VaultEntry { + kind: Kind::Intent as i32, + id: info.id.clone(), + title: info.title.clone(), + status: intent_status_to_proto(&info.status).map(|s| s as i32), + body: None, // listings are summaries; bodies stay unloaded + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: Some(info.priority.clone()), + }) + .collect(); + Ok::<_, Status>(ListVaultEntriesResponse { + entries, + next_cursor: None, + snapshot: None, + }) + } + Kind::Memory => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let metas = repo + .vault_list("memory/", Some(VaultEntryType::Memory)) + .map_err(repository_error)?; + let entries = metas + .into_iter() + .map(|meta| { + let id = meta + .path + .trim_end_matches(".md") + .trim_start_matches("memory/") + .to_string(); + VaultEntry { + kind: Kind::Memory as i32, + id: id.clone(), + title: id, + status: None, + body: None, + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(format!(".vault/{}", meta.path)), + priority: None, + } + }) + .collect(); + Ok(ListVaultEntriesResponse { + entries, + next_cursor: None, + snapshot: None, + }) + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("vault entity kind {other:?} lands with its slice (unimplemented)"), + )), + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } + + async fn validate_vault_entity( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let kind = Kind::try_from(request.kind) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown vault entity kind"))?; + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ValidateVaultEntity", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let subject = request.subject.ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "validate subject is required") + })?; + let response = tokio::task::spawn_blocking(move || { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let id = match subject { + validate_vault_entity_request::Subject::Id(id) => id, + validate_vault_entity_request::Subject::Document(_) => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "document-bytes validation lands with its slice", + )) + } + }; + let issues = match kind { + Kind::Intent => { + let entry = repo.vault_intent_show(&id).map_err(repository_error)?; + let frontmatter = parse_frontmatter(&entry)?; + // Consult the stored attestation exactly like the CLI + // bridge: a fresh attested node validates as itself; a + // raw (unattested or stale) node validates as-is. + let raw_node = + lift_intent(&frontmatter, &body_of(&entry)).map_err(|error| { + domain_status( + ErrorCode::InvalidArgument, + format!("could not lift intent: {error}"), + ) + })?; + let node = fresh_attested_intent(&repo, &id, &frontmatter, &body_of(&entry)) + .unwrap_or(raw_node); + let report = validate_intent(&node); + report + .results + .iter() + .map(|violation| { + format!( + "[{}] {}{}: {}", + violation.shape, + violation.focus_node, + violation + .path + .as_ref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(), + violation.message + ) + }) + .collect::>() + } + Kind::Memory => { + let vault_path = format!("memory/{id}.md"); + let entry = repo + .vault_retrieve(&vault_path) + .map_err(repository_error)? + .ok_or_else(|| domain_status(ErrorCode::NotFound, "memory not found"))?; + let frontmatter = parse_frontmatter(&entry)?; + let raw_node = + lift_memory(&frontmatter, &body_of(&entry)).map_err(|error| { + domain_status( + ErrorCode::InvalidArgument, + format!("could not lift memory: {error}"), + ) + })?; + let node = fresh_attested_memory(&repo, &id, &frontmatter, &body_of(&entry)) + .unwrap_or(raw_node); + let report = validate_memory(&node); + report + .results + .iter() + .map(|violation| { + format!( + "[{}] {}{}: {}", + violation.shape, + violation.focus_node, + violation + .path + .as_ref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(), + violation.message + ) + }) + .collect::>() + } + other => { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!("vault entity kind {other:?} lands with its slice (unimplemented)"), + )) + } + }; + let valid = issues.is_empty(); + Ok::<_, Status>(ValidateVaultEntityResponse { valid, issues }) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } + + async fn sync_vault( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("SyncVault", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let handle_for_task = handle.clone(); + let response = tokio::task::spawn_blocking({ + let handle = handle_for_task; + move || { + let repo = handle.repository()?; + let synced = repo.vault_record_working_copy().map_err(repository_error)?; + Ok::<_, Status>(SyncVaultResponse { + entities_synced: synced.len() as u32, + meta: response_meta(&meta), + }) + } + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } + + async fn init_vault( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("InitVault lands with its slice")) + } + + async fn get_vault_context( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("GetVaultContext", Some(&handle)); + let max = request.max_entries.unwrap_or(20).max(1) as usize; + let kinds = request.kinds; + let handle_for_task = handle.clone(); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let entries = tokio::task::spawn_blocking(move || { + // A query: read-only open with the read-concurrency wait. + let repo = handle_for_task.repository_readonly()?; + let mut entries = Vec::new(); + let want = |kind: Kind| kinds.is_empty() || kinds.contains(&(kind as i32)); + if want(Kind::Intent) { + for info in repo.vault_intent_list(None).map_err(repository_error)? { + entries.push(VaultEntry { + kind: Kind::Intent as i32, + id: info.id.clone(), + title: info.title.clone(), + status: intent_status_to_proto(&info.status).map(|s| s as i32), + body: None, + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: Some(info.priority.clone()), + }); + } + } + if want(Kind::Memory) { + for meta in repo + .vault_list("memory/", Some(VaultEntryType::Memory)) + .map_err(repository_error)? + { + let id = meta + .path + .trim_end_matches(".md") + .trim_start_matches("memory/") + .to_string(); + entries.push(VaultEntry { + kind: Kind::Memory as i32, + id: id.clone(), + title: id, + status: None, + body: None, + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(format!(".vault/{}", meta.path)), + priority: None, + }); + } + } + entries.truncate(max); + Ok::<_, Status>(entries) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(GetVaultContextResponse { + entries, + snapshot: None, + })) + } + + async fn export_vault( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("ExportVault lands with its slice")) + } + + async fn delete_vault_entity( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "DeleteVaultEntity lands with its slice", + )) + } + + async fn link_vault_entities( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "LinkVaultEntities lands with its slice", + )) + } +} + +// --------------------------------------------------------------------------- +// AttestationService +// --------------------------------------------------------------------------- + +pub struct AttestationImpl { + pub state: Arc, +} + +struct IdentityMaterial { + identity: Identity, + keypair: atomic_identity::keypair::KeyPair, +} + +fn load_identity(name: Option<&str>) -> Result { + let store = IdentityStore::open_default() + .map_err(|error| domain_status(ErrorCode::Internal, format!("identity store: {error}")))?; + let identity = match name { + Some(name) => store.load_by_name(name).map_err(|error| { + domain_status(ErrorCode::NotFound, format!("identity '{name}': {error}")) + })?, + None => store + .get_default() + .map_err(|error| { + domain_status(ErrorCode::Internal, format!("default identity: {error}")) + })? + .ok_or_else(|| domain_status(ErrorCode::NotFound, "no default identity selected"))?, + }; + let keypair = store + .load_keypair(&identity.id, None) + .map_err(|error| domain_status(ErrorCode::Internal, format!("signing key: {error}")))?; + Ok(IdentityMaterial { identity, keypair }) +} + +/// blake3 source hash of (frontmatter + body) — the attestation freshness +/// anchor, mirroring the CLI bridges. +fn source_content_hash(frontmatter: &Map, body: &str) -> String { + let fm = serde_json::to_string(frontmatter).unwrap_or_default(); + let mut hasher = blake3::Hasher::new(); + hasher.update(fm.as_bytes()); + hasher.update(b"\0"); + hasher.update(body.as_bytes()); + format!("blake3:{}", hasher.finalize().to_hex()) +} + +fn write_all(path: &std::path::Path, bytes: &[u8]) -> Result<(), Status> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| domain_status(ErrorCode::Internal, format!("sidecar dir: {e}")))?; + } + std::fs::write(path, bytes) + .map_err(|e| domain_status(ErrorCode::Internal, format!("sidecar write: {e}"))) +} + +#[tonic::async_trait] +impl attestation_service_server::AttestationService for AttestationImpl { + async fn prepare_attestation( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "PrepareAttestation (external signing) lands with its slice", + )) + } + + async fn record_attestation( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let target = request.target.ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "attestation target required") + })?; + let target_kind = target.kind.ok_or_else(|| { + domain_status( + ErrorCode::InvalidArgument, + "attestation target kind required", + ) + })?; + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("RecordAttestation", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let identity_name = request.identity_did.clone(); + let meta = request.meta.clone(); + + let response = tokio::task::spawn_blocking(move || match target_kind { + TargetKind::IntentId(intent_id) => { + let repo = handle.repository()?; + let entry = repo + .vault_intent_show(&intent_id) + .map_err(repository_error)?; + let frontmatter = parse_frontmatter(&entry)?; + let body = body_of(&entry); + let material = load_identity(if identity_name.is_empty() { + None + } else { + Some(&identity_name) + })?; + let node: CanonicalNode = + lift_and_attest(&frontmatter, &body, &material.identity, &material.keypair) + .map_err(|error| { + domain_status(ErrorCode::InvalidArgument, format!("attest: {error}")) + })?; + let report = validate_intent(&node); + if !report.conforms { + return Err(domain_status( + ErrorCode::PreconditionFailed, + format!("attested intent does not conform: {}", report), + )); + } + verify(&node, &material.keypair.public).map_err(|error| { + domain_status(ErrorCode::Internal, format!("self-check failed: {error}")) + })?; + let normalized = normalized_id(&repo, &intent_id); + let sanitized = sanitize_id(&normalized); + let source_hash = source_content_hash(&frontmatter, &body); + let node_json = + serde_json::to_value(&node).map_err(|e| Status::internal(e.to_string()))?; + let sidecar = repo + .dot_dir() + .join("canonical") + .join("intents") + .join(&sanitized) + .join("attested.jsonld"); + let sidecar_body = serde_json::json!({ + "node": node_json, + "source": { "sourceContentHash": source_hash }, + }); + write_all( + &sidecar, + serde_json::to_vec_pretty(&sidecar_body) + .map_err(|e| Status::internal(e.to_string()))? + .as_slice(), + )?; + let vault_path = format!("attestations/{sanitized}/attested.md"); + let attested_body = format!( + "{}\n", + serde_json::to_string_pretty(&node_json) + .map_err(|e| Status::internal(e.to_string()))? + ); + let attested_frontmatter = serde_json::json!({ + "intentId": intent_id, + "sourceContentHash": source_hash, + }); + repo.vault_store( + &vault_path, + VaultEntryType::Attestation, + attested_body.into_bytes(), + attested_frontmatter.to_string(), + ) + .map_err(repository_error)?; + repo.vault_materialize(&vault_path) + .map_err(repository_error)?; + Ok::<_, Status>(RecordAttestationResponse { + attestation: Some(AttestationInfo { + id: normalized, + target: Some(AttestationTarget { + kind: Some(TargetKind::IntentId(intent_id.clone())), + }), + identity_did: node.attributed_to.clone().unwrap_or_default(), + signature: None, + recorded_at: Some(prost_types::Timestamp { + seconds: chrono::Utc::now().timestamp(), + nanos: 0, + }), + vault_path: Some(format!(".vault/{vault_path}")), + sidecar_path: Some(sidecar.display().to_string()), + }), + meta: response_meta(&meta), + }) + } + TargetKind::MemoryId(memory_id) => { + let repo = handle.repository()?; + let vault_path = format!("memory/{memory_id}.md"); + let entry = repo + .vault_retrieve(&vault_path) + .map_err(repository_error)? + .ok_or_else(|| domain_status(ErrorCode::NotFound, "memory not found"))?; + let frontmatter = parse_frontmatter(&entry)?; + let body = body_of(&entry); + let material = load_identity(if identity_name.is_empty() { + None + } else { + Some(&identity_name) + })?; + let node: MemoryNode = lift_and_attest_memory( + &frontmatter, + &body, + &material.identity, + &material.keypair, + ) + .map_err(|error| { + domain_status(ErrorCode::InvalidArgument, format!("attest: {error}")) + })?; + let report = validate_memory(&node); + if !report.conforms { + return Err(domain_status( + ErrorCode::PreconditionFailed, + format!("attested memory does not conform: {}", report), + )); + } + verify_memory(&node, &material.keypair.public).map_err(|error| { + domain_status(ErrorCode::Internal, format!("self-check failed: {error}")) + })?; + let sanitized = sanitize_id(&memory_id); + let source_hash = source_content_hash(&frontmatter, &body); + let node_json = + serde_json::to_value(&node).map_err(|e| Status::internal(e.to_string()))?; + let sidecar = repo + .dot_dir() + .join("canonical") + .join("memory") + .join(&sanitized) + .join("attested.jsonld"); + let sidecar_body = serde_json::json!({ + "node": node_json, + "source": { "sourceContentHash": source_hash }, + }); + write_all( + &sidecar, + serde_json::to_vec_pretty(&sidecar_body) + .map_err(|e| Status::internal(e.to_string()))? + .as_slice(), + )?; + let attested_vault_path = format!("attestations/memory/{sanitized}/attested.md"); + let attested_body = format!( + "{}\n", + serde_json::to_string_pretty(&node_json) + .map_err(|e| Status::internal(e.to_string()))? + ); + let attested_frontmatter = serde_json::json!({ + "memoryId": memory_id, + "sourceContentHash": source_hash, + }); + repo.vault_store( + &attested_vault_path, + VaultEntryType::Attestation, + attested_body.into_bytes(), + attested_frontmatter.to_string(), + ) + .map_err(repository_error)?; + repo.vault_materialize(&attested_vault_path) + .map_err(repository_error)?; + Ok(RecordAttestationResponse { + attestation: Some(AttestationInfo { + id: memory_id.clone(), + target: Some(AttestationTarget { + kind: Some(TargetKind::MemoryId(memory_id.clone())), + }), + identity_did: node.attributed_to.clone().unwrap_or_default(), + signature: None, + recorded_at: Some(prost_types::Timestamp { + seconds: chrono::Utc::now().timestamp(), + nanos: 0, + }), + vault_path: Some(format!(".vault/{attested_vault_path}")), + sidecar_path: Some(sidecar.display().to_string()), + }), + meta: response_meta(&meta), + }) + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("attestation target {other:?} lands with its slice"), + )), + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } + + async fn verify_attestation( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("VerifyAttestation", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let target = match request + .target + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "verify target required"))? + { + verify_attestation_request::Target::AttestationId(_id) => { + return Ok(Response::new(VerifyAttestationResponse { + valid: false, + reason: Some("attestation-id verification lands with its slice".to_string()), + })) + } + verify_attestation_request::Target::Subject(subject) => { + subject.kind.ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "verify target kind required") + })? + } + }; + let identity_name = request.identity_name.clone(); + let response = tokio::task::spawn_blocking(move || match target { + TargetKind::IntentId(intent_id) => { + let repo = handle.repository()?; + let entry = repo + .vault_intent_show(&intent_id) + .map_err(repository_error)?; + let frontmatter = parse_frontmatter(&entry)?; + let body = body_of(&entry); + let normalized = normalized_id(&repo, &intent_id); + let vault_path = format!("attestations/{}/attested.md", sanitize_id(&normalized)); + let attested = repo + .vault_retrieve(&vault_path) + .map_err(repository_error)? + .ok_or_else(|| { + domain_status( + ErrorCode::NotFound, + "intent has no attestation; run `atomic intent attest`", + ) + })?; + let node: CanonicalNode = serde_json::from_str(body_of(&attested).trim_end()) + .map_err(|e| { + domain_status( + ErrorCode::Internal, + format!("attested node unreadable: {e}"), + ) + })?; + let recorded_hash = serde_json::from_str::(&attested.frontmatter_json) + .ok() + .and_then(|v| { + v.get("sourceContentHash") + .and_then(Value::as_str) + .map(str::to_string) + }); + if recorded_hash.as_deref() != Some(&source_content_hash(&frontmatter, &body)) { + return Ok(VerifyAttestationResponse { + valid: false, + reason: Some( + "attestation is stale: the intent changed since it was signed" + .to_string(), + ), + }); + } + let material = load_identity(identity_name.as_deref())?; + match verify(&node, &material.keypair.public) { + Ok(()) => Ok(VerifyAttestationResponse { + valid: true, + reason: node.attributed_to.clone(), + }), + Err(error) => Ok(VerifyAttestationResponse { + valid: false, + reason: Some(error.to_string()), + }), + } + } + TargetKind::MemoryId(memory_id) => { + let repo = handle.repository()?; + let vault_path = format!("memory/{memory_id}.md"); + let entry = repo + .vault_retrieve(&vault_path) + .map_err(repository_error)? + .ok_or_else(|| domain_status(ErrorCode::NotFound, "memory not found"))?; + let frontmatter = parse_frontmatter(&entry)?; + let body = body_of(&entry); + let attested_path = format!( + "attestations/memory/{}/attested.md", + sanitize_id(&memory_id) + ); + let attested = repo + .vault_retrieve(&attested_path) + .map_err(repository_error)? + .ok_or_else(|| { + domain_status( + ErrorCode::NotFound, + "memory has no attestation; run `atomic memory attest`", + ) + })?; + let node: MemoryNode = serde_json::from_str(body_of(&attested).trim_end()) + .map_err(|e| { + domain_status( + ErrorCode::Internal, + format!("attested node unreadable: {e}"), + ) + })?; + let recorded_hash = serde_json::from_str::(&attested.frontmatter_json) + .ok() + .and_then(|v| { + v.get("sourceContentHash") + .and_then(Value::as_str) + .map(str::to_string) + }); + if recorded_hash.as_deref() != Some(&source_content_hash(&frontmatter, &body)) { + return Ok(VerifyAttestationResponse { + valid: false, + reason: Some( + "attestation is stale: the memory changed since it was signed" + .to_string(), + ), + }); + } + let material = load_identity(identity_name.as_deref())?; + match verify_memory(&node, &material.keypair.public) { + Ok(()) => Ok(VerifyAttestationResponse { + valid: true, + reason: node.attributed_to.clone(), + }), + Err(error) => Ok(VerifyAttestationResponse { + valid: false, + reason: Some(error.to_string()), + }), + } + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("attestation target {other:?} lands with its slice"), + )), + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } + + async fn list_attestations( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ListAttestations", Some(&handle)); + // The agent-attest read path filters by view; the unfiltered listing + // walks the whole audit graph and lands with its slice. + let view = request + .filter + .as_ref() + .and_then(|filter| filter.kind.clone()); + let view = match view { + Some(TargetKind::GraphStats(stats)) => stats.session_id, + _ => None, + }; + let view_name = view.unwrap_or_else(|| handle.current_view()); + let handle_for_task = handle.clone(); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let attestations = tokio::task::spawn_blocking(move || { + let repo = handle_for_task.repository()?; + let results = repo + .find_attestations_for_view(&view_name) + .map_err(repository_error)?; + Ok::<_, Status>( + results + .into_iter() + .map(|(hash, _, _covered)| AttestationInfo { + id: format!( + "attestation:{}", + super::state::hash_bytes(&hash) + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ), + target: Some(AttestationTarget { + kind: Some(TargetKind::GraphStats(GraphStatsRef { + session_id: None, + turn_number: None, + change_hash: Some(super::convert::hash_proto(&hash)), + })), + }), + identity_did: String::new(), + signature: None, + recorded_at: None, + vault_path: None, + sidecar_path: None, + }) + .collect::>(), + ) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(ListAttestationsResponse { + attestations, + next_cursor: None, + })) + } +} + +// --------------------------------------------------------------------------- +// KnowledgeService +// --------------------------------------------------------------------------- + +pub struct KnowledgeImpl { + pub state: Arc, +} + +#[tonic::async_trait] +impl knowledge_service_server::KnowledgeService for KnowledgeImpl { + async fn query_graph( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("QueryGraph", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let query = request + .query + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "query kind required"))?; + let limit = if request.limit == 0 { + 10 + } else { + request.limit as usize + }; + let root = handle.root.clone(); + let response = tokio::task::spawn_blocking(move || match query { + QueryOneof::KgSearch(search) => { + let repo = handle.repository()?; + let pool = 5000usize; + let nodes = repo + .vault_kg_search(&search.query, pool, Some(pool)) + .map_err(repository_error)?; + let nodes: Vec<_> = nodes + .into_iter() + .take(limit) + .map(|node| KgNode { + id: node.id.clone(), + node_type: node.kind.clone(), + name: Some(node.label.clone()), + data: node.summary.clone().map(String::into_bytes), + score: None, + }) + .collect(); + Ok::<_, Status>(QueryGraphResponse { + nodes, + edges: Vec::new(), + hits: Vec::new(), + answer: None, + plan_result: None, + snapshot: None, + }) + } + QueryOneof::Neighbors(neighbors) => { + let repo = handle.repository()?; + let (nodes, edges) = + crate::daemon::services_query::neighbors_impl(&repo, &neighbors.node_id, 1)?; + Ok(QueryGraphResponse { + nodes, + edges, + hits: Vec::new(), + answer: None, + plan_result: None, + snapshot: None, + }) + } + QueryOneof::Entity(entity) => { + // `query entities ` — tree-sitter extraction over the + // working-copy file. This is a working-copy READ, not a + // repository database operation. + let source = + std::fs::read_to_string(root.join(&entity.node_id)).map_err(|error| { + domain_status( + ErrorCode::NotFound, + format!("cannot read {}: {error}", entity.node_id), + ) + })?; + let mut registry = atomic_semantic::ParserRegistry::new(); + let entities = registry.extract(&source, &entity.node_id); + let nodes = entities + .into_iter() + .map(|entity| KgNode { + id: format!("entity:{}:{}:{}", entity.file, entity.name, entity.line), + node_type: entity.kind.to_string(), + name: Some(entity.name.clone()), + data: entity.signature.clone().map(String::into_bytes), + score: None, + }) + .collect(); + Ok(QueryGraphResponse { + nodes, + edges: Vec::new(), + hits: Vec::new(), + answer: None, + plan_result: None, + snapshot: None, + }) + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("query kind {other:?} lands with its slice"), + )), + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } + + async fn maintain_knowledge_graph( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let action = KnowledgeMaintainAction::try_from(request.action) + .unwrap_or(KnowledgeMaintainAction::Unspecified); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("MaintainKnowledgeGraph", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let response = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let processed = match action { + KnowledgeMaintainAction::Enrich => { + // The CLI --rebuild flag rides the scope field ("rebuild"). + if request.scope.as_deref() == Some("rebuild") { + repo.kg_clear_derived().map_err(repository_error)?; + } + let mut total: u64 = 0; + total += repo.kg_enrich_views().map_err(repository_error)? as u64; + total += repo.kg_enrich_files().map_err(repository_error)? as u64; + total += repo.kg_enrich_modules().map_err(repository_error)? as u64; + total += repo.kg_enrich_changes().map_err(repository_error)? as u64; + total += repo.kg_enrich_entities().map_err(repository_error)? as u64; + total += repo.kg_enrich_includes().map_err(repository_error)? as u64; + total += repo.kg_enrich_calls().map_err(repository_error)? as u64; + total + } + KnowledgeMaintainAction::Reindex => { + repo.vault_reindex_kg().map_err(repository_error)? as u64 + } + other => { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!("maintain action {other:?} lands with its slice"), + )) + } + }; + Ok::<_, Status>(MaintainKnowledgeGraphResponse { + processed, + meta: response_meta(&meta), + }) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) + } +} + +// --------------------------------------------------------------------------- +// ProvenanceService — the transitional turn-dispatch seam +// --------------------------------------------------------------------------- + +pub struct ProvenanceImpl { + pub state: Arc, +} + +#[tonic::async_trait] +impl provenance_service_server::ProvenanceService for ProvenanceImpl { + async fn dispatch_turn_event( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("DispatchTurnEvent", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let agent_id = request.agent_id.clone(); + let display = request + .agent_display_name + .clone() + .unwrap_or_else(|| agent_id.clone()); + let identity = request.agent_identity.clone(); + let body = request + .event + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "typed event required"))?; + let hook_type = hook_type(&body.event_type).ok_or_else(|| { + domain_status( + ErrorCode::InvalidArgument, + format!("unknown event type '{}'", body.event_type), + ) + })?; + let raw_json = body + .raw_json + .as_ref() + .filter(|bytes| !bytes.is_empty()) + .and_then(|bytes| serde_json::from_slice::(bytes).ok()); + let event = TurnEvent { + session_id: body.session_id.clone(), + event_type: hook_type, + transcript_path: None, + prompt: body.prompt.clone(), + tool_name: body.tool_name.clone(), + tool_use_id: body.tool_use_id.clone(), + timestamp: body + .timestamp + .as_ref() + .map(timestamp_domain) + .unwrap_or_else(chrono::Utc::now), + raw_json, + }; + + // The daemon hosts the turn orchestrator: it owns the repository + // databases, so the dispatch (session view, recording, journal + // checkpoint) runs in-process with a direct journal sink. + let sink = DirectJournalSink::open(&handle.root) + .map_err(|error| domain_status(ErrorCode::ProvenanceStore, error))?; + let mut orchestrator = + TurnOrchestrator::new(handle.root.clone()) + .await + .map_err(|error| { + domain_status(ErrorCode::Internal, format!("orchestrator: {error}")) + })?; + orchestrator.set_agent(agent_id, display); + orchestrator.set_agent_identity(identity); + orchestrator.set_journal_sink(Arc::new(sink)); + let result = orchestrator.dispatch(event).await.map_err(|error| { + domain_status(ErrorCode::Internal, format!("dispatch failed: {error}")) + })?; + + let recorded = result.change_recorded.as_ref(); + Ok(Response::new(DispatchTurnEventResponse { + session_id: result.session_id.clone(), + recorded: recorded.is_some(), + change_hash: recorded.map(|outcome| super::convert::hash_proto(&outcome.hash)), + view: result.view.clone(), + files: recorded + .map(|outcome| outcome.recorded_file_list().to_vec()) + .unwrap_or_default(), + warnings: result.warnings.clone(), + meta: response_meta(&meta), + })) + } + + async fn reserve_turn( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_provenance::reserve_turn_impl(&self.state, request).await + } + + async fn append_envelopes( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_provenance::append_envelopes_impl(&self.state, request).await + } + + async fn prepare_checkpoint( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_provenance::prepare_checkpoint_impl(&self.state, request).await + } + + async fn load_frozen_envelopes( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_provenance::load_frozen_envelopes_impl(&self.state, request).await + } + + async fn bind_checkpoint_hash( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_provenance::bind_checkpoint_hash_impl(&self.state, request).await + } + + async fn acknowledge_checkpoint( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_provenance::acknowledge_checkpoint_impl(&self.state, request).await + } + + async fn update_turn( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_provenance::update_turn_impl(&self.state, request).await + } + + async fn get_turn( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_provenance::get_turn_impl(&self.state, request).await + } + + async fn get_session( + &self, + request: Request, + ) -> Result, Status> { + crate::daemon::services_query::get_session_impl(&self.state, request).await + } + + async fn list_sessions( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ListSessions", Some(&handle)); + let limit = if request.limit == 0 { + 10 + } else { + request.limit as usize + }; + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + let sessions = tokio::task::spawn_blocking(move || { + let repo = handle_for_task.repository()?; + let ledgers = repo.list_session_ledgers(limit).map_err(repository_error)?; + Ok::<_, Status>( + ledgers + .into_iter() + .map(|(record, turns)| SessionSummary { + session_id: record.session_id, + view: record.view_name, + turn_count: record.turn_count.max(turns.len() as u32), + started_at: Some(prost_types::Timestamp { + seconds: record.started_at, + nanos: 0, + }), + ended_at: record.ended_at.map(|ended| prost_types::Timestamp { + seconds: ended, + nanos: 0, + }), + }) + .collect::>(), + ) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(ListSessionsResponse { sessions })) + } + + async fn fork_session( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("ForkSession lands with its slice")) + } + + async fn rebuild_session_index( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "RebuildSessionIndex lands with its slice", + )) + } + + async fn get_provenance( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("GetProvenance lands with its slice")) + } + + async fn export_provenance( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "ExportProvenance lands with its slice", + )) + } +} diff --git a/libatomic/src/daemon/services_maintenance.rs b/libatomic/src/daemon/services_maintenance.rs new file mode 100644 index 00000000..e2cf5a95 --- /dev/null +++ b/libatomic/src/daemon/services_maintenance.rs @@ -0,0 +1,166 @@ +//! MaintenanceService handlers: repository repair and consistency. +//! +//! `atomic doctor` folds into Repair (the mutating actions) and +//! CheckRepository (the read-only consistency check). The read/write split +//! the state gate defers lands here for the read side: CheckRepository +//! opens the repository READ-ONLY (redb read-only handles coexist with +//! the writer's exclusive lock) and never acquires the per-repo gate — +//! a consistency check never queues behind writers, exactly as the +//! contract's "EXECUTES AS A READ TRANSACTION" demands. Repair is a +//! repository write: gated like every other mutating handler. + +use std::sync::Arc; + +use crate::atomic::maintenance_service_server::MaintenanceService; +use crate::atomic::*; +use atomic_repository::CrdtMaterializeOptions; +use tonic::{Request, Response, Status}; + +use super::services::default_ref; +use super::state::{domain_status, repository_error, DaemonState}; + +pub struct MaintenanceImpl { + pub state: Arc, +} + +fn repair_action(action: i32) -> Result { + use RepairAction as Action; + Ok(match action { + x if x == Action::RebuildDependencyIndex as i32 => Action::RebuildDependencyIndex, + x if x == Action::MaterializeCrdt as i32 => Action::MaterializeCrdt, + _ => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "repair action is required (REBUILD_DEPENDENCY_INDEX or MATERIALIZE_CRDT)", + )) + } + }) +} + +#[tonic::async_trait] +impl MaintenanceService for MaintenanceImpl { + async fn repair( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("Repair", Some(&handle)); + let action = repair_action(request.action)?; + let force = request.force; + let view = request.view; + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || -> Result { + let repo = handle.repository()?; + match action { + RepairAction::RebuildDependencyIndex => { + let (indexed, skipped, failed) = repo + .repair_change_dependency_index(force) + .map_err(repository_error)?; + Ok(RepairResponse { + findings: Vec::new(), + repaired: indexed as u64, + indexed: indexed as u64, + skipped: skipped as u64, + failed: failed as u64, + ..Default::default() + }) + } + RepairAction::MaterializeCrdt => { + let outcome = repo + .materialize_crdt_from_changes(CrdtMaterializeOptions { + view: if view.is_empty() { None } else { Some(view) }, + force, + }) + .map_err(repository_error)?; + Ok(RepairResponse { + findings: Vec::new(), + repaired: outcome.changes_applied as u64, + indexed: 0, + skipped: 0, + failed: 0, + changes_scanned: outcome.changes_scanned as u64, + changes_applied: outcome.changes_applied as u64, + file_ops_applied: outcome.file_ops_applied as u64, + file_ops_already_materialized: outcome.file_ops_already_materialized as u64, + file_ops_skipped: outcome.file_ops_skipped as u64, + elapsed_ms: outcome.elapsed_ms as u64, + trunks_created: outcome.stats.trunks_created as u64, + branches_created: outcome.stats.branches_created as u64, + leaves_created: outcome.stats.leaves_created as u64, + skip_stats: Some(CrdtSkipStats { + non_create_trunk: outcome.skip_stats.non_create_trunk as u64, + unresolved_path: outcome.skip_stats.unresolved_path as u64, + unresolved_line: outcome.skip_stats.unresolved_line as u64, + missing_content_range: outcome.skip_stats.missing_content_range as u64, + non_insert_branch: outcome.skip_stats.non_insert_branch as u64, + non_insert_leaf: outcome.skip_stats.non_insert_leaf as u64, + }), + skip_samples: outcome.skip_samples.clone(), + meta: None, + }) + } + RepairAction::Unspecified => unreachable!("validated above"), + } + }) + .await + .map_err(|error| { + domain_status( + ErrorCode::Internal, + format!("repair task panicked: {error}"), + ) + })??; + Ok(Response::new(result)) + } + + async fn check_repository( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self + .state + .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; + self.state.log_rpc("CheckRepository", Some(&handle)); + // No gate, no writer queue: read-only opens coexist with the + // writer's exclusive handle, so the check runs concurrent with + // in-flight writes ("EXECUTES AS A READ TRANSACTION"). + let root = handle.root.clone(); + let result = tokio::task::spawn_blocking(move || { + let repo = atomic_repository::Repository::open_readonly(&root).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("failed to open repository read-only: {error}"), + ) + })?; + let report = repo.verify_working_copy().map_err(repository_error)?; + Ok::<_, Status>(CheckRepositoryResponse { + findings: report.problems.iter().map(|p| p.to_string()).collect(), + consistent: report.is_healthy(), + clean_files_checked: report.clean_files_checked as u64, + uncommitted_skipped: report.uncommitted_skipped as u64, + conflicted_files: report.conflicted_files as u64, + }) + }) + .await + .map_err(|error| { + domain_status( + ErrorCode::Internal, + format!("consistency check task panicked: {error}"), + ) + })??; + Ok(Response::new(result)) + } + + async fn reindex_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "ReindexWorkspace lands with its slice", + )) + } +} diff --git a/libatomic/src/daemon/services_provenance.rs b/libatomic/src/daemon/services_provenance.rs new file mode 100644 index 00000000..bdbc8651 --- /dev/null +++ b/libatomic/src/daemon/services_provenance.rs @@ -0,0 +1,598 @@ +//! ProvenanceService journal handlers — the wire half of the provenance +//! journal port (Slice 2). +//! +//! Each of the eight journal RPCs maps its request onto the shared +//! [`crate::daemon::provenance_core`] functions (the same code path the +//! in-process dispatch uses), behind the per-repository gate — changes.redb +//! is one writable handle per process, exactly like pristine. +//! +//! Semantics parity with the legacy owner protocol +//! (`handle_request`): generation fencing with the store's verbatim error +//! text, caller-supplied `now` (never a server clock), the five named +//! crash-injection failpoints, prepare's reuse_frozen_changes recovery, and +//! strict-continuity frozen-envelope paging. Error classification follows +//! the legacy codes: reserve/append → PROVENANCE_STORE, prepare/load/bind/ +//! acknowledge → PROVENANCE_CHECKPOINT, lifecycle → PROVENANCE_LIFECYCLE. + +use std::sync::Arc; + +use crate::atomic::ErrorCode; +use crate::atomic::{ + AcknowledgeCheckpointRequest, AcknowledgeCheckpointResponse, AppendEnvelopesRequest, + AppendEnvelopesResponse, BindCheckpointHashRequest, BindCheckpointHashResponse, + CheckpointSource, EnvelopeAck, FrozenFragment, FrozenPage, GetTurnRequest, GetTurnResponse, + HashAlgorithm, LoadFrozenEnvelopesRequest, LoadFrozenEnvelopesResponse, PageCursor, + PrepareCheckpointRequest, PrepareCheckpointResponse, ProvenanceCheckpointAttempt, RequestMeta, + ReserveTurnRequest, ReserveTurnResponse, StopCause as PbStopCause, StopState as PbStopState, + StoredProvenanceTurn, TurnAction, UpdateTurnRequest, UpdateTurnResponse, +}; +use atomic_core::change::session::SessionTurn; +use atomic_core::types::Hash; +use atomic_repository::redb_change_store::{ + FrozenProvenanceCursor, ProvenanceCheckpointAttempt as StoreAttempt, ProvenanceCheckpointPhase, + ProvenanceCheckpointSource, ProvenanceId, ProvenanceTurnState, StopCause, StopState, + StoredProvenanceTurn as StoreTurn, +}; +use tonic::{Request, Response, Status}; + +use super::convert::hash_proto; +use super::provenance_core as core; +use super::services_agent::response_meta; +use super::state::{domain_status, DaemonState}; + +// --------------------------------------------------------------------------- +// domain ↔ protobuf mapping +// --------------------------------------------------------------------------- + +/// The caller-supplied observation time. The server NEVER substitutes its +/// own clock for observation times — `now` rides the request exactly like +/// the legacy protocol's explicit `now` field. +fn observed_now(meta: &Option) -> i64 { + meta.as_ref() + .and_then(|meta| meta.observed_at.as_ref()) + .map(|stamp| stamp.seconds) + .unwrap_or(0) +} + +fn timestamp(seconds: i64) -> prost_types::Timestamp { + prost_types::Timestamp { seconds, nanos: 0 } +} + +fn stored_turn_proto(turn: &StoreTurn) -> StoredProvenanceTurn { + use crate::atomic::ProvenanceTurnState as PbState; + let (state, stop_state) = match &turn.state { + ProvenanceTurnState::Running => (PbState::Running, None), + ProvenanceTurnState::Stopped(stop) => (PbState::Stopped, Some(stop_state_proto(stop))), + ProvenanceTurnState::Checkpointing => (PbState::Checkpointing, None), + ProvenanceTurnState::Completed => (PbState::Completed, None), + ProvenanceTurnState::Abandoned(stop) => (PbState::Abandoned, Some(stop_state_proto(stop))), + }; + StoredProvenanceTurn { + schema_version: turn.schema_version as u32, + provenance_id: turn.provenance_id.get(), + session_id: turn.session_id.clone(), + turn_number: turn.turn_number, + state: state as i32, + generation: turn.generation, + next_event_seq: turn.next_event_seq, + created_at: Some(timestamp(turn.created_at)), + updated_at: Some(timestamp(turn.updated_at)), + completed_at: turn.completed_at.map(timestamp), + final_hash: turn.final_hash.as_ref().map(hash_proto), + checkpoint_attempt: turn.checkpoint_attempt.as_ref().map(attempt_proto), + stop_state, + } +} + +fn stop_state_proto(stop: &StopState) -> PbStopState { + PbStopState { + cause: match stop.cause { + StopCause::UserRequested => PbStopCause::UserRequested, + StopCause::LeaseExpired => PbStopCause::LeaseExpired, + StopCause::ProcessExited => PbStopCause::ProcessExited, + StopCause::HookFailure => PbStopCause::HookFailure, + StopCause::SystemShutdown => PbStopCause::SystemShutdown, + StopCause::Abandoned => PbStopCause::Abandoned, + } as i32, + observed_at: Some(timestamp(stop.observed_at)), + last_event_seq: stop.last_event_seq, + resumable: stop.resumable, + } +} + +fn checkpoint_source_proto(source: &ProvenanceCheckpointSource) -> CheckpointSource { + CheckpointSource { + agent_name: source.agent_name.clone(), + agent_display_name: source.agent_display_name.clone(), + agent_vendor: source.agent_vendor.clone(), + change_hashes: source + .change_hashes + .iter() + .map(hash_proto) + .collect(), + previous_provenance: source + .previous_provenance + .as_ref() + .map(hash_proto), + plan_id: source.plan_id.clone(), + ledger_turn_number: source.ledger_turn_number, + } +} + +fn attempt_proto(attempt: &StoreAttempt) -> ProvenanceCheckpointAttempt { + use crate::atomic::CheckpointPhase as PbPhase; + let phase = match attempt.phase { + ProvenanceCheckpointPhase::Prepared => PbPhase::Prepared, + ProvenanceCheckpointPhase::HashBound => PbPhase::HashBound, + ProvenanceCheckpointPhase::Published => PbPhase::Published, + }; + ProvenanceCheckpointAttempt { + attempt_generation: attempt.attempt_generation, + frozen_event_count: attempt.frozen_event_count, + source: Some(checkpoint_source_proto(&attempt.source)), + phase: phase as i32, + provenance_hash: attempt + .provenance_hash + .as_ref() + .map(hash_proto), + session_turn: attempt + .session_turn + .as_ref() + .map(|turn| crate::atomic::SessionTurn { + session_id: turn.session_id.clone(), + turn_number: turn.turn_number, + }), + manifest_hash: attempt.manifest_hash.as_ref().map(hash_proto), + prepared_at: Some(timestamp(attempt.prepared_at)), + updated_at: Some(timestamp(attempt.updated_at)), + } +} + +fn hash_domain(hash: &Option) -> Result { + let hash = hash + .as_ref() + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "hash is required"))?; + let bytes: [u8; 32] = + hash.value.clone().try_into().map_err(|_| { + domain_status(ErrorCode::InvalidArgument, "hash must be exactly 32 bytes") + })?; + Ok(Hash::from_bytes(bytes)) +} + +fn checkpoint_source_domain(source: &CheckpointSource) -> ProvenanceCheckpointSource { + ProvenanceCheckpointSource { + agent_name: source.agent_name.clone(), + agent_display_name: source.agent_display_name.clone(), + agent_vendor: source.agent_vendor.clone(), + change_hashes: source + .change_hashes + .iter() + .filter_map(|hash| hash.value.clone().try_into().ok()) + .map(Hash::from_bytes) + .collect(), + previous_provenance: source + .previous_provenance + .as_ref() + .and_then(|hash| hash.value.clone().try_into().ok().map(Hash::from_bytes)), + plan_id: source.plan_id.clone(), + ledger_turn_number: source.ledger_turn_number, + } +} + +/// The wire SessionTurn carries only the identity pair; the domain bind +/// requires the full stored turn shape. Enrich from the prepared +/// checkpoint's frozen source (change hashes, previous provenance, plan, +/// prepared_at as the stable timestamp) so a retried bind reconstructs +/// the IDENTICAL session turn and hits the store's idempotency path — +/// the goal/todos fields remain a wire-contract limitation (the +/// in-process dispatch path keeps the full session turn). +fn enriched_session_turn( + store: &atomic_repository::redb_change_store::RedbChangeStore, + id: ProvenanceId, + turn: &crate::atomic::SessionTurn, + hash: Hash, + now: i64, +) -> SessionTurn { + let mut enriched = SessionTurn { + session_id: turn.session_id.clone(), + turn_number: turn.turn_number, + goal: None, + provenance_hash: hash, + change_hashes: Vec::new(), + previous_provenance: None, + timestamp: now, + plan_id: None, + todos: Vec::new(), + }; + if let Ok(Some(stored)) = store.get_provenance_turn(id) { + if let Some(attempt) = stored.checkpoint_attempt { + enriched.change_hashes = attempt.source.change_hashes; + enriched.previous_provenance = attempt.source.previous_provenance; + enriched.plan_id = attempt.source.plan_id; + // Deterministic across retries (a retry's observed_at differs; + // the prepared_at does not) — bind idempotency depends on it. + enriched.timestamp = attempt.prepared_at; + } + } + enriched +} + +fn core_status(error: core::JournalError) -> Status { + domain_status(error.code, error.message) +} + +fn hash_is_blake3(hash: &crate::atomic::Hash) -> bool { + hash.algorithm == HashAlgorithm::Blake3 as i32 || hash.algorithm == 0 +} + +// --------------------------------------------------------------------------- +// ProvenanceService — the eight journal RPCs +// --------------------------------------------------------------------------- + +pub async fn reserve_turn_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("ReserveTurn", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let now = observed_now(&request.meta); + let session_id = request.session_id.clone(); + let turn_number = request.turn_number; + let response = tokio::task::spawn_blocking(move || { + let store = handle.change_store()?; + let turn = + core::reserve_turn(&store, &session_id, turn_number, now).map_err(core_status)?; + // reserve_provenance_turn returns only after txn.commit(). + Ok::<_, Status>(ReserveTurnResponse { + turn: Some(stored_turn_proto(&turn)), + committed: true, + meta: response_meta(&meta), + }) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(response)) +} + +pub async fn append_envelopes_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("AppendEnvelopes", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let now = observed_now(&request.meta); + let provenance_id = request.provenance_id; + let expected_generation = request.expected_generation; + let batch: Vec<(String, Vec)> = request + .envelopes + .into_iter() + .map(|envelope| (envelope.event_id, envelope.envelope)) + .collect(); + let response = tokio::task::spawn_blocking(move || { + let store = handle.change_store()?; + let refs: Vec<(&str, &[u8])> = batch + .iter() + .map(|(event_id, bytes)| (event_id.as_str(), bytes.as_slice())) + .collect(); + let stored = core::append_envelopes( + &store, + ProvenanceId::new(provenance_id), + expected_generation, + &refs, + now, + ) + .map_err(core_status)?; + let acknowledgements = stored + .into_iter() + .map(|event| EnvelopeAck { + event_id: event.event_id, + sequence: event.seq, + }) + .collect(); + Ok::<_, Status>(AppendEnvelopesResponse { + acknowledgements, + meta: response_meta(&meta), + }) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(response)) +} + +pub async fn prepare_checkpoint_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("PrepareCheckpoint", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let now = observed_now(&request.meta); + let provenance_id = request.provenance_id; + let expected_generation = request.expected_generation; + let reuse = request.reuse_frozen_changes; + let source = request + .source + .as_ref() + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "checkpoint source is required"))? + .clone(); + let response = tokio::task::spawn_blocking(move || { + let store = handle.change_store()?; + let attempt = core::prepare_checkpoint( + &store, + ProvenanceId::new(provenance_id), + expected_generation, + checkpoint_source_domain(&source), + reuse, + now, + ) + .map_err(core_status)?; + Ok::<_, Status>(PrepareCheckpointResponse { + attempt: Some(attempt_proto(&attempt)), + meta: response_meta(&meta), + }) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(response)) +} + +pub async fn load_frozen_envelopes_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("LoadFrozenEnvelopes", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let provenance_id = request.provenance_id; + let attempt_generation = request.attempt_generation; + let frozen_event_count = request.frozen_event_count; + let cursor = request + .cursor + .map(|cursor| FrozenProvenanceCursor { + seq: cursor.seq, + offset: cursor.offset as usize, + }) + .unwrap_or_default(); + let requested_budget = request + .budget + .as_ref() + .and_then(|budget| budget.max_bytes) + .map(|max| max as usize); + let response = tokio::task::spawn_blocking(move || { + let store = handle.change_store()?; + let page = core::load_frozen_page( + &store, + ProvenanceId::new(provenance_id), + attempt_generation, + frozen_event_count, + cursor, + requested_budget, + ) + .map_err(core_status)?; + let fragments = page + .fragments + .into_iter() + .map(|fragment| FrozenFragment { + cursor: Some(PageCursor { + seq: fragment.cursor.seq, + offset: fragment.cursor.offset as u64, + }), + bytes: fragment.bytes, + complete: fragment.complete, + }) + .collect(); + Ok::<_, Status>(LoadFrozenEnvelopesResponse { + page: Some(FrozenPage { + fragments, + next: Some(PageCursor { + seq: page.next.seq, + offset: page.next.offset as u64, + }), + }), + }) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(response)) +} + +pub async fn bind_checkpoint_hash_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("BindCheckpointHash", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let now = observed_now(&request.meta); + let provenance_id = request.provenance_id; + let expected_generation = request.expected_generation; + let request_hash = request.hash.clone(); + let hash = hash_domain(&request_hash)?; + if !request_hash.as_ref().map(hash_is_blake3).unwrap_or(false) { + return Err(domain_status( + ErrorCode::InvalidArgument, + "only blake3 hashes are accepted", + )); + } + let session_turn = request + .session_turn + .clone() + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "session turn is required"))?; + let response = tokio::task::spawn_blocking(move || { + let store = handle.change_store()?; + let session_turn = enriched_session_turn( + &store, + ProvenanceId::new(provenance_id), + &session_turn, + hash, + now, + ); + let attempt = core::bind_checkpoint_hash( + &store, + ProvenanceId::new(provenance_id), + expected_generation, + hash, + session_turn, + now, + ) + .map_err(core_status)?; + Ok::<_, Status>(BindCheckpointHashResponse { + attempt: Some(attempt_proto(&attempt)), + meta: response_meta(&meta), + }) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(response)) +} + +pub async fn acknowledge_checkpoint_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("AcknowledgeCheckpoint", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + // Caller-supplied completion time — never the server clock. + let completed_at = request.completed_at.as_ref().map(|stamp| stamp.seconds); + let completed_at = completed_at + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "completed_at is required"))?; + let provenance_id = request.provenance_id; + let expected_generation = request.expected_generation; + let manifest_hash = hash_domain(&request.manifest_hash)?; + let response = tokio::task::spawn_blocking(move || { + let store = handle.change_store()?; + core::acknowledge_checkpoint( + &store, + ProvenanceId::new(provenance_id), + expected_generation, + manifest_hash, + completed_at, + ) + .map_err(core_status)?; + Ok::<_, Status>(AcknowledgeCheckpointResponse { + meta: response_meta(&meta), + }) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(response)) +} + +pub async fn update_turn_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("UpdateTurn", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let observed_at = observed_now(&request.meta); + let session_id = request.session_id.clone(); + let turn_number = request.turn_number; + let action = TurnAction::try_from(request.action) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown turn action"))?; + if action == TurnAction::Unspecified { + return Err(domain_status( + ErrorCode::InvalidArgument, + "turn action is required", + )); + } + // Required, nonzero: a lookup never grants a generation, so this is + // request-shape discipline (the transition uses the looked-up + // turn's own current generation, exactly like the legacy owner). + if request.expected_generation == 0 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "expected_generation is required and must be nonzero", + )); + } + let response = tokio::task::spawn_blocking(move || { + let store = handle.change_store()?; + let turn = match action { + TurnAction::Stop => { + let cause = request + .cause + .and_then(|cause| PbStopCause::try_from(cause).ok()) + .and_then(|cause| match cause { + PbStopCause::UserRequested => Some(StopCause::UserRequested), + PbStopCause::LeaseExpired => Some(StopCause::LeaseExpired), + PbStopCause::ProcessExited => Some(StopCause::ProcessExited), + PbStopCause::HookFailure => Some(StopCause::HookFailure), + PbStopCause::SystemShutdown => Some(StopCause::SystemShutdown), + PbStopCause::Abandoned => Some(StopCause::Abandoned), + PbStopCause::Unspecified => None, + }) + .ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "stop cause is required") + })?; + let resumable = request.resumable.unwrap_or(false); + core::stop_turn( + &store, + &session_id, + turn_number, + cause, + resumable, + observed_at, + ) + } + TurnAction::Resume => core::resume_turn(&store, &session_id, turn_number, observed_at), + TurnAction::Abandon => { + core::abandon_turn(&store, &session_id, turn_number, observed_at) + } + TurnAction::Unspecified => unreachable!("validated above"), + } + .map_err(core_status)?; + Ok::<_, Status>(UpdateTurnResponse { + turn: turn.as_ref().map(stored_turn_proto), + meta: response_meta(&meta), + }) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(response)) +} + +pub async fn get_turn_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("GetTurn", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let session_id = request.session_id.clone(); + let turn_number = request.turn_number; + let response = tokio::task::spawn_blocking(move || { + let store = handle.change_store()?; + let turn = core::get_turn(&store, &session_id, turn_number).map_err(core_status)?; + Ok::<_, Status>(GetTurnResponse { + turn: turn.as_ref().map(stored_turn_proto), + }) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(response)) +} diff --git a/libatomic/src/daemon/services_query.rs b/libatomic/src/daemon/services_query.rs new file mode 100644 index 00000000..640cebc5 --- /dev/null +++ b/libatomic/src/daemon/services_query.rs @@ -0,0 +1,762 @@ +//! Additional query-surface handlers landed after the first full-turn slice: +//! working-copy diff streaming, change lookup, session ledger, view listing, +//! goal listing, and KG neighbors — each surfaced by the live harness audit. + +use std::sync::Arc; + +use crate::atomic::ErrorCode; +use crate::atomic::*; +use atomic_core::pristine::ViewTxnT; +use atomic_core::types::Base32; +use atomic_repository::status::{FileStatus as DomainFileStatus, StatusOptions}; +use atomic_repository::Repository; +use tonic::{Request, Response, Status}; + +use super::convert::hash_proto; +use super::state::{domain_status, repository_error, DaemonState}; + +// --------------------------------------------------------------------------- +// DiffWorkingCopy (streaming) — `atomic diff` +// --------------------------------------------------------------------------- + +/// The handler body, shared with the trait impl in `services.rs`. +pub async fn diff_working_copy_impl( + state: &Arc, + request: Request, +) -> Result::DiffWorkingCopyStream>, Status>{ + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("DiffWorkingCopy", Some(&handle)); + let scope = request.working_copy.unwrap_or_default(); + let stat_only = request.stat_only; + let paths = scope.paths; + + let (sender, receiver) = tokio::sync::mpsc::channel::>(16); + let handle_for_task = handle; + tokio::spawn(async move { + let gate_handle = handle_for_task.clone(); + let _gate = gate_handle.exclusive().await; + let result = tokio::task::spawn_blocking(move || { + compute_working_copy_diff(&handle_for_task, &paths, scope.include_untracked, stat_only) + }) + .await + .unwrap_or_else(|error| Err(Status::internal(error.to_string()))); + match result { + Ok(chunks) => { + for chunk in chunks { + if sender.send(Ok(chunk)).await.is_err() { + break; // client went away + } + } + } + Err(error) => { + let _ = sender.send(Err(error)).await; + } + } + }); + Ok(Response::new(Box::pin( + tokio_stream::wrappers::ReceiverStream::new(receiver), + ))) +} + +/// Compute the working-copy diff chunks: recorded graph content (via +/// `get_file_content_on_view`) against the on-disk bytes, unified-diffed +/// with `similar` — the same comparison the CLI's local path performs. +fn compute_working_copy_diff( + handle: &super::state::RepoHandle, + paths: &[String], + include_untracked: bool, + stat_only: bool, +) -> Result, Status> { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let view = repo.current_view().to_string(); + let status = repo + .status(StatusOptions::default()) + .map_err(repository_error)?; + + let mut targets: Vec<(std::path::PathBuf, DomainFileStatus)> = Vec::new(); + for entry in status.entries() { + let entry_status = entry.status(); + let wanted = match entry_status { + DomainFileStatus::Modified | DomainFileStatus::Added | DomainFileStatus::Deleted => { + true + } + DomainFileStatus::Untracked => include_untracked, + _ => false, + }; + if !wanted { + continue; + } + if !paths.is_empty() && !paths.iter().any(|p| entry.path().starts_with(p)) { + continue; + } + targets.push((entry.path().to_path_buf(), entry_status)); + } + + let mut chunks = Vec::new(); + for (path, file_status) in targets { + let path_str = path.display().to_string(); + let old_content: Vec = match file_status { + DomainFileStatus::Added | DomainFileStatus::Untracked => Vec::new(), + _ => repo + .get_file_content_on_view(&path, &view) + .map_err(repository_error)? + .unwrap_or_default(), + }; + let new_content: Option> = match file_status { + DomainFileStatus::Deleted => None, + _ => std::fs::read(handle.root.join(&path)).ok(), + }; + + let old_text = String::from_utf8_lossy(&old_content).into_owned(); + let new_text = match &new_content { + Some(bytes) => String::from_utf8_lossy(bytes).into_owned(), + None => String::new(), + }; + let old_binary = old_content.contains(&0); + let new_binary = new_content + .as_ref() + .map(|c| c.contains(&0)) + .unwrap_or(false); + if old_binary || new_binary { + chunks.push(DiffChunk { + path: path_str, + patch: None, + additions: 0, + deletions: 0, + binary: true, + }); + continue; + } + + let diff = similar::TextDiff::from_lines(&old_text, &new_text); + let mut additions = 0u64; + let mut deletions = 0u64; + for change in diff.iter_all_changes() { + match change.tag() { + similar::ChangeTag::Insert => additions += 1, + similar::ChangeTag::Delete => deletions += 1, + similar::ChangeTag::Equal => {} + } + } + let patch = if stat_only { + None + } else { + Some( + similar::TextDiff::from_lines(&old_text, &new_text) + .unified_diff() + .context_radius(3) + .to_string() + .into_bytes(), + ) + }; + chunks.push(DiffChunk { + path: path_str, + patch, + additions, + deletions, + binary: false, + }); + } + Ok(chunks) +} + +// --------------------------------------------------------------------------- +// GetChange — `atomic change ` +// --------------------------------------------------------------------------- + +pub async fn get_change_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("GetChange", Some(&handle)); + let reference = request + .r#ref + .and_then(|r| r.kind) + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "change ref required"))?; + let view = request + .view + .as_ref() + .map(|v| v.name.clone().unwrap_or_default()); + + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + let change = tokio::task::spawn_blocking(move || { + // A query: read-only open with the read-concurrency wait. + let repo = handle_for_task.repository_readonly()?; + let hash = match reference { + change_ref::Kind::Hash(hash) => { + let mut bytes = [0u8; 32]; + if hash.value.len() != 32 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "hash must be 32 bytes", + )); + } + bytes.copy_from_slice(&hash.value); + atomic_core::types::Merkle(bytes) + } + change_ref::Kind::Sequence(sequence) => { + let view_name = view.unwrap_or_else(|| repo.current_view().to_string()); + let txn = repo + .pristine() + .read_txn() + .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; + let view_state = txn + .get_view(&view_name) + .map_err(|error| domain_status(ErrorCode::View, error.to_string()))? + .ok_or_else(|| { + domain_status(ErrorCode::View, format!("view '{view_name}' not found")) + })?; + let entry = + atomic_repository::history::get_change_at_sequence(&txn, &view_state, sequence) + .map_err(|error| domain_status(ErrorCode::NotFound, error.to_string()))?; + entry.hash + } + }; + let change = repo + .load_change(&hash) + .map_err(|error| domain_status(ErrorCode::NotFound, error.to_string()))?; + Ok::<_, Status>((change, hash)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + + let (change, hash) = change; + let header = &change.hashed.header; + Ok(Response::new(GetChangeResponse { + change: Some(ChangeInfo { + hash: Some(hash_proto(&hash)), + message: Some(header.message.clone()), + description: header.description.clone(), + authors: header + .authors + .iter() + .map(|author| Author { + name: author.name.clone(), + email: author.email.clone(), + }) + .collect(), + recorded_at: Some(prost_types::Timestamp { + seconds: header.timestamp.timestamp(), + nanos: header.timestamp.timestamp_subsec_nanos() as i32, + }), + dependencies: change.hashed.dependencies.iter().map(hash_proto).collect(), + graph_section_count: 0, + semantic_section_count: 0, + content_chunk_count: 0, + has_provenance: change.has_provenance(), + has_unhashed: change.unhashed.is_some(), + has_signature: change.signature.is_some(), + }), + graph_section: None, + semantic_section: None, + signatures: Vec::new(), + content_chunks: None, + })) +} + +// --------------------------------------------------------------------------- +// GetSession — `atomic session show` +// --------------------------------------------------------------------------- + +pub async fn get_session_impl( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("GetSession", Some(&handle)); + let session_id = request.session_id.clone(); + + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + let ledger = tokio::task::spawn_blocking(move || { + let repo = handle_for_task.repository()?; + repo.get_session_ledger(&session_id) + .map_err(repository_error) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + + let Some((_record, turns)) = ledger else { + return Err(domain_status(ErrorCode::NotFound, "session not found")); + }; + let turns = turns + .into_iter() + .filter(|turn| { + let from = request.from_turn.unwrap_or(0); + let to = request.to_turn.unwrap_or(u32::MAX); + turn.turn_number >= from && turn.turn_number <= to + }) + .map(stored_turn_proto) + .collect(); + Ok(Response::new(GetSessionResponse { turns })) +} + +/// Map a stored session turn to the wire shape (the fields the ledger owns: +/// turn number, provenance hash, and the turn timestamp). +fn stored_turn_proto(turn: atomic_core::change::session::SessionTurn) -> StoredProvenanceTurn { + StoredProvenanceTurn { + schema_version: 1, + provenance_id: 0, + session_id: turn.session_id.clone(), + turn_number: turn.turn_number, + state: ProvenanceTurnState::Unspecified as i32, + generation: 0, + next_event_seq: 0, + created_at: Some(prost_types::Timestamp { + seconds: turn.timestamp, + nanos: 0, + }), + updated_at: None, + completed_at: None, + final_hash: Some(hash_proto(&turn.provenance_hash)), + checkpoint_attempt: None, + stop_state: None, + } +} + +// --------------------------------------------------------------------------- +// ListViews — `atomic view list` +// --------------------------------------------------------------------------- + +pub struct ViewImpl { + pub state: Arc, +} + +#[tonic::async_trait] +impl view_service_server::ViewService for ViewImpl { + async fn list_views( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ListViews", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + let views = tokio::task::spawn_blocking(move || { + // A query: read-only open with the read-concurrency wait. + let repo = handle_for_task.repository_readonly()?; + let names = repo.list_views().map_err(repository_error)?; + let mut infos = Vec::with_capacity(names.len()); + for name in names { + infos.push(repo.get_view_info(&name).map_err(repository_error)?); + } + Ok::<_, Status>(infos) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + + let current = handle.current_view(); + let views = views + .into_iter() + .map(|info| { + let name = info.name.clone(); + ViewInfo { + name: name.clone(), + scope: match info.scope { + atomic_core::pristine::ViewScope::Draft => ViewScope::Draft, + atomic_core::pristine::ViewScope::Shared => ViewScope::Shared, + } as i32, + parent: info.parent_name, + head: Some(hash_proto(&info.state)), + change_count: info.change_count, + current: name == current, + r#ref: Some(ViewRef { + view_id: blake3::hash(name.as_bytes()).as_bytes().to_vec(), + name: Some(name), + }), + snapshot: None, + } + }) + .collect(); + Ok(Response::new(ListViewsResponse { views })) + } + + async fn create_view( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("CreateView", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + let view = + tokio::task::spawn_blocking(move || -> Result<(String, usize, String), Status> { + let mut repo = handle_for_task.repository()?; + let name = request.name.trim().to_string(); + validate_view_name(&name)?; + if repo.view_exists(&name).map_err(repository_error)? { + return Err(domain_status( + ErrorCode::View, + format!("view '{name}' already exists"), + )); + } + // The overlay-chain mapping (views.rs): from_view anchors + // AND seeds on the source; empty/default anchors on the + // nearest SHARED ancestor with no seed — sibling drafts + // must not see each other through the overlay. + let (anchor, seed) = match request.base { + Some(create_view_request::Base::FromView(source)) => { + if !repo.view_exists(&source).map_err(repository_error)? { + return Err(domain_status( + ErrorCode::View, + format!("view '{source}' not found"), + )); + } + (Some(source.clone()), Some(source)) + } + Some(create_view_request::Base::Empty(_)) | None => (None, None), + }; + repo.create_overlay_view(&name, anchor.as_deref(), seed.as_deref()) + .map_err(repository_error)?; + let info = repo.get_view_info(&name).map_err(repository_error)?; + Ok(( + name, + info.change_count as usize, + info.parent_name.unwrap_or_default(), + )) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + let (name, change_count, parent) = view; + let current = handle.current_view(); + Ok(Response::new(CreateViewResponse { + view: Some(ViewInfo { + name: name.clone(), + scope: ViewScope::Unspecified as i32, + parent: if parent.is_empty() { + None + } else { + Some(parent) + }, + head: None, + change_count: change_count as u64, + current: name == current, + r#ref: Some(ViewRef { + view_id: blake3::hash(name.as_bytes()).as_bytes().to_vec(), + name: Some(name), + }), + snapshot: None, + }), + meta: None, + })) + } + + async fn switch_view( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("SwitchView", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + let files_written = tokio::task::spawn_blocking(move || -> Result { + let mut repo = handle_for_task.repository()?; + let name = request.view.trim().to_string(); + if !repo.view_exists(&name).map_err(repository_error)? { + return Err(domain_status( + ErrorCode::View, + format!("view '{name}' not found"), + )); + } + // The CLI's safety gate, verbatim: refuse to switch away + // from a dirty working copy (the wire carries no bypass — + // --force/--stash stay client-side). + if handle_for_task.current_view() != name { + let status = repo + .status(StatusOptions::default()) + .map_err(repository_error)?; + if !status.is_clean() { + return Err(domain_status( + ErrorCode::PreconditionFailed, + "working copy has unrecorded changes — record them or \ + switch with --force/--stash from the client", + )); + } + } + let result = repo.switch_view(&name).map_err(repository_error)?; + Ok(result.files_written as u64) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(SwitchViewResponse { + files_written, + meta: None, + })) + } + + async fn delete_view( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("DeleteView", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + tokio::task::spawn_blocking(move || -> Result<(), Status> { + let mut repo = handle_for_task.repository()?; + let name = request.view.trim().to_string(); + // Never switch implicitly: refuse the current view. + if handle_for_task.current_view() == name { + return Err(domain_status( + ErrorCode::View, + format!("cannot delete the current view '{name}' — switch first"), + )); + } + if !repo.view_exists(&name).map_err(repository_error)? { + return Err(domain_status( + ErrorCode::View, + format!("view '{name}' not found"), + )); + } + repo.delete_view(&name).map_err(repository_error)?; + Ok(()) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(DeleteViewResponse { meta: None })) + } + + async fn set_view_scope( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("SetViewScope", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + tokio::task::spawn_blocking(move || -> Result<(), Status> { + let repo = handle_for_task.repository()?; + let name = request.view.trim().to_string(); + if !repo.view_exists(&name).map_err(repository_error)? { + return Err(domain_status( + ErrorCode::View, + format!("view '{name}' not found"), + )); + } + let scope = match request.scope { + x if x == ViewScope::Draft as i32 => atomic_core::pristine::ViewScope::Draft, + x if x == ViewScope::Shared as i32 => atomic_core::pristine::ViewScope::Shared, + _ => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "view scope is required (DRAFT or SHARED)", + )) + } + }; + repo.set_view_scope(&name, scope) + .map_err(repository_error)?; + Ok(()) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(SetViewScopeResponse { meta: None })) + } + + async fn split_view( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("SplitView", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let handle_for_task = handle.clone(); + let result = tokio::task::spawn_blocking( + move || -> Result { + let mut repo = handle_for_task.repository()?; + let mut changes = Vec::with_capacity(request.changes.len()); + for hash in &request.changes { + let mut bytes = [0u8; 32]; + if hash.value.len() != 32 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "hash must be 32 bytes", + )); + } + bytes.copy_from_slice(&hash.value); + changes.push(atomic_core::types::Merkle(bytes)); + } + let outcome = repo + .split_view(atomic_repository::SplitOptions { + target_view: request.name.clone(), + from_view: if request.from_view.is_empty() { + None + } else { + Some(request.from_view.clone()) + }, + changes, + cascade: request.cascade, + dry_run: false, + materialize: request.materialize, + }) + .map_err(repository_error)?; + // A blocked split is a domain refusal carrying the + // dependent hashes — never a partial mutation. + if outcome.blocked { + let dependents = outcome + .dependents + .iter() + .map(|c| c.hash.to_base32()) + .collect::>() + .join(", "); + return Err(domain_status( + ErrorCode::PreconditionFailed, + format!( + "split blocked: {} change(s) remaining in '{}' depend on the split-out set: {dependents}", + outcome.dependents.len(), + outcome.from_view, + ), + )); + } + let map = |changes: &[atomic_repository::SplitChange]| { + changes + .iter() + .map(|c| SplitChangeInfo { + hash: Some(hash_proto(&c.hash)), + sequence: c.sequence, + }) + .collect::>() + }; + let view_info = repo + .get_view_info(&outcome.target_view) + .map_err(repository_error)?; + Ok(SplitViewResponse { + requested: map(&outcome.requested), + dependents: map(&outcome.dependents), + moved: map(&outcome.moved), + source_change_count: outcome.source_change_count, + target_change_count: outcome.target_change_count, + working_copy_updated: outcome.working_copy_updated, + files_written: outcome.files_written as u64, + files_removed: outcome.files_removed as u64, + view: Some(ViewInfo { + name: outcome.target_view.clone(), + scope: ViewScope::Unspecified as i32, + parent: view_info.parent_name.clone(), + head: None, + change_count: outcome.target_change_count, + current: handle_for_task.current_view() == outcome.target_view, + r#ref: Some(ViewRef { + view_id: blake3::hash(outcome.target_view.as_bytes()).as_bytes().to_vec(), + name: Some(outcome.target_view.clone()), + }), + snapshot: None, + }), + meta: None, + }) + }, + ) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(result)) + } +} + +/// The CLI's view-name rules (ported so the daemon refuses the same +/// names the local path would). +fn validate_view_name(name: &str) -> Result<(), Status> { + if name.is_empty() { + return Err(domain_status( + ErrorCode::InvalidArgument, + "view name cannot be empty", + )); + } + if name.len() > 100 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "view name cannot exceed 100 characters", + )); + } + if name.contains("..") || name.contains('/') || name.contains('\\') { + return Err(domain_status( + ErrorCode::InvalidArgument, + "view name cannot contain '..' or path separators", + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Goals — `atomic vault goal list` (ListVaultEntries kind=GOAL) +// --------------------------------------------------------------------------- + +pub fn list_goal_entries(repo: &Repository) -> Result, Status> { + let goals = repo.vault_goal_list(None).map_err(repository_error)?; + Ok(goals + .into_iter() + .map(|goal| { + let status = match goal.status.as_str() { + "active" => VaultEntityStatus::Active, + "suspended" => VaultEntityStatus::Suspended, + "completed" => VaultEntityStatus::Completed, + _ => VaultEntityStatus::Unspecified, + }; + VaultEntry { + kind: VaultEntityKind::Goal as i32, + id: goal.name.clone(), + title: goal.developer.clone(), + status: Some(status as i32), + body: None, + created_at: None, + updated_at: None, + linked: goal.intent.clone().into_iter().collect(), + vault_path: Some(format!(".vault/goals/{}/_goal.md", goal.name)), + priority: None, + } + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Neighbors — `atomic vault query neighbors` +// --------------------------------------------------------------------------- + +pub fn neighbors_impl( + repo: &Repository, + node_id: &str, + depth: u8, +) -> Result<(Vec, Vec), Status> { + let subgraph = repo + .vault_kg_neighbors(node_id, depth) + .map_err(repository_error)?; + let nodes = subgraph + .nodes + .into_iter() + .map(|node| KgNode { + id: node.id.clone(), + node_type: node.kind.clone(), + name: Some(node.label.clone()), + data: node.summary.clone().map(String::into_bytes), + score: None, + }) + .collect(); + let edges = subgraph + .edges + .into_iter() + .map(|edge| KgEdge { + source: edge.from_id, + target: edge.to_id, + relation: edge.kind, + }) + .collect(); + Ok((nodes, edges)) +} diff --git a/libatomic/src/daemon/state.rs b/libatomic/src/daemon/state.rs new file mode 100644 index 00000000..a69b3222 --- /dev/null +++ b/libatomic/src/daemon/state.rs @@ -0,0 +1,302 @@ +//! Daemon state: the repository registry, RPC logging, error mapping. +//! +//! One daemon multiplexes many repositories (RFC D3): each resolved root is +//! registered under its persistent ID and opened lazily, exactly once, and +//! the handle is reused for every subsequent request. redb permits one +//! writable process per database file — that process is this daemon. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; + +use crate::atomic::ErrorCode; +use crate::atomic::{ErrorInfo, RepositoryRef}; +use atomic_repository::redb_change_store::RedbChangeStore; +use atomic_repository::Repository; +use tonic::{Code, Status}; + +use prost::Message; + +/// Env-configured JSONL log of handled RPCs (one line per request, so +/// external auditing can pair client invocations with served requests). +pub const ENV_LOG_REQUESTS: &str = "ATOMIC_DAEMON_LOG_REQUESTS"; + +/// How long a read-only open retries when another process holds a +/// writable handle: short-lived writers (a recording agent turn, a stop +/// checkpoint publication) finish first; readers then share the database +/// across processes. The query handlers' policy — the same one the CLI's +/// query commands apply. +pub const READ_OPEN_WAIT: std::time::Duration = std::time::Duration::from_secs(10); + +/// One registered repository: canonical root + the per-repository +/// serialization gate. redb permits ONE handle per database file even +/// within a process, so the daemon never holds a repository handle across +/// requests: each RPC acquires this gate, opens the database, runs, and +/// drops the handle. (RFC D2's bounded per-repo queue, simplified to full +/// serialization for this slice — the read/write split is future work.) +pub struct RepoHandle { + pub root: PathBuf, + gate: tokio::sync::Mutex<()>, +} + +impl RepoHandle { + pub fn new(root: PathBuf) -> Self { + Self { + root, + gate: tokio::sync::Mutex::new(()), + } + } + + /// Serialize repository database access: at most one open handle at a + /// time for this repository. Acquire before any domain call. + pub async fn exclusive(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.gate.lock().await + } + + /// The persistent repository ID for this daemon: a blake3 digest of the + /// canonical root. Slice limitation: RFC D3 wants IDs assigned at init + /// and recorded in `.atomic` so they survive daemon restarts and follow + /// the repository across daemons; a path digest is stable across + /// restarts of the same layout and unambiguous locally. + pub fn repository_id(&self) -> Vec { + blake3::hash(self.root.to_string_lossy().as_bytes()) + .as_bytes() + .to_vec() + } + + /// The current view, read from the `.atomic/current_view` marker WITHOUT + /// opening any database (ResolveRepository is a lookup, not a storage + /// open — CONTRACT "routing" section). + pub fn current_view(&self) -> String { + let path = self.root.join(".atomic").join("current_view"); + std::fs::read_to_string(&path) + .map(|content| content.trim().to_string()) + .unwrap_or_else(|_| "dev".to_string()) + } + + pub fn repository_ref(&self) -> RepositoryRef { + RepositoryRef { + authority: "local".to_string(), + repository_id: self.repository_id(), + workspace_id: None, + } + } + + /// Open the repository database for this request. The caller drops the + /// handle when done; the gate guarantees exclusivity. `open_existing` + /// skips the table-init write transaction (the repository exists — the + /// daemon never bootstraps). + pub fn repository(&self) -> Result { + Repository::open_existing(&self.root).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!( + "failed to open repository at {}: {error}", + self.root.display() + ), + ) + }) + } + + /// Open the repository database READ-ONLY for this query, with the + /// read-concurrency wait: redb refuses any open while a writable + /// handle exists (this or another process), so a short-lived writer — + /// a recording agent turn in another process, a gated mutation in this + /// one — is given [`READ_OPEN_WAIT`] to finish before the read fails. + /// Readers share the database across processes the same way the CLI's + /// query commands always have. The caller must hold no other handle. + pub fn repository_readonly(&self) -> Result { + Repository::open_readonly_wait(&self.root, READ_OPEN_WAIT).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!( + "failed to open repository at {}: {error}", + self.root.display() + ), + ) + }) + } + + /// The redb-native change/provenance store (changes.redb) for this + /// request — opened by path WITHOUT opening the repository database. + /// The hosted turn orchestrator opens pristine itself; changes.redb is a + /// separate database file, so both can be open in-process together. + pub fn change_store(&self) -> Result { + let path = Repository::canonical_change_store_path(&self.root) + .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; + RedbChangeStore::open(&path).map_err(|error| { + domain_status( + ErrorCode::ProvenanceStore, + format!("failed to open {}: {error}", path.display()), + ) + }) + } +} + +pub struct DaemonState { + repos: Mutex, std::sync::Arc>>, + pub shutdown: tokio::sync::Notify, + log_path: Option, +} + +impl DaemonState { + pub fn new() -> Self { + Self { + repos: Mutex::new(HashMap::new()), + shutdown: tokio::sync::Notify::new(), + log_path: std::env::var_os(ENV_LOG_REQUESTS).map(PathBuf::from), + } + } + + /// Register (or return the already-registered) handle for a repository + /// root. The ID is derived from the canonical root, so re-resolution is + /// idempotent. + pub fn register(&self, root: PathBuf) -> std::sync::Arc { + let root = root.canonicalize().unwrap_or_else(|_| root.clone()); + let id = blake3::hash(root.to_string_lossy().as_bytes()) + .as_bytes() + .to_vec(); + let mut repos = self.repos.lock().expect("repository registry"); + repos + .entry(id) + .or_insert_with(|| std::sync::Arc::new(RepoHandle::new(root.clone()))) + .clone() + } + + /// Resolve a request's RepositoryRef to its handle. Unknown IDs fail + /// closed (REPOSITORY_NOT_FOUND) — never a filesystem fallback. + pub fn resolve(&self, reference: &RepositoryRef) -> Result, Status> { + if reference.authority != "local" { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!( + "this daemon serves the local authority; '{}' is not served here", + reference.authority + ), + )); + } + let repos = self.repos.lock().expect("repository registry"); + repos.get(&reference.repository_id).cloned().ok_or_else(|| { + domain_status( + ErrorCode::RepositoryNotFound, + "repository is not registered with this daemon; resolve it by path first", + ) + }) + } + + pub fn list(&self) -> Vec> { + let repos = self.repos.lock().expect("repository registry"); + let mut handles: Vec<_> = repos.values().cloned().collect(); + handles.sort_by_key(|handle| handle.root.clone()); + handles + } + + /// Append one JSONL line to the env-configured RPC log. Logging must + /// never break a request. + pub fn log_rpc(&self, method: &str, handle: Option<&RepoHandle>) { + let Some(path) = &self.log_path else { return }; + let record = serde_json::json!({ + "ts": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + "method": method, + "repository_id": handle.map(|h| data_encoding::HEXLOWER.encode(&h.repository_id()[..16])), + "root": handle.map(|h| h.root.display().to_string()), + }); + use std::io::Write; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(file, "{record}"); + } + } +} + + +impl Default for DaemonState { + fn default() -> Self { + Self::new() + } +} + +/// A domain refusal as a gRPC status carrying the contract's stable +/// machine-readable ErrorInfo detail. +pub fn domain_status(code: ErrorCode, message: impl Into) -> Status { + let message: String = message.into(); + fn code_name(code: ErrorCode) -> &'static str { + match code { + ErrorCode::Unspecified => "ERROR_CODE_UNSPECIFIED", + ErrorCode::CapabilityUnavailable => "CAPABILITY_UNAVAILABLE", + ErrorCode::ProtocolVersionMismatch => "PROTOCOL_VERSION_MISMATCH", + ErrorCode::WorkspaceRequired => "WORKSPACE_REQUIRED", + ErrorCode::Unauthorized => "UNAUTHORIZED", + ErrorCode::Forbidden => "FORBIDDEN", + ErrorCode::SandboxToken => "SANDBOX_TOKEN", + ErrorCode::IdempotencyMismatch => "IDEMPOTENCY_MISMATCH", + ErrorCode::PreconditionFailed => "PRECONDITION_FAILED", + ErrorCode::GenerationConflict => "GENERATION_CONFLICT", + ErrorCode::InvalidArgument => "INVALID_ARGUMENT", + ErrorCode::NotFound => "NOT_FOUND", + ErrorCode::Repository => "REPOSITORY", + ErrorCode::RepositoryNotFound => "REPOSITORY_NOT_FOUND", + ErrorCode::ResourceExhausted => "RESOURCE_EXHAUSTED", + ErrorCode::RateLimited => "RATE_LIMITED", + ErrorCode::View => "VIEW", + ErrorCode::ViewStale => "VIEW_STALE", + ErrorCode::ChangeRejected => "CHANGE_REJECTED", + ErrorCode::ProvenanceStore => "PROVENANCE_STORE", + ErrorCode::ProvenanceCheckpoint => "PROVENANCE_CHECKPOINT", + ErrorCode::ProvenanceLifecycle => "PROVENANCE_LIFECYCLE", + ErrorCode::Provenance => "PROVENANCE", + ErrorCode::Changes => "CHANGES", + ErrorCode::FileStates => "FILE_STATES", + ErrorCode::Materialize => "MATERIALIZE", + ErrorCode::Remote => "REMOTE", + ErrorCode::Internal => "INTERNAL", + } + } + let info = ErrorInfo { + code: code as i32, + message: message.clone(), + context: HashMap::new(), + request_id: None, + }; + let mut payload = Vec::new(); + let _ = info.encode(&mut payload); + let _ = payload; + let grpc_code = match code { + ErrorCode::NotFound | ErrorCode::RepositoryNotFound => Code::NotFound, + ErrorCode::InvalidArgument => Code::InvalidArgument, + ErrorCode::PreconditionFailed + | ErrorCode::GenerationConflict + | ErrorCode::IdempotencyMismatch + | ErrorCode::ViewStale + | ErrorCode::ChangeRejected => Code::FailedPrecondition, + ErrorCode::ResourceExhausted | ErrorCode::RateLimited => Code::ResourceExhausted, + ErrorCode::Unauthorized => Code::Unauthenticated, + ErrorCode::Forbidden | ErrorCode::SandboxToken | ErrorCode::View => Code::PermissionDenied, + ErrorCode::ProtocolVersionMismatch => Code::Internal, + ErrorCode::Internal | ErrorCode::Repository => Code::Internal, + _ => Code::InvalidArgument, + }; + // Slice note: rich grpc-status-details-bin attachments land with the + // compatibility suite; the stable code rides the message prefix until then. + Status::new( + grpc_code, + format!("atomic:{}: {}", code_name(code), message), + ) +} + +/// A domain result mapped to the contract's error vocabulary where the +/// caller has no more specific mapping. +pub fn repository_error(error: atomic_repository::RepositoryError) -> Status { + domain_status(ErrorCode::Repository, error.to_string()) +} + +/// A quick helper so handlers can echo hash bytes consistently. +pub fn hash_bytes(hash: &atomic_core::types::Merkle) -> Vec { + hash.0.to_vec() +} diff --git a/libatomic/src/lib.rs b/libatomic/src/lib.rs index 255dce1f..efdffd26 100644 --- a/libatomic/src/lib.rs +++ b/libatomic/src/lib.rs @@ -1,21 +1,25 @@ -//! libatomic — the canonical Atomic contract. +//! libatomic — the canonical Atomic contract and its reference +//! implementation. //! -//! This crate owns the one Atomic protobuf contract: the canonical -//! sources live under `proto/` (package `atomic`), `build.rs` compiles -//! them with protox (a pure-Rust protoc replacement — no system protoc) -//! and tonic-build, and `proto/check_contract.py` gates the declared -//! descriptor invariants (add-only; scopes, callers, effects and -//! capabilities on every method; read/write discipline; retired fields; -//! fencing). +//! This crate owns both halves of the Atomic service layer: //! -//! The generated module is [`atomic`] — every file in the contract -//! declares `package atomic`, so all messages and the generated service -//! clients/servers land in that single module — with a transport-neutral -//! [`proto`] alias for callers that prefer it. +//! * the **contract** — the canonical protobuf sources under `proto/` +//! (package `atomic`), compiled by `build.rs` with protox (a pure-Rust +//! protoc replacement — no system protoc) and tonic-build into the +//! [`atomic`] module (aliased as [`proto`]), plus the descriptor +//! contract gate (`proto/check_contract.py` — add-only; scope, caller, +//! effect and capability declarations on every method; read/write +//! discipline; retired fields; generation/snapshot fencing). +//! * the **handlers** — the reference implementation of that contract +//! under [`daemon`]: the repository registry and per-repository +//! serialization gates, the tonic service impls and their plain inner +//! functions, the provenance journal core and sink, and the +//! domain/protobuf converters. //! -//! The contract is transport-neutral by design: any process or tooling -//! that speaks Atomic imports this crate for the types. This crate ships -//! the contract and the codegen only — implementations live elsewhere. +//! The handlers are transport-neutral: a server process wires them onto +//! any transport (the generated servers exist for exactly that), and any +//! tool that speaks Atomic can call the same handlers in-process instead +//! of over a wire — one implementation everywhere. pub mod atomic { // The generated module is machine-written; lints do not apply to it. @@ -25,3 +29,5 @@ pub mod atomic { /// The generated contract module under its transport-neutral name. pub use crate::atomic as proto; + +pub mod daemon; From 6f377e89e2b24096f326ab8106ac0a59f979fd3d Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 5 Oct 2026 19:01:45 -0500 Subject: [PATCH 05/23] style(libatomic): rustfmt --- libatomic/src/daemon/services.rs | 5 +---- libatomic/src/daemon/services_provenance.rs | 16 +++------------- libatomic/src/daemon/state.rs | 1 - 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/libatomic/src/daemon/services.rs b/libatomic/src/daemon/services.rs index f11e664e..a6f57d27 100644 --- a/libatomic/src/daemon/services.rs +++ b/libatomic/src/daemon/services.rs @@ -748,10 +748,7 @@ fn compute_ref_pair_diff( .get_file_content_on_view(&path, to_view) .map_err(repository_error)?; let old_binary = old.contains(&0); - let new_binary = new - .as_ref() - .map(|c| c.contains(&0)) - .unwrap_or(false); + let new_binary = new.as_ref().map(|c| c.contains(&0)).unwrap_or(false); if old_binary || new_binary { chunks.push(DiffChunk { path: path.clone(), diff --git a/libatomic/src/daemon/services_provenance.rs b/libatomic/src/daemon/services_provenance.rs index bdbc8651..790c385c 100644 --- a/libatomic/src/daemon/services_provenance.rs +++ b/libatomic/src/daemon/services_provenance.rs @@ -105,15 +105,8 @@ fn checkpoint_source_proto(source: &ProvenanceCheckpointSource) -> CheckpointSou agent_name: source.agent_name.clone(), agent_display_name: source.agent_display_name.clone(), agent_vendor: source.agent_vendor.clone(), - change_hashes: source - .change_hashes - .iter() - .map(hash_proto) - .collect(), - previous_provenance: source - .previous_provenance - .as_ref() - .map(hash_proto), + change_hashes: source.change_hashes.iter().map(hash_proto).collect(), + previous_provenance: source.previous_provenance.as_ref().map(hash_proto), plan_id: source.plan_id.clone(), ledger_turn_number: source.ledger_turn_number, } @@ -131,10 +124,7 @@ fn attempt_proto(attempt: &StoreAttempt) -> ProvenanceCheckpointAttempt { frozen_event_count: attempt.frozen_event_count, source: Some(checkpoint_source_proto(&attempt.source)), phase: phase as i32, - provenance_hash: attempt - .provenance_hash - .as_ref() - .map(hash_proto), + provenance_hash: attempt.provenance_hash.as_ref().map(hash_proto), session_turn: attempt .session_turn .as_ref() diff --git a/libatomic/src/daemon/state.rs b/libatomic/src/daemon/state.rs index a69b3222..4ab37bbe 100644 --- a/libatomic/src/daemon/state.rs +++ b/libatomic/src/daemon/state.rs @@ -215,7 +215,6 @@ impl DaemonState { } } - impl Default for DaemonState { fn default() -> Self { Self::new() From 14744679f61538065805050ae27e5cfe1aec37ac Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 5 Oct 2026 20:11:11 -0500 Subject: [PATCH 06/23] feat(contract): wire extensions for full routing parity (add-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangeRef gains a prefix arm (whole-store case-insensitive resolution; the target operation's own membership/guard errors speak). GetChange carries the versioned change bundle (atomic.change.v3) plus the provenance ledger graphs (atomic.prov.graph.v1) and the view sequence. Sessions carry the ledger bundles (record+turns, domain-shaped JSON), vault-derived intent counts, turn-to-intent resolution, and the session manifest data. Status reports needs_reindex; Log filters by paths/tags and includes inherited entries; AddFiles gains dry-run/force/directory/ no-recursive with a tolerant per-path report; Record carries an explicit author or named identity; Remove/Move gain dry-run and force; Insert gains the apply_dependencies escape hatch; CreateView gains the parent (anchored, unseeded) base; SwitchView carries an explicit dirty-check bypass; SplitView gains a dry-run analysis mode; Repair gains REINDEX_WORKING_COPY; KnowledgeGraph enrichment scopes to explicit changes; vault listings filter by identity. The unrecord preview now runs the SAME domain dry-run as the mutating unrecord — one guard surface (membership, dependents, emptiness). --- libatomic/proto/atomic/common/change.proto | 2 +- libatomic/proto/atomic/common/common.proto | 11 +- libatomic/proto/atomic/common/error.proto | 6 +- libatomic/proto/atomic/common/event.proto | 5 +- libatomic/proto/atomic/common/options.proto | 9 +- .../proto/atomic/common/pagination.proto | 2 +- .../attestation/attestation_messages.proto | 2 +- .../attestation/attestation_service.proto | 2 +- .../services/daemon/daemon_messages.proto | 4 +- .../services/daemon/daemon_service.proto | 2 +- .../git_interop/git_interop_messages.proto | 2 +- .../git_interop/git_interop_service.proto | 2 +- .../knowledge/knowledge_messages.proto | 3 +- .../knowledge/knowledge_service.proto | 2 +- .../maintenance/maintenance_messages.proto | 5 +- .../maintenance/maintenance_service.proto | 2 +- .../provenance/provenance_messages.proto | 27 +- .../provenance/provenance_service.proto | 2 +- .../services/reactor/reactor_messages.proto | 6 +- .../services/reactor/reactor_service.proto | 2 +- .../repository_mutation_messages.proto | 25 +- .../repository_mutation_service.proto | 2 +- .../repository_query_messages.proto | 17 +- .../repository_query_service.proto | 2 +- .../services/sandbox/sandbox_messages.proto | 18 +- .../services/sandbox/sandbox_service.proto | 2 +- .../atomic/services/sync/sync_messages.proto | 2 +- .../atomic/services/sync/sync_service.proto | 2 +- .../atomic/services/tag/tag_messages.proto | 2 +- .../atomic/services/tag/tag_service.proto | 2 +- .../services/triage/triage_messages.proto | 2 +- .../services/triage/triage_service.proto | 2 +- .../services/vault/vault_messages.proto | 3 +- .../atomic/services/vault/vault_service.proto | 2 +- .../atomic/services/view/view_messages.proto | 10 +- .../atomic/services/view/view_service.proto | 2 +- libatomic/src/daemon/mod.rs | 10 +- libatomic/src/daemon/provenance_core.rs | 10 +- libatomic/src/daemon/services.rs | 317 ++++++++++++++---- libatomic/src/daemon/services_agent.rs | 86 ++++- libatomic/src/daemon/services_maintenance.rs | 16 +- libatomic/src/daemon/services_provenance.rs | 4 +- libatomic/src/daemon/services_query.rs | 202 +++++++++-- libatomic/src/daemon/state.rs | 4 +- 44 files changed, 683 insertions(+), 159 deletions(-) diff --git a/libatomic/proto/atomic/common/change.proto b/libatomic/proto/atomic/common/change.proto index 1abe5e85..358bf62b 100644 --- a/libatomic/proto/atomic/common/change.proto +++ b/libatomic/proto/atomic/common/change.proto @@ -1,6 +1,6 @@ // atomic-protocol — shared change-domain types. // -// Add-only. Shared across RepositoryQueryService, +// DRAFT — add-only. Shared across RepositoryQueryService, // RepositoryMutationService, and friends. Shapes are verified against the // atomic source (2026-10-02) — this is ATOMIC's model (patch theory, a // semantic change graph), NOT git's: diff --git a/libatomic/proto/atomic/common/common.proto b/libatomic/proto/atomic/common/common.proto index 139b29a6..0ff2bfa3 100644 --- a/libatomic/proto/atomic/common/common.proto +++ b/libatomic/proto/atomic/common/common.proto @@ -1,7 +1,7 @@ // atomic-protocol — shared primitives. // -// Add-only. Field numbers are never reused or renumbered. -// Conventions carried from the earlier parked draft: +// DRAFT — add-only. Field numbers are never reused or renumbered. +// Conventions carried from the parked Slice 0 draft (REAC::aaron::2): // package `atomic`, RepositoryRef numbering per the RFC. // // CONTRACT RULES (descriptor gate now; runtime conformance tests at implementation): @@ -126,13 +126,13 @@ message SessionTurn { // internal encoding is owned elsewhere (V3 change files, provenance graph // exports, knowledge-graph slices). SubmitChange/Changes carry V3 change // bytes as versioned-opaque with this schema field, like the event envelope's -// type URL (recorded recommendation). +// type URL (HANDOFF §10.3 recommendation). message VersionedBytes { string schema = 1; // e.g. "atomic.change.v3", "atomic.prov.graph.v1" bytes payload = 2; } -// Expected-state precondition on a mutation (RFC D5/D7: fencing +// Expected-state precondition on a mutation (RFC D5/D7 + HANDOFF §6: fencing // on EVERY mutation, not just provenance). A mismatch fails with // PRECONDITION_FAILED (or GENERATION_CONFLICT for the provenance fencing // flavor) instead of silently racing. @@ -151,6 +151,9 @@ message ChangeRef { oneof kind { Hash hash = 1; uint64 sequence = 2; + // Case-insensitive unique-prefix resolution against the whole change + // store; the target operation's own membership/guard errors speak. + string prefix = 3; } } diff --git a/libatomic/proto/atomic/common/error.proto b/libatomic/proto/atomic/common/error.proto index cbe1461c..e11bd2ea 100644 --- a/libatomic/proto/atomic/common/error.proto +++ b/libatomic/proto/atomic/common/error.proto @@ -1,19 +1,19 @@ // atomic-protocol — the stable error model. // -// Add-only. Enum values 28+ reserved: enum numbers are never reused. +// DRAFT — add-only. Enum values 28+ reserved: enum numbers are never reused. // // Vocabulary provenance: // * legacy agent journal protocol (verified in source, 2026-10-01): // "unsupported-version", "provenance-store", "provenance-checkpoint", // "provenance-lifecycle". -// * sandbox branch per the remote-sandboxes extension: +// * sandbox branch per HANDOFF §4/§5 (Aaron's unmerged feat/remote-sandboxes): // unauthorized, forbidden, view, repository, provenance, changes, submit, // file-states, materialize, sandbox-token, remote, internal. // * RFC D7/capability section: CAPABILITY_UNAVAILABLE, WORKSPACE_REQUIRED. // * standard transport/domain classes, stable and machine-readable. // // Errors carry stable machine-readable codes plus actionable context -// Transport-level failures use gRPC status with ErrorInfo as +// (HANDOFF §12). Transport-level failures use gRPC status with ErrorInfo as // the detail payload; domain refusals that must carry recovery payloads // (e.g. a stale-view skeleton) travel inside the response message as oneof // outcomes, not as transport errors. diff --git a/libatomic/proto/atomic/common/event.proto b/libatomic/proto/atomic/common/event.proto index 05cc35da..8cd01907 100644 --- a/libatomic/proto/atomic/common/event.proto +++ b/libatomic/proto/atomic/common/event.proto @@ -1,8 +1,9 @@ // atomic-protocol — the Reactor event envelope (RFC "Event envelope" section). // -// Add-only. The envelope is defined now so repository mutation events +// DRAFT — add-only. CONTRACT DRAFT: the ReactorService server lands in +// Phase 3; this envelope is drafted now so repository mutation events // (transactional outbox, RFC D8) and agent ingestion rhyme with it -// (align with the Reactor event envelope so the two contracts +// (HANDOFF §6: "align with the Reactor event envelope so the two contracts // rhyme"). // // Durability semantics (RFC D9) belong to the server, but the envelope diff --git a/libatomic/proto/atomic/common/options.proto b/libatomic/proto/atomic/common/options.proto index 67b3e9b5..8fe407f7 100644 --- a/libatomic/proto/atomic/common/options.proto +++ b/libatomic/proto/atomic/common/options.proto @@ -1,8 +1,9 @@ // atomic-protocol — method options (RFC D7). // -// Numbers are add-only after the first tagged release. Field and -// extension numbers are NEVER reused or renumbered. 51001/51002 are the permanent allocations for Atomic's -// method options (chosen once, per RFC). +// DRAFT — numbers are correctable until Lee's review and the first tagged +// release; add-only thereafter. Field and extension numbers are NEVER reused +// or renumbered. 51001/51002 are the permanent allocations for Atomic's +// method options (chosen once, per RFC + HANDOFF §7.7/§12). // // THE ONE PROTOCOL: this package is the sole public protobuf contract for // local daemon and hosted server. There is no second protocol. @@ -10,7 +11,7 @@ // Capability tokens use dot style ("repository.read") — the RFC is // internally inconsistent (dot style in its method-option examples, colon // style in its authorization section); dot style is carried from the parked -// earlier draft and flagged for ratification. +// Slice 0 draft (REAC::aaron::2) and flagged for Lee's ratification. syntax = "proto3"; diff --git a/libatomic/proto/atomic/common/pagination.proto b/libatomic/proto/atomic/common/pagination.proto index 5e327e21..cbfa3c2d 100644 --- a/libatomic/proto/atomic/common/pagination.proto +++ b/libatomic/proto/atomic/common/pagination.proto @@ -1,6 +1,6 @@ // atomic-protocol — pagination. // -// Add-only. +// DRAFT — add-only. // // The frozen-provenance cursor {seq, offset} (verified against the // provenance store source) diff --git a/libatomic/proto/atomic/services/attestation/attestation_messages.proto b/libatomic/proto/atomic/services/attestation/attestation_messages.proto index 065da8e6..68f81037 100644 --- a/libatomic/proto/atomic/services/attestation/attestation_messages.proto +++ b/libatomic/proto/atomic/services/attestation/attestation_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — AttestationService: the external-signing flow and // attestation records. // -// Add-only. +// DRAFT — add-only. // // Covers `atomic intent attest --prepare/--signed` (REMOTE-capable — that is // the point of the feature: a signer holding the key elsewhere — browser, diff --git a/libatomic/proto/atomic/services/attestation/attestation_service.proto b/libatomic/proto/atomic/services/attestation/attestation_service.proto index 0b5ac482..2d4c90cd 100644 --- a/libatomic/proto/atomic/services/attestation/attestation_service.proto +++ b/libatomic/proto/atomic/services/attestation/attestation_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — AttestationService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/attestation/attestation_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/daemon/daemon_messages.proto b/libatomic/proto/atomic/services/daemon/daemon_messages.proto index d796f012..e021e4d9 100644 --- a/libatomic/proto/atomic/services/daemon/daemon_messages.proto +++ b/libatomic/proto/atomic/services/daemon/daemon_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — DaemonService: daemon health, capability negotiation, // repository registry, lifecycle. // -// Add-only. Extends the earlier parked draft. +// DRAFT — add-only. Extends the parked Slice 0 draft (REAC::aaron::2). // // The legacy agent journal protocol is being fully dismantled (with the // `atomic agent database-owner` command that served it); liveness, @@ -89,7 +89,7 @@ message GetCapabilitiesResponse { string implementation_version = 2; EndpointKind endpoint_kind = 3; repeated MethodDescriptorInfo methods = 4; // the generated method matrix - repeated string features = 5; // e.g. "harness_reflection" (see the + repeated string features = 5; // e.g. "harness_reflection" (HANDOFF §10.6: // grpcurl + server reflection is the // recommended harness flavor) Limits limits = 6; diff --git a/libatomic/proto/atomic/services/daemon/daemon_service.proto b/libatomic/proto/atomic/services/daemon/daemon_service.proto index 66e7fa27..62e505bf 100644 --- a/libatomic/proto/atomic/services/daemon/daemon_service.proto +++ b/libatomic/proto/atomic/services/daemon/daemon_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — DaemonService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/daemon/daemon_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto b/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto index a8712048..4836a6a5 100644 --- a/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto +++ b/libatomic/proto/atomic/services/git_interop/git_interop_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — GitInteropService: the git import/push pipelines. // -// Add-only. Git interop is LOCAL-only (working copies and git +// DRAFT — add-only. Git interop is LOCAL-only (working copies and git // mirrors are local). `git hooks install/uninstall` only writes .git/hooks // shim files — no RPC (exempt, file-only). Import may create the repository // (init_with_view) on first run — bootstrap-adjacent, flagged in COVERAGE. diff --git a/libatomic/proto/atomic/services/git_interop/git_interop_service.proto b/libatomic/proto/atomic/services/git_interop/git_interop_service.proto index 75b011c6..d89e717b 100644 --- a/libatomic/proto/atomic/services/git_interop/git_interop_service.proto +++ b/libatomic/proto/atomic/services/git_interop/git_interop_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — GitInteropService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/git_interop/git_interop_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto index 94567802..2ee6ce4c 100644 --- a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto +++ b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — KnowledgeService: knowledge-graph queries and // maintenance. // -// Add-only. The CLI's eleven `query` verbs collapse into TWO RPCs +// DRAFT — add-only. The CLI's eleven `query` verbs collapse into TWO RPCs // (the headline combine of this draft): // * QueryGraph — every read: content/code search, kg search, entity, // neighbors, callers, graph slice, plan execution, RAG ask; @@ -105,6 +105,7 @@ message MaintainKnowledgeGraphRequest { RequestMeta meta = 2; KnowledgeMaintainAction action = 3; optional string scope = 4; // e.g. entity-type filter + repeated Hash changes = 5; // ENRICH: limit to these changes' nodes } message MaintainKnowledgeGraphResponse { uint64 processed = 1; diff --git a/libatomic/proto/atomic/services/knowledge/knowledge_service.proto b/libatomic/proto/atomic/services/knowledge/knowledge_service.proto index 226697cb..f86ccb07 100644 --- a/libatomic/proto/atomic/services/knowledge/knowledge_service.proto +++ b/libatomic/proto/atomic/services/knowledge/knowledge_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — KnowledgeService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/knowledge/knowledge_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto b/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto index 49a2347e..7270676a 100644 --- a/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto +++ b/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — MaintenanceService: repository repair and consistency. // -// Add-only. `atomic doctor` folds into Repair (the +// DRAFT — add-only. `atomic doctor` folds into Repair (the // mutating actions) and CheckRepository (the read-only consistency check, // split out per the read/write principle — a check never gates on a // mutation or enters the write queue; see COVERAGE §9). ReindexWorkspace is @@ -20,6 +20,7 @@ enum RepairAction { REPAIR_ACTION_MATERIALIZE_CRDT = 2; // CRDT tables from FileOps reserved 3; // was CONSISTENCY_CHECK — // split to CheckRepository + REPAIR_ACTION_REINDEX_WORKING_COPY = 4; // `status --reindex` } message RepairRequest { RepositoryRef repository = 1; @@ -60,6 +61,8 @@ message RepairResponse { uint64 leaves_created = 14; CrdtSkipStats skip_stats = 15; repeated string skip_samples = 16; + // REINDEX_WORKING_COPY render data. + uint64 reindexed = 17; ResponseMeta meta = 100; } message CheckRepositoryRequest { diff --git a/libatomic/proto/atomic/services/maintenance/maintenance_service.proto b/libatomic/proto/atomic/services/maintenance/maintenance_service.proto index bc9ae051..4600e3fe 100644 --- a/libatomic/proto/atomic/services/maintenance/maintenance_service.proto +++ b/libatomic/proto/atomic/services/maintenance/maintenance_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — MaintenanceService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/maintenance/maintenance_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/provenance/provenance_messages.proto b/libatomic/proto/atomic/services/provenance/provenance_messages.proto index 0ce43131..d7c24080 100644 --- a/libatomic/proto/atomic/services/provenance/provenance_messages.proto +++ b/libatomic/proto/atomic/services/provenance/provenance_messages.proto @@ -2,7 +2,7 @@ // session/turn journal, generation fencing, checkpoint phases, frozen- // envelope storage, session ledger, provenance projection. // -// Add-only. Context: the legacy agent journal protocol and the +// DRAFT — add-only. Context: the legacy agent journal protocol and the // `atomic agent database-owner` command that served it are being FULLY // DISMANTLED; atomicd becomes the only storage owner. What carries forward // is the provenance tracking semantics below — verified against the @@ -35,7 +35,7 @@ // * Turn lifecycle: stop/resume/abandon combined into one UpdateTurn with // an action enum (no crash-injection seams live there); status is the // read-side GetTurn. -// * Authorization (sandbox matrix): the first ReserveTurn +// * Authorization (sandbox matrix, HANDOFF §5): the first ReserveTurn // claims the session for its view; later ops from another view → // FORBIDDEN. After a successful remote reserve, envelope/checkpoint ops // on that turn from another view → FORBIDDEN. Remote callers present the @@ -132,7 +132,7 @@ message ProvenanceCheckpointAttempt { // ————— wire envelopes ————— // Legacy {event_id, bytes} journal envelope + the schema/type URL the unified -// contract requires (align with the Reactor event envelope so +// contract requires (HANDOFF §6: align with the Reactor event envelope so // the two contracts rhyme). message WireEnvelope { string event_id = 1; @@ -285,6 +285,21 @@ message GetSessionResponse { repeated StoredProvenanceTurn turns = 1; // SHAPE-CONFIRM (Slice 2): session-stamp fields beyond turns live in the // session ledger; finalize against commands/session.rs. + // The complete session ledger as versioned-opaque bytes (schema + // "atomic.session.ledger.v1": JSON of the {record, turns} pair). + optional VersionedBytes ledger_bundle = 2; + // Turn → intent resolution (vault paths and explicit plan ids), the + // client-side inheritance chain starts from this. + repeated SessionTurnIntent turn_intents = 3; + optional Hash manifest_head = 4; + optional Hash parent_manifest = 5; + optional uint32 fork_turn = 6; +} + +// One turn's resolved intent (from plan_id or the vault's session links). +message SessionTurnIntent { + uint32 turn = 1; + string intent = 2; } message ForkSessionRequest { @@ -363,6 +378,12 @@ message ListSessionsRequest { } message ListSessionsResponse { repeated SessionSummary sessions = 1; + // The complete recent-session ledgers as versioned-opaque bytes (schema + // "atomic.session.ledgers.v1": JSON array of {record, turns} pairs) — + // the client renders listings/JSON from the domain shape it knows. + optional VersionedBytes ledgers_bundle = 2; + // Vault-derived intent count per session, parallel to `sessions` order. + repeated uint32 intent_counts = 3; } message SessionSummary { string session_id = 1; diff --git a/libatomic/proto/atomic/services/provenance/provenance_service.proto b/libatomic/proto/atomic/services/provenance/provenance_service.proto index 539c5d77..92051ee3 100644 --- a/libatomic/proto/atomic/services/provenance/provenance_service.proto +++ b/libatomic/proto/atomic/services/provenance/provenance_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — ProvenanceService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/provenance/provenance_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/reactor/reactor_messages.proto b/libatomic/proto/atomic/services/reactor/reactor_messages.proto index fb4ab5a5..c7e4e020 100644 --- a/libatomic/proto/atomic/services/reactor/reactor_messages.proto +++ b/libatomic/proto/atomic/services/reactor/reactor_messages.proto @@ -1,7 +1,9 @@ // atomic-protocol — ReactorService: durable cross-project event ingestion. // -// Add-only. The ReactorService server lands with atomicd; the -// repository-outbox events (RFC D8) rhyme with this envelope (event.proto). +// DRAFT — add-only. CONTRACT DRAFT ONLY: the server lands in Phase 3 with +// atomicd; the contract is drafted now because the Reactor is its own first +// consumer and the repository-outbox events (RFC D8) must rhyme with this +// envelope (event.proto). // // Durability semantics (RFC D9) that the contract names explicitly: // * append-before-acknowledgement (an acked event is durable); diff --git a/libatomic/proto/atomic/services/reactor/reactor_service.proto b/libatomic/proto/atomic/services/reactor/reactor_service.proto index 5d7a3c31..85f5b69f 100644 --- a/libatomic/proto/atomic/services/reactor/reactor_service.proto +++ b/libatomic/proto/atomic/services/reactor/reactor_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — ReactorService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/reactor/reactor_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto index 7838ac9b..ef74b6bd 100644 --- a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto +++ b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — RepositoryMutationService: working-copy and change-graph // mutations. // -// Add-only. Every mutation carries RequestMeta (idempotency key) and +// DRAFT — add-only. Every mutation carries RequestMeta (idempotency key) and // an ExpectedState precondition where the caller knows prior state (RFC // 2.2/2.3: expected-Merkle/sequence preconditions on mutating operations — // the fencing pattern generalized from provenance to ALL mutations). @@ -25,9 +25,20 @@ message AddFilesRequest { RequestMeta meta = 2; repeated string paths = 3; // working-copy paths optional ExpectedState expected = 4; + bool dry_run = 5; // report what would be tracked; no mutation + bool force = 6; // add even ignored paths + bool directory = 7; // explicit directory tracking + bool no_recursive = 8; // directories without their contents } message AddFilesResponse { uint32 tracked = 1; + // Dry-run / tolerant report: what would be tracked, what was skipped + // (already tracked / ignored) and what could not be added, per path. + repeated string would_add = 2; + repeated string skipped_tracked = 3; + repeated string skipped_ignored = 4; + repeated string failed_paths = 5; + bool dry_run = 6; ResponseMeta meta = 100; } @@ -36,6 +47,8 @@ message RemoveFilesRequest { RequestMeta meta = 2; repeated string paths = 3; bool keep_working_copy = 4; // `--keep` + bool dry_run = 5; // report what would be untracked; no mutation + bool force = 6; // remove even with uncommitted edits } message RemoveFilesResponse { uint32 removed = 1; @@ -47,6 +60,8 @@ message MoveFileRequest { RequestMeta meta = 2; string source = 3; string destination = 4; + bool dry_run = 5; // validate only; no disk move + bool force = 6; // overwrite an existing destination } message MoveFileResponse { ResponseMeta meta = 100; } @@ -69,6 +84,10 @@ message RecordRequest { repeated string paths = 4; // empty = tracked-files snapshot optional ExpectedState expected = 5; // required matching view_snapshot; // snapshot.view must equal this workspace's current view when publishing. + // Authorship override: an explicit author, or a named identity from the + // serving host's identity store. Absent → the host's default identity. + optional Author author = 6; + optional string identity_name = 7; } message RecordResponse { ChangeInfo change = 1; @@ -105,7 +124,9 @@ message InsertChangesRequest { ViewRef target = 12; ViewSnapshot expected_snapshot = 13; optional ViewSnapshot source_snapshot = 14; // required for from_view/tag - // Dependency closure is mandatory; a confirmation flag cannot bypass policy. + // Dependency closure is the default; false is the `--deps=false` escape + // hatch (single-change inserts without closure). + optional bool apply_dependencies = 15; } message InsertChangesResponse { repeated ChangeInfo inserted = 1; diff --git a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto index 85da200e..8057000f 100644 --- a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto +++ b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — RepositoryMutationService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/repository_mutation/repository_mutation_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto index f0fbe24b..54c41017 100644 --- a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto +++ b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — RepositoryQueryService: read-only repository operations. // -// Add-only. Reads execute as independent concurrent read +// DRAFT — add-only. Reads execute as independent concurrent read // transactions (RFC concurrency model); no global write lock is ever assumed. // LOCAL-scoped reads are working-copy-aware (status/DiffWorkingCopy against the // tree); BOTH reads (log/change/Diff/PreviewMutation) are semantically identical on @@ -37,6 +37,7 @@ message StatusResponse { uint32 stale_index_count = 9; // non-zero → `status --reindex` would // resolve false positives ViewSnapshot snapshot = 10; + bool needs_reindex = 11; // stale_index_count > 0 } // atomic's file-status vocabulary, source-verified (status.rs:136): // Modified, Deleted, Untracked, Added, Conflicted — no git-shaped states. @@ -64,6 +65,8 @@ message LogRequest { optional string view = 3; // required unless all_views; no daemon current-view default optional PageCursor cursor = 4; optional PageBudget budget = 5; + repeated string path_filter = 6; // `--path`: entries whose changes touch it + bool tags_only = 7; // tagged entries only } message LogResponse { repeated ChangeLogEntry entries = 1; @@ -92,6 +95,16 @@ message GetChangeResponse { optional VersionedBytes semantic_section = 3; repeated ChangeSignature signatures = 4; optional bytes content_chunks = 5; + // The complete change as versioned-opaque bytes (schema + // "atomic.change.v3") — deserialize with atomic-core for full-detail + // rendering (hunks, content, unhashed sections) without a second + // server-side render per client need. + optional VersionedBytes change_bundle = 6; + // The causal decision graph(s) explaining this change (schema + // "atomic.prov.graph.v1", one per covering graph, in order). + repeated VersionedBytes provenance_ledger = 7; + // The change's sequence on the resolved view, when known. + optional uint64 sequence = 8; } // ChangeInfo lives in atomic/common/change.proto (shared change-domain). @@ -102,11 +115,13 @@ message DiffRequest { RefPairScope refs = 3; // view/tag/change-pair diff } bool stat_only = 4; + bool word_diff = 5; // word-level highlight inside hunks } message DiffWorkingCopyRequest { RepositoryRef repository = 1; // workspace_id required WorkingCopyScope working_copy = 2; bool stat_only = 3; + bool word_diff = 4; // word-level highlight inside hunks } message WorkingCopyScope { bool include_untracked = 1; diff --git a/libatomic/proto/atomic/services/repository_query/repository_query_service.proto b/libatomic/proto/atomic/services/repository_query/repository_query_service.proto index 7624be01..78f0689e 100644 --- a/libatomic/proto/atomic/services/repository_query/repository_query_service.proto +++ b/libatomic/proto/atomic/services/repository_query/repository_query_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — RepositoryQueryService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/repository_query/repository_query_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto b/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto index 6b3de868..67a1ef55 100644 --- a/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto +++ b/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto @@ -1,8 +1,8 @@ // atomic-protocol — SandboxService: sandbox grants, local sandbox working -// trees, and the remote-sandbox data ops (the remote-sandboxes extension +// trees, and the remote-sandbox data ops (HANDOFF §5, Aaron's unmerged branch // — the handoff is the spec here). // -// Add-only. +// DRAFT — add-only. // // "A remote sandbox is a machine holding only a pointer file .atomic-sandbox // = {iroh EndpointAddr of the serving daemon, repository, view, token}. @@ -20,7 +20,7 @@ // to be visible on the caller's view; // * grant admin (Open/Renew/Close) is LOCAL-only. // -// Inode keying (recorded recommendation): FileStates inodes +// Inode keying (HANDOFF §10.1 recommendation on record): FileStates inodes // are OPAQUE SERVER-ISSUED HANDLES — they originate from the server's own // skeleton export (like cursors). They are not permissions: every use checks // repository, snapshot and effective-view visibility; guesses are refused. @@ -42,13 +42,13 @@ import "atomic/common/error.proto"; import "atomic/common/change.proto"; // ————— sandbox cache exports (client-side; only transport changes with the -// daemon — "SandboxSkeleton"/"SandboxSlice") ————— +// daemon — HANDOFF §5 "SandboxSkeleton"/"SandboxSlice") ————— message SandboxViewRow { string path = 1; uint64 inode = 2; // opaque server-issued handle optional Hash content = 3; - // SHAPE-CONFIRM (Slice 3): full row fields. + // SHAPE-CONFIRM (Slice 3, from Aaron's branch): full row fields. } message SandboxAncestorView { @@ -251,7 +251,7 @@ message PublishProvenanceResponse { message ProvenancePublished { Hash provenance_hash = 1; google.protobuf.Timestamp published_at = 2; - // SHAPE-CONFIRM (Slice 3): publication record shape. + // SHAPE-CONFIRM (Slice 3): publication record shape from Aaron's branch. } message SubmitChangeRequest { @@ -289,14 +289,14 @@ message ChangeRefused { // atomic-protocol — grants and sandbox tokens. // -// Add-only. +// DRAFT — add-only. // -// Model carried from the remote-sandbox extension: a grant is +// Model carried from the remote-sandbox extension (HANDOFF §5): a grant is // view-scoped, optionally identity-bound, and time-boxed. A remote caller is // trusted ONLY as far as its token: exactly one view, optionally one acting // identity, until expiry. // -// CONTRACT BEHAVIOR (recorded recommendation): +// CONTRACT BEHAVIOR (HANDOFF §10.2 recommendation, on record for Lee): // the grant registry is EPHEMERAL — only blake3 hashes of tokens are stored, // grant-admin issuance replay is memory-only and invalidated with its grant; // no durable response/outbox/event payload may contain the token, diff --git a/libatomic/proto/atomic/services/sandbox/sandbox_service.proto b/libatomic/proto/atomic/services/sandbox/sandbox_service.proto index 0e89e313..fc5e7910 100644 --- a/libatomic/proto/atomic/services/sandbox/sandbox_service.proto +++ b/libatomic/proto/atomic/services/sandbox/sandbox_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — SandboxService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/sandbox/sandbox_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/sync/sync_messages.proto b/libatomic/proto/atomic/services/sync/sync_messages.proto index d814dfcc..791b0a1f 100644 --- a/libatomic/proto/atomic/services/sync/sync_messages.proto +++ b/libatomic/proto/atomic/services/sync/sync_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — SyncService: remote synchronization and the remote // registry. // -// Add-only. The existing HTTP `/code` + SyncPack behavior must be +// DRAFT — add-only. The existing HTTP `/code` + SyncPack behavior must be // preserved behind — or deliberately migrated into — ONE SyncService domain // implementation (RFC "one synchronization protocol"): local repo sync, // atomicd-to-server sync, and future server replication use the same diff --git a/libatomic/proto/atomic/services/sync/sync_service.proto b/libatomic/proto/atomic/services/sync/sync_service.proto index d4226c17..b6e9c560 100644 --- a/libatomic/proto/atomic/services/sync/sync_service.proto +++ b/libatomic/proto/atomic/services/sync/sync_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — SyncService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/sync/sync_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/tag/tag_messages.proto b/libatomic/proto/atomic/services/tag/tag_messages.proto index e995617b..6243ef34 100644 --- a/libatomic/proto/atomic/services/tag/tag_messages.proto +++ b/libatomic/proto/atomic/services/tag/tag_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — TagService: named Merkle-state snapshots. // -// Add-only. Tags are repository metadata: reads are BOTH (hosted +// DRAFT — add-only. Tags are repository metadata: reads are BOTH (hosted // servers serve them), writes BOTH with repository.write (a hosted // repository can be tagged remotely). The RFC names tags in its BOTH scope // list. diff --git a/libatomic/proto/atomic/services/tag/tag_service.proto b/libatomic/proto/atomic/services/tag/tag_service.proto index 1e342162..539e5479 100644 --- a/libatomic/proto/atomic/services/tag/tag_service.proto +++ b/libatomic/proto/atomic/services/tag/tag_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — TagService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/tag/tag_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/triage/triage_messages.proto b/libatomic/proto/atomic/services/triage/triage_messages.proto index 5b3fae93..5aa8bcc1 100644 --- a/libatomic/proto/atomic/services/triage/triage_messages.proto +++ b/libatomic/proto/atomic/services/triage/triage_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — TriageService: promotion review. // -// Add-only. Triage review is a paid-surface workflow (the +// DRAFT — add-only. Triage review is a paid-surface workflow (the // intent→change pipeline's review gate): candidates are the change-sets // pending promotion between views; the review is the canonical report // (ordered semantic layers, provenance-backed) that `atomic triage review diff --git a/libatomic/proto/atomic/services/triage/triage_service.proto b/libatomic/proto/atomic/services/triage/triage_service.proto index 8ef50efc..43e7bc72 100644 --- a/libatomic/proto/atomic/services/triage/triage_service.proto +++ b/libatomic/proto/atomic/services/triage/triage_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — TriageService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/triage/triage_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/vault/vault_messages.proto b/libatomic/proto/atomic/services/vault/vault_messages.proto index 0d4fed8b..144dcc7c 100644 --- a/libatomic/proto/atomic/services/vault/vault_messages.proto +++ b/libatomic/proto/atomic/services/vault/vault_messages.proto @@ -1,7 +1,7 @@ // atomic-protocol — VaultService: the `.vault/` knowledge store — intents, // memories, goals, vault sync, context. // -// Add-only. This is the agent-facing product surface (the +// DRAFT — add-only. This is the agent-facing product surface (the // intent→change workflow with triage + knowledge graph), so the combine // discipline matters most here: intents/memories/goals are one entity model // with a kind enum, not three parallel RPC families. `vault intent`/ @@ -95,6 +95,7 @@ message ListVaultEntriesRequest { bool summaries_only = 4; // `vault summaries` optional PageCursor cursor = 5; optional PageBudget budget = 6; + optional string identity = 7; // filter by the entry's author identity ViewRef view = 20; } message ListVaultEntriesResponse { diff --git a/libatomic/proto/atomic/services/vault/vault_service.proto b/libatomic/proto/atomic/services/vault/vault_service.proto index 921e6878..55d3804e 100644 --- a/libatomic/proto/atomic/services/vault/vault_service.proto +++ b/libatomic/proto/atomic/services/vault/vault_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — VaultService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/vault/vault_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/proto/atomic/services/view/view_messages.proto b/libatomic/proto/atomic/services/view/view_messages.proto index ccd2cf5b..9698fca3 100644 --- a/libatomic/proto/atomic/services/view/view_messages.proto +++ b/libatomic/proto/atomic/services/view/view_messages.proto @@ -1,6 +1,6 @@ // atomic-protocol — ViewService: view lifecycle and scope. // -// Add-only. Views are the workflow spine (draft → shared). View +// DRAFT — add-only. Views are the workflow spine (draft → shared). View // reads and repository-only lifecycle writes are BOTH. Only switch follows a // host working copy and stays LOCAL. Sandboxes have no lifecycle authority. // @@ -50,6 +50,9 @@ message CreateViewRequest { oneof base { string from_view = 4; // `view create --from` / `split` from another view bool empty = 6; // `--empty` + // `--parent`: anchor on this parent WITHOUT seeding from it (the + // draft workspace form). + string parent = 10; } ViewScope scope = 8; optional ViewSnapshot base_snapshot = 9; // required for from_view @@ -63,6 +66,9 @@ message SwitchViewRequest { RepositoryRef repository = 1; RequestMeta meta = 2; string view = 3; + // The client's explicit dirty-working-copy bypass (`--force`/`--stash`); + // the server-side refusal remains the default. + optional bool bypass_dirty_check = 4; } message SwitchViewResponse { uint64 files_written = 1; // materialized files (add-only render) @@ -98,6 +104,7 @@ message SplitViewRequest { repeated Hash changes = 5; // the changes to split out bool cascade = 6; // move dependents instead of refusing bool materialize = 7; // reconcile the source's working copy + bool dry_run = 8; // report the split without mutating } // One change involved in a split, with its pre-split source sequence. message SplitChangeInfo { @@ -114,5 +121,6 @@ message SplitViewResponse { uint64 files_written = 7; uint64 files_removed = 8; ViewInfo view = 9; + bool dry_run = 10; // the report above is a preview ResponseMeta meta = 100; } diff --git a/libatomic/proto/atomic/services/view/view_service.proto b/libatomic/proto/atomic/services/view/view_service.proto index 4bdc3fe6..9a5d859a 100644 --- a/libatomic/proto/atomic/services/view/view_service.proto +++ b/libatomic/proto/atomic/services/view/view_service.proto @@ -1,5 +1,5 @@ // atomic-protocol — ViewService definition (the RPC contract). -// Add-only. Messages for this service live in +// DRAFT — add-only. Messages for this service live in // atomic/services/view/view_messages.proto · shared primitives in atomic/common/. syntax = "proto3"; diff --git a/libatomic/src/daemon/mod.rs b/libatomic/src/daemon/mod.rs index 3d68cc80..7bcfc245 100644 --- a/libatomic/src/daemon/mod.rs +++ b/libatomic/src/daemon/mod.rs @@ -1,10 +1,10 @@ //! The service-layer handlers: the domain half of the daemon. //! -//! ONE implementation, any number of callers: a transport process -//! (e.g. `atomicd`) serves these handlers over a socket through the -//! generated tonic servers, and any consumer may call the very same -//! handlers in-process — there is no second copy of service-layer -//! logic anywhere. +//! ONE implementation, two callers: the reactor's transport shell +//! (`atomicd`) serves these handlers over a private Unix-domain socket, +//! and the Atomic CLI's local service mode calls the very same handlers +//! in-process (`ATOMIC_SERVICE=local`) — there is no second copy of +//! service-layer logic anywhere. //! //! One state ([`state::DaemonState`]) multiplexes many repositories //! (RFC D3): each resolved root is registered under its persistent ID diff --git a/libatomic/src/daemon/provenance_core.rs b/libatomic/src/daemon/provenance_core.rs index 18391079..fd7b696b 100644 --- a/libatomic/src/daemon/provenance_core.rs +++ b/libatomic/src/daemon/provenance_core.rs @@ -1,8 +1,8 @@ //! The provenance journal core — one code path for the in-process dispatch //! and the wire. //! -//! Every store operation the legacy owner protocol served lives here -//! exactly once: the RPC handlers in +//! Every store operation the legacy owner protocol served (`atomic agent +//! database-owner`, owner.rs) lives here exactly once: the RPC handlers in //! `services_provenance.rs` and the in-process `DirectJournalSink` are thin //! wrappers over these functions, so the two surfaces cannot drift. //! @@ -83,7 +83,7 @@ impl JournalError { } /// The crash-injection seam, ported VERBATIM from the legacy owner -/// (`owner_failpoint`). The env names are load-bearing: the +/// (owner.rs `owner_failpoint`). The env names are load-bearing: the /// legacy test suite spawns owners (now daemons) with them and asserts /// the process aborts and the retry converges. pub fn owner_failpoint(name: &str) { @@ -230,8 +230,8 @@ pub fn load_frozen_page( /// Reassemble only complete envelopes, advancing the cursor after a /// successful page. Retrying an RPC cannot append its bytes twice. -/// Ported verbatim from the legacy owner client -/// (`append_frozen_page`). +/// Ported verbatim from the legacy owner client (owner.rs +/// `append_frozen_page`). pub fn append_frozen_page( envelopes: &mut Vec>, partial: &mut Vec, diff --git a/libatomic/src/daemon/services.rs b/libatomic/src/daemon/services.rs index a6f57d27..09294f51 100644 --- a/libatomic/src/daemon/services.rs +++ b/libatomic/src/daemon/services.rs @@ -277,6 +277,7 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { Ok::<_, Status>(StatusResponse { view: status.view().to_string(), view_merkle: status.state().map(hash_proto), + needs_reindex: status.needs_reindex(), files, modified_count: status.modified_count() as u32, deleted_count: status.deleted_count() as u32, @@ -310,6 +311,9 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { .unwrap_or_else(|| handle.current_view()), ) }; + let include_inherited = request.all_views; + let tags_only = request.tags_only; + let path_filter = request.path_filter.clone(); let from_sequence = request.cursor.as_ref().map_or(0, |c| c.seq); let limit = request .budget @@ -323,10 +327,41 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { if let Some(view) = &view { options = options.view(view.clone()); } + if include_inherited { + options = options.include_inherited(true); + } + if tags_only { + options = options.tagged_only(true); + } if let Some(limit) = limit { options = options.limit(limit); } let entries = repo.reverse_log(options).map_err(repository_error)?; + // `--path`: keep the entries whose changes touch the path — + // change content is loaded here (server-side), not per client. + let entries = if path_filter.is_empty() { + entries + } else { + entries + .into_iter() + .filter(|entry| { + if entry.is_tagged { + return false; // tag rows carry no hunks + } + repo.load_change(&entry.hash) + .map(|change| { + change.hashed.hunks.iter().any(|op| { + op.path().is_some_and(|p| { + path_filter.iter().any(|filter| { + p == filter || p.starts_with(&format!("{filter}/")) + }) + }) + }) + }) + .unwrap_or(false) + }) + .collect() + }; let entries = entries .into_iter() .map(|entry| ChangeLogEntry { @@ -468,21 +503,26 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; self.state.log_rpc("PreviewMutation", Some(&handle)); // Read transaction: read-only open, never queued behind writers. + // The unrecord preview opens its own WRITABLE handle below (the + // domain's dry-run rides a write transaction, and the gate is + // held for this request). let root = handle.root.clone(); let current_view = handle.current_view(); + let unrecord_handle = handle.clone(); let result = tokio::task::spawn_blocking(move || -> Result { - let repo = Repository::open_readonly(&root).map_err(|error| { - domain_status( - ErrorCode::Repository, - format!("failed to open repository read-only: {error}"), - ) - })?; match request.operation { Some(preview_mutation_request::Operation::Insert(preview)) => { // The would-insert listing: missing changes between // the source and the target (tag cutoff through the - // domain dry-run). + // domain dry-run). Read-only open, never queued + // behind writers. + let repo = Repository::open_readonly(&root).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("failed to open repository read-only: {error}"), + ) + })?; let target = if preview.target_view.is_empty() { current_view } else { @@ -531,6 +571,7 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { .and_then(|v| v.name) .filter(|name| !name.is_empty()) .unwrap_or_else(|| current_view.clone()); + let repo = unrecord_handle.repository()?; let target_hash = match preview.target.and_then(|target| target.kind) { Some(change_ref::Kind::Hash(hash)) => { let mut bytes = [0u8; 32]; @@ -543,6 +584,33 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { bytes.copy_from_slice(&hash.value); Some(atomic_core::types::Merkle(bytes)) } + // Case-insensitive unique-prefix resolution — + // same resolver as the mutating twin. + Some(change_ref::Kind::Prefix(prefix)) => { + let prefix = prefix.trim().to_ascii_uppercase(); + match repo.find_change_by_prefix(&prefix) { + Ok(Some(hash)) => Some(hash), + Ok(None) => { + return Err(domain_status( + ErrorCode::NotFound, + format!("no change found matching '{prefix}'"), + )) + } + Err(atomic_repository::RepositoryError::AmbiguousHash { + prefix, + matches, + }) => { + return Err(domain_status( + ErrorCode::NotFound, + format!( + "ambiguous change prefix '{prefix}' (matches: {})", + matches.join(", ") + ), + )) + } + Err(error) => return Err(repository_error(error)), + } + } Some(change_ref::Kind::Sequence(sequence)) => { let txn = repo.pristine().read_txn().map_err(|error| { domain_status(ErrorCode::Repository, error.to_string()) @@ -572,40 +640,23 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { } None => None, }; - // The would-unrecord listing: preview_unrecord is - // the READ path (the mutating unrecord's dry_run - // still opens a write transaction). - let txn = repo.pristine().read_txn().map_err(|error| { - domain_status(ErrorCode::Repository, error.to_string()) - })?; - let view_state = txn - .get_view(&view) - .map_err(|error| domain_status(ErrorCode::View, error.to_string()))? - .ok_or_else(|| { - domain_status(ErrorCode::View, format!("view '{view}' not found")) - })?; - let target_hash = match target_hash { - Some(hash) => hash, - None => atomic_repository::unrecord::get_last_change(&txn, &view_state) - .map_err(|error| { - domain_status(ErrorCode::PreconditionFailed, error.to_string()) - })? - .ok_or_else(|| { - domain_status( - ErrorCode::PreconditionFailed, - format!("view '{view}' is empty — nothing to unrecord"), - ) - })?, + // The would-unrecord preview is the SAME domain call + // as the mutating unrecord's dry_run — one guard + // surface (membership, dependents, emptiness), not + // a second read-path approximation of it. The + // domain's dry-run opens a write transaction, so + // this arm uses the request's writable handle (the + // gate is held) for BOTH the target resolution and + // the preview. + let options = atomic_repository::UnrecordOptions::dry_run().view(view); + let outcome = match target_hash { + Some(hash) => repo.unrecord(&hash, options).map_err(|error| { + domain_status(ErrorCode::PreconditionFailed, error.to_string()) + })?, + None => repo.unrecord_last(options).map_err(|error| { + domain_status(ErrorCode::PreconditionFailed, error.to_string()) + })?, }; - let outcome = atomic_repository::unrecord::preview_unrecord( - &txn, - &view_state, - &[target_hash], - &atomic_repository::UnrecordOptions::dry_run().view(view), - ) - .map_err(|error| { - domain_status(ErrorCode::PreconditionFailed, error.to_string()) - })?; let mut changes = Vec::with_capacity(outcome.unrecorded.len()); for hash in &outcome.unrecorded { let change = repo.load_change(hash).map_err(|error| { @@ -852,6 +903,31 @@ fn resolve_author() -> Result, String> { ))) } +/// Authorship for a record: an explicit wire author wins; else a named +/// identity from the serving host's store; else the default identity. +fn resolve_author_for( + author: Option<&crate::atomic::Author>, + identity_name: Option<&str>, +) -> Result, String> { + if let Some(author) = author { + return Ok(Some(Author { + name: author.name.clone(), + email: author.email.clone(), + identity: None, + })); + } + if let Some(name) = identity_name { + let store = IdentityStore::open_default().map_err(|e| e.to_string())?; + let identity = store.load_by_name(name).map_err(|e| e.to_string())?; + return Ok(Some(Author::with_identity( + identity.name.clone(), + identity.email.clone(), + identity.public_key_base32(), + ))); + } + resolve_author() +} + #[tonic::async_trait] impl repository_mutation_service_server::RepositoryMutationService for MutationImpl { async fn add_files( @@ -867,17 +943,69 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI let _gate = gate_handle.exclusive().await; let paths: Vec = request.paths; let meta = request.meta.unwrap_or_default(); + let options = TrackingOptions { + recursive: !request.no_recursive, + force: request.force, + dry_run: request.dry_run, + ..TrackingOptions::default() + }; + let directory = request.directory; + let dry_run = request.dry_run; let result = tokio::task::spawn_blocking(move || { let repo = handle.repository()?; let mut tracked = 0usize; + // The tolerant per-path report (dry-run or not): skips never + // fail the batch; failures are collected per path. + let mut would_add: Vec = Vec::new(); + let mut skipped_tracked: Vec = Vec::new(); + let mut skipped_ignored: Vec = Vec::new(); + let mut failed_paths: Vec = Vec::new(); for path in &paths { - let stats = repo - .add(path, TrackingOptions::default()) - .map_err(repository_error)?; - tracked += stats.files_added; + let result = if directory { + repo.add_directory(path, options.clone()) + } else { + repo.add(path, options.clone()) + }; + match result { + Ok(stats) => { + if dry_run { + if stats.total_added() > 0 { + would_add.push(path.clone()); + } + for (skipped, reason) in &stats.skipped_paths { + if reason.contains("tracked") { + skipped_tracked.push(skipped.to_string_lossy().into_owned()); + } else if reason.contains("ignored") { + skipped_ignored.push(skipped.to_string_lossy().into_owned()); + } + } + } else { + tracked += stats.files_added; + } + } + Err(atomic_repository::RepositoryError::FileAlreadyTracked { path }) => { + skipped_tracked.push(path.to_string_lossy().into_owned()); + } + Err(atomic_repository::RepositoryError::PathIgnored { path }) => { + if !options.force { + skipped_ignored.push(path.to_string_lossy().into_owned()); + } + } + Err(_) if dry_run => { + failed_paths.push(path.clone()); + } + Err(error) => { + return Err(repository_error(error)); + } + } } Ok::<_, Status>(AddFilesResponse { tracked: tracked as u32, + would_add, + skipped_tracked, + skipped_ignored, + failed_paths, + dry_run, meta: Some(ResponseMeta { request_id: meta.request_id, replayed: false, @@ -904,6 +1032,8 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI let message = request.message; let paths = request.paths; let meta = request.meta.unwrap_or_default(); + let author = request.author; + let identity_name = request.identity_name; let view = handle.current_view(); let result = tokio::task::spawn_blocking(move || { if message.trim().is_empty() { @@ -912,12 +1042,13 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI "record message is empty", )); } - let author = resolve_author().map_err(|error| { - domain_status( - ErrorCode::Internal, - format!("identity resolution failed: {error}"), - ) - })?; + let author = + resolve_author_for(author.as_ref(), identity_name.as_deref()).map_err(|error| { + domain_status( + ErrorCode::Internal, + format!("identity resolution failed: {error}"), + ) + })?; let mut header = ChangeHeader::builder().message(message.clone()); if let Some(author) = author { header = header.author(author); @@ -981,7 +1112,11 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI let _gate = gate_handle.exclusive().await; let result = tokio::task::spawn_blocking(move || -> Result { let repo = handle.repository()?; - let options = TrackingOptions::default(); + let options = TrackingOptions { + force: request.force, + dry_run: request.dry_run, + ..TrackingOptions::default() + }; let mut removed: u32 = 0; let mut to_delete: Vec = Vec::new(); for path in &request.paths { @@ -1009,10 +1144,13 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI } } // The CLI deletes from disk only after every tracking removal - // succeeded; the daemon mirrors that ordering. - for path in to_delete { - if path.is_file() { - let _ = std::fs::remove_file(&path); + // succeeded; the daemon mirrors that ordering. A dry run + // reports the untrack counts without touching disk. + if !request.dry_run { + for path in to_delete { + if path.is_file() { + let _ = std::fs::remove_file(&path); + } } } Ok(RemoveFilesResponse { @@ -1053,9 +1191,10 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI format!("file not found: {}", source_path.display()), )); } - if repo - .is_tracked(&request.destination) - .map_err(repository_error)? + if !request.force + && repo + .is_tracked(&request.destination) + .map_err(repository_error)? { return Err(domain_status( ErrorCode::InvalidArgument, @@ -1071,6 +1210,10 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI ) })?; } + if request.dry_run { + // Validated only: the move would happen; nothing touches disk. + return Ok(MoveFileResponse { meta: None }); + } // Disk move ONLY — the raw-rename working-copy shape the CLI // leaves behind on purpose: record's move detection pairs the // tracked-missing source with the untracked identical-content @@ -1220,6 +1363,33 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI "revise target must be a change hash (the client resolves @/@~N)", )) } + // Case-insensitive unique-prefix resolution over the whole + // change store; the revise stack surgery speaks. + Some(change_ref::Kind::Prefix(prefix)) => { + let prefix = prefix.trim().to_ascii_uppercase(); + match repo.find_change_by_prefix(&prefix) { + Ok(Some(hash)) => hash, + Ok(None) => { + return Err(domain_status( + ErrorCode::NotFound, + format!("no change found matching '{prefix}'"), + )) + } + Err(atomic_repository::RepositoryError::AmbiguousHash { + prefix, + matches, + }) => { + return Err(domain_status( + ErrorCode::NotFound, + format!( + "ambiguous change prefix '{prefix}' (matches: {})", + matches.join(", ") + ), + )) + } + Err(error) => return Err(repository_error(error)), + } + } }; let message = request.reword_message.clone().ok_or_else(|| { domain_status( @@ -1284,7 +1454,7 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI } bytes.copy_from_slice(&hash.value); let options = InsertOptions::default() - .apply_deps(true) + .apply_deps(request.apply_dependencies.unwrap_or(true)) .allow_conflict(request.allow_conflicts) .view(target.clone()); let outcome = repo @@ -1425,6 +1595,35 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI "unrecord takes a hash target (or none for the last change)", )) } + // Case-insensitive unique-prefix resolution over the whole + // change store; the unrecord membership guard speaks. + Some(change_ref::Kind::Prefix(prefix)) => { + let prefix = prefix.trim().to_ascii_uppercase(); + match repo.find_change_by_prefix(&prefix) { + Ok(Some(hash)) => { + repo.unrecord(&hash, options).map_err(repository_error)? + } + Ok(None) => { + return Err(domain_status( + ErrorCode::NotFound, + format!("no change found matching '{prefix}'"), + )) + } + Err(atomic_repository::RepositoryError::AmbiguousHash { + prefix, + matches, + }) => { + return Err(domain_status( + ErrorCode::NotFound, + format!( + "ambiguous change prefix '{prefix}' (matches: {})", + matches.join(", ") + ), + )) + } + Err(error) => return Err(repository_error(error)), + } + } // No target: the most recent change on the view. An empty // view refuses with the domain's own message. None => repo.unrecord_last(options).map_err(|error| { diff --git a/libatomic/src/daemon/services_agent.rs b/libatomic/src/daemon/services_agent.rs index 290cd4f1..37540c3a 100644 --- a/libatomic/src/daemon/services_agent.rs +++ b/libatomic/src/daemon/services_agent.rs @@ -1414,6 +1414,28 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { let repo = handle.repository()?; let processed = match action { KnowledgeMaintainAction::Enrich => { + // Per-change scope (`enrich --changes`): enrich only + // the named changes' nodes. + if !request.changes.is_empty() { + let mut total: u64 = 0; + for hash in &request.changes { + if hash.value.len() != 32 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "hash must be 32 bytes", + )); + } + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&hash.value); + repo.kg_enrich_change(&atomic_core::types::Merkle(bytes)) + .map_err(repository_error)?; + total += 1; + } + return Ok::<_, Status>(MaintainKnowledgeGraphResponse { + processed: total, + meta: response_meta(&meta), + }); + } // The CLI --rebuild flag rides the scope field ("rebuild"). if request.scope.as_deref() == Some("rebuild") { repo.kg_clear_derived().map_err(repository_error)?; @@ -1617,10 +1639,52 @@ impl provenance_service_server::ProvenanceService for ProvenanceImpl { let sessions = tokio::task::spawn_blocking(move || { let repo = handle_for_task.repository()?; let ledgers = repo.list_session_ledgers(limit).map_err(repository_error)?; - Ok::<_, Status>( - ledgers - .into_iter() - .map(|(record, turns)| SessionSummary { + // Vault-derived session→intents map (the same map the client + // built locally — one implementation, served here). + let mut intent_map: std::collections::HashMap> = + std::collections::HashMap::new(); + if let Ok(manifest) = repo.vault_manifest() { + for (key, summary) in &manifest.intents { + if let Some(session_id) = &summary.session { + let label = if summary.human_key.is_empty() { + key.clone() + } else { + summary.human_key.clone() + }; + let ids = intent_map.entry(session_id.clone()).or_default(); + if !ids.contains(&label) { + ids.push(label); + } + } + } + } + // The complete ledgers as versioned-opaque bytes (JSON of the + // {record, turns} pairs — atomic.session.ledgers.v1); the + // client renders listings/JSON from the domain shape. + let ledgers_bundle = serde_json::to_vec(&ledgers) + .ok() + .map(|payload| VersionedBytes { + schema: "atomic.session.ledgers.v1".to_string(), + payload, + }); + let mut intent_counts = Vec::with_capacity(ledgers.len()); + let summaries = ledgers + .into_iter() + .map(|(record, turns)| { + // Prefer explicit plan ids from the turns; the vault + // map covers the rest. + let from_turns: std::collections::HashSet<&str> = + turns.iter().filter_map(|t| t.plan_id.as_deref()).collect(); + let intent_count = if !from_turns.is_empty() { + from_turns.len() + } else { + intent_map + .get(&record.session_id) + .map(|ids| ids.len()) + .unwrap_or(0) + }; + intent_counts.push(intent_count as u32); + SessionSummary { session_id: record.session_id, view: record.view_name, turn_count: record.turn_count.max(turns.len() as u32), @@ -1632,13 +1696,19 @@ impl provenance_service_server::ProvenanceService for ProvenanceImpl { seconds: ended, nanos: 0, }), - }) - .collect::>(), - ) + } + }) + .collect::>(); + Ok::<_, Status>((summaries, ledgers_bundle, intent_counts)) }) .await .map_err(|error| Status::internal(error.to_string()))??; - Ok(Response::new(ListSessionsResponse { sessions })) + let (sessions, ledgers_bundle, intent_counts) = sessions; + Ok(Response::new(ListSessionsResponse { + sessions, + ledgers_bundle, + intent_counts, + })) } async fn fork_session( diff --git a/libatomic/src/daemon/services_maintenance.rs b/libatomic/src/daemon/services_maintenance.rs index e2cf5a95..49569f37 100644 --- a/libatomic/src/daemon/services_maintenance.rs +++ b/libatomic/src/daemon/services_maintenance.rs @@ -28,10 +28,12 @@ fn repair_action(action: i32) -> Result { Ok(match action { x if x == Action::RebuildDependencyIndex as i32 => Action::RebuildDependencyIndex, x if x == Action::MaterializeCrdt as i32 => Action::MaterializeCrdt, + x if x == Action::ReindexWorkingCopy as i32 => Action::ReindexWorkingCopy, _ => { return Err(domain_status( ErrorCode::InvalidArgument, - "repair action is required (REBUILD_DEPENDENCY_INDEX or MATERIALIZE_CRDT)", + "repair action is required (REBUILD_DEPENDENCY_INDEX, MATERIALIZE_CRDT, \ + or REINDEX_WORKING_COPY)", )) } }) @@ -66,6 +68,17 @@ impl MaintenanceService for MaintenanceImpl { indexed: indexed as u64, skipped: skipped as u64, failed: failed as u64, + reindexed: 0, + ..Default::default() + }) + } + RepairAction::ReindexWorkingCopy => { + // `status --reindex`: rebuild the working-copy index so + // stale rows stop producing false positives. + let reindexed = repo.reindex_working_copy().map_err(repository_error)?; + Ok(RepairResponse { + findings: Vec::new(), + reindexed: reindexed as u64, ..Default::default() }) } @@ -82,6 +95,7 @@ impl MaintenanceService for MaintenanceImpl { indexed: 0, skipped: 0, failed: 0, + reindexed: 0, changes_scanned: outcome.changes_scanned as u64, changes_applied: outcome.changes_applied as u64, file_ops_applied: outcome.file_ops_applied as u64, diff --git a/libatomic/src/daemon/services_provenance.rs b/libatomic/src/daemon/services_provenance.rs index 790c385c..b8af17a0 100644 --- a/libatomic/src/daemon/services_provenance.rs +++ b/libatomic/src/daemon/services_provenance.rs @@ -6,8 +6,8 @@ //! in-process dispatch uses), behind the per-repository gate — changes.redb //! is one writable handle per process, exactly like pristine. //! -//! Semantics parity with the legacy owner protocol -//! (`handle_request`): generation fencing with the store's verbatim error +//! Semantics parity with the legacy owner protocol (owner.rs +//! `handle_request`): generation fencing with the store's verbatim error //! text, caller-supplied `now` (never a server clock), the five named //! crash-injection failpoints, prepare's reuse_frozen_changes recovery, and //! strict-continuity frozen-envelope paging. Error classification follows diff --git a/libatomic/src/daemon/services_query.rs b/libatomic/src/daemon/services_query.rs index 640cebc5..c6c3be95 100644 --- a/libatomic/src/daemon/services_query.rs +++ b/libatomic/src/daemon/services_query.rs @@ -185,10 +185,11 @@ pub async fn get_change_impl( let gate_handle = handle.clone(); let _gate = gate_handle.exclusive().await; let handle_for_task = handle.clone(); + let wants_bundle = request.includes.as_ref().is_some_and(|i| i.metadata); let change = tokio::task::spawn_blocking(move || { // A query: read-only open with the read-concurrency wait. let repo = handle_for_task.repository_readonly()?; - let hash = match reference { + let (hash, sequence) = match reference { change_ref::Kind::Hash(hash) => { let mut bytes = [0u8; 32]; if hash.value.len() != 32 { @@ -198,10 +199,12 @@ pub async fn get_change_impl( )); } bytes.copy_from_slice(&hash.value); - atomic_core::types::Merkle(bytes) + (atomic_core::types::Merkle(bytes), None) } change_ref::Kind::Sequence(sequence) => { - let view_name = view.unwrap_or_else(|| repo.current_view().to_string()); + let view_name = view + .clone() + .unwrap_or_else(|| repo.current_view().to_string()); let txn = repo .pristine() .read_txn() @@ -215,18 +218,79 @@ pub async fn get_change_impl( let entry = atomic_repository::history::get_change_at_sequence(&txn, &view_state, sequence) .map_err(|error| domain_status(ErrorCode::NotFound, error.to_string()))?; - entry.hash + (entry.hash, Some(sequence)) + } + change_ref::Kind::Prefix(prefix) => { + // Case-insensitive unique-prefix resolution over the whole + // change store — the same resolver `insert` uses; the + // caller's membership/guard errors come from the target op. + let prefix = prefix.trim().to_ascii_uppercase(); + if prefix.is_empty() { + return Err(domain_status( + ErrorCode::InvalidArgument, + "change prefix required", + )); + } + match repo.find_change_by_prefix(&prefix) { + Ok(Some(hash)) => (hash, None), + Ok(None) => { + return Err(domain_status( + ErrorCode::NotFound, + format!("no change found matching '{prefix}'"), + )) + } + Err(atomic_repository::RepositoryError::AmbiguousHash { prefix, matches }) => { + return Err(domain_status( + ErrorCode::NotFound, + format!( + "ambiguous change prefix '{prefix}' (matches: {})", + matches.join(", ") + ), + )) + } + Err(error) => { + return Err(domain_status(ErrorCode::Repository, error.to_string())) + } + } } }; let change = repo .load_change(&hash) .map_err(|error| domain_status(ErrorCode::NotFound, error.to_string()))?; - Ok::<_, Status>((change, hash)) + // The complete change, V3-serialized, plus the causal decision + // graph(s) explaining it — the client's full-detail renders + // (change ledger, hunks, JSON) deserialize these with atomic-core. + let bundle = if wants_bundle { + let mut buffer = Vec::new(); + change + .serialize(&mut buffer) + .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; + Some(VersionedBytes { + schema: "atomic.change.v3".to_string(), + payload: buffer, + }) + } else { + None + }; + let ledger = repo + .find_provenance_for_change(&hash) + .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))? + .into_iter() + .map(|(_graph_hash, graph)| { + let payload = serde_json::to_vec(&graph) + .map_err(|error| domain_status(ErrorCode::Internal, error.to_string()))?; + Ok::(VersionedBytes { + schema: "atomic.prov.graph.v1".to_string(), + payload, + }) + }) + .collect::, _>>()?; + Ok::<_, Status>((change, hash, sequence, bundle, ledger)) }) .await .map_err(|error| Status::internal(error.to_string()))??; - let (change, hash) = change; + let (change, hash, sequence, change_bundle, provenance_ledger) = change; let header = &change.hashed.header; Ok(Response::new(GetChangeResponse { change: Some(ChangeInfo { @@ -257,6 +321,9 @@ pub async fn get_change_impl( semantic_section: None, signatures: Vec::new(), content_chunks: None, + change_bundle, + provenance_ledger, + sequence, })) } @@ -278,15 +345,64 @@ pub async fn get_session_impl( let handle_for_task = handle.clone(); let ledger = tokio::task::spawn_blocking(move || { let repo = handle_for_task.repository()?; - repo.get_session_ledger(&session_id) - .map_err(repository_error) + let ledger = repo + .get_session_ledger(&session_id) + .map_err(repository_error)?; + // Turn → intent resolution: explicit plan ids ride the turns; vault + // session links come from the manifest (the same map the client + // built locally — one implementation, served here). + let turn_intents = match &ledger { + Some((_record, turns)) => turns + .iter() + .filter_map(|turn| { + let intent = turn + .plan_id + .clone() + .or_else(|| vault_turn_intent(&repo, &session_id, turn.turn_number))?; + Some(SessionTurnIntent { + turn: turn.turn_number, + intent, + }) + }) + .collect::>(), + None => Vec::new(), + }; + let head_hash = repo.get_session_head(&session_id).ok().flatten(); + let manifest_head = head_hash.as_ref().map(hash_proto); + let (parent_manifest, fork_turn) = head_hash + .as_ref() + .and_then(|head| repo.get_session_manifest(head).ok().flatten()) + .map(|manifest| { + ( + manifest.parent_session.as_ref().map(hash_proto), + manifest.fork_turn, + ) + }) + .unwrap_or((None, None)); + Ok::<_, Status>(( + ledger, + turn_intents, + manifest_head, + parent_manifest, + fork_turn, + )) }) .await .map_err(|error| Status::internal(error.to_string()))??; - let Some((_record, turns)) = ledger else { + let (ledger, turn_intents, manifest_head, parent_manifest, fork_turn) = ledger; + let Some((record, turns)) = ledger else { return Err(domain_status(ErrorCode::NotFound, "session not found")); }; + // The complete ledger as versioned-opaque bytes: JSON of the + // {record, turns} pair — the client's listings/renders deserialize the + // domain shape it knows (atomic.session.ledger.v1). + let ledger_bundle = serde_json::to_vec(&(record, &turns)) + .ok() + .map(|payload| VersionedBytes { + schema: "atomic.session.ledger.v1".to_string(), + payload, + }); let turns = turns .into_iter() .filter(|turn| { @@ -296,7 +412,34 @@ pub async fn get_session_impl( }) .map(stored_turn_proto) .collect(); - Ok(Response::new(GetSessionResponse { turns })) + Ok(Response::new(GetSessionResponse { + turns, + ledger_bundle, + turn_intents, + manifest_head, + parent_manifest, + fork_turn, + })) +} + +/// The vault-manifest turn→intent map for one session (the stored `turn` is +/// `turn_count + 1` at creation, so subtract one for the ledger's 0-indexed +/// turn number — mirrors the vault's own resolution). +fn vault_turn_intent(repo: &Repository, session_id: &str, turn_number: u32) -> Option { + let manifest = repo.vault_manifest().ok()?; + for (key, summary) in &manifest.intents { + if summary.session.as_deref() != Some(session_id) { + continue; + } + if summary.turn.and_then(|t| t.checked_sub(1)) == Some(turn_number) { + return Some(if summary.human_key.is_empty() { + key.clone() + } else { + summary.human_key.clone() + }); + } + } + None } /// Map a stored session turn to the wire shape (the fields the ledger owns: @@ -416,6 +559,17 @@ impl view_service_server::ViewService for ViewImpl { } (Some(source.clone()), Some(source)) } + // `--parent`: anchor on the parent WITHOUT seeding — + // the draft workspace form. + Some(create_view_request::Base::Parent(parent)) => { + if !repo.view_exists(&parent).map_err(repository_error)? { + return Err(domain_status( + ErrorCode::View, + format!("view '{parent}' not found"), + )); + } + (Some(parent), None) + } Some(create_view_request::Base::Empty(_)) | None => (None, None), }; repo.create_overlay_view(&name, anchor.as_deref(), seed.as_deref()) @@ -473,9 +627,11 @@ impl view_service_server::ViewService for ViewImpl { )); } // The CLI's safety gate, verbatim: refuse to switch away - // from a dirty working copy (the wire carries no bypass — - // --force/--stash stay client-side). - if handle_for_task.current_view() != name { + // from a dirty working copy — unless the caller explicitly + // bypassed it (--force/--stash, an informed client decision). + if !request.bypass_dirty_check.unwrap_or(false) + && handle_for_task.current_view() != name + { let status = repo .status(StatusOptions::default()) .map_err(repository_error)?; @@ -605,7 +761,7 @@ impl view_service_server::ViewService for ViewImpl { }, changes, cascade: request.cascade, - dry_run: false, + dry_run: request.dry_run, materialize: request.materialize, }) .map_err(repository_error)?; @@ -636,10 +792,18 @@ impl view_service_server::ViewService for ViewImpl { }) .collect::>() }; - let view_info = repo - .get_view_info(&outcome.target_view) - .map_err(repository_error)?; + // A dry run creates nothing: the report below is a + // preview, so there is no target view to describe. + let view_info = if request.dry_run { + None + } else { + Some( + repo.get_view_info(&outcome.target_view) + .map_err(repository_error)?, + ) + }; Ok(SplitViewResponse { + dry_run: request.dry_run, requested: map(&outcome.requested), dependents: map(&outcome.dependents), moved: map(&outcome.moved), @@ -648,10 +812,10 @@ impl view_service_server::ViewService for ViewImpl { working_copy_updated: outcome.working_copy_updated, files_written: outcome.files_written as u64, files_removed: outcome.files_removed as u64, - view: Some(ViewInfo { + view: view_info.map(|info| ViewInfo { name: outcome.target_view.clone(), scope: ViewScope::Unspecified as i32, - parent: view_info.parent_name.clone(), + parent: info.parent_name.clone(), head: None, change_count: outcome.target_change_count, current: handle_for_task.current_view() == outcome.target_view, diff --git a/libatomic/src/daemon/state.rs b/libatomic/src/daemon/state.rs index 4ab37bbe..545c856d 100644 --- a/libatomic/src/daemon/state.rs +++ b/libatomic/src/daemon/state.rs @@ -17,8 +17,8 @@ use tonic::{Code, Status}; use prost::Message; -/// Env-configured JSONL log of handled RPCs (one line per request, so -/// external auditing can pair client invocations with served requests). +/// Env-configured JSONL log of handled RPCs (the e2e harness audit pairs +/// client invocations with served requests through it). pub const ENV_LOG_REQUESTS: &str = "ATOMIC_DAEMON_LOG_REQUESTS"; /// How long a read-only open retries when another process holds a From 8960dcdf7363ad56f5871f779f4673c9307dde5d Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 5 Oct 2026 21:46:13 -0500 Subject: [PATCH 07/23] feat(vault): rank memory context handler-side + memory identity filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetVaultContext grows the retrieval seeds (query/intent/files/budget/ include_body, add-only) and a ranked ContextItem list: the gather+rank behind `atomic vault context` now lives in the handler (one ranking recipe, both transports), with the CLI rendering md/candidates/JSON from the wire items. ListVaultEntries' Memory arm honors the identity filter — only memories whose fresh attestation verifies under that identity list. --- .../services/vault/vault_messages.proto | 34 + libatomic/src/daemon/services_agent.rs | 699 +++++++++++++++++- 2 files changed, 731 insertions(+), 2 deletions(-) diff --git a/libatomic/proto/atomic/services/vault/vault_messages.proto b/libatomic/proto/atomic/services/vault/vault_messages.proto index 144dcc7c..054e262b 100644 --- a/libatomic/proto/atomic/services/vault/vault_messages.proto +++ b/libatomic/proto/atomic/services/vault/vault_messages.proto @@ -108,13 +108,47 @@ message GetVaultContextRequest { RepositoryRef repository = 1; optional uint32 max_entries = 2; repeated VaultEntityKind kinds = 3; + // The retrieval seeds from `atomic vault context` (add-only): free-text + // query terms, an intent to seed from, and file paths whose neighbors are + // candidates. Absent seeds fall back to the most recently updated + // memories — the command's documented bare form. + repeated string query = 4; // free-text seed terms + optional string intent = 5; // --intent seed (id) + repeated string files = 6; // --files seeds (working-copy paths) + optional uint32 budget_chars = 7; // total body budget across items + optional bool include_body = 8; // false → candidate metadata only ViewRef view = 20; } message GetVaultContextResponse { repeated VaultEntry entries = 1; + // The ranked, budgeted memory context (add-only) — the gather+rank + // domain computation `atomic vault context` performs, served as one + // ranked list (schema: atomic.context.item.v1, mirroring the CLI's + // ranked MemoryItem). + repeated ContextItem context = 2; ViewSnapshot snapshot = 20; // SHAPE-CONFIRM: context-pack composition from vault/context.rs. } +// One ranked memory returned by GetVaultContext — the fields of the CLI's +// MemoryItem (atomic-cli/src/commands/vault/context.rs), add-only. +message ContextItem { + string memory_id = 1; // canonical urn:atomic:memory:… identity + string kg_node_id = 2; // the KG's node identity + string revision_hash = 3; // entry type + frontmatter + body revision + string content_hash = 4; // body-only hash + string path = 5; // vault-relative path (memory/.md) + string name = 6; + string kind = 7; // memoryKind (constraint/lesson/…) + string status = 8; + double score = 9; // final ranking score + string why_matched = 10; // the ranking signals that matched + optional string body = 11; // present when include_body + optional string preview = 12; // first line of the body (with body) + string updated_at = 13; + uint64 introduced_by = 14; // introducing change sequence (0 = unrecorded) + bool recorded = 15; // introduced_by != 0 + bool truncated = 16; // body clipped to the budget +} message ExportVaultRequest { RepositoryRef repository = 1; diff --git a/libatomic/src/daemon/services_agent.rs b/libatomic/src/daemon/services_agent.rs index 37540c3a..e9ebd2ab 100644 --- a/libatomic/src/daemon/services_agent.rs +++ b/libatomic/src/daemon/services_agent.rs @@ -12,6 +12,7 @@ use crate::atomic::VaultEntityKind as Kind; use crate::atomic::*; use atomic_agent::event::TurnEvent; use atomic_agent::turn::orchestrator::TurnOrchestrator; +use atomic_canonical::did::did_for_public_key; use atomic_canonical::gate::{validate_intent, validate_memory}; use atomic_canonical::lift::lift_intent; use atomic_canonical::memory::lift_memory; @@ -34,6 +35,611 @@ use super::state::{domain_status, repository_error, DaemonState}; // VaultService // --------------------------------------------------------------------------- +// The memory-context gather+rank — the domain computation behind +// `atomic vault context`, ported from the CLI's retrieval layer so the +// ranking recipe lives in exactly one place (the handler) and both +// transports serve it. The CLI renders; it no longer ranks. + +/// Tuning constants, mirroring the CLI retrieval layer's documented recipe. +mod context_ranking { + use atomic_core::pristine::tables::tokenize_for_fts; + use atomic_core::pristine::vault::{KgNode, VaultEntry, VaultEntryType}; + use atomic_core::types::{Base32, Hasher}; + use atomic_repository::Repository; + use std::collections::{HashMap, HashSet}; + + use crate::atomic::{ContextItem, ErrorCode}; + use crate::daemon::state::{domain_status, repository_error}; + use tonic::Status; + + /// Fetch this many times `--limit` from typed KG search so body and + /// graph signals can rerank a useful candidate pool. + pub const SEARCH_POOL_MULTIPLIER: usize = 4; + + /// Score bonus for memories directly connected (in the KG) to a seed. + pub const NEIGHBOR_BONUS: f64 = 0.75; + + /// Weight of a full body-term match: memory *bodies* are matched by a + /// direct scan (the KG FTS indexes ids/labels/summaries only). + pub const BODY_MATCH_WEIGHT: f64 = 0.8; + + /// Meaningful short terms the shared FTS tokenizer drops (<3 chars). + pub const SHORT_TECH_TERMS: &[&str] = &["ai", "go", "r", "c"]; + + /// Weight of the recency component in the final score. + pub const RECENCY_WEIGHT: f64 = 0.25; + + /// Half-life, in days, of the recency component. + pub const RECENCY_HALF_LIFE_DAYS: f64 = 90.0; + + /// Cap on intent body text used as retrieval terms. + pub const INTENT_BODY_SEED_CHARS: usize = 2000; + + /// Memories whose frontmatter `type` matches one of these are never + /// injected (index/table-of-contents files, not knowledge). + pub const EXCLUDED_MEMORY_TYPES: &[&str] = &["index"]; + + /// Partial score for a candidate memory node, accumulated across seeds. + #[derive(Clone, Debug, PartialEq)] + pub struct CandidateScore { + /// Rank-derived score from the KG keyword search (0 if graph-only). + pub base: f64, + /// Whether the memory is a direct KG neighbor of a seed node. + pub neighbor: bool, + /// Vault path carried by KG metadata or a vault listing. + pub path: Option, + } + + pub fn merge_candidate( + candidates: &mut HashMap, + node_id: &str, + path: Option, + base: f64, + neighbor: bool, + ) { + candidates + .entry(node_id.to_string()) + .and_modify(|candidate| { + candidate.base = candidate.base.max(base); + candidate.neighbor |= neighbor; + if candidate.path.is_none() { + candidate.path = path.clone(); + } + }) + .or_insert(CandidateScore { + base, + neighbor, + path, + }); + } + + /// Add all memory nodes adjacent to `node_id` as neighbor candidates. + fn add_memory_neighbors( + repo: &Repository, + node_id: &str, + depth: u8, + candidates: &mut HashMap, + ) -> Result<(), Status> { + let subgraph = repo + .vault_kg_neighbors(node_id, depth) + .map_err(repository_error)?; + for node in subgraph.nodes { + if node.kind == "memory" && node.id != node_id { + merge_candidate( + candidates, + &node.id, + memory_path_from_node(&node), + 0.0, + true, + ); + } + } + Ok(()) + } + + /// Rank-derived score for the `rank`-th KG search result (0-based). + fn rank_score(rank: usize) -> f64 { + 1.0 / (1.0 + rank as f64) + } + + /// Lowercased, deduplicated terms using KG FTS rules plus a small + /// allowlist of meaningful short technology names. Exact token + /// matching prevents substring matches such as `rust` in `trust`. + fn search_terms(seed_query: &str) -> Vec { + let mut terms = tokenize_for_fts(seed_query); + terms.extend( + seed_query + .split(|character: char| !character.is_alphanumeric() && character != '_') + .map(str::to_lowercase) + .filter(|term| SHORT_TECH_TERMS.contains(&term.as_str())), + ); + terms.sort(); + terms.dedup(); + terms + } + + /// Fraction of seed terms present as exact body tokens. + fn body_match_fraction(terms: &[String], body: &str) -> f64 { + if terms.is_empty() { + return 0.0; + } + let body_tokens: HashSet = search_terms(body).into_iter().collect(); + let matched = terms + .iter() + .filter(|term| body_tokens.contains(*term)) + .count(); + matched as f64 / terms.len() as f64 + } + + /// Recency component in [0, 1]: 1.0 for "just updated", halving every + /// [`RECENCY_HALF_LIFE_DAYS`]. Unparseable timestamps score 0. + fn recency_score(updated_at: &str, now: chrono::DateTime) -> f64 { + let Ok(updated) = chrono::DateTime::parse_from_rfc3339(updated_at) else { + return 0.0; + }; + let age_days = (now - updated.with_timezone(&chrono::Utc)).num_seconds() as f64 / 86_400.0; + if age_days <= 0.0 { + return 1.0; + } + 0.5_f64.powf(age_days / RECENCY_HALF_LIFE_DAYS) + } + + /// Combine the candidate score parts into the final ranking score. + fn final_score(candidate: &CandidateScore, recency: f64) -> f64 { + let neighbor = if candidate.neighbor { + NEIGHBOR_BONUS + } else { + 0.0 + }; + candidate.base + neighbor + RECENCY_WEIGHT * recency + } + + fn why_matched(candidate: &CandidateScore) -> &'static str { + match (candidate.base > 0.0, candidate.neighbor) { + (true, true) => "task terms and knowledge-graph relationship", + (true, false) => "task terms", + (false, true) => "knowledge-graph relationship", + (false, false) => "recent-memory fallback", + } + } + + /// Read a vault path from KG node metadata; the legacy fallback maps the + /// node identity onto `memory/.md`. + fn memory_path_from_node(node: &KgNode) -> Option { + node.metadata + .as_ref() + .and_then(|metadata| metadata.get("vault_path")) + .and_then(|path| path.as_str()) + .filter(|path| path.starts_with("memory/") && path.ends_with(".md")) + .map(String::from) + .or_else(|| legacy_memory_node_id_to_path(&node.id)) + } + + /// Legacy fallback: `memory:architecture` -> `memory/architecture.md`. + fn legacy_memory_node_id_to_path(node_id: &str) -> Option { + let name = node_id.strip_prefix("memory:")?; + if name.is_empty() { + return None; + } + Some(format!("memory/{}.md", name)) + } + + /// `memory/architecture.md` -> `memory:architecture`. + fn memory_path_to_node_id(path: &str) -> Option { + let name = path.strip_prefix("memory/")?.strip_suffix(".md")?; + if name.is_empty() { + return None; + } + Some(format!("memory:{}", name)) + } + + /// `intents/pimo-1/intent.md` -> `intent:PIMO-1` (nested paths keep + /// their inner segments, uppercased, matching the KG indexer). + fn intent_path_to_node_id(path: &str) -> String { + let id = path + .strip_prefix("intents/") + .and_then(|s| s.strip_suffix("/intent.md")) + .unwrap_or(path) + .to_uppercase(); + format!("intent:{}", id) + } + + // Frontmatter helpers (the stored entries carry JSON-object strings). + + /// The canonical `memoryKind`, accepting legacy `kind`/`type` fields. + fn frontmatter_kind(frontmatter_json: &str) -> String { + serde_json::from_str::(frontmatter_json) + .ok() + .and_then(|v| { + ["memoryKind", "kind", "type"] + .iter() + .find_map(|key| v.get(key).and_then(|value| value.as_str())) + .map(String::from) + }) + .unwrap_or_else(|| "project".to_string()) + } + + fn frontmatter_status(frontmatter_json: &str) -> Option { + let value = serde_json::from_str::(frontmatter_json).ok()?; + match value.get("status") { + None => Some("active".to_string()), + Some(serde_json::Value::String(status)) if !status.is_empty() => Some(status.clone()), + Some(_) => None, + } + } + + fn frontmatter_string(frontmatter_json: &str, keys: &[&str]) -> Option { + let value = serde_json::from_str::(frontmatter_json).ok()?; + keys.iter() + .find_map(|key| value.get(key).and_then(|item| item.as_str())) + .filter(|item| !item.is_empty()) + .map(String::from) + } + + fn frontmatter_name(frontmatter_json: &str) -> Option { + frontmatter_string(frontmatter_json, &["name"]) + } + + fn frontmatter_memory_id(frontmatter_json: &str) -> Option { + let id = frontmatter_string(frontmatter_json, &["@id", "uid", "id"])?; + if id.starts_with("urn:atomic:") { + Some(id) + } else { + Some(format!("urn:atomic:memory:{id}")) + } + } + + /// Extract `labels` (array of strings) from frontmatter JSON. + fn frontmatter_labels(frontmatter_json: &str) -> Vec { + serde_json::from_str::(frontmatter_json) + .ok() + .and_then(|v| { + v.get("labels").and_then(|l| l.as_array()).map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + }) + .unwrap_or_default() + } + + fn is_eligible_memory(entry: &VaultEntry) -> bool { + if entry.entry_type != VaultEntryType::Memory { + return false; + } + let kind = frontmatter_kind(&entry.frontmatter_json); + if EXCLUDED_MEMORY_TYPES.contains(&kind.as_str()) { + return false; + } + frontmatter_status(&entry.frontmatter_json) + .is_some_and(|status| status.eq_ignore_ascii_case("active")) + } + + /// Identify the exact stored Vault revision (entry type, frontmatter, + /// and body — not only the body content hash). + fn vault_entry_revision_hash(entry: &VaultEntry) -> String { + fn append_revision_field(hasher: &mut Hasher, value: &[u8]) { + hasher.update(&(value.len() as u64).to_be_bytes()); + hasher.update(value); + } + let mut hasher = Hasher::new(); + hasher.update(b"atomic-vault-revision-v1"); + append_revision_field(&mut hasher, entry.entry_type.as_str().as_bytes()); + append_revision_field(&mut hasher, entry.frontmatter_json.as_bytes()); + append_revision_field(&mut hasher, &entry.content_bytes); + hasher.finalize().to_base32() + } + + /// Truncate to at most `max_chars` characters on a char boundary. + fn truncate_chars(value: &str, max_chars: usize) -> String { + value.chars().take(max_chars).collect() + } + + /// One-line preview of a body (first 160 chars, newlines collapsed). + fn preview(body: &str) -> String { + let flat: String = body.split_whitespace().collect::>().join(" "); + truncate_chars(&flat, 160) + } + + /// Gather candidate memory nodes from all seeds: an intent's terms and + /// graph neighbors, file neighbors, keyword search, body-term matches. + /// With no seeds at all, falls back to the most recently updated + /// memories so a bare context request is still useful. + fn gather_candidates( + repo: &Repository, + query: &[String], + intent: Option<&str>, + files: &[String], + limit: usize, + ) -> Result, Status> { + let mut candidates: HashMap = HashMap::new(); + let mut seed_terms: Vec = query.to_vec(); + let mut has_explicit_seeds = + query.iter().any(|term| !term.trim().is_empty()) || intent.is_some(); + + if let Some(intent_id) = intent { + seed_from_intent(repo, intent_id, &mut seed_terms, &mut candidates)?; + has_explicit_seeds = true; + } + + for file in files { + let node_id = format!("file:{}", file.trim_start_matches("./")); + add_memory_neighbors(repo, &node_id, 1, &mut candidates)?; + has_explicit_seeds = true; + } + + let seed_query = seed_terms.join(" "); + if !seed_query.trim().is_empty() { + let pool = limit.saturating_mul(SEARCH_POOL_MULTIPLIER); + let nodes = repo + .vault_kg_search_by_kind(&seed_query, pool, "memory") + .map_err(repository_error)?; + for (rank, node) in nodes.iter().enumerate() { + let base = rank_score(rank); + merge_candidate( + &mut candidates, + &node.id, + memory_path_from_node(node), + base, + false, + ); + } + scan_memory_bodies(repo, &seed_query, &mut candidates)?; + } + + // No seeds of any kind: fall back to the most recent memories. + if candidates.is_empty() && !has_explicit_seeds { + let mut metas = repo + .vault_list("memory/", Some(VaultEntryType::Memory)) + .map_err(repository_error)?; + metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + for meta in metas { + let Some(entry) = repo.vault_retrieve(&meta.path).map_err(repository_error)? else { + continue; + }; + if !is_eligible_memory(&entry) { + continue; + } + if let Some(node_id) = memory_path_to_node_id(&meta.path) { + merge_candidate(&mut candidates, &node_id, Some(meta.path), 0.0, false); + } + if candidates.len() >= limit { + break; + } + } + } + + Ok(candidates) + } + + /// Match seed terms against memory bodies directly — the KG FTS + /// indexes only node ids/labels/summaries, so body words are invisible + /// to the keyword search. Memories are few and small; a linear scan + /// keeps free-text queries useful without a storage-layer change. + fn scan_memory_bodies( + repo: &Repository, + seed_query: &str, + candidates: &mut HashMap, + ) -> Result<(), Status> { + let terms = search_terms(seed_query); + if terms.is_empty() { + return Ok(()); + } + let metas = repo + .vault_list("memory/", Some(VaultEntryType::Memory)) + .map_err(repository_error)?; + for meta in metas { + let Some(node_id) = memory_path_to_node_id(&meta.path) else { + continue; + }; + let Some(entry) = repo.vault_retrieve(&meta.path).map_err(repository_error)? else { + continue; + }; + if !is_eligible_memory(&entry) { + continue; + } + let body = String::from_utf8_lossy(&entry.content_bytes); + let matched = body_match_fraction(&terms, &body); + if matched > 0.0 { + let base = BODY_MATCH_WEIGHT * matched; + merge_candidate(candidates, &node_id, Some(meta.path), base, false); + } + } + Ok(()) + } + + /// Seed query terms and graph-neighbor candidates from an intent. + fn seed_from_intent( + repo: &Repository, + intent_id: &str, + seed_terms: &mut Vec, + candidates: &mut HashMap, + ) -> Result<(), Status> { + let manifest = repo.vault_manifest().map_err(repository_error)?; + let Some((resolved_id, summary)) = manifest + .intents + .iter() + .find(|(id, _)| id.eq_ignore_ascii_case(intent_id)) + else { + return Err(domain_status( + ErrorCode::NotFound, + format!("vault intent '{intent_id}' not found"), + )); + }; + + seed_terms.push(summary.title.clone()); + + // Legacy manifest entries may not carry `vault_path`; resolve the + // path rather than querying the empty and meaningless KG node. + let intent_path = repo + .vault_intent_path(resolved_id) + .map_err(repository_error)? + .ok_or_else(|| { + domain_status( + ErrorCode::NotFound, + format!("vault intent '{intent_id}' not found"), + ) + })?; + + if let Some(entry) = repo + .vault_retrieve(&intent_path) + .map_err(repository_error)? + { + seed_terms.extend(frontmatter_labels(&entry.frontmatter_json)); + let body = String::from_utf8_lossy(&entry.content_bytes); + let body = truncate_chars(body.trim(), INTENT_BODY_SEED_CHARS); + if !body.is_empty() { + seed_terms.push(body); + } + } + + let node_id = intent_path_to_node_id(&intent_path); + // Intent -> referenced file/domain -> memory is commonly two hops. + add_memory_neighbors(repo, &node_id, 2, candidates)?; + Ok(()) + } + + /// Load candidate bodies from the vault, score, and rank them — the + /// final ranked context items, truncated to `limit`. + fn resolve_and_rank( + repo: &Repository, + candidates: HashMap, + limit: usize, + include_body: bool, + ) -> Result, Status> { + let now = chrono::Utc::now(); + let mut items: Vec = Vec::new(); + + for (node_id, candidate) in candidates { + let Some(path) = candidate + .path + .clone() + .or_else(|| legacy_memory_node_id_to_path(&node_id)) + else { + continue; + }; + let Some(entry) = repo.vault_retrieve(&path).map_err(repository_error)? else { + continue; // stale KG node — the entry no longer exists + }; + + if !is_eligible_memory(&entry) { + continue; + } + let kind = frontmatter_kind(&entry.frontmatter_json); + let Some(status) = frontmatter_status(&entry.frontmatter_json) else { + continue; + }; + + let memory_id = + frontmatter_memory_id(&entry.frontmatter_json).unwrap_or_else(|| node_id.clone()); + let name = frontmatter_name(&entry.frontmatter_json).unwrap_or_else(|| { + node_id + .rsplit_once(':') + .map(|(_, n)| n) + .unwrap_or(&node_id) + .to_string() + }); + let recency = recency_score(&entry.updated_at, now); + let body = if include_body { + String::from_utf8_lossy(&entry.content_bytes) + .trim() + .to_string() + } else { + String::new() + }; + items.push(ContextItem { + memory_id, + kg_node_id: node_id, + revision_hash: vault_entry_revision_hash(&entry), + content_hash: atomic_core::types::Hash::from_bytes(entry.content_hash).to_base32(), + path, + name, + kind, + status, + updated_at: entry.updated_at.clone(), + introduced_by: entry.introduced_by, + score: final_score(&candidate, recency), + why_matched: why_matched(&candidate).to_string(), + body: include_body.then_some(body), + preview: None, + recorded: entry.introduced_by != 0, + truncated: false, + }); + } + + items.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.name.cmp(&b.name)) + }); + items.truncate(limit); + Ok(items) + } + + /// Truncate bodies so their combined length fits `budget_chars`: an + /// even allocation first, then unused capacity from short bodies goes + /// to higher-ranked items instead of being discarded. + fn apply_budget(items: &mut [ContextItem], budget_chars: usize) { + if items.is_empty() { + return; + } + let lengths: Vec = items + .iter() + .map(|item| item.body.as_deref().map_or(0, |body| body.chars().count())) + .collect(); + let baseline = budget_chars / items.len(); + let mut allocations: Vec = lengths + .iter() + .map(|length| (*length).min(baseline)) + .collect(); + let mut remaining = budget_chars.saturating_sub(allocations.iter().sum()); + for (allocation, length) in allocations.iter_mut().zip(&lengths) { + let extra = length.saturating_sub(*allocation).min(remaining); + *allocation += extra; + remaining -= extra; + if remaining == 0 { + break; + } + } + + for ((item, allocation), length) in items.iter_mut().zip(allocations).zip(lengths) { + if allocation < length { + let body = item.body.clone().unwrap_or_default(); + item.body = Some(truncate_chars(&body, allocation)); + item.truncated = true; + } + } + } + + /// The gather→rank→budget pipeline for one context request: the ranked + /// ContextItems the CLI renders (md, candidates, and JSON forms). + pub fn context_items( + repo: &Repository, + query: &[String], + intent: Option<&str>, + files: &[String], + limit: usize, + budget_chars: usize, + include_body: bool, + ) -> Result, Status> { + let candidates = gather_candidates(repo, query, intent, files, limit)?; + let mut items = resolve_and_rank(repo, candidates, limit, include_body)?; + if include_body { + apply_budget(&mut items, budget_chars); + for item in &mut items { + let preview = item.body.as_deref().map(preview).unwrap_or_default(); + item.preview = Some(preview); + } + } else { + for item in &mut items { + item.preview = None; + } + } + Ok(items) + } +} + pub struct VaultImpl { pub state: Arc, } @@ -126,6 +732,43 @@ fn fresh_attested_memory( } } +/// Does the memory stored at `memory/.md` carry a fresh attestation +/// signed by `did` that verifies under `public_key`? The +/// identity-filtered listing's domain check — the same +/// DID-match-then-verify rule the CLI's attestation-aware memory listing +/// applies: only a current, same-signer, cryptographically valid +/// attestation passes (a stale or other-signed attestation does not). +fn memory_attests_identity( + repo: &Repository, + vault_path: &str, + public_key: &atomic_identity::keypair::PublicKey, + did: &str, +) -> bool { + let Ok(Some(entry)) = repo.vault_retrieve(vault_path) else { + return false; + }; + let Ok(frontmatter) = parse_frontmatter(&entry) else { + return false; + }; + let body = body_of(&entry); + let Some(id) = vault_path + .strip_prefix("memory/") + .and_then(|stem| stem.strip_suffix(".md")) + else { + return false; + }; + let Some(node) = fresh_attested_memory(repo, id, &frontmatter, &body) else { + return false; + }; + // DID pre-check: a different (or absent) signer is not a match. + if node.attributed_to.as_deref() != Some(did) { + return false; + } + // Same signer ⇒ the only failure that can follow is a real + // hash/signature failure. + verify_memory(&node, public_key).is_ok() +} + fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } @@ -499,6 +1142,7 @@ impl vault_service_server::VaultService for VaultImpl { self.state.log_rpc("ListVaultEntries", Some(&handle)); let gate_handle = handle.clone(); let _gate = gate_handle.exclusive().await; + let identity = request.identity; let response = tokio::task::spawn_blocking(move || match kind { Kind::Intent => { // A query: read-only open with the read-concurrency wait. @@ -531,8 +1175,40 @@ impl vault_service_server::VaultService for VaultImpl { let metas = repo .vault_list("memory/", Some(VaultEntryType::Memory)) .map_err(repository_error)?; + // `--identity`: keep only the memories whose attestation + // verifies under that identity — the domain check the CLI's + // attestation-aware listing performs (resolve the identity, + // load each memory's fresh attested node, DID-match the + // signer, verify the signature). A named-but-missing + // identity is a hard error, matching the verify verbs. + let verifier = identity + .map(|name| { + let store = IdentityStore::open_default().map_err(|error| { + domain_status( + ErrorCode::NotFound, + format!("identity store unavailable: {error}"), + ) + })?; + let identity = store.load_by_name(&name).map_err(|_| { + domain_status( + ErrorCode::NotFound, + format!("identity '{name}' not found"), + ) + })?; + Ok::<_, Status>(( + identity.public_key.clone(), + did_for_public_key(&identity.public_key), + )) + }) + .transpose()?; let entries = metas .into_iter() + .filter(|meta| match &verifier { + None => true, + Some((public_key, did)) => { + memory_attests_identity(&repo, &meta.path, public_key, did) + } + }) .map(|meta| { let id = meta .path @@ -723,10 +1399,17 @@ impl vault_service_server::VaultService for VaultImpl { self.state.log_rpc("GetVaultContext", Some(&handle)); let max = request.max_entries.unwrap_or(20).max(1) as usize; let kinds = request.kinds; + // The retrieval seeds and render knobs from `atomic vault context` + // (add-only): the gather+rank runs in the handler; the CLI renders. + let query = request.query; + let intent = request.intent; + let files = request.files; + let budget_chars = request.budget_chars.unwrap_or(8000) as usize; + let include_body = request.include_body.unwrap_or(false); let handle_for_task = handle.clone(); let gate_handle = handle.clone(); let _gate = gate_handle.exclusive().await; - let entries = tokio::task::spawn_blocking(move || { + let (entries, context) = tokio::task::spawn_blocking(move || { // A query: read-only open with the read-concurrency wait. let repo = handle_for_task.repository_readonly()?; let mut entries = Vec::new(); @@ -772,12 +1455,24 @@ impl vault_service_server::VaultService for VaultImpl { } } entries.truncate(max); - Ok::<_, Status>(entries) + // The ranked, budgeted memory context — the ported + // gather+rank domain computation behind `atomic vault context`. + let context = context_ranking::context_items( + &repo, + &query, + intent.as_deref(), + &files, + max, + budget_chars, + include_body, + )?; + Ok::<_, Status>((entries, context)) }) .await .map_err(|error| Status::internal(error.to_string()))??; Ok(Response::new(GetVaultContextResponse { entries, + context, snapshot: None, })) } From b13a703910c777a2d5d34effaf4b09bbb8470a45 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 5 Oct 2026 21:53:28 -0500 Subject: [PATCH 08/23] docs(contract): escape an HTML-looking angle bracket in a vault comment --- libatomic/proto/atomic/services/vault/vault_messages.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libatomic/proto/atomic/services/vault/vault_messages.proto b/libatomic/proto/atomic/services/vault/vault_messages.proto index 054e262b..1bcc7112 100644 --- a/libatomic/proto/atomic/services/vault/vault_messages.proto +++ b/libatomic/proto/atomic/services/vault/vault_messages.proto @@ -136,7 +136,7 @@ message ContextItem { string kg_node_id = 2; // the KG's node identity string revision_hash = 3; // entry type + frontmatter + body revision string content_hash = 4; // body-only hash - string path = 5; // vault-relative path (memory/.md) + string path = 5; // vault-relative path, e.g. memory/``.md string name = 6; string kind = 7; // memoryKind (constraint/lesson/…) string status = 8; From 3df57efc3dff5815ed3c83057a88abe7a643720f Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 6 Oct 2026 00:35:13 -0500 Subject: [PATCH 09/23] feat(contract): wire the remaining listing, show, KG, attest, and revise surfaces (add-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the wire (add-only; 97 RPCs unchanged, checker PASS) so every remaining flag-form of the wired commands carries its data through the handlers: - VaultEntry carries the listing-surface columns: manifest kind, the attestation fresh/stale/none state, the DID-match-then-verify token, the memory kind/status/about columns, the whole-vault type/size/date columns, the goal started_at/turns, and the intent update report fields. ListVaultEntries gains the path-prefix, entry-type, and raw status filters; kind-absent is the whole-vault listing (the handler serves every entry, including the previously-unimplemented Goal arm). - GetVaultEntry resolves by vault-relative path and carries the versioned entry bundle (schema atomic.vault.entry.bundle.v1): the stored entry's domain serialization plus the RAW attestation sources (the tracked entry and the legacy sidecar), so clients run their existing lift/classification code over wire-carried inputs. - ViewInfo carries the own/inherited change-count split (view list JSON). - KGNode/KGEdge carry the full domain fields (source, metadata); KgSearchQuery gains the kind filter and the candidate pool; NeighborsQuery gains the traversal depth. - ListAttestations resolves one attestation by hash/prefix (the atomic.attestation.detail.v1 bundle: domain payload + per-view coverage) and serves the AI provenance summary (atomic.provenance.summary.v1) for the --summary/--pending modes. - GetChange reconstructs per-file before/after content server-side (the legacy no-file_ops diff fallback and the FileOps context padding). - ReviseRequest carries the content-modification mode (message, author override, positional paths) and the reword-form author; the domain gains revise_content — the unrecord/record/re-apply stack surgery. - InsertChanges honors apply_dependencies across all source arms (the --deps=false opt-out); UpdateVaultEntity gains the composed multi- field update arm applied atomically. - Repository::find_root accepts sandbox working trees (the .atomic- sandbox pointer), so service-layer resolution handles sandboxed cwd invocations the same way the CLI's root finder always has. --- atomic-repository/src/repository/mod.rs | 11 +- .../src/repository/provenance_summary.rs | 6 +- atomic-repository/src/repository/revise.rs | 115 +- .../attestation/attestation_messages.proto | 22 + .../knowledge/knowledge_messages.proto | 15 + .../repository_mutation_messages.proto | 15 + .../repository_query_messages.proto | 17 + .../services/vault/vault_messages.proto | 64 ++ .../atomic/services/view/view_messages.proto | 5 + libatomic/src/daemon/services.rs | 65 +- libatomic/src/daemon/services_agent.rs | 1022 ++++++++++++++--- libatomic/src/daemon/services_query.rs | 130 ++- 12 files changed, 1258 insertions(+), 229 deletions(-) diff --git a/atomic-repository/src/repository/mod.rs b/atomic-repository/src/repository/mod.rs index 796e916d..25bb6211 100644 --- a/atomic-repository/src/repository/mod.rs +++ b/atomic-repository/src/repository/mod.rs @@ -99,7 +99,7 @@ pub use filter::{ collect_view_change_ids, collect_visible_change_ids, collect_visible_change_ids_with_deps, expand_indexed_dependency_closure, view_set_id, }; -pub use revise::RewordOutcome; +pub use revise::{ReviseOutcome, RewordOutcome}; pub use sandbox::{SealOptions, SealResult, StageOptions, StageResult, SANDBOX_POINTER}; pub use split::{SplitChange, SplitOptions, SplitOutcome}; pub use stash::{StashEntry, StashPushOptions, DEFAULT_STASH_MESSAGE, STASH_PREFIX}; @@ -629,6 +629,15 @@ default = "{}" if dot_dir.is_dir() && database::has_database(&dot_dir) { return Ok(dir); } + // A sandbox working tree has no `.atomic/` of its own — it + // carries a pointer to the canonical graph. Treat the + // sandbox root as a valid repository root; + // `Repository::open*` resolves the pointer (the same rule + // the CLI's root finder applies, so service-layer + // resolution accepts sandboxed working trees). + if dir.join(SANDBOX_POINTER).is_file() { + return Ok(dir); + } current = dir.parent().map(Path::to_path_buf); } diff --git a/atomic-repository/src/repository/provenance_summary.rs b/atomic-repository/src/repository/provenance_summary.rs index 74b22ce9..1a3fed7c 100644 --- a/atomic-repository/src/repository/provenance_summary.rs +++ b/atomic-repository/src/repository/provenance_summary.rs @@ -43,8 +43,10 @@ use crate::history::HistoryOptions; /// Summary of AI-vs-human change attribution for a single view. /// -/// Counts unique changes, not lines. -#[derive(Debug, Clone, Default)] +/// Counts unique changes, not lines. Serializes as the +/// "atomic.provenance.summary.v1" bundle the AttestationService serves +/// (the client renders it with its existing summary code). +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct ProvenanceSummary { /// The view this summary covers. pub view_name: String, diff --git a/atomic-repository/src/repository/revise.rs b/atomic-repository/src/repository/revise.rs index b9c80176..e8bc801c 100644 --- a/atomic-repository/src/repository/revise.rs +++ b/atomic-repository/src/repository/revise.rs @@ -8,13 +8,17 @@ //! signature is the revised change's provenance), insert it, and re-apply //! the pending changes above the target in source order. //! -//! This module is the ONE code path for the reword flow: the CLI command -//! and the daemon's Revise RPC both call it (the shared-core pattern from -//! `provenance_core`). The content-modification form of revise — -//! re-capturing the working copy through an interactive editor — is -//! client-side orchestration and stays out of the domain. +//! The content-modification form (`atomic revise` without `--reword`) +//! re-captures the working copy: the same unrecord/re-apply surgery, but +//! the replacement change is RECORDED from the working copy (the +//! client-side editor flow composes the message; the record itself is +//! the domain's). +//! +//! This module is the ONE code path for both flows: the CLI command and +//! the daemon's Revise RPC both call it (the shared-core pattern from +//! `provenance_core`). -use atomic_core::change::{Author, Change}; +use atomic_core::change::{Author, Change, ChangeHeader}; use atomic_core::types::{Base32, Hash}; use crate::Repository; @@ -28,6 +32,15 @@ pub struct RewordOutcome { pub reinserted: Vec, } +/// The outcome of a content-mode revise. +#[derive(Debug, Clone)] +pub struct ReviseOutcome { + /// The newly recorded change's hash. + pub new_hash: Hash, + /// The changes re-applied above the target, in source order. + pub reinserted: Vec, +} + impl Repository { /// Reword one recorded change on the current view: same hunks, new /// header (message, optional author), signed with the default @@ -132,4 +145,94 @@ impl Repository { reinserted, }) } + + /// Revise one recorded change by re-capturing the working copy into + /// it: unrecord from the top of the current view down to (and + /// including) the target, RECORD a replacement change from the + /// working copy (the message and optional author the client composed + /// — the editor flow is client-side by design), and re-apply the + /// pending changes above the target in source order. A record failure + /// rolls the stack back (everything unrecorded is re-applied, newest + /// first) and surfaces the error. + pub fn revise_content( + &mut self, + target: &Hash, + message: &str, + author: Option, + paths: Vec, + ) -> Result { + // Resolve the target's sequence and the pending changes above it. + let history = self.log(crate::HistoryOptions::default())?; + let Some(entry) = history.iter().find(|e| &e.hash == target) else { + return Err(crate::RepositoryError::ChangeNotFound { + hash: target.to_base32(), + }); + }; + let sequence = entry.sequence; + + let stack_info = self.get_view_info(&self.current_view)?; + let changes_to_unrecord = stack_info.change_count as usize - sequence as usize; + + // Pending changes (above the target), oldest first for re-apply. + let mut pending: Vec<(u64, Hash)> = history + .into_iter() + .filter(|e| e.sequence > sequence) + .map(|e| (e.sequence, e.hash)) + .collect(); + pending.sort_by_key(|(sequence, _)| *sequence); + let pending = pending + .into_iter() + .map(|(_, hash)| hash) + .collect::>(); + + // Step 1: unrecord from the top down to (and including) the target. + for _ in 0..changes_to_unrecord { + self.unrecord_last(crate::UnrecordOptions::default())?; + } + + // Step 2: the new header — the composed message, the author + // override or the original's first author (the same preservation + // the CLI's content mode applies). + let original = self.load_change(target)?; + let author = author.or_else(|| original.hashed.header.authors.first().cloned()); + let mut header_builder = ChangeHeader::builder().message(message); + if let Some(author) = author { + header_builder = header_builder.author(author); + } + let header = header_builder.build(); + + // Step 3: record the revised change from the working copy (the + // record pipeline reads it — the daemon holds the repository + // root's working tree). + let mut options = crate::RecordOptions::default(); + if !paths.is_empty() { + options = options.paths(paths); + } + let outcome = self.record(header, options).map_err(|error| { + // Rollback: re-apply everything unrecorded, newest first, and + // surface the error (never a silent partial stack). + for hash in pending.iter().rev() { + let _ = self.reinsert_change(hash, None); + } + match error { + crate::record::RecordError::Repository(error) => error, + other => crate::RepositoryError::InvalidOperation { + message: other.to_string(), + }, + } + })?; + let new_hash = *outcome.hash(); + + // Step 4: re-apply the pending changes in source order. + let mut reinserted = Vec::with_capacity(pending.len()); + for hash in &pending { + self.reinsert_change(hash, None)?; + reinserted.push(*hash); + } + + Ok(ReviseOutcome { + new_hash, + reinserted, + }) + } } diff --git a/libatomic/proto/atomic/services/attestation/attestation_messages.proto b/libatomic/proto/atomic/services/attestation/attestation_messages.proto index 68f81037..d3aeb576 100644 --- a/libatomic/proto/atomic/services/attestation/attestation_messages.proto +++ b/libatomic/proto/atomic/services/attestation/attestation_messages.proto @@ -97,10 +97,32 @@ message ListAttestationsRequest { optional PageCursor cursor = 3; optional PageBudget budget = 4; ViewRef view = 5; + // Resolve ONE attestation by hash or prefix (add-only): `agent attest + // --hash` — the hash names an attestation, which the change-oriented Log + // cannot resolve. The response carries the detail bundle. + optional string hash_prefix = 6; + // The provenance-summary mode (add-only): `agent attest --summary + // [--view V]` — the response carries the summary bundle instead of the + // attestation list. + optional string summary_view = 7; + // With summary_view (add-only): summarize only the delta of the view + // relative to this parent — `agent attest --pending PARENT --view V`. + optional string pending_parent = 8; } message ListAttestationsResponse { repeated AttestationInfo attestations = 1; optional PageCursor next_cursor = 2; + // One resolved attestation's full detail (add-only; schema + // "atomic.attestation.detail.v1"): JSON {"hash": ``, + // "attestation": ``, "coverage": [{"view": string, + // "covered": uint, "total": uint}]} — the client renders the detail with + // the same code the local body runs. + optional VersionedBytes detail_bundle = 3; + // The AI provenance summary (add-only; schema + // "atomic.provenance.summary.v1"): JSON {"summary": + // ``, "parent": string | null} — `parent` is + // the summarized view's recorded parent (the draft-view hint). + optional VersionedBytes summary_bundle = 4; } message AttestationInfo { diff --git a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto index 2ee6ce4c..62a836ab 100644 --- a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto +++ b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto @@ -43,6 +43,12 @@ message ContentSearchQuery { } message KgSearchQuery { string query = 1; + // `query search --kind` (add-only): keep only nodes of this kind + // (case-insensitive), applied over the candidate pool before the limit. + optional string kind = 2; + // The candidate pool size (add-only): how many candidates to score + // before selecting the top results (the CLI's --pool, default 5000). + optional uint32 pool = 3; } message EntityQuery { string node_id = 1; @@ -50,6 +56,8 @@ message EntityQuery { message NeighborsQuery { string node_id = 1; optional bool incoming = 2; + // Traversal depth (add-only): `query neighbors -d` (1 or 2; default 1). + optional uint32 depth = 3; } message CallersQuery { string node_id = 1; @@ -81,11 +89,18 @@ message KGNode { optional string name = 3; optional bytes data = 4; // node payload (SHAPE-CONFIRM) optional double score = 5; // search ranking when applicable + // Full KG-node fields (add-only) for the `--json` projections: the + // domain node's source and metadata ride the wire so the client + // serializes the same shape the local body emits. + optional string source = 6; + optional bytes metadata = 7; // JSON-encoded metadata value } message KGEdge { string source = 1; string target = 2; string relation = 3; + // Full KG-edge field (add-only): the domain edge's metadata (JSON). + optional bytes metadata = 4; } message ContentHit { string path = 1; diff --git a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto index ef74b6bd..69d9750d 100644 --- a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto +++ b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto @@ -103,6 +103,21 @@ message ReviseRequest { ChangeRef target = 3; optional string reword_message = 4; // `--reword` optional ExpectedState expected = 5; // required matching view_snapshot + // The content-modification mode (add-only): `atomic revise` without + // --reword — re-capture the working copy into the target change. The + // stack surgery (unrecord → record → re-apply) is the domain's; the + // message/author compose client-side (editors are interactive). + optional ContentRevise content = 6; + // The `--author` override for the reword form (add-only). + optional Author author = 7; +} +// One content-mode revise (add-only): the new header message (composed +// client-side from -m or the editor), an optional author override, and the +// positional file filter. +message ContentRevise { + string message = 1; + optional Author author = 2; // `--author "Name "` + repeated string paths = 3; // the positional file filter } message ReviseResponse { ChangeInfo change = 1; diff --git a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto index 54c41017..a39b129a 100644 --- a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto +++ b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto @@ -79,6 +79,11 @@ message GetChangeRequest { ChangeRef ref = 2; ChangeIncludes includes = 3; optional ViewRef view = 4; // required when ref is a view-log sequence + // Reconstruct the per-file content around the change (add-only): + // `diff -c` needs each touched file's pre/post-change bytes — the + // legacy no-file_ops fallback and the FileOps context padding. The + // handler reads them from the graph; the response carries them. + bool include_file_contents = 5; } // Sections fetched by flag instead of by verb — the combine that keeps // GetChange one RPC. @@ -105,6 +110,18 @@ message GetChangeResponse { repeated VersionedBytes provenance_ledger = 7; // The change's sequence on the resolved view, when known. optional uint64 sequence = 8; + // Per-file reconstructed content (add-only), present when the request + // set include_file_contents: each touched file's bytes before and after + // the change, as the graph holds them. + repeated ChangeFileContent file_contents = 9; +} +// One file's content around a change (add-only): what the graph +// reconstruction reads server-side for `diff -c` (the legacy +// no-file_ops fallback) and the FileOps hunks' context padding. +message ChangeFileContent { + string path = 1; + optional bytes before = 2; // pre-change content (absent = none/unreadable) + optional bytes after = 3; // post-change content (absent = none/unreadable) } // ChangeInfo lives in atomic/common/change.proto (shared change-domain). diff --git a/libatomic/proto/atomic/services/vault/vault_messages.proto b/libatomic/proto/atomic/services/vault/vault_messages.proto index 1bcc7112..152e57ae 100644 --- a/libatomic/proto/atomic/services/vault/vault_messages.proto +++ b/libatomic/proto/atomic/services/vault/vault_messages.proto @@ -81,11 +81,28 @@ message GetVaultEntryRequest { RepositoryRef repository = 1; VaultEntityKind kind = 2; string id = 3; + // Resolve by vault-relative path first (add-only): `vault show PATH` + // names entries by path (any entry type), not by kind+id. + optional string path = 4; + // Request the versioned-opaque entry bundle (add-only): the stored + // entry's full domain serialization plus the RAW attestation sources + // (the tracked attestation entry and the legacy sidecar text), so the + // client runs its existing lift/classification code over wire-carried + // inputs (the canonical --json projections). + bool include_bundle = 5; ViewRef view = 20; } message GetVaultEntryResponse { VaultEntry entry = 1; ViewSnapshot snapshot = 20; + // The stored entry + raw attestation sources (add-only; schema + // "atomic.vault.entry.bundle.v1"): JSON {"entry": ``, "attestation": null | {"tracked": null | {"path": string, + // "frontmatter_json": string, "content": [bytes]}, "sidecar": null | + // {"path": string, "text": string}}}. The client deserializes the + // domain entry and classifies the attestation with the same bridge code + // the local body uses. + optional VersionedBytes entry_bundle = 21; } message ListVaultEntriesRequest { @@ -96,6 +113,14 @@ message ListVaultEntriesRequest { optional PageCursor cursor = 5; optional PageBudget budget = 6; optional string identity = 7; // filter by the entry's author identity + // `vault list --prefix` (add-only): filter to paths under this prefix. + optional string path_prefix = 8; + // `vault list --type` (add-only): the vault entry-type string + // (session|tool_result|memory|intent|skill|scratch|attestation). + optional string entry_type = 9; + // Raw status filter (add-only): the goal listing's free-form status + // string ("active"/"completed"/"suspended"/"all"), applied verbatim. + optional string status_filter = 10; ViewRef view = 20; } message ListVaultEntriesResponse { @@ -192,10 +217,27 @@ message UpdateVaultEntityRequest { string body = 5; // rewrite content (memory write) VaultEntityStatus status = 6; // status transition (intent lifecycle, // goal start/stop/resume) + // The fully composed update (add-only): every intent-update field in + // ONE request — the handler applies them atomically through the same + // domain update the CLI's multi-flag form performs. + IntentUpdateFields fields = 8; } optional ExpectedState expected = 7; ViewRef view = 20; // expected.kind must be view_snapshot matching this view } +// One composed intent update (`intent update`'s multi-flag form, add-only). +// Status stays the raw lifecycle string the domain update consumes +// ("backlog"/"in_progress"/"needs-review"/"done"/"planned"/"icebox"). +message IntentUpdateFields { + optional string status = 1; + optional string assignee = 2; + optional string priority = 3; + optional string title = 4; + optional string reason = 5; + repeated string informed_by = 6; + optional string body = 7; + bool force = 8; +} message UpdateVaultEntityResponse { VaultEntry entry = 1; ResponseMeta meta = 100; @@ -254,6 +296,28 @@ message VaultEntry { // intent/memory priority label. optional string vault_path = 9; optional string priority = 10; + // Listing-surface columns (add-only): the per-entry classification the + // attestation-aware listings render. The handler computes these with the + // same domain logic the local bodies use (manifest kind, the tracked + // attestation's fresh/stale state, the DID-match-then-verify rule). + optional string manifest_kind = 11; // intent classification ("feature"/"review"/…) + optional string attested = 12; // "fresh" | "stale" | "none" + optional string verifies = 13; // "yes" | "no" | "na" + optional string memory_kind = 14; // memory listing's kind column + optional string memory_status = 15; // memory listing's status column + optional uint32 about_count = 16; // memory listing's about-input count + // Whole-vault listing columns (add-only): `vault list`'s per-entry + // type/size/date table. + optional uint64 content_size = 17; // stored content size in bytes + optional string updated_at_label = 18; // RFC 3339, as stored + optional string entry_type = 19; // "memory"/"intent"/"session"/… + // Goal listing columns (add-only): `vault goal list --json`. + optional string started_at = 20; + optional uint32 turns = 21; + // Intent update report columns (add-only): the post-update assignee and + // the raw status label as stored (the frontmatter's own string). + optional string assignee = 22; + optional string status_label = 23; // SHAPE-CONFIRM: attestation/verification state surfacing (attestations // live in AttestationService; entries reference their latest attestation). } diff --git a/libatomic/proto/atomic/services/view/view_messages.proto b/libatomic/proto/atomic/services/view/view_messages.proto index 9698fca3..50f6edeb 100644 --- a/libatomic/proto/atomic/services/view/view_messages.proto +++ b/libatomic/proto/atomic/services/view/view_messages.proto @@ -40,6 +40,11 @@ message ViewInfo { bool current = 6; ViewRef ref = 7; ViewSnapshot snapshot = 8; + // Own/inherited classification (add-only): the split `view list --json` + // carries — the view's own-log count and the inherited half. With both + // present, `change_count` reads as their sum (the effective total). + optional uint64 own_change_count = 9; + optional uint64 inherited_change_count = 10; } message CreateViewRequest { diff --git a/libatomic/src/daemon/services.rs b/libatomic/src/daemon/services.rs index 09294f51..78938c6e 100644 --- a/libatomic/src/daemon/services.rs +++ b/libatomic/src/daemon/services.rs @@ -1391,28 +1391,55 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI } } }; - let message = request.reword_message.clone().ok_or_else(|| { - domain_status( + // The content-modification mode: the message (and optional + // author override, and the positional file filter) compose + // CLIENT-side — the editor flow is interactive by design — + // and the stack surgery (unrecord → record from the working + // copy → re-apply) is the domain's, applied atomically. + let wire_author = request.author.map(|author| Author { + name: author.name, + email: author.email, + identity: None, + }); + let (message, author, paths, content_mode) = match ( + request.reword_message, + request.content, + ) { + (Some(message), _) => (message, wire_author, Vec::new(), false), + (None, Some(content)) => ( + content.message, + content.author.map(|author| Author { + name: author.name, + email: author.email, + identity: None, + }), + content.paths, + true, + ), + (None, None) => return Err(domain_status( ErrorCode::InvalidArgument, - "reword_message is required (the interactive editor is client-side)", - ) + "reword_message or content is required (the interactive editor is client-side)", + )), + }; + let new_hash = if content_mode { + repo.revise_content(&target, &message, author, paths) + .map(|outcome| outcome.new_hash) + } else { + repo.reword_change(&target, &message, author) + .map(|outcome| outcome.new_hash) + } + .map_err(|error| match error { + atomic_repository::RepositoryError::ChangeNotFound { hash } => domain_status( + ErrorCode::NotFound, + format!("change {hash} not found on the current view"), + ), + other => domain_status(ErrorCode::ChangeRejected, other.to_string()), })?; - let outcome = - repo.reword_change(&target, &message, None) - .map_err(|error| match error { - atomic_repository::RepositoryError::ChangeNotFound { hash } => { - domain_status( - ErrorCode::NotFound, - format!("change {hash} not found on the current view"), - ) - } - other => domain_status(ErrorCode::ChangeRejected, other.to_string()), - })?; let change = repo - .load_change(&outcome.new_hash) + .load_change(&new_hash) .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; Ok(ReviseResponse { - change: Some(change_info(&change, &outcome.new_hash)), + change: Some(change_info(&change, &new_hash)), meta: None, }) }) @@ -1466,7 +1493,7 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI } Some(insert_changes_request::Source::FromView(source)) => { let options = CrossViewInsertOptions::new(&source, &target) - .with_dependencies(true) + .with_dependencies(request.apply_dependencies.unwrap_or(true)) .allow_conflicts(request.allow_conflicts); let outcome = repo.insert_from_view(options).map_err(|error| { domain_status(ErrorCode::ChangeRejected, error.to_string()) @@ -1480,7 +1507,7 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI let from_view = handle.current_view(); let options = CrossViewInsertOptions::new(&from_view, &target) .up_to_tag(&tag) - .with_dependencies(true) + .with_dependencies(request.apply_dependencies.unwrap_or(true)) .allow_conflicts(request.allow_conflicts); let outcome = repo.insert_from_view(options).map_err(|error| { domain_status(ErrorCode::ChangeRejected, error.to_string()) diff --git a/libatomic/src/daemon/services_agent.rs b/libatomic/src/daemon/services_agent.rs index e9ebd2ab..2e35ba08 100644 --- a/libatomic/src/daemon/services_agent.rs +++ b/libatomic/src/daemon/services_agent.rs @@ -19,7 +19,9 @@ use atomic_canonical::memory::lift_memory; use atomic_canonical::{ lift_and_attest, lift_and_attest_memory, verify, verify_memory, CanonicalNode, MemoryNode, }; +use atomic_core::change::attestation::Attestation; use atomic_core::pristine::VaultEntryType; +use atomic_core::types::Base32; use atomic_identity::{Identity, IdentityStore}; use atomic_repository::IntentCreateOptions; use atomic_repository::IntentUpdateOptions; @@ -732,47 +734,334 @@ fn fresh_attested_memory( } } -/// Does the memory stored at `memory/.md` carry a fresh attestation -/// signed by `did` that verifies under `public_key`? The -/// identity-filtered listing's domain check — the same -/// DID-match-then-verify rule the CLI's attestation-aware memory listing -/// applies: only a current, same-signer, cryptographically valid -/// attestation passes (a stale or other-signed attestation does not). -fn memory_attests_identity( +/// The versioned-opaque entry bundle (schema "atomic.vault.entry.bundle.v1"): +/// the stored entry's full domain serialization plus the RAW attestation +/// sources, so the client runs its existing lift/classification code over +/// wire-carried inputs. `kind` selects the attestation scheme (the intent +/// and memory bridges' dual-read paths); `None` bundles the entry with no +/// attestation (the path-resolved `vault show` projection). +fn vault_entry_bundle( repo: &Repository, - vault_path: &str, - public_key: &atomic_identity::keypair::PublicKey, - did: &str, -) -> bool { - let Ok(Some(entry)) = repo.vault_retrieve(vault_path) else { - return false; - }; - let Ok(frontmatter) = parse_frontmatter(&entry) else { - return false; + kind: Option, + id_or_path: &str, + entry: &atomic_core::pristine::VaultEntry, +) -> Result { + let attestation = match kind { + Some(Kind::Intent) => intent_attestation_sources(repo, id_or_path), + Some(Kind::Memory) => memory_attestation_sources(repo, id_or_path), + _ => Value::Null, }; - let body = body_of(&entry); - let Some(id) = vault_path - .strip_prefix("memory/") - .and_then(|stem| stem.strip_suffix(".md")) - else { - return false; + let payload = serde_json::json!({ + "entry": entry, + "attestation": attestation, + }); + serde_json::to_vec(&payload) + .map(|payload| VersionedBytes { + schema: "atomic.vault.entry.bundle.v1".to_string(), + payload, + }) + .map_err(|error| Status::internal(error.to_string())) +} + +/// The RAW intent attestation sources (the bridge's dual-read inputs): +/// the tracked vault entry (frontmatter + body bytes) and the legacy +/// sidecar candidate's text — the first EXISTING candidate (the +/// normalized-id path, then the raw-arg path a pre-upgrade build wrote). +/// Both ride the wire when both exist; the client's classification keeps +/// the bridge's precedence (tracked first, sidecar fallback). +fn intent_attestation_sources(repo: &Repository, id: &str) -> Value { + let vpath = format!( + "attestations/{}/attested.md", + sanitize_id(&normalized_id(repo, id)) + ); + let tracked = repo.vault_retrieve(&vpath).ok().flatten().map(|entry| { + serde_json::json!({ + "path": vpath, + "frontmatter_json": entry.frontmatter_json, + "content": entry.content_bytes, + }) + }); + let normalized = repo + .dot_dir() + .join("canonical") + .join("intents") + .join(sanitize_id(&normalized_id(repo, id))) + .join("attested.jsonld"); + let raw = repo + .dot_dir() + .join("canonical") + .join("intents") + .join(sanitize_id(id)) + .join("attested.jsonld"); + let sidecar = [normalized, raw] + .into_iter() + .find(|candidate| candidate.exists()) + .and_then(|candidate| { + std::fs::read_to_string(&candidate).ok().map(|text| { + serde_json::json!({ + "path": candidate.display().to_string(), + "text": text, + }) + }) + }); + serde_json::json!({ "tracked": tracked, "sidecar": sidecar }) +} + +/// The RAW memory attestation sources: the tracked +/// `attestations/memory//attested.md` entry and the single legacy +/// sidecar path's text. +fn memory_attestation_sources(repo: &Repository, id: &str) -> Value { + let vpath = format!("attestations/memory/{}/attested.md", sanitize_id(id)); + let tracked = repo.vault_retrieve(&vpath).ok().flatten().map(|entry| { + serde_json::json!({ + "path": vpath, + "frontmatter_json": entry.frontmatter_json, + "content": entry.content_bytes, + }) + }); + let sidecar_path = repo + .dot_dir() + .join("canonical") + .join("memory") + .join(sanitize_id(id)) + .join("attested.jsonld"); + let sidecar = if sidecar_path.exists() { + std::fs::read_to_string(&sidecar_path).ok().map(|text| { + serde_json::json!({ + "path": sidecar_path.display().to_string(), + "text": text, + }) + }) + } else { + None }; - let Some(node) = fresh_attested_memory(repo, id, &frontmatter, &body) else { + serde_json::json!({ "tracked": tracked, "sidecar": sidecar }) +} + +fn memory_is_index_scaffold(repo: &Repository, path: &str) -> bool { + let Ok(Some(entry)) = repo.vault_retrieve(path) else { return false; }; - // DID pre-check: a different (or absent) signer is not a match. - if node.attributed_to.as_deref() != Some(did) { - return false; - } - // Same signer ⇒ the only failure that can follow is a real - // hash/signature failure. - verify_memory(&node, public_key).is_ok() + serde_json::from_str::(&entry.frontmatter_json) + .ok() + .and_then(|v| v.get("type").and_then(Value::as_str).map(str::to_owned)) + .as_deref() + == Some("index") } fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } +// --------------------------------------------------------------------------- +// Listing-surface domain logic — the attestation-aware listings +// +// The per-entry classification the `intent list`/`memory list` columns and +// JSON carry: the manifest kind tag, the tracked attestation's fresh/stale +// state (with the legacy-sidecar dual-read), and the +// DID-match-then-verify rule. Ported from the CLI bodies so the ranking +// recipe lives in exactly one place (the handler); the CLI renders. +// --------------------------------------------------------------------------- + +/// The verifying identity resolved ONCE for a whole listing: its public +/// key and DID. `None` soft-fails (no identity resolvable — every +/// `verifies` cell reads "na"); a NAMED-but-missing identity is a hard +/// error, matching the verify verbs. +struct ListingVerifier { + public_key: atomic_identity::keypair::PublicKey, + did: String, +} + +fn resolve_listing_verifier(name: &Option) -> Result, Status> { + let Ok(store) = IdentityStore::open_default() else { + return Ok(None); + }; + let identity = match name { + Some(name) => Some(store.load_by_name(name).map_err(|_| { + domain_status(ErrorCode::NotFound, format!("identity '{name}' not found")) + })?), + None => store.get_default().ok().flatten(), + }; + Ok(identity.map(|identity| ListingVerifier { + did: did_for_public_key(&identity.public_key), + public_key: identity.public_key.clone(), + })) +} + +/// The DID-match-then-verify rule (the lists' `verifies` column): +/// "na" when there is no fresh attestation, no resolvable identity, or a +/// different signer; "yes"/"no" only for a same-signer fresh node. The +/// same rule the CLI bodies apply, ported so the handler owns it. +fn verifies_token( + node: Option<&N>, + attributed_to: Option<&str>, + verifier: Option<&ListingVerifier>, + verify: impl FnOnce(&N, &atomic_identity::keypair::PublicKey) -> bool, +) -> &'static str { + let (Some(node), Some(verifier)) = (node, verifier) else { + return "na"; + }; + // DID pre-check FIRST: a different (or absent) signer is "na", not + // "no" (a merely-unresolvable signer is NOT a failure). + if attributed_to != Some(verifier.did.as_str()) { + return "na"; + } + // Same signer ⇒ the only path to "no" is a real hash/signature failure. + if verify(node, &verifier.public_key) { + "yes" + } else { + "no" + } +} + +/// One loaded attestation artifact, classified against the current source +/// (the fresh/stale/none trichotomy the listings render). +enum LoadedAttestation { + Fresh(N), + Stale(N), + None, +} + +/// The fresh/stale classification: the recorded source-content anchor +/// versus the current source hash (the same rule the CLI bridge applies). +fn classify_artifact( + node: N, + recorded: Option, + current: String, +) -> LoadedAttestation { + if recorded.as_deref() == Some(current.as_str()) { + LoadedAttestation::Fresh(node) + } else { + LoadedAttestation::Stale(node) + } +} + +/// The `sourceContentHash` anchor off a tracked attestation entry's +/// frontmatter. +fn tracked_source_anchor(entry: &atomic_core::pristine::VaultEntry) -> Option { + serde_json::from_str::(&entry.frontmatter_json) + .ok() + .and_then(|v| { + v.get("sourceContentHash") + .and_then(Value::as_str) + .map(str::to_string) + }) +} + +/// An intent's attestation, loaded through the same dual-read the CLI +/// bridge performs: the tracked vault entry first (its body parses to a +/// `CanonicalNode`), then the legacy sidecar candidates (the normalized +/// id path, then the raw-arg path a pre-upgrade build wrote). +fn intent_list_attestation( + repo: &Repository, + id: &str, + frontmatter: &Map, + body: &str, +) -> LoadedAttestation { + let vpath = format!( + "attestations/{}/attested.md", + sanitize_id(&normalized_id(repo, id)) + ); + if let Ok(Some(entry)) = repo.vault_retrieve(&vpath) { + if let Ok(node) = serde_json::from_str::(body_of(&entry).trim_end()) { + return classify_artifact( + node, + tracked_source_anchor(&entry), + source_content_hash(frontmatter, body), + ); + } + // A malformed tracked entry falls through to the sidecar. + } + let normalized = repo + .dot_dir() + .join("canonical") + .join("intents") + .join(sanitize_id(&normalized_id(repo, id))) + .join("attested.jsonld"); + let raw = repo + .dot_dir() + .join("canonical") + .join("intents") + .join(sanitize_id(id)) + .join("attested.jsonld"); + for path in [normalized, raw] { + if !path.exists() { + continue; + } + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(artifact) = serde_json::from_str::(&text) { + if let Ok(node) = serde_json::from_value::( + artifact.get("node").cloned().unwrap_or(Value::Null), + ) { + let recorded = artifact + .get("source") + .and_then(|s| s.get("sourceContentHash")) + .and_then(Value::as_str) + .map(str::to_string); + return classify_artifact( + node, + recorded, + source_content_hash(frontmatter, body), + ); + } + } + // The CLI bridge warns and treats an unreadable sidecar as + // none; the listing degrades the row the same way. + return LoadedAttestation::None; + } + } + LoadedAttestation::None +} + +/// A memory's attestation, loaded through the memory bridge's dual-read: +/// the tracked `attestations/memory//attested.md` entry first, then +/// the single legacy sidecar path. +fn memory_list_attestation( + repo: &Repository, + id: &str, + frontmatter: &Map, + body: &str, +) -> LoadedAttestation { + let vpath = format!("attestations/memory/{}/attested.md", sanitize_id(id)); + if let Ok(Some(entry)) = repo.vault_retrieve(&vpath) { + if let Ok(node) = serde_json::from_str::(body_of(&entry).trim_end()) { + return classify_artifact( + node, + tracked_source_anchor(&entry), + source_content_hash(frontmatter, body), + ); + } + } + let path = repo + .dot_dir() + .join("canonical") + .join("memory") + .join(sanitize_id(id)) + .join("attested.jsonld"); + if path.exists() { + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(artifact) = serde_json::from_str::(&text) { + if let Ok(node) = serde_json::from_value::( + artifact.get("node").cloned().unwrap_or(Value::Null), + ) { + let recorded = artifact + .get("source") + .and_then(|s| s.get("sourceContentHash")) + .and_then(Value::as_str) + .map(str::to_string); + return classify_artifact( + node, + recorded, + source_content_hash(frontmatter, body), + ); + } + } + return LoadedAttestation::None; + } + } + LoadedAttestation::None +} + pub(crate) fn response_meta(meta: &Option) -> Option { meta.as_ref().map(|meta| ResponseMeta { request_id: meta.request_id.clone(), @@ -846,6 +1135,7 @@ impl vault_service_server::VaultService for VaultImpl { linked: Vec::new(), vault_path: Some(format!(".vault/{}", result.intent_file)), priority: Some("medium".to_string()), + ..Default::default() }), meta: response_meta(&meta), } @@ -929,6 +1219,7 @@ impl vault_service_server::VaultService for VaultImpl { linked: Vec::new(), vault_path: Some(format!(".vault/{vault_path}")), priority: None, + ..Default::default() }), meta: response_meta(&meta), } @@ -979,6 +1270,20 @@ impl vault_service_server::VaultService for VaultImpl { UpdateKind::Body(body) => { options.content = Some(body); } + // The fully composed update: every field in ONE + // request, applied atomically by the same domain + // update the CLI's multi-flag form performs. + UpdateKind::Fields(fields) => { + options.status = fields.status; + options.assignee = fields.assignee; + options.priority = fields.priority; + options.title = fields.title; + options.reason = fields.reason; + options.informed_by = + (!fields.informed_by.is_empty()).then_some(fields.informed_by); + options.content = fields.body; + options.force = fields.force; + } } let info = repo .vault_intent_update(&id, options) @@ -995,6 +1300,9 @@ impl vault_service_server::VaultService for VaultImpl { linked: Vec::new(), vault_path: None, priority: Some(info.priority.clone()), + assignee: info.assignee.clone(), + status_label: Some(info.status.clone()), + ..Default::default() }), meta: response_meta(&meta), }) @@ -1032,11 +1340,12 @@ impl vault_service_server::VaultService for VaultImpl { linked: Vec::new(), vault_path: Some(format!(".vault/{vault_path}")), priority: None, + ..Default::default() }), meta: response_meta(&meta), }) } - UpdateKind::Status(_) => Err(domain_status( + UpdateKind::Status(_) | UpdateKind::Fields(_) => Err(domain_status( ErrorCode::InvalidArgument, "memory status transitions are new revisions, not updates", )), @@ -1063,66 +1372,125 @@ impl vault_service_server::VaultService for VaultImpl { let gate_handle = handle.clone(); let _gate = gate_handle.exclusive().await; let id = request.id.clone(); - let response = tokio::task::spawn_blocking(move || match kind { - Kind::Intent => { - // A query: read-only open with the read-concurrency wait. - let repo = handle.repository_readonly()?; - let entry = repo.vault_intent_show(&id).map_err(repository_error)?; - let frontmatter = parse_frontmatter(&entry)?; - let title = frontmatter - .get("title") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let status = frontmatter - .get("status") - .and_then(Value::as_str) - .and_then(intent_status_to_proto) - .map(|s| s as i32); - Ok::<_, Status>(GetVaultEntryResponse { - entry: Some(VaultEntry { - kind: Kind::Intent as i32, - id: normalized_id(&repo, &id), - title, - status, - body: Some(body_of(&entry)), - created_at: None, - updated_at: None, - linked: Vec::new(), - vault_path: None, - priority: None, - }), - snapshot: None, - }) - } - Kind::Memory => { + let path = request.path.clone(); + let include_bundle = request.include_bundle; + let response = tokio::task::spawn_blocking(move || { + // Path-first resolution (add-only): `vault show ` names + // ANY vault entry by its vault-relative path — no kind + // inference. The bundle carries the stored entry (plus no + // attestation: the vault-show projection never consults one). + if let Some(path) = path.as_deref().filter(|path| !path.is_empty()) { // A query: read-only open with the read-concurrency wait. let repo = handle.repository_readonly()?; - let vault_path = format!("memory/{id}.md"); let entry = repo - .vault_retrieve(&vault_path) + .vault_retrieve(path) .map_err(repository_error)? - .ok_or_else(|| domain_status(ErrorCode::NotFound, "memory not found"))?; - Ok(GetVaultEntryResponse { + .ok_or_else(|| { + domain_status( + ErrorCode::NotFound, + format!("vault entry '{path}' not found"), + ) + })?; + let bundle = include_bundle + .then(|| vault_entry_bundle(&repo, None, path, &entry)) + .transpose()?; + return Ok::<_, Status>(GetVaultEntryResponse { entry: Some(VaultEntry { - kind: Kind::Memory as i32, - id: id.clone(), - title: id.clone(), + kind: Kind::Unspecified as i32, + id: path.to_string(), + title: String::new(), status: None, body: Some(body_of(&entry)), created_at: None, updated_at: None, linked: Vec::new(), - vault_path: Some(format!(".vault/{vault_path}")), + vault_path: Some(path.to_string()), priority: None, + entry_type: Some(entry.entry_type.to_string()), + updated_at_label: Some(entry.updated_at.clone()), + ..Default::default() }), snapshot: None, - }) + entry_bundle: bundle, + }); + } + match kind { + Kind::Intent => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let entry = repo.vault_intent_show(&id).map_err(repository_error)?; + let frontmatter = parse_frontmatter(&entry)?; + let title = frontmatter + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let status = frontmatter + .get("status") + .and_then(Value::as_str) + .and_then(intent_status_to_proto) + .map(|s| s as i32); + let bundle = include_bundle + .then(|| vault_entry_bundle(&repo, Some(Kind::Intent), &id, &entry)) + .transpose()?; + Ok(GetVaultEntryResponse { + entry: Some(VaultEntry { + kind: Kind::Intent as i32, + id: normalized_id(&repo, &id), + title, + status, + body: Some(body_of(&entry)), + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: None, + ..Default::default() + }), + snapshot: None, + entry_bundle: bundle, + }) + } + Kind::Memory => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + // The memory id/path resolver (no manifest lookup, no + // case-folding): a bare id, a `memory/` path, an + // `.md` name, or a full `memory/.md` path all + // resolve to `memory/.md`. + let without_prefix = id.strip_prefix("memory/").unwrap_or(&id); + let stem = without_prefix.strip_suffix(".md").unwrap_or(without_prefix); + let vault_path = format!("memory/{stem}.md"); + let entry = repo + .vault_retrieve(&vault_path) + .map_err(repository_error)? + .ok_or_else(|| domain_status(ErrorCode::NotFound, "memory not found"))?; + let bundle = include_bundle + .then(|| vault_entry_bundle(&repo, Some(Kind::Memory), stem, &entry)) + .transpose()?; + Ok(GetVaultEntryResponse { + entry: Some(VaultEntry { + kind: Kind::Memory as i32, + id: stem.to_string(), + title: stem.to_string(), + status: None, + body: Some(body_of(&entry)), + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(format!(".vault/{vault_path}")), + priority: None, + ..Default::default() + }), + snapshot: None, + entry_bundle: bundle, + }) + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("vault entity kind {other:?} lands with its slice (unimplemented)"), + )), } - other => Err(domain_status( - ErrorCode::InvalidArgument, - format!("vault entity kind {other:?} lands with its slice (unimplemented)"), - )), }) .await .map_err(|e| Status::internal(e.to_string()))??; @@ -1134,33 +1502,80 @@ impl vault_service_server::VaultService for VaultImpl { request: Request, ) -> Result, Status> { let request = request.into_inner(); - let kind = request - .kind - .and_then(|k| Kind::try_from(k).ok()) - .unwrap_or(Kind::Intent); + // kind absent → the whole-vault listing (`vault list`: every entry + // with its type/size/date columns, prefix- and type-filtered). + let kind = request.kind.and_then(|k| Kind::try_from(k).ok()); let handle = self.state.resolve(request.repository.as_ref().unwrap())?; self.state.log_rpc("ListVaultEntries", Some(&handle)); let gate_handle = handle.clone(); let _gate = gate_handle.exclusive().await; let identity = request.identity; + let path_prefix = request.path_prefix.clone().unwrap_or_default(); + let entry_type = request.entry_type.clone(); + let status_filter = request.status_filter.clone(); + let limit = request + .budget + .and_then(|budget| budget.max_items) + .map(|max| max as usize); let response = tokio::task::spawn_blocking(move || match kind { - Kind::Intent => { + Some(Kind::Intent) => { // A query: read-only open with the read-concurrency wait. let repo = handle.repository_readonly()?; + // The verifying identity resolved ONCE (soft-fail to "no + // identity"; a named-but-missing identity is a hard error). + let verifier = resolve_listing_verifier(&identity)?; + let manifest = repo.vault_manifest().map_err(repository_error)?; let infos = repo.vault_intent_list(None).map_err(repository_error)?; let entries = infos .into_iter() - .map(|info| VaultEntry { - kind: Kind::Intent as i32, - id: info.id.clone(), - title: info.title.clone(), - status: intent_status_to_proto(&info.status).map(|s| s as i32), - body: None, // listings are summaries; bodies stay unloaded - created_at: None, - updated_at: None, - linked: Vec::new(), - vault_path: None, - priority: Some(info.priority.clone()), + .map(|info| { + // The manifest kind tag (a manifest read, never a + // lift); a row missing from the manifest degrades + // to the default "feature". + let manifest_kind = manifest + .intents + .get(&info.id) + .map(|summary| summary.kind.clone()) + .unwrap_or_else(|| "feature".to_string()); + // The attestation columns: a read/attestation + // failure for a SINGLE intent degrades that row's + // cells ("none"/"na"), never the whole list. + let (attested, verifies) = (|| { + let entry = repo.vault_intent_show(&info.id).ok()?; + let frontmatter = parse_frontmatter(&entry).ok()?; + let body = body_of(&entry); + match intent_list_attestation(&repo, &info.id, &frontmatter, &body) { + LoadedAttestation::Fresh(node) => Some(( + "fresh", + verifies_token( + Some(&node), + node.attributed_to.as_deref(), + verifier.as_ref(), + |node, public_key| verify(node, public_key).is_ok(), + ), + )), + LoadedAttestation::Stale(_) => Some(("stale", "na")), + LoadedAttestation::None => Some(("none", "na")), + } + })() + .unwrap_or(("none", "na")); + VaultEntry { + kind: Kind::Intent as i32, + id: info.id.clone(), + title: info.title.clone(), + status: intent_status_to_proto(&info.status).map(|s| s as i32), + body: None, // listings are summaries; bodies stay unloaded + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: Some(info.priority.clone()), + status_label: Some(info.status.clone()), + manifest_kind: Some(manifest_kind), + attested: Some(attested.to_string()), + verifies: Some(verifies.to_string()), + ..Default::default() + } }) .collect(); Ok::<_, Status>(ListVaultEntriesResponse { @@ -1169,63 +1584,120 @@ impl vault_service_server::VaultService for VaultImpl { snapshot: None, }) } - Kind::Memory => { + Some(Kind::Memory) => { // A query: read-only open with the read-concurrency wait. let repo = handle.repository_readonly()?; - let metas = repo - .vault_list("memory/", Some(VaultEntryType::Memory)) - .map_err(repository_error)?; - // `--identity`: keep only the memories whose attestation - // verifies under that identity — the domain check the CLI's - // attestation-aware listing performs (resolve the identity, - // load each memory's fresh attested node, DID-match the - // signer, verify the signature). A named-but-missing - // identity is a hard error, matching the verify verbs. - let verifier = identity - .map(|name| { - let store = IdentityStore::open_default().map_err(|error| { - domain_status( - ErrorCode::NotFound, - format!("identity store unavailable: {error}"), - ) - })?; - let identity = store.load_by_name(&name).map_err(|_| { - domain_status( - ErrorCode::NotFound, - format!("identity '{name}' not found"), - ) - })?; - Ok::<_, Status>(( - identity.public_key.clone(), - did_for_public_key(&identity.public_key), - )) - }) - .transpose()?; - let entries = metas - .into_iter() - .filter(|meta| match &verifier { - None => true, - Some((public_key, did)) => { - memory_attests_identity(&repo, &meta.path, public_key, did) - } - }) + // The verifying identity resolved ONCE — the `verifies` + // column's resolver (the same soft/hard-fail rule). + let verifier = resolve_listing_verifier(&identity)?; + // The SAME enumeration the local body performs: the + // `memory/` prefix, never attestation entries, never the + // `MEMORY.md` index scaffold, most-recent first with the + // id as the stable tiebreaker, truncated to the budget. + let metas = repo.vault_list("memory/", None).map_err(repository_error)?; + let mut items: Vec<(String, String)> = metas + .iter() + .filter(|meta| !meta.path.starts_with("attestations/")) + .filter(|meta| !memory_is_index_scaffold(&repo, &meta.path)) .map(|meta| { let id = meta .path .trim_end_matches(".md") .trim_start_matches("memory/") .to_string(); + (id, meta.updated_at.clone()) + }) + .collect(); + items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + if let Some(limit) = limit { + items.truncate(limit); + } + let entries = items + .iter() + .map(|(id, _)| { + // kind/status/about degrade to "–"/0 when a single + // memory cannot be read or lifted — never the list. + let degraded = ( + "\u{2013}".to_string(), + "\u{2013}".to_string(), + 0u32, + "none", + "na", + ); + let (memory_kind, memory_status, about, attested, verifies) = (|| { + let path = format!("memory/{id}.md"); + let entry = repo.vault_retrieve(&path).ok()??; + let frontmatter = parse_frontmatter(&entry).ok()?; + let body = body_of(&entry); + match memory_list_attestation(&repo, id, &frontmatter, &body) { + LoadedAttestation::Fresh(node) => { + // The fresh node drives the display + // columns; the DID-match-then-verify + // rule drives `verifies`. + let verifies = verifies_token( + Some(&node), + node.attributed_to.as_deref(), + verifier.as_ref(), + |node, public_key| verify_memory(node, public_key).is_ok(), + ); + Some(( + node.memory_kind.clone(), + node.status.clone(), + node.about.len() as u32, + "fresh", + verifies, + )) + } + other => { + // Stale/None: the columns come from the + // CURRENT source (the lift); the drift + // is still surfaced by the attested + // column. + let token = match other { + LoadedAttestation::Stale(_) => "stale", + _ => "none", + }; + match lift_memory(&frontmatter, &body) { + Ok(node) => Some(( + node.memory_kind, + node.status, + node.about.len() as u32, + token, + "na", + )), + Err(_) => { + let fm = |key: &str| { + frontmatter + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| "\u{2013}".to_string()) + }; + Some((fm("memoryKind"), fm("status"), 0, token, "na")) + } + } + } + } + })( + ) + .unwrap_or(degraded); VaultEntry { kind: Kind::Memory as i32, id: id.clone(), - title: id, + title: id.clone(), status: None, body: None, created_at: None, updated_at: None, linked: Vec::new(), - vault_path: Some(format!(".vault/{}", meta.path)), + vault_path: Some(format!(".vault/memory/{id}.md")), priority: None, + memory_kind: Some(memory_kind), + memory_status: Some(memory_status), + about_count: Some(about), + attested: Some(attested.to_string()), + verifies: Some(verifies.to_string()), + ..Default::default() } }) .collect(); @@ -1235,6 +1707,83 @@ impl vault_service_server::VaultService for VaultImpl { snapshot: None, }) } + Some(Kind::Goal) => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let goals = repo + .vault_goal_list(status_filter.as_deref()) + .map_err(repository_error)?; + let entries = goals + .into_iter() + .map(|goal| { + let status = match goal.status.as_str() { + "active" => VaultEntityStatus::Active, + "suspended" => VaultEntityStatus::Suspended, + "completed" => VaultEntityStatus::Completed, + _ => VaultEntityStatus::Unspecified, + }; + VaultEntry { + kind: Kind::Goal as i32, + id: goal.name.clone(), + title: goal.developer.clone(), + status: Some(status as i32), + body: None, + created_at: None, + updated_at: None, + linked: goal.intent.clone().into_iter().collect(), + vault_path: Some(format!(".vault/goals/{}/_goal.md", goal.name)), + priority: None, + status_label: Some(goal.status.clone()), + started_at: Some(goal.started_at.clone()), + turns: Some(goal.turns), + ..Default::default() + } + }) + .collect(); + Ok::<_, Status>(ListVaultEntriesResponse { + entries, + next_cursor: None, + snapshot: None, + }) + } + None => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + // `vault list`: EVERY vault entry with its type, content + // size, and updated-at date, filtered by the path prefix + // and the parsed entry type — the same enumeration the + // local body performs. + let type_filter = entry_type + .as_deref() + .and_then(|raw| raw.parse::().ok()); + let metas = repo + .vault_list(&path_prefix, type_filter) + .map_err(repository_error)?; + let entries = metas + .into_iter() + .map(|meta| VaultEntry { + kind: Kind::Unspecified as i32, + id: meta.path.clone(), + title: String::new(), + status: None, + body: None, + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(meta.path.clone()), + priority: None, + content_size: Some(meta.content_size as u64), + updated_at_label: Some(meta.updated_at.clone()), + entry_type: Some(meta.entry_type.to_string()), + ..Default::default() + }) + .collect(); + Ok(ListVaultEntriesResponse { + entries, + next_cursor: None, + snapshot: None, + }) + } other => Err(domain_status( ErrorCode::InvalidArgument, format!("vault entity kind {other:?} lands with its slice (unimplemented)"), @@ -1427,6 +1976,7 @@ impl vault_service_server::VaultService for VaultImpl { linked: Vec::new(), vault_path: None, priority: Some(info.priority.clone()), + ..Default::default() }); } } @@ -1451,6 +2001,7 @@ impl vault_service_server::VaultService for VaultImpl { linked: Vec::new(), vault_path: Some(format!(".vault/{}", meta.path)), priority: None, + ..Default::default() }); } } @@ -1507,6 +2058,51 @@ impl vault_service_server::VaultService for VaultImpl { // AttestationService // --------------------------------------------------------------------------- +/// One resolved attestation's detail (schema "atomic.attestation.detail.v1"): +/// the domain payload plus the per-view coverage rows (in the view +/// listing's order, zero-total views skipped), so the client renders the +/// detail with the same code the local body runs. +fn attestation_detail_bundle( + repo: &Repository, + hash: &atomic_core::types::Hash, + attest: &Attestation, +) -> Result { + let mut coverage: Vec = Vec::new(); + if let Ok(views) = repo.list_views() { + for view_name in &views { + let Ok(history) = + repo.log(atomic_repository::history::HistoryOptions::default().view(view_name)) + else { + continue; + }; + let total = history.len(); + if total == 0 { + continue; + } + let covered = history + .iter() + .filter(|entry| attest.covers_change(&entry.hash)) + .count(); + coverage.push(serde_json::json!({ + "view": view_name, + "covered": covered, + "total": total, + })); + } + } + let payload = serde_json::json!({ + "hash": hash.to_base32(), + "attestation": attest, + "coverage": coverage, + }); + serde_json::to_vec(&payload) + .map(|payload| VersionedBytes { + schema: "atomic.attestation.detail.v1".to_string(), + payload, + }) + .map_err(|error| Status::internal(error.to_string())) +} + pub struct AttestationImpl { pub state: Arc, } @@ -1928,6 +2524,114 @@ impl attestation_service_server::AttestationService for AttestationImpl { let request = request.into_inner(); let handle = self.state.resolve(request.repository.as_ref().unwrap())?; self.state.log_rpc("ListAttestations", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + + // `agent attest --hash `: resolve ONE attestation by hash + // or prefix (the hash names an attestation; the change-oriented Log + // cannot resolve it) and serve its full detail — the domain + // payload plus the per-view coverage — as versioned-opaque bytes + // the client renders with its existing detail code. + if let Some(prefix) = request.hash_prefix.clone().filter(|p| !p.is_empty()) { + let handle_for_task = handle.clone(); + let detail = tokio::task::spawn_blocking(move || -> Result { + let repo = handle_for_task.repository()?; + // Exact match first, then the prefix scan — the same + // resolution the CLI's find_by_prefix performs. + let mut matches: Vec<(atomic_core::types::Hash, Attestation)> = Vec::new(); + if let Some(hash) = + atomic_core::types::Hash::from_base32(prefix.as_bytes()) + { + if let Ok(attest) = repo.load_attestation(&hash) { + return attestation_detail_bundle(&repo, &hash, &attest); + } + } + let prefix_upper = prefix.to_uppercase(); + for result in repo.change_store().iter_attestations() { + let hash = match result { + Ok(hash) => hash, + Err(_) => continue, + }; + if hash.to_base32().starts_with(&prefix_upper) { + if let Ok(attest) = repo.load_attestation(&hash) { + matches.push((hash, attest)); + } + } + } + match matches.len() { + 0 => Err(domain_status( + ErrorCode::NotFound, + format!("No attestation found matching '{prefix}'"), + )), + 1 => Ok(attestation_detail_bundle( + &repo, + &matches[0].0, + &matches[0].1, + )?), + n => Err(domain_status( + ErrorCode::NotFound, + format!( + "Ambiguous hash prefix '{prefix}' matches {n} attestations. Be more specific." + ), + )), + } + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + return Ok(Response::new(ListAttestationsResponse { + attestations: Vec::new(), + next_cursor: None, + detail_bundle: Some(detail), + summary_bundle: None, + })); + } + + // `agent attest --summary [--view V] [--pending P]`: the AI + // provenance summary (AI/Human/Needs-attention/System + // classification over the view's changes), served as + // versioned-opaque bytes the client renders with its existing + // summary code. `--pending` classifies only the view's delta + // relative to the parent. + if let Some(summary_view) = request.summary_view.clone().filter(|v| !v.is_empty()) { + let pending_parent = request.pending_parent.clone(); + let handle_for_task = handle.clone(); + let summary = tokio::task::spawn_blocking(move || -> Result { + let repo = handle_for_task.repository()?; + let summary = if let Some(parent) = pending_parent.clone().filter(|p| !p.is_empty()) + { + repo.provenance_summary_pending(&summary_view, &parent) + .map_err(repository_error)? + } else { + repo.provenance_summary(&summary_view) + .map_err(repository_error)? + }; + // The draft-view hint source: the summarized view's + // recorded parent, exactly what the local body reads. + let parent = repo + .get_view_info(&summary_view) + .ok() + .and_then(|info| info.parent_name); + let payload = serde_json::json!({ + "summary": summary, + "parent": parent, + }); + serde_json::to_vec(&payload) + .map(|payload| VersionedBytes { + schema: "atomic.provenance.summary.v1".to_string(), + payload, + }) + .map_err(|error| Status::internal(error.to_string())) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + return Ok(Response::new(ListAttestationsResponse { + attestations: Vec::new(), + next_cursor: None, + detail_bundle: None, + summary_bundle: Some(summary), + })); + } + // The agent-attest read path filters by view; the unfiltered listing // walks the whole audit graph and lands with its slice. let view = request @@ -1940,8 +2644,6 @@ impl attestation_service_server::AttestationService for AttestationImpl { }; let view_name = view.unwrap_or_else(|| handle.current_view()); let handle_for_task = handle.clone(); - let gate_handle = handle.clone(); - let _gate = gate_handle.exclusive().await; let attestations = tokio::task::spawn_blocking(move || { let repo = handle_for_task.repository()?; let results = repo @@ -1979,6 +2681,8 @@ impl attestation_service_server::AttestationService for AttestationImpl { Ok(Response::new(ListAttestationsResponse { attestations, next_cursor: None, + detail_bundle: None, + summary_bundle: None, })) } } @@ -2014,21 +2718,31 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { let response = tokio::task::spawn_blocking(move || match query { QueryOneof::KgSearch(search) => { let repo = handle.repository()?; - let pool = 5000usize; - let nodes = repo - .vault_kg_search(&search.query, pool, Some(pool)) - .map_err(repository_error)?; - let nodes: Vec<_> = nodes - .into_iter() - .take(limit) - .map(|node| KgNode { - id: node.id.clone(), - node_type: node.kind.clone(), - name: Some(node.label.clone()), - data: node.summary.clone().map(String::into_bytes), - score: None, - }) - .collect(); + // The candidate pool the CLI's --pool names (default 5000) + // — the pool is what lets lower-scored kinds survive the + // diversity selection, so it rides the request. + let pool = search.pool.map(|pool| pool as usize).unwrap_or(5000); + let nodes = if let Some(kind_filter) = + search.kind.clone().filter(|kind| !kind.is_empty()) + { + // The --kind form: search the full pool, keep the + // kind's nodes, take the limit AFTER filtering — the + // local post-filter order. + let kind = kind_filter.to_lowercase(); + repo.vault_kg_search(&search.query, pool, Some(pool)) + .map_err(repository_error)? + .into_iter() + .filter(|node| node.kind.to_lowercase() == kind) + .take(limit) + .map(crate::daemon::services_query::kg_node_proto) + .collect::>() + } else { + repo.vault_kg_search(&search.query, limit, Some(pool)) + .map_err(repository_error)? + .into_iter() + .map(crate::daemon::services_query::kg_node_proto) + .collect() + }; Ok::<_, Status>(QueryGraphResponse { nodes, edges: Vec::new(), @@ -2040,8 +2754,11 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { } QueryOneof::Neighbors(neighbors) => { let repo = handle.repository()?; - let (nodes, edges) = - crate::daemon::services_query::neighbors_impl(&repo, &neighbors.node_id, 1)?; + let (nodes, edges) = crate::daemon::services_query::neighbors_impl( + &repo, + &neighbors.node_id, + neighbors.depth.unwrap_or(1) as u8, + )?; Ok(QueryGraphResponse { nodes, edges, @@ -2072,6 +2789,7 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { name: Some(entity.name.clone()), data: entity.signature.clone().map(String::into_bytes), score: None, + ..Default::default() }) .collect(); Ok(QueryGraphResponse { diff --git a/libatomic/src/daemon/services_query.rs b/libatomic/src/daemon/services_query.rs index c6c3be95..58ca064e 100644 --- a/libatomic/src/daemon/services_query.rs +++ b/libatomic/src/daemon/services_query.rs @@ -186,6 +186,7 @@ pub async fn get_change_impl( let _gate = gate_handle.exclusive().await; let handle_for_task = handle.clone(); let wants_bundle = request.includes.as_ref().is_some_and(|i| i.metadata); + let wants_file_contents = request.include_file_contents; let change = tokio::task::spawn_blocking(move || { // A query: read-only open with the read-concurrency wait. let repo = handle_for_task.repository_readonly()?; @@ -285,12 +286,44 @@ pub async fn get_change_impl( }) }) .collect::, _>>()?; - Ok::<_, Status>((change, hash, sequence, bundle, ledger)) + // Per-file content reconstruction (`diff -c`): each touched + // file's pre/post-change bytes as the graph holds them — the + // legacy no-file_ops fallback AND the FileOps hunks' context + // padding read server-side; the client renders. + let file_contents = if wants_file_contents { + let mut paths: Vec = atomic_repository::get_files_in_change(&change); + for ops in change.file_ops() { + paths.push(ops.path().to_string()); + } + paths.sort(); + paths.dedup(); + paths + .into_iter() + .map(|path| { + let before = repo + .get_file_content_before_change(&path, &hash) + .ok() + .flatten(); + let after = repo + .get_file_content_after_change(&path, &hash) + .ok() + .flatten(); + ChangeFileContent { + path, + before, + after, + } + }) + .collect::>() + } else { + Vec::new() + }; + Ok::<_, Status>((change, hash, sequence, bundle, ledger, file_contents)) }) .await .map_err(|error| Status::internal(error.to_string()))??; - let (change, hash, sequence, change_bundle, provenance_ledger) = change; + let (change, hash, sequence, change_bundle, provenance_ledger, file_contents) = change; let header = &change.hashed.header; Ok(Response::new(GetChangeResponse { change: Some(ChangeInfo { @@ -324,6 +357,7 @@ pub async fn get_change_impl( change_bundle, provenance_ledger, sequence, + file_contents, })) } @@ -518,6 +552,12 @@ impl view_service_server::ViewService for ViewImpl { name: Some(name), }), snapshot: None, + // The own/inherited split `view list --json` carries: + // change_count reads as their sum (the effective + // total) — the same classification the local body + // computes per view. + own_change_count: Some(info.own_change_count), + inherited_change_count: Some(info.inherited_change_count), } }) .collect(); @@ -597,6 +637,8 @@ impl view_service_server::ViewService for ViewImpl { head: None, change_count: change_count as u64, current: name == current, + own_change_count: Some(change_count as u64), + inherited_change_count: Some(0), r#ref: Some(ViewRef { view_id: blake3::hash(name.as_bytes()).as_bytes().to_vec(), name: Some(name), @@ -819,6 +861,12 @@ impl view_service_server::ViewService for ViewImpl { head: None, change_count: outcome.target_change_count, current: handle_for_task.current_view() == outcome.target_view, + own_change_count: Some(outcome.target_change_count), + inherited_change_count: Some( + info.inherited_change_count.saturating_add( + (info.change_count).saturating_sub(info.own_change_count), + ), + ), r#ref: Some(ViewRef { view_id: blake3::hash(outcome.target_view.as_bytes()).as_bytes().to_vec(), name: Some(outcome.target_view.clone()), @@ -863,33 +911,6 @@ fn validate_view_name(name: &str) -> Result<(), Status> { // Goals — `atomic vault goal list` (ListVaultEntries kind=GOAL) // --------------------------------------------------------------------------- -pub fn list_goal_entries(repo: &Repository) -> Result, Status> { - let goals = repo.vault_goal_list(None).map_err(repository_error)?; - Ok(goals - .into_iter() - .map(|goal| { - let status = match goal.status.as_str() { - "active" => VaultEntityStatus::Active, - "suspended" => VaultEntityStatus::Suspended, - "completed" => VaultEntityStatus::Completed, - _ => VaultEntityStatus::Unspecified, - }; - VaultEntry { - kind: VaultEntityKind::Goal as i32, - id: goal.name.clone(), - title: goal.developer.clone(), - status: Some(status as i32), - body: None, - created_at: None, - updated_at: None, - linked: goal.intent.clone().into_iter().collect(), - vault_path: Some(format!(".vault/goals/{}/_goal.md", goal.name)), - priority: None, - } - }) - .collect()) -} - // --------------------------------------------------------------------------- // Neighbors — `atomic vault query neighbors` // --------------------------------------------------------------------------- @@ -902,25 +923,36 @@ pub fn neighbors_impl( let subgraph = repo .vault_kg_neighbors(node_id, depth) .map_err(repository_error)?; - let nodes = subgraph - .nodes - .into_iter() - .map(|node| KgNode { - id: node.id.clone(), - node_type: node.kind.clone(), - name: Some(node.label.clone()), - data: node.summary.clone().map(String::into_bytes), - score: None, - }) - .collect(); - let edges = subgraph - .edges - .into_iter() - .map(|edge| KgEdge { - source: edge.from_id, - target: edge.to_id, - relation: edge.kind, - }) - .collect(); + let nodes = subgraph.nodes.into_iter().map(kg_node_proto).collect(); + let edges = subgraph.edges.into_iter().map(kg_edge_proto).collect(); Ok((nodes, edges)) } + +/// Domain KG node → wire, with the FULL field set (add-only source and +/// metadata) so the client serializes the same JSON shape the local body +/// emits. +pub(crate) fn kg_node_proto(node: atomic_core::pristine::vault::KgNode) -> KgNode { + KgNode { + id: node.id, + node_type: node.kind, + name: Some(node.label), + data: node.summary.clone().map(String::into_bytes), + score: None, + source: Some(node.source), + metadata: node + .metadata + .and_then(|metadata| serde_json::to_vec(&metadata).ok()), + } +} + +/// Domain KG edge → wire, with the edge metadata (add-only). +pub(crate) fn kg_edge_proto(edge: atomic_core::pristine::vault::KgEdge) -> KgEdge { + KgEdge { + source: edge.from_id, + target: edge.to_id, + relation: edge.kind, + metadata: edge + .metadata + .and_then(|metadata| serde_json::to_vec(&metadata).ok()), + } +} From 4953396f869082fe67cae768985b321860994b63 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 6 Oct 2026 01:32:43 -0500 Subject: [PATCH 10/23] feat(contract): wire the insert dry-run/tag-from-view/multi-pick and restore single-file tails (add-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the wire (add-only; 97 RPCs unchanged, checker PASS) so the last local-dispatch tails of the wired commands route through the handlers: - InsertChangesRequest gains dry_run (the handler previews every arm's plan with the same domain call the local bodies make), tag_from_view (the `insert tag --from-view` source override), the promote arm PromoteCurrentView (the bare promotion resolves the current view, its parent, the missing set, and the shared-to-shared confirm gate server-side — the CLI holds no repository handle), the change_set arm (multi-pick: raw change references resolved handler-side with the insert resolver's full-parse/prefix/ambiguity semantics, applied with cherry-pick), and the single_ref arm (a raw single reference, applied with the single-insert semantics honoring --deps=false). - InsertChangesResponse carries the report fields the local renders need: the resolved source/target views, the confirm gate, the resolved change, the post-insert state, the skipped count, the materialize counts, the outcome conflict flag, and the still-on-disk conflict listing with kind/line/sides (per record, in file order). - PreviewRestoreRequest gains content_path: the single-file dry-run's pristine-bytes dump — the handler reads the file's content with the same domain call; PreviewMutationResponse carries pristine_content (absent = no pristine content, the CLI's not-found error). --- libatomic/Cargo.toml | 6 +- libatomic/proto/CONTRACT.md | 221 +++++++++ libatomic/proto/COVERAGE.md | 374 ++++++++++++++ libatomic/proto/README.md | 207 +++++--- .../repository_mutation_messages.proto | 47 +- .../repository_query_messages.proto | 8 + libatomic/src/daemon/services.rs | 465 +++++++++++++++--- libatomic/src/lib.rs | 43 +- 8 files changed, 1214 insertions(+), 157 deletions(-) create mode 100644 libatomic/proto/CONTRACT.md create mode 100644 libatomic/proto/COVERAGE.md diff --git a/libatomic/Cargo.toml b/libatomic/Cargo.toml index fdf86b8e..cf2a2d27 100644 --- a/libatomic/Cargo.toml +++ b/libatomic/Cargo.toml @@ -6,10 +6,10 @@ authors.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true -description = "The canonical Atomic contract: protobuf service definitions, tonic/prost codegen, the descriptor contract gate, and the reference implementation of the contract" +description = "The one Atomic service-layer library: the protobuf contract (proto + codegen + the descriptor checker) and the handlers the atomicd transport serves and the CLI calls in-process" [dependencies] -# Domain crates — the handlers execute on these. +# Domain crates — the service layer executes on these. atomic-agent = { workspace = true } atomic-canonical = { workspace = true } atomic-core = { workspace = true } @@ -22,7 +22,7 @@ tonic = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } -# Async: the handlers are async and offload database work to the blocking pool. +# Async: the handlers are async and offload redb work to the blocking pool. tokio = { workspace = true } tokio-stream = { workspace = true } futures-core = { workspace = true } diff --git a/libatomic/proto/CONTRACT.md b/libatomic/proto/CONTRACT.md new file mode 100644 index 00000000..6c04e4ab --- /dev/null +++ b/libatomic/proto/CONTRACT.md @@ -0,0 +1,221 @@ +# Atomic RPC contract — revision 2 + +This specifies obligations for the generated adapters, middleware and domain +handlers. The protobufs expose targets, fences and permitted effects; +`check_contract.py` checks those declarations. Payload validation, leases, +transaction choice, filtering and replay enforcement remain implementation work. + +Grounding: `~/REACTOR_HANDOFF.md` §§2, 5, 9, 12 and the unified-service RFC D5–D8. +The legacy owner process/protocol is dismantled; its provenance/isolation +semantics survive. Atomic is a patch graph with view filters, not Git branches +or independent per-view databases. + +## Four independent checks + +| Declaration | Meaning | +|---|---| +| execution_scope | LOCAL daemon, REMOTE hosted server, or BOTH deployments | +| allowed_caller | Explicit eligibility of local, remote-identity, sandbox callers | +| required_capability | All listed permissions must be granted on this resource | +| effect | Maximum permitted storage, workspace, daemon and external effects | + +Missing/UNSPECIFIED annotations fail closed. An endpoint may advertise fewer +methods but cannot expand caller eligibility or effects. The advertised matrix +comes from the descriptors. A client preflight does not replace server checks. + +LOCAL does **not** authorize all callers reaching atomicd. The network listener +uses the same handlers, but local-only methods reject network principals even +if they possess similarly named capabilities. BOTH does not grant a sandbox +access to every repository operation. REMOTE is not used for sandbox data: +that would incorrectly exclude the serving local atomicd. + +## Middleware and request metadata + +- `GetCapabilities.protocol_version` is **2**. Every non-negotiation call must + carry `x-atomic-contract-version: 2` in gRPC metadata. Missing or mismatched + versions fail before dispatch. The old draft's removed flags must never be + ignored and then applied as writes. This is a behavioral incompatibility, + even though removed field numbers are reserved rather than reused. +- Local transport authenticates an OS principal; TCP authenticates over TLS; + iroh authenticates its encrypted peer connection. Socket access alone grants + no repository permission. Proxies preserve the originating principal, selected + authority, version, request ID and correlation, with verified delegation. +- Sandbox credentials use `x-atomic-sandbox-token-bin` (binary metadata) on **all** + sandbox calls, including Provenance/Vault/Knowledge/Attestation services. Other + remote identities use `authorization: Bearer ...` with verified audience. + Multiple/conflicting credentials are refused. A restricted token is never + reclassified as an unrestricted remote identity. Body tokens are retired. +- Resolve credentials and check the method allowlist, resource grant and expiry + before opening a repository or looking up replay results. A sandbox cannot + cause arbitrary IDs to be opened. Revalidate grant/resource validity at + publication, including expiry/revocation after time spent in a write queue. +- Observation timestamps remain caller supplied. Grant TTL, leases, deadlines, + authorization and ingest time use the trusted server clock instead. +- Ordinary mutations require a nonempty UUID request_id. Responses carry + ResponseMeta at field 100; refusals/errors echo in ResponseMeta/ErrorInfo. + Replay keys bind authority, repository (or daemon/bootstrap namespace), + principal/grant, method and request_id. Fingerprint canonical request contents; + changed contents fail IDEMPOTENCY_MISMATCH. Replays require current authority. + Reactor publish uses principal-bound event_id deduplication instead. +- Grant-admin replay is process-lifetime-only, matching ephemeral grants. The + authorization registry stores token hashes; any cached issuance reply is held + only in memory, never in a durable outcome/event log. Replacement, revocation, + expiry or restart refuses stale issuance replay; local administration uses a + new request ID to mint a fresh grant. +- Repository-write replay outcomes/outbox commit with the mutation. Filesystem- + only/external operations use the daemon outcome ledger and explicit recovery; + a request ID does not magically make filesystem or network effects ACID. +- Cancellation before publication performs no mutation. After a durable commit, + a lost response is recovered by replay, not by repeating effects. Bounded + stream/message/queue limits apply per principal; slow streaming cannot keep a + writer open. Negotiate limits before allocating opaque payloads. + +## Repository and workspace routing + +RepositoryRef.authority selects an explicit configured authority; it is not an +arbitrary forwarding URL. repository_id resolves one authorized physical store. +Aliases are canonicalized; duplicate IDs at different stores are refused. +Normal handlers use the persistent owner and transactions, never per-request +Repository::open or an embedded-storage fallback. + +ResolveRepository is a local **lookup**, returning RepositoryRef and WorkspaceInfo +(root, relative cwd, current ViewRef). It does not register, initialize, migrate, +repair, or align a tree. RegisterRepository is an explicit local administrative +mutation that verifies ownership and maps the repository/workspace; legacy IDs +are persisted to the registry/.atomic record. Schema migration is separate. + +The CLI converts paths from its source subdirectory to workspace-root-relative +paths. Workspace IDs bind repository, root and current view; cross-repository +IDs are refused. No operation uses atomicd's cwd. Bootstrap Git import requires +an explicit destination when no RepositoryRef exists. Physical paths occur only +in locally authorized operations; content search paths are logical index filters. + +## Sandbox grants and visibility + +The issuing local admin supplies a stable ViewRef and an explicit capability +subset. The grant binds authority/repository/view ID, optional acting identity, +capabilities, and server-clock expiry. The only delegable tokens are: + +``` +sandbox.read sandbox.submit +provenance.read provenance.write agent.checkpoint +vault.read vault.write +knowledge.read +attestation.read attestation.write +``` + +Never delegate repository.*, working-copy.*, view lifecycle, daemon, maintenance, +sync, Reactor, Git interop, or sandbox.admin rights to a sandbox token. If the +issuer cannot delegate a requested capability on that view, reject the grant. +An empty permission set is invalid; clients choose their desired subset. +SandboxOpened.repository has no workspace_id; sandbox requests containing one +are refused rather than interpreted as host workspace selection. + +One live token per (repository, stable view ID); mint replaces, renew does not +change permissions/identity, close revokes. The grant registry is hash-only and ephemeral: +daemon restart invalidates them. Deleting a view invalidates its grants and +associated ownership; recreating the same name cannot recover that authority. +Reissuing a token does not claim previously local/foreign sessions. Session and +turn ownership checks use repository + stable view ID, never just a name. + +For SandboxService, omitted target derives from the grant; other principals must +name one. An explicit target must equal the grant. For Vault/Knowledge/Attestation +requests the required view must match. Session/turn operations verify ownership, +even when the request has only a provenance/session ID. PrepareCheckpoint checks +explained changes, previous provenance and plan links; BindCheckpointHash must +refer to the prepared turn/attempt, never replace another SessionTurn. + +Every requested inode/change/node is checked against the authorized **effective +filter**, including inherited dependencies, before returning content or metadata. +An inode is a domain handle, not a capability; knowing one or a hash grants no +access. QueryGraph filters nodes/edges/snippets and each plan step before external +inference. Provider calls may use authorized context only; read plans cannot +invoke maintenance, arbitrary paths or arbitrary network tools. + +## Sandbox and vault writes + +Sandbox SubmitChange parses and verifies schema, hash, dependencies, referenced +graph nodes, all path-bearing operations and reserved bookkeeping names. Validate +the effective snapshot, permissions and graph references again inside the short +publication transaction. Refusal leaves graph, view, vault and outbox unchanged. +Dependency closure is mandatory; a client flag cannot bypass patch correctness. + +Publication may append global graph data and target-view membership: that is +Atomic's ambient graph model. It cannot promote into another view, overwrite +existing content-addressed objects, switch current_view, or materialize the host +working tree. Workspace refresh is a separate local authorization/lease. + +**Vault writes from a sandbox are supported.** Direct VaultService CRUD/status/ +link operations and submitted `.vault` edits both create target-view entity +revisions and update that view's index. Inherited entities are edited through +target-local revisions, deleted with target-local tombstones and linked only to +visible targets. Neither path replaces ancestor/sibling entities or indexes. +Represent visibility using Atomic's graph/revision model, not Git-like independent +vault copies. Writes require vault.write; SubmitChange requires it additionally +when a bundle changes `.vault`. Promotion is a separately authorized operation. + +Direct RPC writes return the new ViewSnapshot in ResponseMeta. Reads/cursors are +view-scoped. InitVault creates view storage; it does not scaffold host files. +SyncVault imports host workspace files only for a local principal. ExportVault +reads the view and writes local workspace files only. Remote VM materialization +and file edits are client work; no VM path is interpreted as a host destination. + +Attestation preparation returns public signing bytes under a read capability. +Recording verifies the supplied signature against the current target, expected +snapshot and signer authorization. Vault attestations are view-local. A sandbox +signer must match its acting identity; unbound grants cannot use daemon default +human keys. ExportProvenance returns unsigned data for caller-side signing. + +PublishProvenance accepts a SessionTurn and expected_generation, not a caller- +supplied StoredProvenanceTurn. Derive state, counters and checkpoint attempts +server-side. Lifecycle stop/resume/abandon also require expected_generation. +Keep prepare/bind/acknowledge separate for crash-injection parity. + +## Snapshot and effect semantics + +ViewSnapshot is a server-derived canonical digest covering repository/stable target ID, +scope, contributing ancestor IDs/states, visible dependency closure and effective +vault revision. Own Merkle log state is diagnostic, not enough to fence a draft. +View-dependent mutations require the matching snapshot; rejecting absence or a +different target is a domain precondition, despite proto3 message optionality. + +Materialize entries/summary/skeleton come from one coherent perspective. Slices +and continuation handles bind to it. On SubmitChange staleness, return the live +skeleton with VIEW_STALE. Rendering a non-current view is a read-only projection, +not an aborted redb write transaction. Client materialization retains lexical, +reserved-name, symlink-parent and final-write path safety checks. + +Repository reads never acquire a write permit or persist repairs/stat caches. +Repository writes queue per repository and commit the outbox together. Workspace +mutations lease the workspace first; filesystem-only writes and mutable tracking/ +index state changes also lease it. Lock +order remains workspace lease → repository permit → redb write transaction. +External providers and daemon bookkeeping are separately declared effects. + +Status does not reindex. ReindexWorkspace is explicit. PreviewMutation (insert/ +unrecord), PreviewRestore and PreviewGitImport cannot apply or create a repository. +Diff is repository-only; DiffWorkingCopy is local. CreateView does not switch; +ApplyStash does not drop. CLI composition preserves commands without hiding extra +authority in bool flags. InsertChanges/Unrecord/PullChanges do not refresh host +files; explicit Restore/SwitchView is separately authorized. + +## Reactor principal boundaries + +Inbound authenticated_principal and daemon-assigned sequence/time assertions are +refused. Derive them from middleware. source/delegation and every repository in +PublishStream are authorized independently; outer/inner repository IDs must agree. +Deduplication never returns another principal's private outcome on an ID collision. + +Consumers are owned by authenticated principal; read filters and replay never +expand that ownership. CheckpointConsumer requires reactor.checkpoint, a request +ID and monotonic progress bounded by delivered state. Merely subscribing grants +no checkpoint-write authority. Sandbox tokens cannot invoke Reactor operations. + +## Implementation verification still required + +Descriptor checks are an early contract gate. Each server slice must additionally +prove caller/resource filtering, cross-repository/workspace refusal, malformed +payload refusal, view-local vault writes, effective-ancestor staleness, expiry +while queued, restart/revocation, replay fingerprint isolation, read/write queue +behavior, filesystem recovery and checkpoint failpoint parity. They are runtime +tests, not claims that annotations alone implement those behaviors. diff --git a/libatomic/proto/COVERAGE.md b/libatomic/proto/COVERAGE.md new file mode 100644 index 00000000..5d40e213 --- /dev/null +++ b/libatomic/proto/COVERAGE.md @@ -0,0 +1,374 @@ +# COVERAGE — atomic-protocol vs every existing surface + +The alignment artifact for Lee/Aaron/Vince: proves (1) the two inventoried RPC +surfaces are fully covered, and (2) every CLI command that touches redb in +its path has a covering service method, with combine decisions explicit. + +Redb verdicts are from source reading of `/Users/aaron/code/work/atomic` +(2026-10-01): `Repository::open*` opens `pristine.redb` (+ file change +store); `changes.redb` is opened ONLY by the legacy database-owner daemon +(`RedbChangeStore::open`, sole caller `owner.rs:900`) — a surface being +fully dismantled. "RW"/"RO" = write/read handle. + +Legend — VERDICT: **direct-RW / direct-RO / creates** (opens redb itself) · +**indirect** (via a shared helper or daemon RPC that opens) · **none** (no +redb access) · **bootstrap** (repository creation, exempt per RFC coverage +definition). COVERAGE: the RPC(s) that replace the command's redb path. +🔒 = hidden command or flag (clap `hide = true` — not shown in `--help`, +e.g. `agent database-owner`, `agent hooks`, `status --debug-ignore`, +`log --all`, `diff --cached`, `remove --keep`, `view list -v`, +`identity delegate`); 🔒🔒 = hidden subcommand within a hidden command +(`agent database-owner serve`). Hidden ≠ unimportant: hooks and the +completion engine are load-bearing surfaces. + +--- + +## 1. Method inventory (94 RPCs / 15 services, contract revision 2) + +| Service | RPCs | Scopes | +|---|---|---| +| DaemonService | Health, GetCapabilities, ResolveRepository, RegisterRepository, ListRepositories, Shutdown | BOTH/BOTH/LOCAL/LOCAL/LOCAL/LOCAL | +| ProvenanceService | ReserveTurn, AppendEnvelopes, PrepareCheckpoint, LoadFrozenEnvelopes, BindCheckpointHash, AcknowledgeCheckpoint, UpdateTurn, GetTurn, GetSession, ForkSession, RebuildSessionIndex, GetProvenance, ExportProvenance | journal 8×BOTH + session/projection 5×(BOTH/LOCAL) | +| SandboxService | OpenSandbox, RenewSandbox, CloseSandbox, CreateSandboxTree, StageSandboxImage, SealSandboxImage, Materialize (stream), GetFileStates, GetChanges, PublishProvenance, SubmitChange | admin/trees LOCAL; data ops BOTH | +| RepositoryQueryService | Status, Log, GetChange, Diff (stream), DiffWorkingCopy (stream), PreviewMutation, PreviewRestore, ListConflicts | workspace reads LOCAL; repository reads/previews BOTH | +| RepositoryMutationService | AddFiles, RemoveFiles, MoveFile, Restore, Record, Revise, InsertChanges, Unrecord, CreateStash, ApplyStash, ListStashes, DropStash | InsertChanges/Unrecord BOTH; workspace/stash LOCAL | +| ViewService | ListViews, CreateView, SwitchView, DeleteView, SetViewScope | BOTH, BOTH, LOCAL, BOTH, BOTH | +| TagService | CreateTag, DeleteTag, ListTags, GetTag | all BOTH | +| VaultService | InitVault, SyncVault, GetVaultEntry, ListVaultEntries, GetVaultContext, ExportVault, CreateVaultEntity, UpdateVaultEntity, DeleteVaultEntity, ValidateVaultEntity, LinkVaultEntities | view-scoped reads/writes BOTH, including sandboxes; SyncVault/ExportVault LOCAL | +| KnowledgeService | QueryGraph, MaintainKnowledgeGraph | BOTH, LOCAL | +| AttestationService | PrepareAttestation, RecordAttestation, VerifyAttestation, ListAttestations | all BOTH | +| SyncService | PushChanges, PullChanges, ListRemotes, ManageRemotes, BindRemoteProject | all LOCAL | +| GitInteropService | ImportFromGit, PreviewGitImport, PushToGit | LOCAL | +| TriageService | ListTriageCandidates, GenerateTriageReview | BOTH | +| MaintenanceService | Repair, CheckRepository, ReindexWorkspace | LOCAL, BOTH, LOCAL | +| ReactorService | Publish, PublishStream, Subscribe, Replay, CheckpointConsumer | all BOTH (contract draft) | + +## 2. Legacy agent journal protocol (13 ops, BEING DISMANTLED) → ProvenanceService + +The legacy journal protocol and the `atomic agent database-owner` command +that served it are being **fully dismantled**; what carries forward is the +provenance tracking semantics. The mapping below proves nothing is lost in +the fold. NOT carried (dismantled with the protocol): the owner process +itself, its fs2 election, per-request re-opens, JSON framing, 3-attempt +reconnect, version-in-frame. Liveness and process lifecycle are daemon +concerns now (Health/Shutdown), not protocol ports. + +| # | Legacy op | Target | Carried semantics | +|---|---|---|---| +| 1 | `Ping` → Pong{pid, flags} | DaemonService.Health (+GetCapabilities) | additive flags → feature tokens + method matrix; clients gate on the negotiated matrix (the additive capability-flag pattern, generalized) | +| 2 | ReserveProvenanceTurn | ProvenanceService.ReserveTurn | grants generation; first reserve claims session for view; committed flag | +| 3 | AppendProvenanceEnvelopes | AppendEnvelopes | expected_generation fencing; chunk under negotiated budget (4 MiB preserved); acks {event_id, sequence} | +| 4 | PrepareCheckpoint | PrepareCheckpoint | exact CheckpointSource fields; reuse_frozen_changes; freezes envelopes | +| 5 | LoadFrozenEnvelopes (unpaged) | — compat mode dropped | the unpaged form was compat with the dismantled protocol; frozen loads are paged-only (a complete journal may arrive as one page) | +| 6 | LoadFrozenEnvelopesPage | LoadFrozenEnvelopes (paged-only) | {seq,offset} cursor; fragments {cursor, bytes, complete}; 256-fragment budget; client reassembly + continuity validation; cursor absent = first page | +| 7 | BindCheckpointHash | BindCheckpointHash | binds manifest hash; session_turn; advances to attempt_generation | +| 8 | AcknowledgeCheckpoint | AcknowledgeCheckpoint | caller-supplied completed_at | +| 9-11 | Stop/Resume/AbandonTurn | UpdateTurn (action enum) | exact StopCause values; resumable; observed_at; expected_generation (verified in current source, missing in the old wire draft) | +| 12 | TurnStatus | GetTurn | read-side | +| 13 | Shutdown | DaemonService.Shutdown | daemon lifecycle admin (graceful drain) — not a carry of the legacy lock-release op | + +**Kept as separate RPCs:** prepare / bind / acknowledge — the named +failpoints (before/after-envelope-commit, after-checkpoint-prepare, +before-frozen-page-continuation, frozen-page-unavailable, after-checkpoint- +bind) sit exactly on those seams. + +**Dismantled deliberately (not carried):** the owner process and its fs2 +election (daemon lifecycle owns storage, D3/D4), per-request re-opens +(persistent daemon), 3-attempt reconnect (`request_with_reconnect` → D4 +client start-and-retry), JSON framing + version-in-frame (→ gRPC transport ++ GetCapabilities protocol_version + stable mismatch error), the unpaged +frozen-load compat mode (its callers are dismantled with the protocol). + +**Exact types verified from source:** StoredProvenanceTurn (schema_version, +provenance_id u64, session_id, turn_number, state, generation, next_event_seq, +created/updated/completed_at, final_hash, checkpoint_attempt); StopState +(cause, observed_at, last_event_seq, resumable); CheckpointPhase (Prepared, +HashBound, Published); attempt (attempt_generation, frozen_event_count, +source, phase, provenance_hash, session_turn, manifest_hash, prepared_at, +updated_at); constants (PROTOCOL_VERSION 1, MAX_FRAME_BYTES 64 MiB, +FROZEN_PAGE_BYTES 1 MiB, MAX_FROZEN_PAGE_FRAGMENTS 256, +APPEND_CHUNK_BUDGET_BYTES 4 MiB). + +## 3. Inventory B — remote-sandbox extension (8 ops) → SandboxService + +| Op | Target | Preserved mechanics | +|---|---|---| +| OpenSandbox | OpenSandbox (LOCAL, sandbox.admin) | one live token/view; TTL 1s..365d checked; default 2h; blake3-only storage; ephemeral registry (restart invalidates — documented contract behavior) | +| RenewSandbox | RenewSandbox (LOCAL) | TTL re-arming | +| CloseSandbox | CloseSandbox (LOCAL) | revocation | +| Materialize | Materialize → **server-streaming** | entries stream, summary carries {snapshot, entries, skeleton}; path safety rules documented on TreeEntry | +| FileStates | GetFileStates (BOTH, sandbox.read) | inodes = **opaque server-issued handles**, visibility-checked and bound to effective snapshot; slice {rows, content spans} | +| Changes | GetChanges | versioned-opaque ChangeBundle; refusal as ErrorInfo oneof | +| PublishProvenance | PublishProvenance | provenance.write; graph as VersionedBytes; owned SessionTurn + generation, not caller-supplied stored state | +| SubmitChange | SubmitChange | sandbox.submit; effective ViewSnapshot fence; .vault edits additionally require vault.write; **VIEW_STALE carries live skeleton**; per-repo queue; no host materialization | + +Grant model → sandbox_messages.proto. Authorization matrix (token's stable view only; session +ownership on first reserve; provenance ownership; on-view checkpoint +hash visibility; admin ops local-only) → documented in provenance.proto + +sandbox message headers and CONTRACT.md, enforced server-side. iroh transport kind stays +(persistent node key, ALPN, one bi-stream per request — §8 of the handoff). + +## 4. CLI command matrix (every command, verdict → coverage) + +**Verdict caveat (see §9):** today's "direct-RW" verdicts reflect the +CLI's write-capable OPEN PATH (`Repository::open` always begins a write +transaction for table init + deferred-alignment recovery), not necessarily +command semantics. Roughly 18 commands are READ-ONLY-SEMANTICS despite the +RW handle — §9 reclassifies them with source evidence; their covering RPCs +are read-capped and they will run as concurrent read transactions under the +daemon, never the write queue. + +### `agent` family +| Command | Verdict | Coverage | Notes | +|---|---|---|---| +| `agent enable` / `disable` | none | — | writes agent config files; exempt (local files) | +| `agent status` | none | — | SessionStore JSON under `.atomic/sessions/`; exempt | +| `agent explain [--save]` | direct-RW | ProvenanceService.GetSession / GetTurn (read) | **FLAG (§7)**: the `--save` write target (stored reasoning) needs confirmation from Aaron → candidate session-annotation op | +| `agent attest` | direct-RW — **read-only semantics** (§9) | AttestationService.ListAttestations (graph_stats filter) | list/inspect only: provenance_summary, get_view_info, iter_attestations — all reads | +| `agent lifecycle begin/renew/stop/resume/abandon/end/status` | indirect | ProvenanceService.UpdateTurn / GetTurn; declarations stay local JSON files | orchestrator IPC becomes daemon client logic; turn ops via ProvenanceService | +| 🔒 `agent database-owner start/ping/reserve/shutdown` | being REMOVED | no coverage required — the command and its daemon are fully dismantled; liveness/process lifecycle are daemon concerns (Health, Shutdown) and journal ops are ProvenanceService | hidden client verbs | +| 🔒🔒 `agent database-owner serve` | being REMOVED | dismantled; `changes.redb` ownership moves into the unified database + atomicd (Phases 1/3) | today the sole `RedbChangeStore::open` caller | +| `agent identity set/unset/show` | none | — | global config + files; exempt | +| 🔒 `agent hooks ` | indirect | aggregate client: session_start/record/scope/provenance/attestation/session_end map onto ProvenanceService + RepositoryMutationService.Record + AttestationService + KnowledgeService | TurnOrchestrator's `open_existing_wait` re-opens vanish under the daemon; journal writes go through the legacy journal sink today and become direct ProvenanceService calls under the contract | + +### core commands +| Command | Verdict | Coverage | +|---|---|---| +| `init` | creates | **bootstrap** — exempt (note: consider DaemonService.CreateRepository later) | +| `status [--reindex]` | direct-RO (reindex → RW) | Status; --reindex explicitly composes MaintenanceService.ReindexWorkspace then Status | +| `conflicts` | direct-RO | RepositoryQueryService.ListConflicts | +| `add` | direct-RW | RepositoryMutationService.AddFiles | +| `remove/rm [--keep]` | direct-RW | RemoveFiles | +| `move/mv` | direct-RW | MoveFile | +| `restore` (alias `reset`) | direct-RW | Restore; dry-run → RepositoryQueryService.PreviewRestore (the listing report AND the single-file pristine-bytes arm) | +| `record -m` | direct-RW | Record | +| `revise [--reword]` | direct-RW | Revise | +| `log [--all]` | direct-RO | Log (paged) | +| `change ` | direct-RO | GetChange (includes flags) | +| `diff [--stat\|--cached]` | direct-RO | DiffWorkingCopy (LOCAL); repository ref-pair Diff (BOTH) | +| `insert` (all modes + bare) | direct-RW | InsertChanges (explicit target + mandatory deps); preview → PreviewMutation; the bare promotion resolves source/parent through the promote arm server-side; multi-pick references resolve handler-side; the request's dry_run previews every arm's plan; host refresh is explicit | +| `unrecord` | direct-RW | Unrecord (explicit view); preview → PreviewMutation | +| `split [--switch]` | direct-RW | CreateView (explicit base); --switch composes SwitchView | +| `stash push/pop/apply/list/show/drop/clear` | direct-RW | CreateStash / ApplyStash / ListStashes / DropStash; pop composes apply then drop | +| `doctor repair-dependency-index/materialize-crdt/check` | direct-RW (check: read semantics) | Repair + CheckRepository (maintenance.read, BOTH) | +| `session show/fork/rebuild` | direct-RW | ProvenanceService.GetSession / ForkSession / RebuildSessionIndex | +| `sandbox create/stage/seal` | direct-RW | SandboxService.CreateSandboxTree / StageSandboxImage / SealSandboxImage (SHAPE-CONFIRM) | +| `tag create/delete/list/show` | direct-RW | TagService (create/delete/list/get) | +| `view promote` | direct-RW | ViewService.SetViewScope — **distinct from bare `insert`** (scope change vs landing changes; verified against insert.rs + promote.rs) | +| `view create` / `view split` | direct-RW | ViewService.CreateView | +| `view switch` | direct-RW | ViewService.SwitchView | +| `view delete` | direct-RW | ViewService.DeleteView | +| `view list [-v] [--remote]` | direct-RW | ViewService.ListViews — `--remote` = same RPC with hosted authority (D7 routing) | +| `triage candidates/review` | direct-RW | TriageService.ListTriageCandidates / GenerateTriageReview | +| `provenance show/trace` | direct-RW handle, read semantics | ProvenanceService.GetProvenance / ExportProvenance | +| `query search/neighbors/callers/entities/graph/embed/enrich/reindex/plan/ask` | direct-RW | KnowledgeService.QueryGraph (oneof kind; plan + ask included) / MaintainKnowledgeGraph (embed/enrich/reindex) | +| `query code` / `query index` | none (file index) | QueryGraph content_search reads a view-scoped index; local file-index construction remains client-side, never inside a read RPC | +| `vault init/show` | direct-RW/RO | VaultService.InitVault / GetVaultEntry | +| `vault list/materialize/sync/summaries` | direct-RW | ListVaultEntries (summaries mode) / ExportVault / SyncVault | +| `vault goal start/stop/resume/list/show` | direct-RW | UpdateVaultEntity (kind=GOAL, status transitions) / ListVaultEntries / GetVaultEntry | +| `vault context` | direct-RO | GetVaultContext | +| `vault query ...` | direct-RW | (alias of `query`) KnowledgeService | +| `vault intent` / `vault memory` shims | none | redirect text only; exempt | +| `intent new/attest/update/delete/link` | direct-RW | CreateVaultEntity / AttestationService / UpdateVaultEntity / DeleteVaultEntity / LinkVaultEntities | +| `intent validate` | forks: path-arg none; ID-arg RW | ValidateVaultEntity (both forms; db-validated vs bytes-only via oneof) | +| `intent show/list` | direct-RO | GetVaultEntry / ListVaultEntries | +| `intent verify` | direct-RW | AttestationService.VerifyAttestation | +| `memory new/show/write/update?` | direct-RW | CreateVaultEntity / GetVaultEntry / UpdateVaultEntity | +| `memory validate/verify` | direct-RW | ValidateVaultEntity / AttestationService.VerifyAttestation | +| `memory attest/list` | direct-RW | AttestationService.RecordAttestation / ListVaultEntries | +| `memory kinds` | none | static output; exempt | +| `completions ` | none | static script; exempt | +| `update [--check]` | none | install-source detection; exempt | +| `COMPLETE= atomic` (dynamic completion) | direct-RW | covered by ViewService.ListViews + RepositoryQueryService.Log (client composes) — no new RPC; optional CompletionHints later if round-trips hurt | + +### git interop + +**CLI-boundary helpers (the seam the daemon replaces):** today every command +runs `find_repository_root` then `open_repository`/`require_repository`/ +`open_readonly_repository` to open redb in-process; under the contract the +flow becomes explicit RegisterRepository when needed, then ResolveRepository +(cwd path → persistent RepositoryRef + WorkspaceInfo, lookup only) → covering RPC. +CLI normalizes subdirectory-relative paths; daemon cwd is irrelevant. The daemon's +repository registry maps the persistent ID to its lazily-opened handle — +one daemon, many redb files (RFC D3). + +| Command | Verdict | Coverage | +|---|---|---| +| `git import [--all/--incremental/--dry-run/--with-crdt]` | direct-RW (or creates) | ImportFromGit (bootstrap requires destination); --dry-run → PreviewGitImport | +| `git push [-m/--no-push/--branch]` | direct-RW | GitInteropService.PushToGit | +| `git hooks install/uninstall/status` | none | `.git/hooks` file shims; exempt | + +### remote / storage +| Command | Verdict | Coverage | +|---|---|---| +| `push [--remote]` | direct-RW + HTTP | SyncService.PushChanges (hosted server = receiving side of same contract) | +| `pull [--remote]` | direct-RW + HTTP | SyncService.PullChanges (sidecar import via ProvenanceService/AttestationService on apply) | +| `clone ` | creates + HTTP | **bootstrap** — exempt; fetch/apply/sidecar phases covered by PullChanges | +| `remote add/remove/rm/set-url/rename/default` (+ bare list) | direct-RW (list: read-only, §9) | ManageRemotes (mutations) + ListRemotes (list, repository.read) | remotes persist in the repository DB | +| `server add/set/show/remove/set-identity` | none | `~/.atomic/config.toml`; exempt (client config) | +| `identity` ×13 (new/list/show/default/delete/whoami/register/sign/verify/agent/grant/delegate/lookup-key) | none | IdentityStore is file-based — no redb → exempt. **FLAG (§7)**: hosted identity/grant management (currently HTTP) is a candidate for the future HostedService family — Lee decision | +| `workspace` ×8, `project` ×4, `org` ×8, `team` ×7 | none (HTTP StorageClient) | no local redb → out of the redb rule; **FLAG (§7)**: hosted-management RPC family (workspaces/projects/orgs/teams) is an open Lee decision for the unified hosted surface | +| `project init` | direct-RW | SyncService.BindRemoteProject | + +## 5. Hidden surfaces audit (§10.9 support) + +Surfaces receiving external input beyond the two inventories: `agent hooks` +stdin receiver (aggregate client — §4 above); the completion engine (covered +by ListViews+Log); `agent lifecycle` JSON IPC (covered via UpdateTurn); +git hook shims (call back into `git import --incremental` — covered); +`/code` HTTP sync endpoints (the hosted server side — the SyncService +receiving contract, server not in this workspace); storage-management HTTP +(FLAGGED §7). The legacy database-owner daemon (being fully dismantled with +its `agent database-owner` command) served the same single-writer purpose +atomicd now owns. **Finding: no third RPC surface exists beyond the two +inventories in this workspace** — supports Aaron's expected confirmation. + +## 6. Combine decisions (the "don't build a hundred same RPCs" ledger) + +| Today | One RPC | Rationale | +|---|---|---| +| Ping + Pong flags | Health + GetCapabilities | flags → negotiated feature tokens + method matrix | +| LoadFrozenEnvelopes + Page | LoadFrozenEnvelopes (paged-only) | paging is the contract from day one; cursor absent = first page; the unpaged compat mode is dropped with the dismantled protocol | +| Stop/Resume/AbandonTurn | UpdateTurn (action enum) | no failpoints on those seams; identical request shape | +| turn status | GetTurn | read side | +| insert: bare/single/from-view/tag/multi-pick | InsertChanges (source oneof) | the promote arm resolves the bare promotion server-side; raw single/multi references resolve handler-side; dependency closure mandatory | +| insert/unrecord previews | PreviewMutation (operation oneof) | read permission/effects; no apply flag or idempotency writes | +| `split` + `view create` | CreateView (base oneof) | alias mapping; optional switch is explicit SwitchView | +| stash ×7 | 4 RPCs | pop composes apply then drop; show=list-detail; clear=drop-all | +| query ×11 | QueryGraph + MaintainKnowledgeGraph | 9 read kinds oneof; 3 rebuild actions enum | +| intent/memory/goal verbs | VaultService entity model (kind enum) | one entity store, one lifecycle; attest→AttestationService targets | +| goal ×5 | folded into Update/List/Get (GOAL kind) | status transitions, not separate ops | +| remote ×6 | ManageRemotes (mutations) + ListRemotes (read) | registry CRUD; the list action is split out so a read never gates on repository.write or enters the write queue (§9) | +| doctor ×3 | Repair (rebuild/materialize) + CheckRepository (check) | maintenance batch; check is doc'd read-only in source — split so it executes as a read transaction (§9) | +| view list `--remote` | ListViews with hosted authority | D7 routing, not a second method | +| GetChange sections | includes flags | per-section verbs avoided | + +Deliberately NOT combined: Record/Insert/Revise/Unrecord (distinct domain +semantics per RFC D5); prepare/bind/acknowledge (failpoints); Open/Renew/ +Close grant admin (distinct result payloads — mint vs extend vs revoke); +Status vs Log vs Diff (different reads), repository vs working-copy Diff, and +preview vs mutation (different authorization/effects). + +## 7. Flags for Lee / Aaron / Vince (from this pass) + +1. **`agent explain --save`** write target (stored reasoning) — needs Aaron's + input; candidate: session-annotation op in ProvenanceService. +2. **Hosted management family** — workspace/project/org/team are HTTP-only + today (no local redb) — do they join a HostedService proto family for the + unified hosted surface, or stay HTTP? (RFC lists "hosted collaboration".) +3. **IdentityService** — file-based identity store means no redb RPC needed + (exempt); hosted identity/grant management is the open question (see 2). +4. **Package naming** — `atomic` (RFC style, inherited) vs `atomic.v1` + (versioned convention); flip before first tagged release if desired. +5. **Capability vocabulary** — dot-style carried; RFC's authorization section + is colon-style; ratify one. +6. **SHAPE-CONFIRM markers** (sandbox trees, vault entry shapes, triage + layers, session ledger, publication records) finalize in their slices + against source; flagged inline in the protos. +7. **Zero-capability negotiation pair** — Health/GetCapabilities declare + zero required capabilities (RFC says "one or more"); carve-out flagged. +8. **Clone/init** remain bootstrap-exempt; consider CreateRepository + + hosted-init RPCs later. +9. **Repository identity bootstrap** — persistent IDs are assigned at init + or explicit `RegisterRepository`; the working copy records its ID + under `.atomic`. Confirm with the Phase 1 colleague: who writes the ID + record (init vs daemon), its filename, and whether pre-existing repos + migrate IDs during Phase 1 consolidation. + +**Counts:** 94 RPCs cover the 21 inventoried ops of the two legacy surfaces +(semantics carried, machinery not) + every redb-touching CLI path (~85 +direct/indirect paths); ~40 commands are file/config/presentation-only and +stay client-side (exempt with reason). + +## 9. RW-handle audit — read-semantics classification (a list never does a write) + +Aaron's principle (2026-10-02): an op whose semantics are read-only must +(a) be gated by a read capability, (b) execute as a concurrent read +transaction under the daemon — never the per-repo write queue — and +(c) never require a write capability to read. Source sweep of +/Users/aaron/code/work/atomic confirmed: today's RW verdicts for the +commands below come from the write-capable OPEN PATH (`Repository::open` +begins a write txn for table init + deferred-alignment recovery, +pristine.rs:128, repository/mod.rs:390), NOT from command semantics — their +bodies only call read methods. The daemon must serve them read-only. + +| Command | Classification | Covering read-capped RPC | +|---|---|---| +| `agent attest` (all flags) | read-only | AttestationService.ListAttestations (attestation.read) | +| `session show` | read-only (fork/rebuild stay mutations) | ProvenanceService.GetSession (provenance.read) | +| `tag list` / `tag show` | read-only | ListTags / GetTag (repository.read) | +| `view list` (local + --remote redb path) | read-only | ListViews (repository.read) | +| `vault list` / `summaries` | read-only | ListVaultEntries (vault.read) | +| `vault materialize` | repository-read; writes working-copy FILES + daemon recovery ledger | ExportVault (vault.read + working-copy.write, never repository writer) | +| `query search/neighbors/callers/entities/graph/plan/ask` | read-only | KnowledgeService.QueryGraph (knowledge.read) | +| `intent verify` | read-only (id-arg; path-arg needs no redb) | VerifyAttestation (attestation.read) | +| `memory show/list/validate/verify` | read-only | vault.read / VerifyAttestation | +| `triage candidates` / `triage review` (all output modes) | read-only (review doc'd "pure and read-only", triage/project.rs:1-7) | TriageService (triage.read) | +| `doctor check` | read-only ("Read-only… Mutates nothing", doctor.rs:36-46) | MaintenanceService.CheckRepository (read execution) | +| dynamic completion (`COMPLETE=`) | read-only | ListViews + Log | + +Not "pure" reads despite the read-redb classification (documented, not +reclassified): `vault materialize`, `query graph --output`, and +`triage review --html` write FILES to the working copy; +`query ask`/`plan(vector_search)` call external LLM/embedding APIs. + +Earlier contract fixes driven by this audit: **ListRemotes** split out of +ManageRemotes (repository.read; enum value 6 reserved) and +**CheckRepository** split out of Repair (read execution; enum value 3 +reserved). The follow-up §10 found remaining exceptions (status reindex, +registration, previews, preparation/check permissions) and closes them. +PrepareAttestation now uses attestation.read (not merely an execution comment). +Read-verb repository +methods verified pure read_txn: vault_kg_*, vault_list/retrieve/manifest, +list_views/get_view_info, get_tag*/list_*_tags, log, +get/list_session_ledger(s), find_attestations_for_view, +triage_candidate_set, status. + +## 10. Contract hardening after the read/write and sandbox audit + +The authoritative behavior is [CONTRACT.md](CONTRACT.md), revision 2. Old draft +behavior must fail version negotiation; reserved protobuf fields alone do not +prevent an old dry-run caller's flag being ignored by a new apply handler. + +| Boundary | Contract change | +|---|---| +| Placement vs caller trust | Every method declares allowed_caller (51003) independently of execution_scope; LOCAL methods explicitly deny network/sandbox callers | +| Storage vs filesystem effects | Every method declares effect (51004); reads cannot acquire even an aborted writer; workspace effects stay local | +| Remote sandbox | Data BOTH, not hosted-only REMOTE; local grant admin preserved; token lives in shared middleware metadata across services | +| Sandbox vault writes | View-scoped Init/CRUD/link/read/validate BOTH and sandbox-allowed; .vault submissions update only target-view revisions/index | +| Broad repository rights | SubmitChange uses sandbox.submit, not repository.write; sandbox.read no longer authorizes provenance publication | +| Stable targets/fencing | ViewRef + effective ViewSnapshot covers ancestor visibility and vault revisions; lifecycle/publication generation; publication accepts SessionTurn only | +| Read/write split | Status/ReindexWorkspace; Resolve/RegisterRepository; PreviewMutation/PreviewRestore/PreviewGitImport; prepare and check use read caps | +| Workspace authority | Separate DiffWorkingCopy; remove switch_after/drop_after; no host materialization from sandbox submit or repository insert/pull | +| Remote repository operations | Create/Delete/SetViewScope, InsertChanges/Unrecord, vault DB mutations, ForkSession, triage and CheckRepository now BOTH | +| Multi-repo routing | Resolve returns WorkspaceInfo; root-relative paths; Git bootstrap requires destination; duplicate physical stores cannot silently share IDs | +| Replay | ResponseMeta on mutations; fingerprint/scope by repository/principal/grant/method; auth before replay | +| Signing/events | Public prepare + caller-held key; no daemon signer under provenance.read; consumer checkpoint has its own capability/ownership | + +`python proto/check_contract.py` compiles 36 protobufs and checks all 94 method +descriptors, including the exact 32-method sandbox allowlist. It checks contract +declarations, not an RPC implementation. Content validation, view-index isolation, +leases, snapshot calculation, replay storage and failpoint parity remain slice- +level implementation gates. This pass does not migrate Atomic storage. + +## 8. Git-pattern audit — Atomic is patch theory + a semantic change graph, NOT git + +Rule going forward: check the atomic model in source before naming a field; +do not import git vocabulary by reflex. Findings from the 2026-10-02 sweep +(all fixed; shapes verified against /Users/aaron/code/work/atomic and +compiled through the descriptor): + +| Git-shaped pattern (was) | Atomic's model (source) | Fix | +|---|---|---| +| change `parents` (commit-DAG thinking) | a change has NO commit-parents: HistoryEntry = sequence + hash + the Merkle state AFTER applying it, ordered by the per-view merkle chain (atomic-repository/src/history/types.rs:79); graph edges are dependency hashes from StoredChangeMeta's hash table — index 0 = self, 1+ = deps (redb_change_store/mod.rs:181) | ChangeLogEntry/ChangeInfo carry sequence/state/dependencies; descriptor sweep confirms zero `parents` fields remain | +| DID-shaped `author` | ChangeHeader carries message/description/timestamp/authors; authors are name/email records (atomic-core/src/change/header.rs:197) | `Author {name, email}` replaces Identity in change-domain messages | +| invented file-status states | FileStatus = Modified/Deleted/Untracked/Added/Conflicted (status.rs:136); FileStatusEntry = path/status/inode/recorded_hash/current_hash/details (status.rs:263); RepositoryStatus carries stale_index_count (status.rs:390) — the `--reindex` driver | StatusResponse, FileStatus, FileStatusEntry reshaped to source; stale_index_count added; ChangeRef.ordinal → sequence (the CLI's #N) | + +**Deliberately kept** (atomic's own verbs, source-verified — not git +imports): record/insert/revise/unrecord (the change lifecycle), stash +(implemented as orphan-VIEW parking of uncommitted work — patch-theory +native, commands/stash.rs), restore, tag (named Merkle-state snapshots), +push/pull/remote/clone (atomic's sync model), view promote/insert. +Node-level "parents"/reparenting in atomic-core is CRDT vertex-edge +machinery — internal to graph sections, which stay versioned-opaque on the +wire. git_interop is the explicit, contained bridge. diff --git a/libatomic/proto/README.md b/libatomic/proto/README.md index 395988bb..b99b176e 100644 --- a/libatomic/proto/README.md +++ b/libatomic/proto/README.md @@ -1,66 +1,51 @@ -# libatomic — the canonical Atomic contract - -`libatomic` is the one crate that owns the Atomic service contract: - -- **`proto/atomic/`** — the canonical protobuf sources (package `atomic`): - shared primitives, the stable error model, and one folder per service - (`_service.proto` for the RPCs, `_messages.proto` for that - service's request/response messages). This tree is the contract's single - source of truth; there is no second copy. -- **codegen** — `build.rs` compiles the tree with - [`protox`](https://crates.io/crates/protox) (a pure-Rust `protoc` - replacement — no system protoc install) and `tonic-build`, generating - both clients and servers into the `atomic` module: - - ```rust - use libatomic::atomic::{StatusRequest, repository_query_service_client::RepositoryQueryServiceClient}; - // transport-neutral alias: - use libatomic::proto as pb; - ``` - -- **`proto/check_contract.py`** — the descriptor contract gate. It compiles - the tree and enforces the invariants the contract promises (details - below); run it from any throwaway environment, it never writes into the - source tree. +# libatomic/proto — the Atomic protobuf service definitions + +The one protobuf contract that `atomicd` serves: local daemon and hosted +server, UDS / named pipe / TLS / iroh. **There is no second protocol.** + +This tree is the contract's single source of truth: it lives in the atomic +repo inside `libatomic` (the crate that owns the codegen AND the handlers), +compiled by `libatomic/build.rs` (protox — no system protoc) and gated by +`check_contract.py`. There is no vendored copy synced into any other repo. ## Layout ``` -libatomic/ - build.rs protox + tonic-build codegen (no system protoc) - src/lib.rs the generated `atomic` module (+ `proto` alias) - proto/ - README.md <- this file - check_contract.py <- protobuf compilation + descriptor contract gate - atomic/ - common/ <- shared primitives - options.proto scope, capabilities, callers, effects (extensions 51001-51004) - common.proto repository/view/snapshot IDs, request/response metadata, hashes - change.proto shared change-domain message shapes - error.proto the stable error model - pagination.proto PageCursor {seq, offset} - event.proto the agent event envelope - services// <- one folder per service - _service.proto the service definition — RPCs only - _messages.proto that service's messages +proto/ + README.md <- this file + COVERAGE.md <- CLI command → RPC matrix (the alignment artifact) + CONTRACT.md <- middleware/domain enforcement obligations + check_contract.py <- protobuf compilation + descriptor contract gate + atomic/ + common/ <- shared primitives + options.proto scope (51001), capabilities (51002), callers (51003), effects (51004) + common.proto repository/view/snapshot IDs, request/response metadata, hashes and bytes + change.proto shared change-domain types — source-verified shapes (ChangeInfo, ChangeLogEntry, ConflictInfo, Author) + error.proto stable error model (provenance-documented vocabulary) + pagination.proto PageCursor {seq, offset} — seeded from the frozen-page cursor + event.proto Reactor event envelope + services// <- one folder per service + _service.proto the service definition — RPCs only + _messages.proto that service's request/response messages ``` -## Add-only discipline - -Field and extension numbers are never reused or renumbered; removed field -numbers are reserved. A client that does not match the negotiated contract -version must fail version negotiation rather than send an ignored field to -a writer. +Services: daemon (Health, GetCapabilities, ResolveRepository, RegisterRepository, ListRepositories, +Shutdown) · provenance (the provenance tracking contract: journal, checkpoint +phases, turn lifecycle, session ledger, projection) · sandbox (grants + local +trees + remote data ops) · repository_query (Status, Log, GetChange, Diff +stream, conflicts) · repository_mutation (record/insert/revise/unrecord/ +add/remove/move/restore/stash) · view · tag · vault (intents/memories/goals as +ONE entity model) · knowledge (11 query verbs → 2 RPCs) · attestation +(prepare/signed external signing) · sync (push/pull/remotes/project binding) +· git_interop · triage · maintenance · reactor (CONTRACT DRAFT — server lands +Phase 3). -## The contract gate +94 RPCs across 15 services. **Contract version 2**: this hardening changes draft +behavior; removed field numbers are reserved. Older draft clients MUST fail +version negotiation, not send an ignored `dry_run`/`drop_after` field to a writer. +See [CONTRACT.md](CONTRACT.md) for normative middleware/domain requirements. -`check_contract.py` compiles the tree and verifies the declared -invariants: every method declares `execution_scope`, `allowed_caller`, -`effect`, and `required_capability`; reads stay read-only (no write/admin -authority, no mutation replay echo); mutations carry request metadata and -echo it in the response; retired fields stay reserved; workspace effects -stay LOCAL; sandbox callers never receive broad authority; and -generation/snapshot fencing is in place. +Compile and descriptor-contract check (temporary output only): ```bash # TEMP_DIR is an existing temporary directory outside the repository @@ -70,5 +55,113 @@ python3 -m venv "$TEMP_DIR/proto-venv" ``` The import root is `proto/`; imports read `atomic/common/common.proto`. -The checker verifies declarations — it does not claim to test any -implementation of them. +The checker verifies all four annotations, read/write permissions, sandbox +allowlisting, metadata echo, retired fields, routing and snapshot/generation +fencing. It does not claim to test an RPC server that has not been implemented. + +## Conventions + +- **Add-only.** Field and extension numbers are never reused or renumbered. + 51001/51002 are the permanent method-option allocations. +- **Package `atomic`** (RFC examples' style; versioning happens at the crate + boundary — revisit with Lee before the first tagged release if desired). +- **Capability tokens are dot-style** (`repository.read`). The RFC is + internally inconsistent (dot in its method-option examples, colon in its + authorization section); dot style is carried from the parked Slice 0 draft + and flagged for ratification. +- **Every method declares** `execution_scope`, `allowed_caller`, `effect` and + `required_capability`, with + one documented carve-out: `Health`/`GetCapabilities` declare zero + capabilities (the negotiation pair — gating the handshake on a capability + would be circular). +- **Every repository request opens with** `RepositoryRef repository = 1`; + daemon/bootstrap and Reactor consumer/event operations are the exceptions. + One daemon multiplexes many repositories — the + persistent `repository_id` resolves to exactly one lazily-opened handle + (RFC D3), local clients discover theirs via `ResolveRepository` (path → + ref + workspace context, lookup only) or the `.atomic` ID record. Explicit + `RegisterRepository` handles registration/legacy ID assignment. Idempotency + state is scoped by authority/repository/principal/grant/method. Every ordinary + mutation carries `RequestMeta` (idempotency key + caller-supplied observed time) + and returns `ResponseMeta` (field 100). View-dependent mutations require the + matching snapshot; other preconditions are defined by the operation. +- **Remote clients never select server filesystem paths.** Working-copy + operations require a registered workspace ID and root-relative paths and are + local-caller-only. Logical graph paths/index filters are not server paths. +- **LOCAL is placement, not caller trust.** It means local-daemon deployment; + REMOTE means hosted-only; BOTH includes an atomicd accepting iroh requests. + Sandbox data stays BOTH. Grant administration and host filesystem operations + separately declare local callers only. Repository-only view lifecycle, + insertion/unrecord, vault mutations, session fork and triage are BOTH. +- **Sandboxes can write the vault.** Vault RPCs name a stable view. Grants can + include vault.read/write, knowledge.read and attestation permissions as well + as sandbox/provenance permissions. Edits/deletes/links affect only the granted + view's entity revisions; inherited entries are overlaid, never overwritten. + SubmitChange also accepts `.vault` changes with vault.write. Materialization + and ExportVault on the VM are client work, not host filesystem permissions. +- **Effective-view fencing.** ViewSnapshot covers inherited graph state and the + view-scoped vault revision. Own-log Merkle state alone is not a draft fence. +- **ErrorInfo** rides transport failures; domain refusals that must carry + recovery payloads (e.g. stale-view skeleton) travel as `oneof` outcomes in + the response. +- **Atomic vocabulary, not git.** Atomic is patch theory and a semantic + change graph: changes have NO commit-parents (their position is a view-log + sequence + post-application Merkle state; their graph edges are dependency + hashes), authors are name/email records, and file statuses are + Modified/Deleted/Untracked/Added/Conflicted. Check the atomic source before + naming a field — see COVERAGE.md §8 for the audit that fixed the first + draft's git-shaped fields. +- **Reads are reads.** An op whose semantics are read-only rides a read + capability and executes as a concurrent read transaction — never the + per-repository write queue — and no read action hides inside a + write-gated RPC. Status has no reindex flag; previews have no apply flag; + prepare/check operations use read permissions. Declared read effects prohibit + lazy repair, cache/index writes and aborted write-transaction projections. + ExportVault may write workspace files and daemon replay metadata, but not the + repository DB. See COVERAGE §10 for the hardening of the earlier §9 audit. +- **SHAPE-CONFIRM comments** mark message fields drafted from the handoff or + CLI shape but not yet verified against source — each names the slice that + finalizes it. + +## Provenance tracking semantics (what this contract must never lose) + +The legacy agent journal protocol and the `atomic agent database-owner` +command that served it are being **fully dismantled** — atomicd becomes the +only storage owner. What carries forward is the provenance tracking core +(types verified against the provenance store source, +`atomic-repository/src/redb_change_store/provenance.rs`): generation fencing +on every mutation; request-ID echo + replay detection; caller-supplied +`now`; frozen-envelope paging with fragment reassembly and strict +continuity checks (paged-only — a complete journal may arrive as one page); +the checkpoint seams (prepare/bind/acknowledge stay SEPARATE RPCs — the +crash-injection story lives on those seams); the additive capability-flag +pattern, generalized into `GetCapabilities`' method matrix. + +From the remote-sandbox extension (HANDOFF §5, Aaron's unmerged branch): +view-scoped identity-bound time-boxed grants; ephemeral fail-closed tokens; +the authorization matrix (session/provenance ownership, on-view checkpoint +checks, local-only admin); stale-view refusals carrying a live skeleton; +materialize path safety (plain relative components, reserved names, +symlink-parent escape refused). + +## §10 recommendations carried (on record from REAC::aaron::2, pending Lee) + +1. inodes → opaque server-issued handles (skeleton-issued, like cursors) +2. sandbox tokens stay ephemeral — restart invalidates; contract behavior +3. V3 change bytes → versioned-opaque (`ChangeBundle.schema`, like the event type URL) +4. compat window → decide before a second release; N-1 default shape +5. database filename → RFC `repository.redb` wins; align with Phase 1 now +6. harness → grpcurl + server reflection (`harness_reflection` feature token) +7. the contract's source of truth lives in libatomic (this crate) — the + reactor's draft tree was absorbed when libatomic was created +8. slice order → HANDOFF §11 (Slice 0 done; Status next; then the provenance port) +9. hidden surface → Aaron confirms no RPC surface outside the two inventories + (this audit's finding: none found beyond them — see COVERAGE.md §5) + +## Next steps + +1. Lee/Aaron/Vince review: COVERAGE.md gaps-and-combines section (§6), + capability vocabulary, package naming. +2. Slice 1 (`RepositoryQueryService.Status`) becomes the first CLI-vs-RPC + parity target. +3. Slice 2 = the ProvenanceService port with failpoint parity. diff --git a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto index 69d9750d..a9c47163 100644 --- a/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto +++ b/libatomic/proto/atomic/services/repository_mutation/repository_mutation_messages.proto @@ -133,6 +133,19 @@ message InsertChangesRequest { Hash single_change = 4; string from_view = 5; string up_to_tag = 6; + // The bare CLI promotion (`atomic insert`), add-only: the handler + // resolves the current view and its parent server-side — the CLI + // holds no repository handle, so source/target/missing and the + // shared-to-shared gate data come back in the response. + PromoteCurrentView promote_current_view = 18; + // Multi-pick (`insert change ...`), add-only: raw change + // references (full base32 or unique prefix), each resolved with the + // insert resolver's semantics and applied with cherry-pick. + ChangeSet change_set = 19; + // A single raw change reference (`insert `), add-only: resolved + // like change_set's entries, applied with the single-insert + // semantics (the dependency closure rides apply_dependencies). + string single_ref = 20; } optional string target_view = 7; // display hint; must agree with target.view bool allow_conflicts = 9; @@ -142,11 +155,43 @@ message InsertChangesRequest { // Dependency closure is the default; false is the `--deps=false` escape // hatch (single-change inserts without closure). optional bool apply_dependencies = 15; + // Preview the plan without inserting (add-only): the handler runs the + // same domain call with its dry-run flag and returns what the local + // preview prints; nothing is applied and the working copy is untouched. + optional bool dry_run = 16; + // The `insert tag --from-view` override (add-only): the source view for + // the up_to_tag arm; absent = the repository's current view (the CLI + // default the arm already carried). + optional string tag_from_view = 17; +} +// The bare promotion's source arm (add-only): everything resolves from +// the repository — source = the current view, target = its parent unless +// the request's target_view overrides it. +message PromoteCurrentView {} +// The multi-pick source (add-only): the raw change references, in the +// order the caller named them. +message ChangeSet { + repeated string changes = 1; } message InsertChangesResponse { repeated ChangeInfo inserted = 1; reserved 2; - repeated ConflictInfo conflicts = 3; // when allowed & occurred + // The post-insert conflict listing (still on disk, per file), rendered + // by the CLI's conflict summary. Present regardless of the outcome + // flag: pre-existing conflicts surface here too. + repeated ConflictInfo conflicts = 3; + // The report fields the local bodies' renders need (add-only): + optional string resolved_source_view = 4; // the arm's resolved source + optional string resolved_target_view = 5; // explicit or default target + optional bool both_views_shared = 6; // the promotion confirm gate + optional Hash resolved_change = 7; // single-ref arms' resolved change + optional Hash new_state = 8; // the target's post-insert state + uint32 skipped_count = 9; // source changes already in the target + // Set when the handler refreshed the working copy (the insert landed + // on the current view): the materialize counts the CLI reports. + optional uint64 files_updated = 10; + optional uint64 directories_created = 11; + bool has_conflicts = 12; // the insert outcome's own flag ResponseMeta meta = 100; } diff --git a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto index a39b129a..4f4ec9fa 100644 --- a/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto +++ b/libatomic/proto/atomic/services/repository_query/repository_query_messages.proto @@ -175,6 +175,11 @@ message PreviewRestoreRequest { RepositoryRef repository = 1; // workspace_id required repeated string paths = 2; // workspace-root-relative bool force = 3; + // The single-file pristine-content arm (add-only): `restore --dry-run + // ` dumps that one file's pristine bytes to stdout. When set, the + // response additionally carries the path's pristine content (absent = + // the graph holds no content for it — the CLI's not-found error). + optional string content_path = 4; } message InsertPreview { oneof source { @@ -197,5 +202,8 @@ message PreviewMutationResponse { // The restore preview's would-untrack list (add-only): Added files // that a restore would untrack but keep on disk. repeated string untracked = 5; + // The single-file restore preview's pristine bytes (add-only): present + // iff the request named a content_path the graph holds content for. + optional bytes pristine_content = 6; } // ConflictInfo lives in atomic/common/change.proto (shared change-domain). diff --git a/libatomic/src/daemon/services.rs b/libatomic/src/daemon/services.rs index 78938c6e..55d7fa02 100644 --- a/libatomic/src/daemon/services.rs +++ b/libatomic/src/daemon/services.rs @@ -560,6 +560,7 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { conflicts: Vec::new(), snapshot: None, untracked: Vec::new(), + pristine_content: None, }) } Some(preview_mutation_request::Operation::Unrecord(preview)) => { @@ -670,6 +671,7 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { conflicts: Vec::new(), snapshot: None, untracked: Vec::new(), + pristine_content: None, }) } None => Err(domain_status( @@ -727,12 +729,23 @@ impl repository_query_service_server::RepositoryQueryService for QueryImpl { _ => {} } } + // The single-file dry-run arm (add-only): the pristine + // bytes `restore --dry-run ` dumps to stdout — the + // SAME domain read the local body makes. Absent content is + // the CLI's not-found error, not a failure here. + let pristine_content = match request.content_path { + Some(ref path) if !path.is_empty() => repo + .get_file_content(std::path::Path::new(path)) + .map_err(repository_error)?, + _ => None, + }; Ok(PreviewMutationResponse { affected_paths: affected, changes: Vec::new(), conflicts: Vec::new(), snapshot: None, untracked, + pristine_content, }) }) .await @@ -875,6 +888,90 @@ fn change_info( } } +/// The hash-only report entry for a change the listing could not load — +/// the local previews print the hash alone in that case, never an error. +fn bare_change_info(hash: &atomic_core::types::Merkle) -> ChangeInfo { + ChangeInfo { + hash: Some(hash_proto(hash)), + message: None, + description: None, + authors: Vec::new(), + recorded_at: None, + dependencies: Vec::new(), + graph_section_count: 0, + semantic_section_count: 0, + content_chunk_count: 0, + has_provenance: false, + has_unhashed: false, + has_signature: false, + } +} + +/// A wire Hash → the domain Merkle, refusing a malformed value. +fn wire_hash(hash: &crate::atomic::Hash) -> Result { + let mut bytes = [0u8; 32]; + if hash.value.len() != 32 { + return Err(domain_status( + ErrorCode::InvalidArgument, + "hash must be 32 bytes", + )); + } + bytes.copy_from_slice(&hash.value); + Ok(atomic_core::types::Merkle(bytes)) +} + +/// Resolve one raw change reference with the local insert bodies' +/// semantics: a full base32 hash parses directly; otherwise a +/// case-insensitive unique prefix over the change store, refusing with +/// the local messages on no match / ambiguity. +fn resolve_change_reference( + repo: &Repository, + reference: &str, +) -> Result { + use atomic_core::types::Base32; + if let Some(hash) = atomic_core::types::Merkle::from_base32(reference.as_bytes()) { + return Ok(hash); + } + if reference.len() >= 2 { + match repo.find_change_by_prefix(reference) { + Ok(Some(hash)) => return Ok(hash), + Ok(None) => {} + Err(atomic_repository::RepositoryError::AmbiguousHash { .. }) => { + return Err(domain_status( + ErrorCode::NotFound, + format!("Ambiguous change hash '{reference}' - matches multiple changes"), + )); + } + Err(error) => return Err(repository_error(error)), + } + } + Err(domain_status( + ErrorCode::NotFound, + format!("Change not found: {reference}"), + )) +} + +/// The single-insert domain calls the local single-insert body makes: +/// the dependency closure by default, `--deps=false` skips it (the +/// plain insert path). +fn single_insert( + repo: &Repository, + hash: &atomic_core::types::Merkle, + target: &str, + apply_dependencies: bool, + allow_conflicts: bool, +) -> Result { + let options = InsertOptions::default() + .apply_deps(apply_dependencies) + .allow_conflict(allow_conflicts) + .view(target.to_string()); + if apply_dependencies { + repo.insert_change_rec(hash, options) + } else { + repo.insert_change(hash, options) + } +} + pub(crate) fn default_ref() -> RepositoryRef { RepositoryRef { authority: String::new(), @@ -1468,51 +1565,243 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI .target_view .clone() .unwrap_or_else(|| handle.current_view()); - // Dependency closure is mandatory on the wire: the CLI's - // --deps=false escape hatch stays client-side. - let (applied, has_conflicts) = match request.source { + let dry_run = request.dry_run.unwrap_or(false); + let apply_dependencies = request.apply_dependencies.unwrap_or(true); + // How the working copy refreshes after the insert — the + // per-arm policy the local bodies apply (the bare + // promotion never rematerializes: its target view is not + // checked out). + #[derive(PartialEq, Clone, Copy)] + enum Refresh { + Skip, + Surgical, + Full, + } + // One report per arm: everything the CLI renders with the + // local bodies' print functions. + struct ArmReport { + applied: Vec, + new_state: atomic_core::types::Merkle, + skipped: usize, + has_conflicts: bool, + resolved_source_view: Option, + resolved_target_view: String, + both_views_shared: Option, + resolved_change: Option, + refresh: Refresh, + } + let cross_view_error = |error: atomic_repository::RepositoryError| -> Status { + domain_status(ErrorCode::ChangeRejected, error.to_string()) + }; + let report = match request.source { + // A resolved single change: the direct domain calls the + // local single-insert body makes (the --deps=false + // escape hatch skips the closure). Some(insert_changes_request::Source::SingleChange(hash)) => { - let mut bytes = [0u8; 32]; - if hash.value.len() != 32 { - return Err(domain_status( - ErrorCode::InvalidArgument, - "hash must be 32 bytes", - )); + let hash = wire_hash(&hash)?; + let outcome = single_insert( + &repo, + &hash, + &target, + apply_dependencies, + request.allow_conflicts, + ) + .map_err(cross_view_error)?; + ArmReport { + applied: outcome.stats.applied_hashes.clone(), + new_state: outcome.new_state, + skipped: 0, + has_conflicts: outcome.has_conflicts, + resolved_source_view: None, + resolved_target_view: target.clone(), + both_views_shared: None, + resolved_change: Some(hash), + refresh: Refresh::Full, + } + } + // A raw single reference (`insert `): resolved + // with the local resolver's semantics, then the same + // single-insert path. + Some(insert_changes_request::Source::SingleRef(reference)) => { + let hash = resolve_change_reference(&repo, &reference)?; + let outcome = single_insert( + &repo, + &hash, + &target, + apply_dependencies, + request.allow_conflicts, + ) + .map_err(cross_view_error)?; + ArmReport { + applied: outcome.stats.applied_hashes.clone(), + new_state: outcome.new_state, + skipped: 0, + has_conflicts: outcome.has_conflicts, + resolved_source_view: None, + resolved_target_view: target.clone(), + both_views_shared: None, + resolved_change: Some(hash), + refresh: Refresh::Full, } - bytes.copy_from_slice(&hash.value); - let options = InsertOptions::default() - .apply_deps(request.apply_dependencies.unwrap_or(true)) - .allow_conflict(request.allow_conflicts) - .view(target.clone()); - let outcome = repo - .insert_change_rec(&atomic_core::types::Merkle(bytes), options) - .map_err(|error| { - domain_status(ErrorCode::ChangeRejected, error.to_string()) - })?; - (outcome.stats.applied_hashes.clone(), outcome.has_conflicts) } Some(insert_changes_request::Source::FromView(source)) => { let options = CrossViewInsertOptions::new(&source, &target) - .with_dependencies(request.apply_dependencies.unwrap_or(true)) - .allow_conflicts(request.allow_conflicts); - let outcome = repo.insert_from_view(options).map_err(|error| { - domain_status(ErrorCode::ChangeRejected, error.to_string()) - })?; - (outcome.applied_hashes.clone(), outcome.has_conflicts) + .with_dependencies(apply_dependencies) + .allow_conflicts(request.allow_conflicts) + .dry_run(dry_run); + let outcome = repo.insert_from_view(options).map_err(cross_view_error)?; + ArmReport { + applied: outcome.applied_hashes.clone(), + new_state: outcome.new_state, + skipped: outcome.skipped_hashes.len(), + has_conflicts: outcome.has_conflicts, + resolved_source_view: Some(source), + resolved_target_view: target.clone(), + both_views_shared: None, + resolved_change: None, + refresh: Refresh::Surgical, + } } Some(insert_changes_request::Source::UpToTag(tag)) => { - // The wire carries only the tag; the source view is - // the repository's current view — exactly the CLI - // default (`insert tag` without --from-view). - let from_view = handle.current_view(); + // The source view: the --from-view override when + // the wire carries one, else the repository's + // current view — exactly the CLI default. + let from_view = request + .tag_from_view + .clone() + .unwrap_or_else(|| handle.current_view()); let options = CrossViewInsertOptions::new(&from_view, &target) .up_to_tag(&tag) - .with_dependencies(request.apply_dependencies.unwrap_or(true)) - .allow_conflicts(request.allow_conflicts); - let outcome = repo.insert_from_view(options).map_err(|error| { - domain_status(ErrorCode::ChangeRejected, error.to_string()) - })?; - (outcome.applied_hashes.clone(), outcome.has_conflicts) + .with_dependencies(apply_dependencies) + .allow_conflicts(request.allow_conflicts) + .dry_run(dry_run); + let outcome = repo.insert_from_view(options).map_err(cross_view_error)?; + ArmReport { + applied: outcome.applied_hashes.clone(), + new_state: outcome.new_state, + skipped: outcome.skipped_hashes.len(), + has_conflicts: outcome.has_conflicts, + resolved_source_view: Some(from_view), + resolved_target_view: target.clone(), + both_views_shared: None, + resolved_change: None, + refresh: Refresh::Surgical, + } + } + // The bare CLI promotion: the current view into its + // parent (or the explicit target override). Everything + // resolves HERE — the CLI holds no repository handle. + Some(insert_changes_request::Source::PromoteCurrentView(_)) => { + let source = handle.current_view(); + let source_info = repo + .get_view_info(&source) + .map_err(|error| domain_status(ErrorCode::View, error.to_string()))?; + let target = match request.target_view.clone() { + Some(override_target) => override_target, + None => source_info.parent_name.clone().ok_or_else(|| { + domain_status( + ErrorCode::View, + format!( + "'{source}' is a root view — there is no parent to \ + insert into.\n Use 'atomic insert from-view ' \ + or pass --to to choose a target." + ), + ) + })?, + }; + if target == source { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!( + "Source and target are the same view ('{source}'). Pass \ + --to to insert somewhere else." + ), + )); + } + // The pre-flight listing: the same domain read the + // local pre-flight makes (the dry-run form's report + // AND the real form's count/confirm inputs). + let missing = repo + .get_missing_changes_between(&source, Some(&target)) + .map_err(cross_view_error)?; + let mut both_views_shared = None; + if !dry_run && !missing.is_empty() { + // The shared→shared confirmation gate the CLI + // prompts for client-side. + let target_info = repo.get_view_info(&target).map_err(|error| { + domain_status(ErrorCode::View, error.to_string()) + })?; + both_views_shared = Some( + source_info.scope.is_shared() && target_info.scope.is_shared(), + ); + } + if dry_run { + // The preview: list what would move; insert + // nothing. (The local dry-run branch lists the + // missing set from this same read.) + ArmReport { + applied: missing, + new_state: atomic_core::types::Merkle::ZERO, + skipped: 0, + has_conflicts: false, + resolved_source_view: Some(source), + resolved_target_view: target, + both_views_shared: None, + resolved_change: None, + refresh: Refresh::Skip, + } + } else { + // The insert itself: the same domain call the + // local body makes after its confirmation. + let options = CrossViewInsertOptions::new(&source, &target) + .with_dependencies(apply_dependencies) + .allow_conflicts(request.allow_conflicts); + let outcome = + repo.insert_from_view(options).map_err(cross_view_error)?; + ArmReport { + applied: outcome.applied_hashes.clone(), + new_state: outcome.new_state, + skipped: outcome.skipped_hashes.len(), + has_conflicts: outcome.has_conflicts, + resolved_source_view: Some(source), + resolved_target_view: target, + both_views_shared, + resolved_change: None, + // The promotion's target is never the + // checked-out view: no rematerialization. + refresh: Refresh::Skip, + } + } + } + // Multi-pick: resolve each reference with the local + // resolver's semantics, then the cherry-pick call the + // local multi-insert body makes. + Some(insert_changes_request::Source::ChangeSet(set)) => { + let mut hashes = Vec::with_capacity(set.changes.len()); + for reference in &set.changes { + hashes.push(resolve_change_reference(&repo, reference)?); + } + let outcome = if dry_run { + let options = CrossViewInsertOptions::new("", &target) + .only_changes(hashes.clone()) + .with_dependencies(true) + .dry_run(true); + repo.insert_from_view(options).map_err(cross_view_error)? + } else { + repo.cherry_pick(&hashes, "", Some(&target)) + .map_err(cross_view_error)? + }; + ArmReport { + applied: outcome.applied_hashes.clone(), + new_state: outcome.new_state, + skipped: outcome.skipped_hashes.len(), + has_conflicts: outcome.has_conflicts, + resolved_source_view: None, + resolved_target_view: target.clone(), + both_views_shared: None, + resolved_change: None, + refresh: Refresh::Full, + } } None => { return Err(domain_status( @@ -1521,62 +1810,86 @@ impl repository_mutation_service_server::RepositoryMutationService for MutationI )) } }; - // Render the inserted changes; surface conflicts when - // they occurred (never swallowed). - let mut inserted = Vec::with_capacity(applied.len()); + // Render the inserted changes (tolerant of an unloadable + // change — the local listings print the hash alone) and + // collect the touched paths for the surgical refresh. + let mut inserted = Vec::with_capacity(report.applied.len()); let mut affected_paths: std::collections::HashSet = std::collections::HashSet::new(); - for hash in &applied { - let change = repo - .load_change(hash) - .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; - // Collect the paths the inserted changes touch so the - // working copy can be refreshed surgically (below). - for op in change.hunks() { - if let Some(path) = op.path() { - affected_paths.insert(path.to_string()); + for hash in &report.applied { + match repo.load_change(hash) { + Ok(change) => { + for op in change.hunks() { + if let Some(path) = op.path() { + affected_paths.insert(path.to_string()); + } + } + inserted.push(change_info(&change, hash)); } + Err(_) => inserted.push(bare_change_info(hash)), } - inserted.push(change_info(&change, hash)); } // Refresh the working copy when the insert landed on the // repository's CURRENT view: the graph insert alone never // touches the working tree, and a CLI standing on the // target view expects the inserted files on disk (the - // local insert's materialization, now owned here so both - // transports behave identically). Affected paths only; - // a full materialize is the fallback when the inserted - // changes carry no path info. - if target == handle.current_view() && !applied.is_empty() { - if affected_paths.is_empty() { - repo.materialize().map_err(|error| { - domain_status(ErrorCode::Materialize, error.to_string()) - })?; - } else { - repo.materialize_paths(affected_paths).map_err(|error| { - domain_status(ErrorCode::Materialize, error.to_string()) - })?; - } + // local bodies' materialization, owned here so both + // transports behave identically). Per arm: view/tag inserts + // refresh the touched paths (full as the fallback when the + // changes carry no path info); single- and multi-pick + // inserts rematerialize the whole view; the bare promotion + // never does (its target is not checked out). + let mut files_updated = None; + let mut directories_created = None; + if !dry_run + && !report.applied.is_empty() + && report.refresh != Refresh::Skip + && target == handle.current_view() + { + let materialized = + if report.refresh == Refresh::Full || affected_paths.is_empty() { + repo.materialize().map_err(|error| { + domain_status(ErrorCode::Materialize, error.to_string()) + })? + } else { + repo.materialize_paths(affected_paths).map_err(|error| { + domain_status(ErrorCode::Materialize, error.to_string()) + })? + }; + files_updated = Some(materialized.files_written as u64); + directories_created = Some(materialized.directories_created as u64); } - let conflicts = if has_conflicts { - repo.list_conflicts() - .map_err(repository_error)? - .into_iter() - .map(|(path, _)| ConflictInfo { - path, + // The still-on-disk conflict listing (per record, file + // order preserved): what the CLI's conflict summary prints. + // A read failure silences the listing — the local summary + // returns silently too. + let conflicts = repo + .list_conflicts() + .unwrap_or_default() + .into_iter() + .flat_map(|(path, records)| { + records.into_iter().map(move |record| ConflictInfo { + path: path.clone(), view: None, base: None, - kind: None, - line: None, - sides: Vec::new(), + kind: Some(format!("{:?}", record.kind).to_lowercase()), + line: record.line.map(|line| line as u64), + sides: record.sides, }) - .collect() - } else { - Vec::new() - }; + }) + .collect(); Ok(InsertChangesResponse { inserted, conflicts, + resolved_source_view: report.resolved_source_view, + resolved_target_view: Some(report.resolved_target_view), + both_views_shared: report.both_views_shared, + resolved_change: report.resolved_change.as_ref().map(hash_proto), + new_state: Some(hash_proto(&report.new_state)), + skipped_count: report.skipped as u32, + files_updated, + directories_created, + has_conflicts: report.has_conflicts, meta: None, }) }) diff --git a/libatomic/src/lib.rs b/libatomic/src/lib.rs index efdffd26..5aebd6c3 100644 --- a/libatomic/src/lib.rs +++ b/libatomic/src/lib.rs @@ -1,25 +1,27 @@ -//! libatomic — the canonical Atomic contract and its reference -//! implementation. +//! libatomic — the one Atomic service-layer library. //! -//! This crate owns both halves of the Atomic service layer: +//! This crate owns BOTH halves of the service layer: //! -//! * the **contract** — the canonical protobuf sources under `proto/` -//! (package `atomic`), compiled by `build.rs` with protox (a pure-Rust -//! protoc replacement — no system protoc) and tonic-build into the -//! [`atomic`] module (aliased as [`proto`]), plus the descriptor -//! contract gate (`proto/check_contract.py` — add-only; scope, caller, -//! effect and capability declarations on every method; read/write -//! discipline; retired fields; generation/snapshot fencing). -//! * the **handlers** — the reference implementation of that contract -//! under [`daemon`]: the repository registry and per-repository -//! serialization gates, the tonic service impls and their plain inner -//! functions, the provenance journal core and sink, and the -//! domain/protobuf converters. +//! * the **contract** — the canonical protobuf sources under `proto/`, +//! the tonic/prost codegen (`build.rs`, protox — no system protoc), +//! and the descriptor contract gate (`proto/check_contract.py`, +//! add-only at 97 RPCs). The atomic repo is the contract's single +//! source of truth; there is no vendored copy synced anywhere. +//! The generated module is [`atomic`] (also re-exported as [`proto`]), +//! and `atomic-client` re-exports it so `atomic_client::proto` keeps +//! its import path. +//! * the **handlers** — the entire service-layer implementation under +//! [`daemon`]: the repository registry and per-repository gates +//! ([`daemon::state`]), the tonic service impls and their plain inner +//! functions (`daemon::services*`), the provenance journal core and +//! sink, and the proto/domain converters. ONE implementation: the +//! `atomicd` transport (the reactor) serves these handlers over the +//! socket, and the CLI's local service mode calls the very same +//! handlers in-process — never a second copy. //! -//! The handlers are transport-neutral: a server process wires them onto -//! any transport (the generated servers exist for exactly that), and any -//! tool that speaks Atomic can call the same handlers in-process instead -//! of over a wire — one implementation everywhere. +//! The transport (socket bind, single-instance lock, tonic `Server` +//! wiring, the `atomicd` binary) deliberately does NOT live here; see +//! the reactor crate for the shell that consumes this library. pub mod atomic { // The generated module is machine-written; lints do not apply to it. @@ -27,7 +29,8 @@ pub mod atomic { tonic::include_proto!("atomic"); } -/// The generated contract module under its transport-neutral name. +/// The generated contract module under its transport-neutral name +/// (`atomic_client::proto` re-exports this path). pub use crate::atomic as proto; pub mod daemon; From f8f1ee1870ba106af4011816211b1bd6d8921e7c Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 6 Oct 2026 14:17:27 -0500 Subject: [PATCH 11/23] feat(contract): wire the tag/provenance/remote/push-pull/sandbox/vault/triage-candidates/session and KG surfaces (add-only) Tags (TagService handlers: create/delete/list/show with the full record on the wire), provenance trace/show (ExportProvenance with the resolved change + per-graph trace bundles), the remote registry (ListRemotes + ManageRemotes with the add-default flag), push/pull (the network sync runs handler-side with the client's auth headers; the structured reports + stage errors carry the local renders), the sandbox trees (create/stage/seal), the vault surfaces (InitVault, ExportVault, DeleteVaultEntity, LinkVaultEntities, goal start/stop/resume, the memory write, the tool-result previews), triage candidates (the view resolution + candidate-set domain call), session fork/rebuild, and the KG extras (graph/callers/plan/ask/embed over QueryGraph + MaintainKnowledgeGraph, plus the CandidateSet Deserialize derives). New RPC: ExplainTurns (fully annotated). Checker PASS (98 RPCs). --- atomic-repository/src/repository/triage.rs | 8 +- libatomic/Cargo.toml | 5 +- .../knowledge/knowledge_messages.proto | 30 + .../provenance/provenance_messages.proto | 88 +- .../provenance/provenance_service.proto | 21 + .../services/sandbox/sandbox_messages.proto | 31 + .../atomic/services/sync/sync_messages.proto | 172 +- .../atomic/services/tag/tag_messages.proto | 48 +- .../services/triage/triage_messages.proto | 11 + .../services/vault/vault_messages.proto | 73 +- libatomic/src/daemon/mod.rs | 8 + libatomic/src/daemon/services_agent.rs | 1880 ++++++++++++++--- libatomic/src/daemon/services_query.rs | 29 + libatomic/src/daemon/services_sandbox.rs | 241 +++ libatomic/src/daemon/services_sync.rs | 150 ++ libatomic/src/daemon/services_sync_remote.rs | 1730 +++++++++++++++ libatomic/src/daemon/services_tag.rs | 222 ++ libatomic/src/daemon/services_triage.rs | 134 ++ 18 files changed, 4596 insertions(+), 285 deletions(-) create mode 100644 libatomic/src/daemon/services_sandbox.rs create mode 100644 libatomic/src/daemon/services_sync.rs create mode 100644 libatomic/src/daemon/services_sync_remote.rs create mode 100644 libatomic/src/daemon/services_tag.rs create mode 100644 libatomic/src/daemon/services_triage.rs diff --git a/atomic-repository/src/repository/triage.rs b/atomic-repository/src/repository/triage.rs index 6e36da1f..0770b9cb 100644 --- a/atomic-repository/src/repository/triage.rs +++ b/atomic-repository/src/repository/triage.rs @@ -22,12 +22,12 @@ use super::*; use atomic_core::pristine::ontology::edge_kind; -use serde::Serialize; +use serde::{Deserialize, Serialize}; /// Whether a closure addition is covered by an intent in the knowledge graph. /// /// Serialized as a lowercase string (`"covered"`, `"uncovered"`, `"unknown"`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Coverage { /// At least one modified file is touched by a task node. @@ -41,7 +41,7 @@ pub enum Coverage { } /// A closure addition flagged as baggage (uncovered or coverage-unknown). -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BaggageEntry { /// The change hash (base32). pub change: String, @@ -52,7 +52,7 @@ pub struct BaggageEntry { } /// The triage candidate set for a `feature` view relative to a `target` view. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CandidateSet { /// The feature (source) view name. pub feature: String, diff --git a/libatomic/Cargo.toml b/libatomic/Cargo.toml index cf2a2d27..c553692c 100644 --- a/libatomic/Cargo.toml +++ b/libatomic/Cargo.toml @@ -14,8 +14,11 @@ atomic-agent = { workspace = true } atomic-canonical = { workspace = true } atomic-core = { workspace = true } atomic-identity = { workspace = true } +atomic-objects = { workspace = true } +atomic-remote = { workspace = true } atomic-repository = { workspace = true } atomic-semantic = { workspace = true } +bytes = { workspace = true } # gRPC: the generated contract (this crate owns the codegen). tonic = { workspace = true } @@ -25,7 +28,7 @@ prost-types = { workspace = true } # Async: the handlers are async and offload redb work to the blocking pool. tokio = { workspace = true } tokio-stream = { workspace = true } -futures-core = { workspace = true } +futures-core = "0.3" # Domain support. serde_json = { workspace = true } diff --git a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto index 62a836ab..c9619748 100644 --- a/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto +++ b/libatomic/proto/atomic/services/knowledge/knowledge_messages.proto @@ -65,6 +65,16 @@ message CallersQuery { message GraphSliceQuery { repeated string node_ids = 1; optional int32 max_depth = 2; + // The search-seeded graph build (add-only): `query graph`'s full knob + // surface — the seed query, the seed limit, the neighbor depth, the + // comma-separated kinds filter, the per-seed change cap, and the node + // ceiling (0 = the format default: 200 DOT / 5000 HTML). + optional string query = 3; + optional uint32 seed_limit = 4; + optional uint32 depth = 5; + optional string kinds = 6; + optional uint32 changes_per_seed = 7; + optional uint32 max_nodes = 8; } message PlanExecutionQuery { bytes plan_json = 1; // structured multi-step plan (parse_plan shape) @@ -72,6 +82,11 @@ message PlanExecutionQuery { message RagAskQuery { string question = 1; uint32 top_k = 2; + // The agentic loop knobs (add-only): the turn budget and the verbose + // tool-call trace (rendered client-side after the fact over the wire — + // the unary RPC cannot stream them live). + optional uint32 max_turns = 3; + optional bool verbose = 4; } message QueryGraphResponse { @@ -81,6 +96,16 @@ message QueryGraphResponse { optional string answer = 4; // rag_ask optional bytes plan_result = 5; // plan step outputs (JSON) ViewSnapshot snapshot = 6; + // The pre-cap node count (add-only): the `query graph` cap note's "N + // nodes collected" figure. + optional uint32 graph_collected = 7; + // Whether the neighbors subgraph holds ANY calls edge (add-only): the + // `query callers` hint's entry-point distinction. + bool calls_present = 8; + // The full agentic ask result (add-only; schema "atomic.query.ask.v1" = + // the domain AgentResult JSON) plus its elapsed time. + optional VersionedBytes ask_result = 9; + optional uint64 ask_elapsed_ms = 10; } message KGNode { @@ -125,4 +150,9 @@ message MaintainKnowledgeGraphRequest { message MaintainKnowledgeGraphResponse { uint64 processed = 1; ResponseMeta meta = 100; + // The embed report (add-only): the resolved provider + dimensions and, + // in the targeted form, the path (processed == 0 = content unchanged). + optional string provider = 2; + optional uint32 dimensions = 3; + optional string path = 4; } diff --git a/libatomic/proto/atomic/services/provenance/provenance_messages.proto b/libatomic/proto/atomic/services/provenance/provenance_messages.proto index d7c24080..d730cf57 100644 --- a/libatomic/proto/atomic/services/provenance/provenance_messages.proto +++ b/libatomic/proto/atomic/services/provenance/provenance_messages.proto @@ -307,17 +307,32 @@ message ForkSessionRequest { RequestMeta meta = 2; string session_id = 3; string forked_session_id = 4; + // The turn boundary (add-only): inherits turns 0..=N — the local + // `session fork --at-turn` semantics. + optional uint32 at_turn = 5; } message ForkSessionResponse { string session_id = 1; ResponseMeta meta = 100; + // The fork report (add-only): both manifest hashes, so the client + // prints the local fork report. + optional Hash parent_manifest = 2; + optional Hash child_manifest = 3; + optional uint32 fork_turn = 4; } message RebuildSessionIndexRequest { RepositoryRef repository = 1; RequestMeta meta = 2; } -message RebuildSessionIndexResponse { ResponseMeta meta = 100; } +message RebuildSessionIndexResponse { + ResponseMeta meta = 100; + // The rebuild report (add-only): the indexed/present/corrupt counts the + // local body prints. + uint64 indexed = 1; + uint64 already_present = 2; + uint64 corrupt = 3; +} // ————— provenance projection ————— @@ -335,10 +350,40 @@ message ExportProvenanceRequest { reserved 2, 4; // mutation header and daemon-held signer selection removed Hash change = 3; ViewRef view = 5; + // The person DID that signs/anchors the projection (add-only): resolved + // CLIENT-SIDE from the identity store (a config file read, not redb) and + // carried on the wire — the handler never reads the caller's identities. + optional string person_did = 6; + // The raw `provenance trace/show` target (add-only): a bare base32 hash, + // a hash prefix, or `urn:atomic:change:`. When set, the handler + // resolves it with the local resolver's exact semantics (prefix match + // against the whole change store, URN strip, ambiguity refusal) and + // ignores `change`. + optional string target = 7; } message ExportProvenanceResponse { bytes prov_jsonld = 1; // W3C PROV projection reserved 2; // caller signs; no daemon private-key operation under a read cap + // The trace inputs (add-only): the resolved change hash plus one bundle per + // explaining graph (most-recent first) — the domain ProvenanceGraph JSON, + // the mapped ProvActivityInput JSON (schema "atomic.prov.input.v1"), the + // graph's own hash, and the pre-walked prior-turn activity URNs the + // human chain renders. + optional Hash resolved_change = 3; + repeated ProvenanceTraceBundle graphs = 4; +} + +// One explaining provenance graph, wire-carried for the client renders +// (add-only; schema "atomic.prov.graph.v1" = the domain ProvenanceGraph +// JSON, schema "atomic.prov.input.v1" = the ProvActivityInput JSON). +message ProvenanceTraceBundle { + optional Hash graph_hash = 1; // the stored graph's content hash + VersionedBytes graph = 2; // atomic.prov.graph.v1 + VersionedBytes input = 3; // atomic.prov.input.v1 + repeated string prior_activities = 4; // activity URNs of the `previous` + // chain, oldest first; empty entry + // = "(graph unavailable)" + bool chain_truncated = 5; // the 64-hop walk cap was hit } // — typed turn dispatch (transition seam; see provenance_service.proto) — @@ -370,6 +415,47 @@ message DispatchTurnEventResponse { ResponseMeta meta = 100; } +// — typed agent-turn reasoning (`atomic agent explain`; add-only) — + +message ExplainTurnsRequest { + RepositoryRef repository = 1; + RequestMeta meta = 2; + string session_id = 3; + optional uint32 turn = 4; // 1-indexed; absent = most recent + bool all = 5; // every turn in the session + bool save = 6; // write the reasoning into the change + string model = 7; // the Claude CLI model + // The session record's client-resolved fields (the session store is a + // working-copy file read, not redb — resolved client-side, add-only). + string view_name = 8; + optional string transcript_path = 9; + repeated string files_touched = 10; + string agent_name = 11; +} +message ExplainTurnsResponse { + repeated ExplainTurnResult turns = 1; + // The session has no recorded turns on its view (the early-out line). + bool session_has_no_turns = 2; + ResponseMeta meta = 100; +} +message ExplainTurnResult { + uint32 turn_number = 1; // 1-indexed + string message = 2; // the change's header message + bool no_transcript = 3; // the skip branch + // The generated reasoning (schema "atomic.agent.reasoning.v1" — the + // domain TurnReasoning JSON). + optional VersionedBytes reasoning = 4; + optional string generation_error = 5; + uint32 anchored = 6; // graph-anchored code learnings count + bool saved = 7; // the reasoning landed in the change + optional string save_error = 8; + // The learnings context-file save outcome (its Display text). + optional string context_saved = 9; + optional string context_save_error = 10; + // The explained change's hash (the save line's 12-char reference). + optional Hash change = 11; +} + // — recent session ledgers (the `session show` no-arg listing) — message ListSessionsRequest { diff --git a/libatomic/proto/atomic/services/provenance/provenance_service.proto b/libatomic/proto/atomic/services/provenance/provenance_service.proto index 92051ee3..ab3e2985 100644 --- a/libatomic/proto/atomic/services/provenance/provenance_service.proto +++ b/libatomic/proto/atomic/services/provenance/provenance_service.proto @@ -160,6 +160,27 @@ rpc DispatchTurnEvent(DispatchTurnEventRequest) returns (DispatchTurnEventRespon // Recent session ledgers, newest activity first (`atomic session show` with // no id / the bare `session` listing). Add-only sibling of GetSession. + // `atomic agent explain` — the typed-turn reasoning surface: the handler + // loads the session's recorded turns, extracts the condensed transcripts + // (the change's unhashed section, with the session transcript file as + // fallback), generates the reasoning via the Claude CLI (external IO), + // anchors code learnings to the CRDT graph, and — with `save` — writes + // the reasoning back into the change's unhashed section and appends + // learnings to the agent context file. The session record resolves + // CLIENT-SIDE (the session store is a working-copy file read, not redb); + // its fields ride the request. + rpc ExplainTurns(ExplainTurnsRequest) returns (ExplainTurnsResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "repository.read"; + option (atomic.required_capability) = "repository.write"; + option (atomic.required_capability) = "working-copy.write"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_READ; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + option (atomic.effect) = RPC_EFFECT_WORKSPACE_WRITE; + option (atomic.effect) = RPC_EFFECT_EXTERNAL_IO; // the Claude CLI subprocess + } + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse) { option (atomic.execution_scope) = EXECUTION_SCOPE_BOTH; option (atomic.required_capability) = "provenance.read"; diff --git a/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto b/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto index 67a1ef55..3f2db3dd 100644 --- a/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto +++ b/libatomic/proto/atomic/services/sandbox/sandbox_messages.proto @@ -139,10 +139,24 @@ message CreateSandboxTreeRequest { RequestMeta meta = 2; string name = 3; optional string from_view = 4; + // `atomic sandbox create`'s remaining surface (add-only): the destination + // (defaulting server-side to `/-sandboxes/`) + // and the explicit --view (mutually exclusive with from_view, validated + // client-side by clap). + optional string dest = 5; + optional string view = 6; } message CreateSandboxTreeResponse { string sandbox_path = 1; // local working-tree path (LOCAL scope) ResponseMeta meta = 100; + // The create report the local body prints (add-only): the resolved + // destination, the recorded view, whether a new draft was created, the + // cloned file count, and the canonical repo root (the Graph line). + string resolved_dest = 2; + string view = 3; + bool created_draft = 4; + uint32 files_cloned = 5; + string repo_root = 6; } message StageSandboxImageRequest { @@ -150,20 +164,37 @@ message StageSandboxImageRequest { RequestMeta meta = 2; string name = 3; // SHAPE-CONFIRM (Slice 3): OCI staging options from commands/sandbox.rs. + // The CLI's full flag surface (add-only). + string base_view = 4; + string out_dir = 5; } message StageSandboxImageResponse { string staged_ref = 1; ResponseMeta meta = 100; + // The staged-image report the local body prints (add-only). + string image_dir = 2; + string manifest_digest = 3; + string base_diff_id = 4; + uint64 delta_files = 5; } message SealSandboxImageRequest { RepositoryRef repository = 1; RequestMeta meta = 2; string name = 3; + // The CLI's full flag surface (add-only): the output directory, the + // entrypoint argv, and the image env assignments. + string out_dir = 4; + repeated string entrypoint = 5; + repeated string env = 6; } message SealSandboxImageResponse { string sealed_ref = 1; ResponseMeta meta = 100; + // The sealed-image report the local body prints (add-only). + string image_dir = 2; + string manifest_digest = 3; + uint64 files = 4; } // ————— data ops ————— diff --git a/libatomic/proto/atomic/services/sync/sync_messages.proto b/libatomic/proto/atomic/services/sync/sync_messages.proto index 791b0a1f..709a6f61 100644 --- a/libatomic/proto/atomic/services/sync/sync_messages.proto +++ b/libatomic/proto/atomic/services/sync/sync_messages.proto @@ -29,11 +29,103 @@ message PushChangesRequest { string remote = 3; repeated string views = 4; // empty = current view manifest chain optional ExpectedState expected = 5; // CAS-on-prev precondition + // `atomic push`'s flag surface (add-only). The network sync IS domain — + // the handler performs it end to end; the client resolves auth + // CLIENT-SIDE (identity store + config are file reads, not redb) and + // ships the headers, so the handler never touches the caller's + // identities. `remote_url` carries the client-resolved remote URL (the + // ListRemotes read); `remote` stays the configured name for the report. + optional string to_view = 6; + optional string from_view = 7; + bool dry_run = 8; + bool force = 9; + bool all = 10; + bool insecure = 11; + uint64 timeout_secs = 12; + map auth_headers = 13; + optional string remote_url = 14; } message PushChangesResponse { uint64 pushed_changes = 1; repeated string manifests = 2; ResponseMeta meta = 100; + // The push report (add-only): the structured per-view sync outcome the + // CLI renders with the local push body's exact print functions — one + // entry per view in the root→leaf chain. + optional PushReport report = 3; + // The plan-phase conflict (add-only): the handler performed NO mutation + // (the conflict aborts before any store/declare), so it rides the + // response instead of an error, letting the client render the local + // divergence block and exit with the local error. + optional PushConflict conflict = 4; +} + +// The push report (add-only): every line the local push body prints, +// structured so the CLI renders. +message PushReport { + string remote_name = 1; + string remote_url = 2; + repeated PushViewReport views = 3; // the chain, root → leaf + uint64 total_stored = 4; + uint64 views_declared = 5; + repeated PushSidecar attestations = 6; + repeated PushSidecar provenance = 7; + repeated PushTag tags = 8; +} + +// One view's sync outcome (add-only). +message PushViewReport { + string remote_name = 1; // the view's name on the remote + string scope = 2; // manifest scope label + optional string parent = 3; + bool exists_on_remote = 4; + uint64 remote_change_count = 5; + bool noop = 6; + bool shrink = 7; // the skip-and-warn branch + bool forced = 8; // the diverged-anyway warning + repeated PushChange to_store = 9; + uint64 log_count = 10; + bool declared = 11; +} + +// One change the handler stored (add-only): hash + message for the +// per-change report lines. +message PushChange { + Hash hash = 1; + optional string message = 2; +} + +// One uploaded sidecar (add-only): an attestation or a provenance graph. +message PushSidecar { + Hash hash = 1; + optional string cost = 2; // attestation cost_display + uint64 covered = 3; // attestation change_count + uint64 nodes = 4; // provenance node_count + uint64 explained = 5; // provenance change_count +} + +// One uploaded tag (add-only). +message PushTag { + Hash hash = 1; // the tag content hash + string name = 2; + string kind = 3; // the TagKind label +} + +// The plan-phase push conflict (add-only): which view, which flavor, the +// conflict's own description, and the two manifests' render states. +message PushConflict { + string view = 1; + bool identity_mismatch = 2; // false = diverged + string description = 3; // the conflict's Display text + optional ManifestState local = 4; + optional ManifestState remote = 5; +} + +// One manifest's render state for the divergence block (add-only). +message ManifestState { + string view = 1; // the view name to render + string state = 2; // the merkle state's base32 + uint64 change_count = 3; } message PullChangesRequest { @@ -41,14 +133,89 @@ message PullChangesRequest { RequestMeta meta = 2; string remote = 3; bool import_sidecars = 4; // provenance/attestation ingestion on - // apply (default true) + // apply (default true) // Imports update repository/view state only. Host materialization is a // separately authorized working-copy operation, not a hidden pull effect. + // `atomic pull`'s flag surface (add-only) — same client-side auth model + // as PushChanges (headers resolved from the identity store, never redb). + optional string to_view = 5; + optional string from_view = 6; + bool dry_run = 7; + bool all = 8; + bool insecure = 9; + uint64 timeout_secs = 10; + optional string identity = 11; // informational; auth rides headers + bool download_only = 12; + map auth_headers = 13; + optional string remote_url = 14; } message PullChangesResponse { uint64 applied_changes = 1; repeated string updated_views = 2; ResponseMeta meta = 100; + // The pull report (add-only): the structured sync outcome the CLI + // renders with the local pull body's exact print functions. + optional PullReport report = 3; +} + +// The pull report (add-only): every line the local pull body prints. +message PullReport { + string remote_name = 1; + string remote_url = 2; + string remote_view = 3; + string local_view = 4; + uint64 local_view_changes = 5; // the local view's log length + uint64 graph_objects = 6; // registered change hashes (the haves) + uint64 remote_changes = 7; // the remote view's log length + optional string remote_state = 8; // the remote tip merkle base32 (""=empty) + repeated string local_only = 9; // diverged-history warning hashes (short) + repeated PullDownload downloads = 10; // per-change save results + uint64 changes_downloaded = 11; + uint64 bytes_transferred = 12; + bool applied = 13; // any view closure advanced + uint64 applied_changes = 14; // replayed + applied count + repeated string apply_errors = 15; + repeated PullSidecar sidecars = 16; // imported provenance/attestation stats + bool set_id_verified = 17; + optional string set_id_warning = 18; // the mismatch/local-error warning text + bool materialized = 19; // the working copy was updated + uint64 files_written = 20; + optional string materialize_error = 21; + repeated PullTag tags = 22; // downloaded tags + repeated PullWarning warnings = 23; // the local body's print_warning lines + optional string local_tip = 24; // the local view's tip hash base32 + bool vault_bootstrapped = 25; // "Vault initialized from pulled content" + uint64 reconciled_views = 26; // the reconciled closure count + bool download_only = 27; + uint64 changes_failed = 28; +} + +// One downloaded change (add-only): hash, message, and save outcome. +message PullDownload { + Hash hash = 1; + optional string message = 2; + bool ok = 3; + optional string error = 4; + uint64 index = 5; // the (i/total) position + uint64 total = 6; +} + +// One imported sidecar's report line (add-only). +message PullSidecar { + string kind = 1; // "provenance" | "attestation" + uint64 count = 2; + optional string detail = 3; +} + +// One downloaded tag (add-only). +message PullTag { + string name = 1; + string kind = 2; +} + +// A warning line the local body prints via print_warning (add-only). +message PullWarning { + string text = 1; } enum RemoteAction { @@ -74,6 +241,9 @@ message ManageRemotesRequest { optional string name = 4; optional string url = 5; optional string new_name = 6; + // ADD + set default in ONE transaction — `remote add --default` + // (add-only; the local body's add_remote_default domain call). + bool set_default = 7; } message ManageRemotesResponse { repeated RemoteInfo remotes = 1; diff --git a/libatomic/proto/atomic/services/tag/tag_messages.proto b/libatomic/proto/atomic/services/tag/tag_messages.proto index 6243ef34..a71ca662 100644 --- a/libatomic/proto/atomic/services/tag/tag_messages.proto +++ b/libatomic/proto/atomic/services/tag/tag_messages.proto @@ -11,6 +11,7 @@ package atomic; import "google/protobuf/timestamp.proto"; import "atomic/common/common.proto"; +import "atomic/common/change.proto"; @@ -19,12 +20,20 @@ message CreateTagRequest { RequestMeta meta = 2; string name = 3; optional Hash state = 4; // merkle state to snapshot (default: - // explicitly named view's own Merkle) + // explicitly named view's own Merkle) optional string message = 5; ViewRef view = 6; ViewSnapshot expected_snapshot = 7; + // Overwrite an existing tag of this name after deleting it first — + // `atomic tag create --force` (add-only; the local body's exact flow). + bool force = 8; +} +message CreateTagResponse { + // The created tag (add-only) — the client renders the local create + // report ("Created [annotated] tag: …") from the wire-carried record. + TagInfo tag = 1; + ResponseMeta meta = 100; } -message CreateTagResponse { ResponseMeta meta = 100; } message DeleteTagRequest { RepositoryRef repository = 1; @@ -32,10 +41,22 @@ message DeleteTagRequest { string name = 3; Hash expected_state = 4; // required; refuse replacing/deleting a raced tag } -message DeleteTagResponse { ResponseMeta meta = 100; } +message DeleteTagResponse { + // Whether the named tag existed and was deleted (add-only) — the client + // renders the local delete report ("Deleted tag" / the not-exist hint). + bool deleted = 1; + ResponseMeta meta = 100; +} message ListTagsRequest { RepositoryRef repository = 1; + // The local listing's filters (add-only), applied with the same + // semantics the local body applies over the domain listing: a view + // filter switches to the view's tag list, the pattern filter is the + // star-stripped substring match, and annotated-only keeps annotated tags. + optional string view = 2; + optional string pattern = 3; + bool annotated_only = 4; } message ListTagsResponse { repeated TagInfo tags = 1; @@ -47,6 +68,10 @@ message GetTagRequest { } message GetTagResponse { TagInfo tag = 1; + // Per-record presence + view membership for ReviewGate metadata + // (add-only): the handler computes each record hash's presence and the + // views carrying it with the same domain reads the local render performs. + repeated TagRecordPresence records = 2; } message TagInfo { @@ -54,4 +79,21 @@ message TagInfo { Hash state = 2; optional string message = 3; optional google.protobuf.Timestamp created_at = 4; + // The full domain tag record (add-only) — the fields the local list/show + // renders: the owning view, the log sequence, the tag kind label, the + // annotation author, and the extensible metadata JSON text. + string view = 5; + uint64 sequence = 6; + string kind = 7; + optional Author author = 8; + optional string metadata = 9; +} + +// One ReviewGate metadata record's resolved presence (add-only). +message TagRecordPresence { + string hash = 1; // the record's base32 change hash + bool present = 2; // the change exists in the store + repeated string views = 3; // the views carrying the change + bool parseable = 4; // the raw text parsed as a base32 hash (the + // local render prints unparseable text raw) } diff --git a/libatomic/proto/atomic/services/triage/triage_messages.proto b/libatomic/proto/atomic/services/triage/triage_messages.proto index 5aa8bcc1..bff191bb 100644 --- a/libatomic/proto/atomic/services/triage/triage_messages.proto +++ b/libatomic/proto/atomic/services/triage/triage_messages.proto @@ -22,6 +22,13 @@ message ListTriageCandidatesRequest { } message ListTriageCandidatesResponse { repeated TriageCandidate candidates = 1; + // The CLI's candidate-set domain serialization (add-only; schema + // "atomic.triage.candidates.v1" = the repository's CandidateSet JSON) + // so the client renders the exact `--json` document the local body + // emits, and the resolved (feature, target) pair for the header lines. + optional VersionedBytes candidate_set = 2; + string feature = 3; + string target = 4; } message TriageCandidate { Hash change = 1; @@ -34,6 +41,10 @@ message GenerateTriageReviewRequest { RepositoryRef repository = 1; string from_view = 2; string to_view = 3; + // The full canonical report (add-only; schema "atomic.triage.report.v1") + // — the walkthrough/dashboard/JSON renders run client-side over the + // wire-carried domain report. + optional VersionedBytes report = 4; } message GenerateTriageReviewResponse { repeated TriageLayer layers = 1; // ordered semantic walkthrough layers diff --git a/libatomic/proto/atomic/services/vault/vault_messages.proto b/libatomic/proto/atomic/services/vault/vault_messages.proto index 152e57ae..f6adfda3 100644 --- a/libatomic/proto/atomic/services/vault/vault_messages.proto +++ b/libatomic/proto/atomic/services/vault/vault_messages.proto @@ -64,7 +64,14 @@ message InitVaultRequest { ViewRef view = 20; ViewSnapshot expected_snapshot = 21; } -message InitVaultResponse { ResponseMeta meta = 100; } +message InitVaultResponse { + ResponseMeta meta = 100; + // The init report (add-only): whether the vault already existed, the + // initialized vault directory, and whether the defaults recorded. + bool already_initialized = 1; + optional string vault_dir = 2; + bool recorded = 3; +} message SyncVaultRequest { RepositoryRef repository = 1; @@ -121,6 +128,10 @@ message ListVaultEntriesRequest { // Raw status filter (add-only): the goal listing's free-form status // string ("active"/"completed"/"suspended"/"all"), applied verbatim. optional string status_filter = 10; + // `atomic vault summaries` (add-only): ToolResult entries under + // path_prefix, with each entry's id set to the filename stem and its + // body set to the first-200-char content preview. + bool tool_result_previews = 11; ViewRef view = 20; } message ListVaultEntriesResponse { @@ -180,11 +191,17 @@ message ExportVaultRequest { RequestMeta meta = 2; repeated VaultEntityKind kinds = 3; optional string destination = 4; // local path (LOCAL scope) + // `atomic vault materialize` (add-only): with a path, materialize only + // that entry; without, materialize every vault entry. + optional string path = 5; ViewRef view = 20; } message ExportVaultResponse { uint32 exported = 1; ResponseMeta meta = 100; + // The materialized path (add-only): the entry's vault-relative path in + // the single-entry form, so the client prints the local report. + optional string materialized_path = 2; } message CreateVaultEntityRequest { @@ -200,6 +217,11 @@ message CreateVaultEntityRequest { repeated string derived_from = 7; // One-line summaries for goals (add-only). repeated string about = 8; + // `vault goal start` metadata (add-only, kind GOAL): the developer, the + // linked intent, and the AI model — GoalStartOptions' fields. + optional string developer = 9; + optional string intent = 10; + optional string model = 11; ViewRef view = 20; ViewSnapshot expected_snapshot = 21; } @@ -221,10 +243,38 @@ message UpdateVaultEntityRequest { // ONE request — the handler applies them atomically through the same // domain update the CLI's multi-flag form performs. IntentUpdateFields fields = 8; + // The freeform memory write (add-only): `atomic memory write` — raw + // stdin content stored (create-or-replace) at the given vault path + // with simple frontmatter, then materialized to the working copy. + MemoryWriteAction memory_write = 9; + // The goal lifecycle (add-only): `vault goal stop` (promote/discard) + // and `vault goal resume` — the domain calls the local bodies make. + GoalStopAction goal_stop = 10; + GoalResumeAction goal_resume = 11; } optional ExpectedState expected = 7; ViewRef view = 20; // expected.kind must be view_snapshot matching this view } + +// The freeform memory write (add-only). +message MemoryWriteAction { + string path = 1; // the vault-relative path (`memory/.md`) + bytes content = 2; // the raw stdin content + string frontmatter_json = 3; // the simple {name, type} frontmatter +} + +// `vault goal stop` (add-only): the goal to stop (empty = the most recent +// active goal) + the promote/discard options. +message GoalStopAction { + optional string goal = 1; + bool promote = 2; + bool discard = 3; +} + +// `vault goal resume` (add-only). +message GoalResumeAction { + string goal = 1; +} // One composed intent update (`intent update`'s multi-flag form, add-only). // Status stays the raw lifecycle string the domain update consumes // ("backlog"/"in_progress"/"needs-review"/"done"/"planned"/"icebox"). @@ -240,6 +290,14 @@ message IntentUpdateFields { } message UpdateVaultEntityResponse { VaultEntry entry = 1; + // The memory-write report (add-only): the stored path + content hash, so + // the client prints the local write report. + optional string write_path = 2; + optional Hash write_hash = 3; + // The goal-stop report (add-only): the stopped goal's outcome status and + // the removed file count. + optional string stop_status = 4; + optional uint32 paths_removed = 5; ResponseMeta meta = 100; } @@ -251,7 +309,13 @@ message DeleteVaultEntityRequest { ViewRef view = 20; ViewSnapshot expected_snapshot = 21; } -message DeleteVaultEntityResponse { ResponseMeta meta = 100; } +message DeleteVaultEntityResponse { + // The delete report (add-only): the normalized id + the removed file's + // vault path, so the client prints the local delete report. + string id = 1; + string vault_path = 2; + ResponseMeta meta = 100; +} message ValidateVaultEntityRequest { RepositoryRef repository = 1; @@ -318,6 +382,11 @@ message VaultEntry { // the raw status label as stored (the frontmatter's own string). optional string assignee = 22; optional string status_label = 23; + // Goal report columns (add-only): `vault goal start`'s created goal + // directory + file, and the goal's developer. + optional string developer = 24; + optional string goal_dir = 25; + optional string goal_file = 26; // SHAPE-CONFIRM: attestation/verification state surfacing (attestations // live in AttestationService; entries reference their latest attestation). } diff --git a/libatomic/src/daemon/mod.rs b/libatomic/src/daemon/mod.rs index 7bcfc245..8c5201bd 100644 --- a/libatomic/src/daemon/mod.rs +++ b/libatomic/src/daemon/mod.rs @@ -21,6 +21,9 @@ //! - [`services_provenance`] — the ProvenanceService journal-port wire handlers. //! - [`services_query`] — the heavy query handlers + ViewService. //! - [`services_maintenance`] — MaintenanceService (doctor). +//! - [`services_sandbox`] — SandboxService (the local sandbox trees). +//! - [`services_tag`] — TagService (tag create/delete/list/show). +//! - [`services_sync`] — SyncService (remote registry, push/pull). //! - [`provenance_core`] — the shared provenance journal core. //! - [`journal_sink`] — the in-process provenance sink (DirectJournalSink). //! - [`convert`] — domain ↔ protobuf converters. @@ -39,4 +42,9 @@ pub mod services_agent; pub mod services_maintenance; pub mod services_provenance; pub mod services_query; +pub mod services_sandbox; +pub mod services_sync; +pub mod services_sync_remote; +pub mod services_tag; +pub mod services_triage; pub mod state; diff --git a/libatomic/src/daemon/services_agent.rs b/libatomic/src/daemon/services_agent.rs index 2e35ba08..ffb35d3a 100644 --- a/libatomic/src/daemon/services_agent.rs +++ b/libatomic/src/daemon/services_agent.rs @@ -206,7 +206,7 @@ mod context_ranking { } /// Read a vault path from KG node metadata; the legacy fallback maps the - /// node identity onto `memory/.md`. + /// node identity onto ``memory/.md``. fn memory_path_from_node(node: &KgNode) -> Option { node.metadata .as_ref() @@ -1062,6 +1062,27 @@ fn memory_list_attestation( LoadedAttestation::None } +/// The local vault-init body's recursive tracker: add every file under +/// the vault directory (relative to the repository root). +fn add_vault_files_recursive( + repo: &atomic_repository::Repository, + dir: &std::path::Path, +) -> Result<(), Status> { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + add_vault_files_recursive(repo, &path)?; + } else if path.is_file() { + if let Ok(relative) = path.strip_prefix(repo.root()) { + let _ = repo.add(relative, atomic_repository::TrackingOptions::default()); + } + } + } + } + Ok(()) +} + pub(crate) fn response_meta(meta: &Option) -> Option { meta.as_ref().map(|meta| ResponseMeta { request_id: meta.request_id.clone(), @@ -1140,6 +1161,51 @@ impl vault_service_server::VaultService for VaultImpl { meta: response_meta(&meta), } } + Kind::Goal => { + // `vault goal start` — GoalStartOptions' fields ride the + // request (add-only); the created goal's dir/file and the + // listing columns ride the response entry. + let handle_for_task = handle.clone(); + let title = request.title.clone(); + let developer = request.developer.clone(); + let intent = request.intent.clone(); + let model = request.model.clone(); + let meta_for_task = meta.clone(); + let result = tokio::task::spawn_blocking({ + let handle = handle_for_task; + move || { + let repo = handle.repository()?; + repo.vault_goal_start(atomic_repository::GoalStartOptions { + name: (!title.is_empty()).then_some(title.clone()), + developer, + intent, + model, + }) + .map_err(repository_error) + } + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + CreateVaultEntityResponse { + entry: Some(VaultEntry { + kind: Kind::Goal as i32, + id: result.name.clone(), + title: String::new(), + status: None, + body: None, + created_at: None, + updated_at: None, + linked: request.intent.clone().into_iter().collect(), + vault_path: None, + priority: None, + developer: request.developer.clone(), + goal_dir: Some(result.goal_dir.clone()), + goal_file: Some(result.goal_file.clone()), + ..Default::default() + }), + meta: response_meta(&meta_for_task), + } + } Kind::Memory => { let memory_kind = request .memory_kind @@ -1284,6 +1350,12 @@ impl vault_service_server::VaultService for VaultImpl { options.content = fields.body; options.force = fields.force; } + _ => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "update kind is not part of the intent lifecycle", + )); + } } let info = repo .vault_intent_update(&id, options) @@ -1304,6 +1376,10 @@ impl vault_service_server::VaultService for VaultImpl { status_label: Some(info.status.clone()), ..Default::default() }), + write_path: None, + write_hash: None, + stop_status: None, + paths_removed: None, meta: response_meta(&meta), }) } @@ -1342,14 +1418,131 @@ impl vault_service_server::VaultService for VaultImpl { priority: None, ..Default::default() }), + write_path: None, + write_hash: None, + stop_status: None, + paths_removed: None, + meta: response_meta(&meta), + }) + } + UpdateKind::MemoryWrite(memory_write) => { + // `atomic memory write` — the raw store + materialize + // (create-or-replace at the named vault path), with the + // content hash riding the response. + let repo = handle.repository()?; + let hash = repo + .vault_store( + &memory_write.path, + VaultEntryType::Memory, + memory_write.content.clone(), + memory_write.frontmatter_json.clone(), + ) + .map_err(repository_error)?; + repo.vault_materialize(&memory_write.path) + .map_err(repository_error)?; + Ok(UpdateVaultEntityResponse { + entry: None, + write_path: Some(memory_write.path.clone()), + write_hash: Some(super::convert::hash_proto(&hash)), + stop_status: None, + paths_removed: None, meta: response_meta(&meta), }) } - UpdateKind::Status(_) | UpdateKind::Fields(_) => Err(domain_status( + UpdateKind::Status(_) + | UpdateKind::Fields(_) + | UpdateKind::GoalStop(_) + | UpdateKind::GoalResume(_) => Err(domain_status( ErrorCode::InvalidArgument, "memory status transitions are new revisions, not updates", )), }, + Kind::Goal => match update { + // `vault goal stop` — the goal to stop (empty = the most + // recent active goal) + the promote/discard options. + UpdateKind::GoalStop(goal_stop) => { + let repo = handle.repository()?; + let goal_name = match &goal_stop.goal { + Some(name) => name.clone(), + None => repo + .vault_goal_list(Some("active")) + .map_err(repository_error)? + .first() + .map(|summary| summary.name.clone()) + .ok_or_else(|| { + domain_status( + ErrorCode::InvalidArgument, + "No active goal found. Specify a goal name.", + ) + })?, + }; + let result = repo + .vault_goal_stop( + &goal_name, + atomic_repository::GoalStopOptions { + promote: goal_stop.promote, + discard: goal_stop.discard, + }, + ) + .map_err(repository_error)?; + Ok(UpdateVaultEntityResponse { + entry: Some(VaultEntry { + kind: Kind::Goal as i32, + id: result.name.clone(), + title: String::new(), + status: None, + body: None, + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: None, + status_label: Some(result.status.clone()), + ..Default::default() + }), + write_path: None, + write_hash: None, + stop_status: Some(result.status.clone()), + paths_removed: Some(result.paths_removed as u32), + meta: response_meta(&meta), + }) + } + // `vault goal resume`. + UpdateKind::GoalResume(goal_resume) => { + let repo = handle.repository()?; + let info = repo + .vault_goal_resume(&goal_resume.goal) + .map_err(repository_error)?; + Ok(UpdateVaultEntityResponse { + entry: Some(VaultEntry { + kind: Kind::Goal as i32, + id: info.name.clone(), + title: info.developer.clone(), + status: None, + body: None, + created_at: None, + updated_at: None, + linked: info.intent.clone().into_iter().collect(), + vault_path: None, + priority: None, + developer: Some(info.developer.clone()), + status_label: Some(info.status.clone()), + started_at: Some(info.started_at.clone()), + turns: Some(info.turns), + ..Default::default() + }), + write_path: None, + write_hash: None, + stop_status: None, + paths_removed: None, + meta: response_meta(&meta), + }) + } + _ => Err(domain_status( + ErrorCode::InvalidArgument, + "update kind is not part of the goal lifecycle", + )), + }, other => Err(domain_status( ErrorCode::InvalidArgument, format!("vault entity kind {other:?} lands with its slice (unimplemented)"), @@ -1486,6 +1679,34 @@ impl vault_service_server::VaultService for VaultImpl { entry_bundle: bundle, }) } + Kind::Goal => { + // `atomic vault goal show ` — the stored goal.md + // entry (content + frontmatter ride the bundle). + let repo = handle.repository_readonly()?; + let entry = repo.vault_goal_show(&id).map_err(repository_error)?; + let bundle = include_bundle + .then(|| vault_entry_bundle(&repo, Some(Kind::Goal), &id, &entry)) + .transpose()?; + Ok(GetVaultEntryResponse { + entry: Some(VaultEntry { + kind: Kind::Goal as i32, + id: id.clone(), + title: String::new(), + status: None, + body: Some(body_of(&entry)), + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: None, + entry_type: Some(entry.entry_type.to_string()), + updated_at_label: Some(entry.updated_at.clone()), + ..Default::default() + }), + snapshot: None, + entry_bundle: bundle, + }) + } other => Err(domain_status( ErrorCode::InvalidArgument, format!("vault entity kind {other:?} lands with its slice (unimplemented)"), @@ -1513,281 +1734,330 @@ impl vault_service_server::VaultService for VaultImpl { let path_prefix = request.path_prefix.clone().unwrap_or_default(); let entry_type = request.entry_type.clone(); let status_filter = request.status_filter.clone(); + let tool_result_previews = request.tool_result_previews; let limit = request .budget .and_then(|budget| budget.max_items) .map(|max| max as usize); - let response = tokio::task::spawn_blocking(move || match kind { - Some(Kind::Intent) => { - // A query: read-only open with the read-concurrency wait. + let response = tokio::task::spawn_blocking(move || { + if tool_result_previews { + // `atomic vault summaries` — the ToolResult entries under the + // prefix, each with its filename-stem id and a 200-char content + // preview (the local body's exact enumeration + preview). let repo = handle.repository_readonly()?; - // The verifying identity resolved ONCE (soft-fail to "no - // identity"; a named-but-missing identity is a hard error). - let verifier = resolve_listing_verifier(&identity)?; - let manifest = repo.vault_manifest().map_err(repository_error)?; - let infos = repo.vault_intent_list(None).map_err(repository_error)?; - let entries = infos - .into_iter() - .map(|info| { - // The manifest kind tag (a manifest read, never a - // lift); a row missing from the manifest degrades - // to the default "feature". - let manifest_kind = manifest - .intents - .get(&info.id) - .map(|summary| summary.kind.clone()) - .unwrap_or_else(|| "feature".to_string()); - // The attestation columns: a read/attestation - // failure for a SINGLE intent degrades that row's - // cells ("none"/"na"), never the whole list. - let (attested, verifies) = (|| { - let entry = repo.vault_intent_show(&info.id).ok()?; - let frontmatter = parse_frontmatter(&entry).ok()?; - let body = body_of(&entry); - match intent_list_attestation(&repo, &info.id, &frontmatter, &body) { - LoadedAttestation::Fresh(node) => Some(( - "fresh", - verifies_token( - Some(&node), - node.attributed_to.as_deref(), - verifier.as_ref(), - |node, public_key| verify(node, public_key).is_ok(), - ), - )), - LoadedAttestation::Stale(_) => Some(("stale", "na")), - LoadedAttestation::None => Some(("none", "na")), - } - })() - .unwrap_or(("none", "na")); - VaultEntry { - kind: Kind::Intent as i32, - id: info.id.clone(), - title: info.title.clone(), - status: intent_status_to_proto(&info.status).map(|s| s as i32), - body: None, // listings are summaries; bodies stay unloaded + let entries = repo + .vault_list(&path_prefix, Some(VaultEntryType::ToolResult)) + .map_err(repository_error)?; + let mut rows = Vec::with_capacity(entries.len()); + for meta in &entries { + let filename = meta.path.rsplit('/').next().unwrap_or(&meta.path); + let id = filename.strip_suffix(".md").unwrap_or(filename); + if let Ok(Some(entry)) = repo.vault_retrieve(&meta.path) { + let content = String::from_utf8_lossy(&entry.content_bytes); + let preview: String = content.chars().take(200).collect(); + rows.push(VaultEntry { + kind: Kind::Unspecified as i32, + id: id.to_string(), + title: String::new(), + status: None, + body: Some(preview), created_at: None, updated_at: None, linked: Vec::new(), - vault_path: None, - priority: Some(info.priority.clone()), - status_label: Some(info.status.clone()), - manifest_kind: Some(manifest_kind), - attested: Some(attested.to_string()), - verifies: Some(verifies.to_string()), + vault_path: Some(meta.path.clone()), + priority: None, + entry_type: Some("tool_result".to_string()), ..Default::default() - } - }) - .collect(); - Ok::<_, Status>(ListVaultEntriesResponse { - entries, + }); + } + } + return Ok::<_, Status>(ListVaultEntriesResponse { + entries: rows, next_cursor: None, snapshot: None, - }) + }); } - Some(Kind::Memory) => { - // A query: read-only open with the read-concurrency wait. - let repo = handle.repository_readonly()?; - // The verifying identity resolved ONCE — the `verifies` - // column's resolver (the same soft/hard-fail rule). - let verifier = resolve_listing_verifier(&identity)?; - // The SAME enumeration the local body performs: the - // `memory/` prefix, never attestation entries, never the - // `MEMORY.md` index scaffold, most-recent first with the - // id as the stable tiebreaker, truncated to the budget. - let metas = repo.vault_list("memory/", None).map_err(repository_error)?; - let mut items: Vec<(String, String)> = metas - .iter() - .filter(|meta| !meta.path.starts_with("attestations/")) - .filter(|meta| !memory_is_index_scaffold(&repo, &meta.path)) - .map(|meta| { - let id = meta - .path - .trim_end_matches(".md") - .trim_start_matches("memory/") - .to_string(); - (id, meta.updated_at.clone()) - }) - .collect(); - items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - if let Some(limit) = limit { - items.truncate(limit); - } - let entries = items - .iter() - .map(|(id, _)| { - // kind/status/about degrade to "–"/0 when a single - // memory cannot be read or lifted — never the list. - let degraded = ( - "\u{2013}".to_string(), - "\u{2013}".to_string(), - 0u32, - "none", - "na", - ); - let (memory_kind, memory_status, about, attested, verifies) = (|| { - let path = format!("memory/{id}.md"); - let entry = repo.vault_retrieve(&path).ok()??; - let frontmatter = parse_frontmatter(&entry).ok()?; - let body = body_of(&entry); - match memory_list_attestation(&repo, id, &frontmatter, &body) { - LoadedAttestation::Fresh(node) => { - // The fresh node drives the display - // columns; the DID-match-then-verify - // rule drives `verifies`. - let verifies = verifies_token( - Some(&node), - node.attributed_to.as_deref(), - verifier.as_ref(), - |node, public_key| verify_memory(node, public_key).is_ok(), - ); - Some(( - node.memory_kind.clone(), - node.status.clone(), - node.about.len() as u32, + match kind { + Some(Kind::Intent) => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + // The verifying identity resolved ONCE (soft-fail to "no + // identity"; a named-but-missing identity is a hard error). + let verifier = resolve_listing_verifier(&identity)?; + let manifest = repo.vault_manifest().map_err(repository_error)?; + let infos = repo.vault_intent_list(None).map_err(repository_error)?; + let entries = infos + .into_iter() + .map(|info| { + // The manifest kind tag (a manifest read, never a + // lift); a row missing from the manifest degrades + // to the default "feature". + let manifest_kind = manifest + .intents + .get(&info.id) + .map(|summary| summary.kind.clone()) + .unwrap_or_else(|| "feature".to_string()); + // The attestation columns: a read/attestation + // failure for a SINGLE intent degrades that row's + // cells ("none"/"na"), never the whole list. + let (attested, verifies) = (|| { + let entry = repo.vault_intent_show(&info.id).ok()?; + let frontmatter = parse_frontmatter(&entry).ok()?; + let body = body_of(&entry); + match intent_list_attestation(&repo, &info.id, &frontmatter, &body) + { + LoadedAttestation::Fresh(node) => Some(( "fresh", - verifies, - )) + verifies_token( + Some(&node), + node.attributed_to.as_deref(), + verifier.as_ref(), + |node, public_key| verify(node, public_key).is_ok(), + ), + )), + LoadedAttestation::Stale(_) => Some(("stale", "na")), + LoadedAttestation::None => Some(("none", "na")), } - other => { - // Stale/None: the columns come from the - // CURRENT source (the lift); the drift - // is still surfaced by the attested - // column. - let token = match other { - LoadedAttestation::Stale(_) => "stale", - _ => "none", - }; - match lift_memory(&frontmatter, &body) { - Ok(node) => Some(( - node.memory_kind, - node.status, + })() + .unwrap_or(("none", "na")); + VaultEntry { + kind: Kind::Intent as i32, + id: info.id.clone(), + title: info.title.clone(), + status: intent_status_to_proto(&info.status).map(|s| s as i32), + body: None, // listings are summaries; bodies stay unloaded + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: None, + priority: Some(info.priority.clone()), + status_label: Some(info.status.clone()), + manifest_kind: Some(manifest_kind), + attested: Some(attested.to_string()), + verifies: Some(verifies.to_string()), + ..Default::default() + } + }) + .collect(); + Ok::<_, Status>(ListVaultEntriesResponse { + entries, + next_cursor: None, + snapshot: None, + }) + } + Some(Kind::Memory) => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + // The verifying identity resolved ONCE — the `verifies` + // column's resolver (the same soft/hard-fail rule). + let verifier = resolve_listing_verifier(&identity)?; + // The SAME enumeration the local body performs: the + // `memory/` prefix, never attestation entries, never the + // `MEMORY.md` index scaffold, most-recent first with the + // id as the stable tiebreaker, truncated to the budget. + let metas = repo.vault_list("memory/", None).map_err(repository_error)?; + let mut items: Vec<(String, String)> = metas + .iter() + .filter(|meta| !meta.path.starts_with("attestations/")) + .filter(|meta| !memory_is_index_scaffold(&repo, &meta.path)) + .map(|meta| { + let id = meta + .path + .trim_end_matches(".md") + .trim_start_matches("memory/") + .to_string(); + (id, meta.updated_at.clone()) + }) + .collect(); + items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + if let Some(limit) = limit { + items.truncate(limit); + } + let entries = items + .iter() + .map(|(id, _)| { + // kind/status/about degrade to "–"/0 when a single + // memory cannot be read or lifted — never the list. + let degraded = ( + "\u{2013}".to_string(), + "\u{2013}".to_string(), + 0u32, + "none", + "na", + ); + let (memory_kind, memory_status, about, attested, verifies) = (|| { + let path = format!("memory/{id}.md"); + let entry = repo.vault_retrieve(&path).ok()??; + let frontmatter = parse_frontmatter(&entry).ok()?; + let body = body_of(&entry); + match memory_list_attestation(&repo, id, &frontmatter, &body) { + LoadedAttestation::Fresh(node) => { + // The fresh node drives the display + // columns; the DID-match-then-verify + // rule drives `verifies`. + let verifies = verifies_token( + Some(&node), + node.attributed_to.as_deref(), + verifier.as_ref(), + |node, public_key| { + verify_memory(node, public_key).is_ok() + }, + ); + Some(( + node.memory_kind.clone(), + node.status.clone(), node.about.len() as u32, - token, - "na", - )), - Err(_) => { - let fm = |key: &str| { - frontmatter - .get(key) - .and_then(Value::as_str) - .map(str::to_owned) - .unwrap_or_else(|| "\u{2013}".to_string()) - }; - Some((fm("memoryKind"), fm("status"), 0, token, "na")) + "fresh", + verifies, + )) + } + other => { + // Stale/None: the columns come from the + // CURRENT source (the lift); the drift + // is still surfaced by the attested + // column. + let token = match other { + LoadedAttestation::Stale(_) => "stale", + _ => "none", + }; + match lift_memory(&frontmatter, &body) { + Ok(node) => Some(( + node.memory_kind, + node.status, + node.about.len() as u32, + token, + "na", + )), + Err(_) => { + let fm = |key: &str| { + frontmatter + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| "\u{2013}".to_string()) + }; + Some(( + fm("memoryKind"), + fm("status"), + 0, + token, + "na", + )) + } } } } + })( + ) + .unwrap_or(degraded); + VaultEntry { + kind: Kind::Memory as i32, + id: id.clone(), + title: id.clone(), + status: None, + body: None, + created_at: None, + updated_at: None, + linked: Vec::new(), + vault_path: Some(format!(".vault/memory/{id}.md")), + priority: None, + memory_kind: Some(memory_kind), + memory_status: Some(memory_status), + about_count: Some(about), + attested: Some(attested.to_string()), + verifies: Some(verifies.to_string()), + ..Default::default() } - })( - ) - .unwrap_or(degraded); - VaultEntry { - kind: Kind::Memory as i32, - id: id.clone(), - title: id.clone(), + }) + .collect(); + Ok(ListVaultEntriesResponse { + entries, + next_cursor: None, + snapshot: None, + }) + } + Some(Kind::Goal) => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + let goals = repo + .vault_goal_list(status_filter.as_deref()) + .map_err(repository_error)?; + let entries = goals + .into_iter() + .map(|goal| { + let status = match goal.status.as_str() { + "active" => VaultEntityStatus::Active, + "suspended" => VaultEntityStatus::Suspended, + "completed" => VaultEntityStatus::Completed, + _ => VaultEntityStatus::Unspecified, + }; + VaultEntry { + kind: Kind::Goal as i32, + id: goal.name.clone(), + title: goal.developer.clone(), + status: Some(status as i32), + body: None, + created_at: None, + updated_at: None, + linked: goal.intent.clone().into_iter().collect(), + vault_path: Some(format!(".vault/goals/{}/_goal.md", goal.name)), + priority: None, + status_label: Some(goal.status.clone()), + started_at: Some(goal.started_at.clone()), + turns: Some(goal.turns), + ..Default::default() + } + }) + .collect(); + Ok::<_, Status>(ListVaultEntriesResponse { + entries, + next_cursor: None, + snapshot: None, + }) + } + None => { + // A query: read-only open with the read-concurrency wait. + let repo = handle.repository_readonly()?; + // `vault list`: EVERY vault entry with its type, content + // size, and updated-at date, filtered by the path prefix + // and the parsed entry type — the same enumeration the + // local body performs. + let type_filter = entry_type + .as_deref() + .and_then(|raw| raw.parse::().ok()); + let metas = repo + .vault_list(&path_prefix, type_filter) + .map_err(repository_error)?; + let entries = metas + .into_iter() + .map(|meta| VaultEntry { + kind: Kind::Unspecified as i32, + id: meta.path.clone(), + title: String::new(), status: None, body: None, created_at: None, updated_at: None, linked: Vec::new(), - vault_path: Some(format!(".vault/memory/{id}.md")), - priority: None, - memory_kind: Some(memory_kind), - memory_status: Some(memory_status), - about_count: Some(about), - attested: Some(attested.to_string()), - verifies: Some(verifies.to_string()), - ..Default::default() - } - }) - .collect(); - Ok(ListVaultEntriesResponse { - entries, - next_cursor: None, - snapshot: None, - }) - } - Some(Kind::Goal) => { - // A query: read-only open with the read-concurrency wait. - let repo = handle.repository_readonly()?; - let goals = repo - .vault_goal_list(status_filter.as_deref()) - .map_err(repository_error)?; - let entries = goals - .into_iter() - .map(|goal| { - let status = match goal.status.as_str() { - "active" => VaultEntityStatus::Active, - "suspended" => VaultEntityStatus::Suspended, - "completed" => VaultEntityStatus::Completed, - _ => VaultEntityStatus::Unspecified, - }; - VaultEntry { - kind: Kind::Goal as i32, - id: goal.name.clone(), - title: goal.developer.clone(), - status: Some(status as i32), - body: None, - created_at: None, - updated_at: None, - linked: goal.intent.clone().into_iter().collect(), - vault_path: Some(format!(".vault/goals/{}/_goal.md", goal.name)), + vault_path: Some(meta.path.clone()), priority: None, - status_label: Some(goal.status.clone()), - started_at: Some(goal.started_at.clone()), - turns: Some(goal.turns), + content_size: Some(meta.content_size as u64), + updated_at_label: Some(meta.updated_at.clone()), + entry_type: Some(meta.entry_type.to_string()), ..Default::default() - } - }) - .collect(); - Ok::<_, Status>(ListVaultEntriesResponse { - entries, - next_cursor: None, - snapshot: None, - }) - } - None => { - // A query: read-only open with the read-concurrency wait. - let repo = handle.repository_readonly()?; - // `vault list`: EVERY vault entry with its type, content - // size, and updated-at date, filtered by the path prefix - // and the parsed entry type — the same enumeration the - // local body performs. - let type_filter = entry_type - .as_deref() - .and_then(|raw| raw.parse::().ok()); - let metas = repo - .vault_list(&path_prefix, type_filter) - .map_err(repository_error)?; - let entries = metas - .into_iter() - .map(|meta| VaultEntry { - kind: Kind::Unspecified as i32, - id: meta.path.clone(), - title: String::new(), - status: None, - body: None, - created_at: None, - updated_at: None, - linked: Vec::new(), - vault_path: Some(meta.path.clone()), - priority: None, - content_size: Some(meta.content_size as u64), - updated_at_label: Some(meta.updated_at.clone()), - entry_type: Some(meta.entry_type.to_string()), - ..Default::default() + }) + .collect(); + Ok(ListVaultEntriesResponse { + entries, + next_cursor: None, + snapshot: None, }) - .collect(); - Ok(ListVaultEntriesResponse { - entries, - next_cursor: None, - snapshot: None, - }) + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("vault entity kind {other:?} lands with its slice (unimplemented)"), + )), } - other => Err(domain_status( - ErrorCode::InvalidArgument, - format!("vault entity kind {other:?} lands with its slice (unimplemented)"), - )), }) .await .map_err(|e| Status::internal(e.to_string()))??; @@ -1934,9 +2204,49 @@ impl vault_service_server::VaultService for VaultImpl { async fn init_vault( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented("InitVault lands with its slice")) + // `atomic vault init` — inside an EXISTING repository (it requires + // one), so it routes normally: has_vault → init_vault → track the + // .vault tree → record the defaults (the local body's exact flow). + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("InitVault", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let (already, vault_dir, recorded) = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + if repo.has_vault().unwrap_or(false) { + return Ok::<_, Status>((true, None, false)); + } + repo.init_vault().map_err(repository_error)?; + let vault_dir = repo.vault_dir().display().to_string(); + // Track every vault file (the local body's recursive add). + if repo.vault_dir().exists() { + add_vault_files_recursive(&repo, &repo.vault_dir())?; + } + // Record the defaults as their own change; NothingToRecord is + // the local body's silent no-op, other failures only log. + let header = atomic_core::change::ChangeHeader::new("Initialize vault"); + let options = atomic_repository::RecordOptions::new() + .add_path(".vault") + .detect_raw_renames(false); + let recorded = match repo.record(header, options) { + Ok(_) => true, + Err(atomic_repository::RecordError::NothingToRecord) => false, + Err(_) => false, + }; + Ok((false, Some(vault_dir), recorded)) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(InitVaultResponse { + already_initialized: already, + vault_dir, + recorded, + meta: response_meta(&meta), + })) } async fn get_vault_context( @@ -2030,27 +2340,116 @@ impl vault_service_server::VaultService for VaultImpl { async fn export_vault( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented("ExportVault lands with its slice")) + // `atomic vault materialize` — inflate vault entries to markdown + // in the working copy. The domain calls (vault_materialize / + // vault_materialize_all) run handler-side; the CLI prints the + // local report from the wire-carried counts. + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ExportVault", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let path = request.path.clone().filter(|path| !path.is_empty()); + let (count, materialized_path) = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + match path { + Some(path) => { + repo.vault_materialize(&path).map_err(repository_error)?; + Ok::<_, Status>((1, Some(path))) + } + None => { + let count = repo.vault_materialize_all().map_err(repository_error)?; + Ok((count as u32, None)) + } + } + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(ExportVaultResponse { + exported: count, + materialized_path, + meta: response_meta(&meta), + })) } async fn delete_vault_entity( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented( - "DeleteVaultEntity lands with its slice", - )) + let request = request.into_inner(); + let kind = Kind::try_from(request.kind) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown vault entity kind"))?; + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("DeleteVaultEntity", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let id = request.id.clone(); + let result = tokio::task::spawn_blocking(move || match kind { + // `atomic intent delete` — the unstarted-backlog guard is the + // domain's (vault_intent_delete); the normalized id + removed + // file ride the response so the client prints the local report. + Kind::Intent => { + let repo = handle.repository()?; + let result = repo.vault_intent_delete(&id).map_err(repository_error)?; + Ok::<_, Status>((result.id, result.intent_file)) + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("vault entity kind {other:?} lands with its slice"), + )), + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(DeleteVaultEntityResponse { + id: result.0, + vault_path: format!(".vault/{}", result.1), + meta: response_meta(&meta), + })) } async fn link_vault_entities( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented( - "LinkVaultEntities lands with its slice", - )) + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("LinkVaultEntities", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let source = request + .source + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "source entity required"))?; + let target = request + .target + .ok_or_else(|| domain_status(ErrorCode::InvalidArgument, "target entity required"))?; + let source_kind = Kind::try_from(source.kind) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown source kind"))?; + let target_kind = Kind::try_from(target.kind) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown target kind"))?; + if !matches!((source_kind, target_kind), (Kind::Intent, Kind::Goal)) { + return Err(domain_status( + ErrorCode::InvalidArgument, + "linking is implemented for intent → goal", + )); + } + let intent_id = source.id.clone(); + let goal_name = target.id.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + repo.vault_intent_link(&intent_id, &goal_name) + .map_err(repository_error)?; + Ok::<_, Status>(()) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(LinkVaultEntitiesResponse { + meta: response_meta(&meta), + })) } } @@ -2734,13 +3133,13 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { .into_iter() .filter(|node| node.kind.to_lowercase() == kind) .take(limit) - .map(crate::daemon::services_query::kg_node_proto) + .map(|node| crate::daemon::services_query::kg_node_proto(node.clone())) .collect::>() } else { repo.vault_kg_search(&search.query, limit, Some(pool)) .map_err(repository_error)? .into_iter() - .map(crate::daemon::services_query::kg_node_proto) + .map(|node| crate::daemon::services_query::kg_node_proto(node.clone())) .collect() }; Ok::<_, Status>(QueryGraphResponse { @@ -2750,6 +3149,10 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { answer: None, plan_result: None, snapshot: None, + graph_collected: None, + calls_present: false, + ask_result: None, + ask_elapsed_ms: None, }) } QueryOneof::Neighbors(neighbors) => { @@ -2766,6 +3169,10 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { answer: None, plan_result: None, snapshot: None, + graph_collected: None, + calls_present: false, + ask_result: None, + ask_elapsed_ms: None, }) } QueryOneof::Entity(entity) => { @@ -2799,6 +3206,296 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { answer: None, plan_result: None, snapshot: None, + graph_collected: None, + calls_present: false, + ask_result: None, + ask_elapsed_ms: None, + }) + } + QueryOneof::Callers(callers) => { + let repo = handle.repository_readonly()?; + // The local body's domain: the 1-hop neighbors subgraph, + // filtered to CALLS edges pointing at the entity. + let (nodes, edges) = + crate::daemon::services_query::neighbors_impl(&repo, &callers.node_id, 1)?; + let subgraph = repo + .vault_kg_neighbors(&callers.node_id, 1) + .map_err(repository_error)?; + let calls_present = subgraph + .edges + .iter() + .any(|edge| edge.kind.to_uppercase() == "CALLS"); + // The caller edges + the caller nodes (their summaries ride + // the nodes so the CLI renders the caller lines). + let caller_edges: Vec = subgraph + .edges + .iter() + .filter(|edge| { + edge.kind.to_uppercase() == "CALLS" && edge.to_id == callers.node_id + }) + .map(|edge| crate::daemon::services_query::kg_edge_proto(edge.clone())) + .collect(); + let caller_ids: std::collections::HashSet<&str> = subgraph + .edges + .iter() + .filter(|edge| { + edge.kind.to_uppercase() == "CALLS" && edge.to_id == callers.node_id + }) + .map(|edge| edge.from_id.as_str()) + .collect(); + let caller_nodes: Vec = subgraph + .nodes + .iter() + .filter(|node| caller_ids.contains(node.id.as_str())) + .map(|node| crate::daemon::services_query::kg_node_proto(node.clone())) + .collect(); + let _ = (nodes, edges); + Ok(QueryGraphResponse { + nodes: caller_nodes, + edges: caller_edges, + hits: Vec::new(), + answer: None, + plan_result: None, + snapshot: None, + graph_collected: None, + calls_present, + ask_result: None, + ask_elapsed_ms: None, + }) + } + QueryOneof::GraphSlice(graph) => { + // `atomic query graph` — the full seed/expand/filter/cap + // build (the local command's algorithm, ported verbatim). + let repo = handle.repository_readonly()?; + let query = graph.query.clone().unwrap_or_default(); + if query.is_empty() { + return Err(domain_status( + ErrorCode::InvalidArgument, + "graph seed query required", + )); + } + let limit = if graph.seed_limit.unwrap_or(0) == 0 { + 10 + } else { + graph.seed_limit.unwrap() as usize + }; + let depth = graph.depth.unwrap_or(1).min(2) as u8; + let kinds = graph.kinds.clone().unwrap_or_else(|| "all".to_string()); + let allow_all = kinds.trim().eq_ignore_ascii_case("all"); + let allowed_kinds: std::collections::HashSet = if allow_all { + std::collections::HashSet::new() + } else { + kinds + .split(',') + .map(|kind| kind.trim().to_lowercase()) + .filter(|kind| !kind.is_empty()) + .collect() + }; + let kind_allowed = |kind: &str| -> bool { + allow_all || allowed_kinds.contains(&kind.to_lowercase()) + }; + let changes_per_seed = graph.changes_per_seed.unwrap_or(5) as usize; + let cap_changes = changes_per_seed > 0; + // 1. Seed: search, filtered to allowed kinds, take limit. + let seed_nodes: Vec = repo + .vault_kg_search(&query, limit * 3, None) + .map_err(repository_error)? + .into_iter() + .filter(|node| kind_allowed(&node.kind)) + .take(limit) + .collect(); + if seed_nodes.is_empty() { + return Ok(QueryGraphResponse { + nodes: Vec::new(), + edges: Vec::new(), + hits: Vec::new(), + answer: None, + plan_result: None, + snapshot: None, + graph_collected: Some(0), + calls_present: false, + ask_result: None, + ask_elapsed_ms: None, + }); + } + // 2. Expand: per-seed neighbor subgraphs, kind-filtered, + // change-capped per seed. + let mut node_map: std::collections::HashMap< + String, + atomic_core::pristine::vault::KgNode, + > = std::collections::HashMap::new(); + let mut edge_set: std::collections::HashSet<(String, String, String)> = + std::collections::HashSet::new(); + let mut all_edges: Vec = Vec::new(); + for seed in &seed_nodes { + node_map + .entry(seed.id.clone()) + .or_insert_with(|| seed.clone()); + let subgraph = repo + .vault_kg_neighbors(&seed.id, depth) + .map_err(repository_error)?; + let mut changes_for_seed: usize = 0; + for node in subgraph.nodes { + if !kind_allowed(&node.kind) { + continue; + } + if node.kind == "change" && cap_changes { + if node_map.contains_key(&node.id) { + continue; + } + if changes_for_seed >= changes_per_seed { + continue; + } + changes_for_seed += 1; + } + node_map.entry(node.id.clone()).or_insert(node); + } + for edge in subgraph.edges { + let from_kind_ok = allow_all || { + let prefix = edge.from_id.split(':').next().unwrap_or(""); + kind_allowed(prefix) + }; + let to_kind_ok = allow_all || { + let prefix = edge.to_id.split(':').next().unwrap_or(""); + kind_allowed(prefix) + }; + if from_kind_ok + && to_kind_ok + && (node_map.contains_key(&edge.from_id) + || node_map.contains_key(&edge.to_id)) + { + let key = (edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()); + if edge_set.insert(key) { + all_edges.push(edge); + } + } + } + } + // 3. Isolated-node removal (seeds always kept). + let connected_ids: std::collections::HashSet = all_edges + .iter() + .flat_map(|edge| [edge.from_id.clone(), edge.to_id.clone()]) + .collect(); + let seed_ids: std::collections::HashSet<&str> = + seed_nodes.iter().map(|node| node.id.as_str()).collect(); + let mut nodes: Vec = node_map + .into_values() + .filter(|node| { + connected_ids.contains(&node.id) || seed_ids.contains(node.id.as_str()) + }) + .collect(); + nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let final_ids: std::collections::HashSet<&str> = + nodes.iter().map(|node| node.id.as_str()).collect(); + all_edges.retain(|edge| { + final_ids.contains(edge.from_id.as_str()) + && final_ids.contains(edge.to_id.as_str()) + }); + // 4. Cap at max_nodes (0 = the format default: 200 DOT / + // 5000 HTML — the caller's own default applies). + let max_nodes = graph.max_nodes.unwrap_or(0) as usize; + let mut collected = nodes.len() as u32; + if max_nodes > 0 && nodes.len() > max_nodes { + let kept_ids: std::collections::HashSet<&str> = nodes[..max_nodes] + .iter() + .map(|node| node.id.as_str()) + .collect(); + all_edges.retain(|edge| { + kept_ids.contains(edge.from_id.as_str()) + && kept_ids.contains(edge.to_id.as_str()) + }); + nodes.truncate(max_nodes); + } + let _ = &collected; + collected = nodes.len() as u32; + let _ = &collected; + Ok(QueryGraphResponse { + nodes: nodes + .iter() + .map(|node| crate::daemon::services_query::kg_node_proto(node.clone())) + .collect(), + edges: all_edges + .iter() + .map(|edge| crate::daemon::services_query::kg_edge_proto(edge.clone())) + .collect(), + hits: Vec::new(), + answer: None, + plan_result: None, + snapshot: None, + graph_collected: None, + calls_present: false, + ask_result: None, + ask_elapsed_ms: None, + }) + } + QueryOneof::Plan(plan) => { + // `atomic query plan` — the structured plan executes against + // the repository; the domain PlanResult JSON rides the + // response for both the --json render (byte parity) and the + // human summary. + let repo = handle.repository()?; + let plan_json = String::from_utf8_lossy(&plan.plan_json).into_owned(); + let parsed = atomic_repository::parse_plan(&plan_json).map_err(repository_error)?; + let result = + atomic_repository::execute_plan(&repo, &parsed).map_err(repository_error)?; + let payload = serde_json::to_vec(&result) + .map_err(|error| Status::internal(format!("plan encode: {error}")))?; + Ok(QueryGraphResponse { + nodes: Vec::new(), + edges: Vec::new(), + hits: Vec::new(), + answer: None, + plan_result: Some(payload), + snapshot: None, + graph_collected: None, + calls_present: false, + ask_result: None, + ask_elapsed_ms: None, + }) + } + QueryOneof::RagAsk(ask) => { + // `atomic query ask` — the agentic tool loop runs over the + // repository (the LLM provider resolves from the same + // environment/config); the full AgentResult rides the wire. + let repo = handle.repository_readonly()?; + let llm = atomic_repository::resolve_llm_provider().ok_or_else(|| { + domain_status( + ErrorCode::InvalidArgument, + "No API key configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY.", + ) + })?; + let executor = atomic_repository::RepoToolExecutor::new(&repo); + let max_turns = ask.max_turns.unwrap_or(5); + let verbose = ask.verbose.unwrap_or(false); + let config = atomic_repository::AgentConfig { + system_prompt: crate::daemon::services_query::ASK_SYSTEM_PROMPT.to_string(), + max_turns: max_turns as u8, + max_tokens: 4096, + verbose, + }; + let start = std::time::Instant::now(); + let result = + atomic_repository::run_tool_loop_sync(&llm, &executor, &ask.question, &config) + .map_err(|error| { + domain_status(ErrorCode::Repository, format!("LLM error: {error}")) + })?; + let elapsed = start.elapsed(); + let payload = serde_json::to_vec(&result) + .map_err(|error| Status::internal(format!("ask encode: {error}")))?; + Ok(QueryGraphResponse { + nodes: Vec::new(), + edges: Vec::new(), + hits: Vec::new(), + answer: Some(result.answer), + plan_result: None, + snapshot: None, + graph_collected: None, + calls_present: false, + ask_result: Some(VersionedBytes { + schema: "atomic.query.ask.v1".to_string(), + payload, + }), + ask_elapsed_ms: Some(elapsed.as_millis() as u64), }) } other => Err(domain_status( @@ -2846,6 +3543,9 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { } return Ok::<_, Status>(MaintainKnowledgeGraphResponse { processed: total, + provider: None, + dimensions: None, + path: None, meta: response_meta(&meta), }); } @@ -2866,6 +3566,53 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { KnowledgeMaintainAction::Reindex => { repo.vault_reindex_kg().map_err(repository_error)? as u64 } + // `atomic query embed` — the provider resolves from the + // same environment/config the CLI reads; the sync embed + // function + the hash fallback are the domain's. + KnowledgeMaintainAction::Embed => { + // The embed target rides the scope field (the per-path + // form; absent = embed everything). + let scope = request.scope.clone(); + let provider = atomic_repository::resolve_embedding_provider(); + let dims = provider.dimensions; + let config = atomic_repository::EmbedConfig { + max_chunk_tokens: 512, + dimensions: dims, + }; + let embed_fn = |text: &str| -> Vec { + provider + .embed_sync(&[text.to_string()]) + .ok() + .and_then(|v| v.into_iter().next()) + .unwrap_or_else(|| atomic_repository::hash_embed(text, dims)) + }; + match scope { + Some(path) if !path.is_empty() => { + let count = repo + .vault_embed(&path, &embed_fn, &config) + .map_err(repository_error)?; + return Ok::<_, Status>(MaintainKnowledgeGraphResponse { + processed: count as u64, + provider: Some(provider.model.clone()), + dimensions: Some(dims as u32), + path: Some(path), + meta: response_meta(&meta), + }); + } + _ => { + let count = repo + .vault_embed_all(&embed_fn, &config) + .map_err(repository_error)?; + return Ok(MaintainKnowledgeGraphResponse { + processed: count as u64, + provider: Some(provider.model.clone()), + dimensions: Some(dims as u32), + path: None, + meta: response_meta(&meta), + }); + } + } + } other => { return Err(domain_status( ErrorCode::InvalidArgument, @@ -2875,6 +3622,9 @@ impl knowledge_service_server::KnowledgeService for KnowledgeImpl { }; Ok::<_, Status>(MaintainKnowledgeGraphResponse { processed, + provider: None, + dimensions: None, + path: None, meta: response_meta(&meta), }) }) @@ -3126,18 +3876,61 @@ impl provenance_service_server::ProvenanceService for ProvenanceImpl { async fn fork_session( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented("ForkSession lands with its slice")) + // `atomic session fork` — the domain fork (parent manifest, child + // session, the inherited turns) runs handler-side; the wire carries + // both manifest hashes for the local fork report. + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ForkSession", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let session_id = request.session_id.clone(); + let child = request.forked_session_id.clone(); + let child_for_report = child.clone(); + let at_turn = request.at_turn.unwrap_or(0); + let (parent_hash, child_hash) = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + repo.fork_session(&session_id, at_turn, &child) + .map_err(repository_error) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(ForkSessionResponse { + session_id: child_for_report, + parent_manifest: Some(super::convert::hash_proto(&parent_hash)), + child_manifest: Some(super::convert::hash_proto(&child_hash)), + fork_turn: Some(at_turn), + meta: response_meta(&meta), + })) } async fn rebuild_session_index( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented( - "RebuildSessionIndex lands with its slice", - )) + // `atomic session rebuild` — the domain rebuild runs handler-side; + // the counts ride the response for the local report. + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("RebuildSessionIndex", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let (indexed, skipped, corrupt) = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + repo.rebuild_session_index().map_err(repository_error) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(RebuildSessionIndexResponse { + indexed: indexed as u64, + already_present: skipped as u64, + corrupt: corrupt as u64, + meta: response_meta(&meta), + })) } async fn get_provenance( @@ -3149,10 +3942,551 @@ impl provenance_service_server::ProvenanceService for ProvenanceImpl { async fn export_provenance( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented( - "ExportProvenance lands with its slice", - )) + export_provenance_impl(self.state.clone(), request).await + } + + async fn explain_turns( + &self, + request: Request, + ) -> Result, Status> { + explain_turns_impl(self.state.clone(), request).await + } +} + +// --------------------------------------------------------------------------- +// ExplainTurns — the `agent explain` domain (the transcript extraction, +// the Claude CLI generation, the graph anchoring, and the save-backs) +// --------------------------------------------------------------------------- + +/// The local `get_condensed_text`: the change's unhashed section first, +/// then the session transcript file condensed on the fly. +#[allow(clippy::too_many_arguments)] +fn explain_condensed_text( + change: &atomic_core::change::Change, + transcript_path: Option<&std::path::Path>, + agent_name: &str, + files_touched: &[String], +) -> String { + use atomic_agent::transcript; + if let Some(unhashed) = transcript::extract_unhashed(change) { + if !unhashed.condensed_text.is_empty() { + return unhashed.condensed_text; + } + if !unhashed.condensed_transcript.is_empty() { + let files: Vec = unhashed + .tools_used + .iter() + .flat_map(|tool| tool.files_affected.clone()) + .collect(); + return transcript::format_condensed(&unhashed.condensed_transcript, &files); + } + } + if let Some(path) = transcript_path { + if let Ok(raw) = std::fs::read(path) { + let format = if agent_name.contains("gemini") { + "json" + } else { + "jsonl" + }; + let entries = transcript::condense_transcript(&raw, format); + if entries.is_empty() { + return String::new(); + } + let files: Vec = if !change.file_ops().is_empty() { + change + .file_ops() + .iter() + .map(|file_op| file_op.path().to_string()) + .collect() + } else { + files_touched.to_vec() + }; + return transcript::format_condensed(&entries, &files); + } + } + String::new() +} + +/// The local explain body's domain, in ONE blocking pass: the turn list +/// on the session's view, then per turn the transcript extraction, the +/// Claude CLI generation, the graph anchoring, and the save-backs. +async fn explain_turns_impl( + state: std::sync::Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("ExplainTurns", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let session_id = request.session_id.clone(); + let view_name = request.view_name.clone(); + let transcript_path = request + .transcript_path + .clone() + .map(std::path::PathBuf::from); + let files_touched = request.files_touched.clone(); + let agent_name = request.agent_name.clone(); + let root = handle.root.clone(); + let model = request.model.clone(); + let turn = request.turn; + let all = request.all; + let save = request.save; + + let (session_has_no_turns, results) = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let entries = repo + .log(atomic_repository::HistoryOptions::with_headers().view(&view_name)) + .map_err(|error| match error { + atomic_repository::RepositoryError::ViewNotFound { name } => { + domain_status(ErrorCode::View, format!("view '{name}' does not exist")) + } + other => repository_error(other), + })?; + if entries.is_empty() { + return Ok::<_, Status>((true, Vec::new())); + } + let len = entries.len(); + let turns: Vec = if all { + (0..len).collect() + } else if let Some(turn_number) = turn { + let index = (turn_number as usize).checked_sub(1).ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "Turn number must be >= 1") + })?; + if index >= len { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!( + "Turn {} not found. Session has {} turn{}.", + turn_number, + len, + if len == 1 { "" } else { "s" } + ), + )); + } + vec![index] + } else { + vec![len - 1] + }; + + let mut results = Vec::with_capacity(turns.len()); + for index in turns { + let entry = entries + .get(index) + .ok_or_else(|| domain_status(ErrorCode::Changes, "turn index out of range"))?; + let change = repo.load_change(&entry.hash).map_err(|error| { + domain_status( + ErrorCode::Changes, + format!("Failed to load change {}: {error}", entry.hash.to_base32()), + ) + })?; + let message = change.hashed.header.message.clone(); + let turn_number = (index + 1) as u32; + let condensed = explain_condensed_text( + &change, + transcript_path.as_deref(), + &agent_name, + &files_touched, + ); + if condensed.is_empty() { + results.push(ExplainTurnResult { + turn_number, + message, + change: Some(super::convert::hash_proto(&entry.hash)), + no_transcript: true, + reasoning: None, + generation_error: None, + anchored: 0, + saved: false, + save_error: None, + context_saved: None, + context_save_error: None, + }); + continue; + } + + // The files for this turn — FileOps paths first, then the + // unhashed tool summaries, then the session's touched files. + let files: Vec = if !change.file_ops().is_empty() { + change + .file_ops() + .iter() + .map(|file_op| file_op.path().to_string()) + .collect() + } else if let Some(unhashed) = atomic_agent::transcript::extract_unhashed(&change) { + unhashed + .tools_used + .iter() + .flat_map(|tool| tool.files_affected.clone()) + .collect() + } else { + files_touched.clone() + }; + + let generator = atomic_agent::transcript::ClaudeCliGenerator::new().with_model(&model); + use atomic_agent::transcript::ReasoningGenerator as _; + let reasoning = match generator.generate(&condensed, &files) { + Ok(reasoning) => reasoning, + Err(error) => { + results.push(ExplainTurnResult { + turn_number, + message, + change: Some(super::convert::hash_proto(&entry.hash)), + no_transcript: false, + reasoning: None, + generation_error: Some(error.to_string()), + anchored: 0, + saved: false, + save_error: None, + context_saved: None, + context_save_error: None, + }); + continue; + } + }; + if reasoning.is_empty() { + results.push(ExplainTurnResult { + turn_number, + message, + change: Some(super::convert::hash_proto(&entry.hash)), + no_transcript: false, + reasoning: None, + generation_error: Some(String::new()), + anchored: 0, + saved: false, + save_error: None, + context_saved: None, + context_save_error: None, + }); + continue; + } + + // Anchor code learnings to the CRDT graph. + let mut reasoning = reasoning; + let mut anchored = 0u32; + if reasoning.has_code_learnings() { + let file_ops = change.file_ops(); + if !file_ops.is_empty() { + atomic_agent::transcript::anchor_to_graph( + &mut reasoning.learnings.code, + file_ops, + ); + anchored = reasoning + .learnings + .code + .iter() + .filter(|learning| learning.is_anchored()) + .count() as u32; + } + } + + let reasoning_payload = serde_json::to_vec(&reasoning) + .map_err(|error| Status::internal(format!("reasoning encode: {error}")))?; + + // Save back into the change's unhashed section (pushable) and + // the learnings to the agent context file. + let (saved, save_error, context_saved, context_save_error) = if save { + let mut updated = change.clone(); + let mut unhashed_data = atomic_agent::transcript::extract_unhashed(&updated) + .unwrap_or_else(|| { + atomic_agent::transcript::UnhashedTurnData::new( + "", + 0, + "unknown", + Vec::new(), + &[], + ) + }); + unhashed_data = unhashed_data.with_reasoning(reasoning.clone()); + let mut saved = false; + let mut save_error = None; + match atomic_agent::transcript::attach_unhashed(&mut updated, &unhashed_data) { + Ok(()) => match repo.save_change(&updated) { + Ok(_) => saved = true, + Err(error) => save_error = Some(error.to_string()), + }, + Err(error) => { + save_error = Some(format!("Failed to attach reasoning: {error}")); + } + } + let (context_saved, context_save_error) = if !reasoning.learnings.is_empty() { + match atomic_agent::learnings::save_learnings_to_context_file( + &root, + &agent_name, + &reasoning.learnings, + ) { + Ok(result) => (Some(result.to_string()), None), + Err(error) => (None, Some(error.to_string())), + } + } else { + (None, None) + }; + (saved, save_error, context_saved, context_save_error) + } else { + (false, None, None, None) + }; + + results.push(ExplainTurnResult { + turn_number, + message, + change: Some(super::convert::hash_proto(&entry.hash)), + no_transcript: false, + reasoning: Some(VersionedBytes { + schema: "atomic.agent.reasoning.v1".to_string(), + payload: reasoning_payload, + }), + generation_error: None, + anchored, + saved, + save_error, + context_saved, + context_save_error, + }); + } + Ok::<_, Status>((false, results)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + let _ = session_id; + + Ok(Response::new(ExplainTurnsResponse { + turns: results, + session_has_no_turns, + meta: response_meta(&meta), + })) +} + +// --------------------------------------------------------------------------- +// ExportProvenance — the `provenance trace/show` domain (add-only fields: +// the resolved change hash + per-graph trace bundles) +// --------------------------------------------------------------------------- + +/// Build a unique activity id for a turn's graph — the CLI's +/// `activity_id_for` semantics: `#` +/// (or just the session id when none are explained), so turnParent joins. +fn provenance_activity_id(graph: &atomic_core::change::ProvenanceGraph) -> String { + use atomic_core::types::Base32; + match graph.changes_explained.first() { + Some(hash) => format!("{}#{}", graph.session_id, hash.to_base32()), + None => graph.session_id.clone(), + } +} + +/// Map a loaded graph into the plain projector input — the CLI's +/// `map_graph_to_input` semantics (the turn parent loads the previous +/// graph and uses ITS activity id; best-effort when it cannot load). +fn provenance_map_input( + repo: &atomic_repository::Repository, + graph: &atomic_core::change::ProvenanceGraph, + change_hash: &atomic_core::types::Hash, + person_did: &str, +) -> ( + atomic_canonical::prov::ProvActivityInput, + Option, +) { + use atomic_canonical::prov::{change_urn, ProvActivityInput}; + use atomic_core::types::Base32; + let previous = graph.previous; + let turn_parent = previous.and_then(|prev_hash| { + repo.load_provenance_graph(&prev_hash) + .ok() + .map(|prev| atomic_canonical::prov::activity_urn(&provenance_activity_id(&prev))) + }); + let agent_vendor = (!graph.agent_vendor.is_empty()).then(|| graph.agent_vendor.clone()); + let input = ProvActivityInput { + change_id_base32: change_hash.to_base32(), + activity_id: provenance_activity_id(graph), + started_at: None, + ended_at: None, + agent_slug: atomic_canonical::prov::normalize_agent_slug(&graph.agent_name), + agent_display_name: graph.agent_display_name.clone(), + agent_vendor, + person_did: person_did.to_string(), + generated: graph + .changes_explained + .iter() + .map(|hash| change_urn(&hash.to_base32())) + .collect(), + used: Vec::new(), + turn_parent, + }; + (input, previous) +} + +/// The ProvActivityInput as the versioned bundle JSON (schema +/// "atomic.prov.input.v1") — the CLI deserializes the same shape. +fn prov_input_bundle(input: &atomic_canonical::prov::ProvActivityInput) -> Vec { + serde_json::json!({ + "change_id_base32": input.change_id_base32, + "activity_id": input.activity_id, + "started_at": input.started_at, + "ended_at": input.ended_at, + "agent_slug": input.agent_slug, + "agent_display_name": input.agent_display_name, + "agent_vendor": input.agent_vendor, + "person_did": input.person_did, + "generated": input.generated, + "used": input.used, + "turn_parent": input.turn_parent, + }) + .to_string() + .into_bytes() +} + +/// The ExportProvenance domain: resolve the target (URN/prefix/hash with +/// the CLI resolver's exact semantics), load the explaining graphs (the +/// REV_DEPS lookup with the disk-scan fallback, newest first), map each +/// to the projection input, and pre-walk the prior-turn chain the human +/// trace renders. The CLI projects/signs client-side over the bundles. +async fn export_provenance_impl( + state: std::sync::Arc, + request: Request, +) -> Result, Status> { + use atomic_core::types::Base32; + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("ExportProvenance", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let target = request.target.clone(); + let exact = request.change.as_ref().and_then(|hash| { + hash.value + .clone() + .try_into() + .ok() + .map(atomic_core::types::Merkle) + }); + let person_did = request.person_did.clone().unwrap_or_default(); + let (resolved, bundles) = tokio::task::spawn_blocking(move || { + let repo = handle.repository_readonly()?; + // Resolve the target: the raw CLI string (URN / prefix / full + // base32), else the request's exact hash. + let change_hash = match &target { + Some(target) => resolve_provenance_target(&repo, target)?, + None => exact.ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "change target required") + })?, + }; + // The graphs that explain the change, newest first (the CLI's + // load_graphs: REV_DEPS first, disk-scan fallback, error when + // nothing explains it). + let mut graphs = repo + .find_provenance_for_change(&change_hash) + .map_err(repository_error)?; + if graphs.is_empty() { + graphs = repo + .find_provenance_for_change_scan(&change_hash) + .map_err(repository_error)?; + } + if graphs.is_empty() { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!( + "no provenance graph explains change {}", + change_hash.to_base32() + ), + )); + } + graphs.sort_by_key(|(_, graph)| std::cmp::Reverse(graph.timestamp)); + let mut bundles = Vec::with_capacity(graphs.len()); + for (graph_hash, graph) in &graphs { + let (input, previous) = provenance_map_input(&repo, graph, &change_hash, &person_did); + // The prior-turn walk the trace render performs (≤64 hops; an + // unloadable graph ends the chain with the empty marker, the + // cap sets the truncated flag — the local render's two + // distinct chain-end lines). + let mut prior_activities = Vec::new(); + let mut chain_truncated = false; + let mut cursor = previous; + let mut depth = 0usize; + while let Some(prev_hash) = cursor { + depth += 1; + if depth > 64 { + chain_truncated = true; + break; + } + match repo.load_provenance_graph(&prev_hash) { + Ok(prev) => { + prior_activities.push(atomic_canonical::prov::activity_urn( + &provenance_activity_id(&prev), + )); + cursor = prev.previous; + } + Err(_) => { + prior_activities.push(String::new()); + break; + } + } + } + let graph_payload = serde_json::to_vec(&graph) + .map_err(|error| Status::internal(format!("graph encode: {error}")))?; + bundles.push(ProvenanceTraceBundle { + graph_hash: Some(super::convert::hash_proto(graph_hash)), + graph: Some(VersionedBytes { + schema: "atomic.prov.graph.v1".to_string(), + payload: graph_payload, + }), + input: Some(VersionedBytes { + schema: "atomic.prov.input.v1".to_string(), + payload: prov_input_bundle(&input), + }), + prior_activities, + chain_truncated, + }); + } + Ok::<_, Status>((change_hash, bundles)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + Ok(Response::new(ExportProvenanceResponse { + prov_jsonld: Vec::new(), + resolved_change: Some(super::convert::hash_proto(&resolved)), + graphs: bundles, + })) +} + +/// The raw CLI target (bare hash, hash prefix, or URN) → the change hash — +/// the provenance command's resolver semantics verbatim. +fn resolve_provenance_target( + repo: &atomic_repository::Repository, + target: &str, +) -> Result { + use atomic_core::types::Base32; + const URN_PREFIX: &str = "urn:atomic:change:"; + if let Some(base32) = target.strip_prefix(URN_PREFIX) { + return atomic_core::types::Hash::from_base32(base32.as_bytes()).ok_or_else(|| { + domain_status( + ErrorCode::InvalidArgument, + format!("invalid change base32 in URN: {target}"), + ) + }); + } + let mut matches = Vec::new(); + for result in repo.iter_changes() { + let hash = + result.map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; + if hash.to_base32().starts_with(target) { + matches.push(hash); + } + } + match matches.len() { + 0 => atomic_core::types::Hash::from_base32(target.as_bytes()).ok_or_else(|| { + domain_status(ErrorCode::NotFound, format!("change not found: {target}")) + }), + 1 => Ok(matches[0]), + _ => { + let list: Vec = matches.iter().map(|hash| hash.to_base32()).collect(); + Err(domain_status( + ErrorCode::InvalidArgument, + format!( + "ambiguous change prefix {} (matches: {})", + target, + list.join(", ") + ), + )) + } } } diff --git a/libatomic/src/daemon/services_query.rs b/libatomic/src/daemon/services_query.rs index 58ca064e..bd433c93 100644 --- a/libatomic/src/daemon/services_query.rs +++ b/libatomic/src/daemon/services_query.rs @@ -931,6 +931,35 @@ pub fn neighbors_impl( /// Domain KG node → wire, with the FULL field set (add-only source and /// metadata) so the client serializes the same JSON shape the local body /// emits. +/// System prompt for the agentic `query ask` loop — the CLI's ASK_SYSTEM_PROMPT, +/// ported verbatim (one implementation: the handler). +pub(crate) const ASK_SYSTEM_PROMPT: &str = "\ +You are a code assistant for a repository tracked by Atomic VCS (not git). + +You have tools to explore the repository: +- kg_search: search the knowledge graph (files, functions, changes, views, goals, intents) +- kg_neighbors: explore connections around a node +- read_file: read source files (with optional line ranges) +- code_search: search source code content for a pattern (regex, path/type filters) +- list_entities: list functions/classes/types in a file via tree-sitter +- vault_read: read vault entries (goals, intents, memories) + +Strategy: +1. Search first: use kg_search or code_search to find relevant files and line numbers. +2. Use list_entities to see what functions/classes a file contains and their line ranges. +3. Use kg_neighbors to follow relationships (which changes modified a file, what it depends on). +4. NEVER call read_file without start_line and end_line. Always use list_entities or \ + code_search first to find the exact line range you need, then read just that range. +5. Answer concisely using what you found. Cite file paths and line numbers. + +The knowledge graph contains: +- file nodes (file:path) — tracked files +- entity nodes (entity:file:name:line) — functions, structs, classes from tree-sitter +- change nodes (change:hash) — commit history with dates and messages +- view nodes (view:name) — like branches +- goal/intent/memory nodes — development sessions and work items +- edges: MODIFIES, DEFINES, AUTHORED_BY, DEPENDS_ON, REFERENCES, ON_VIEW, etc."; + pub(crate) fn kg_node_proto(node: atomic_core::pristine::vault::KgNode) -> KgNode { KgNode { id: node.id, diff --git a/libatomic/src/daemon/services_sandbox.rs b/libatomic/src/daemon/services_sandbox.rs new file mode 100644 index 00000000..fbeeda47 --- /dev/null +++ b/libatomic/src/daemon/services_sandbox.rs @@ -0,0 +1,241 @@ +//! SandboxService handlers — the local working-tree surface +//! (`atomic sandbox create/stage/seal`): the copy-on-write sandbox +//! provisioning, the OCI layer staging, and the sealed image, each running +//! the exact domain calls the local CLI bodies make. The grant-admin and +//! remote data-op RPCs are separate slices (HANDOFF §5) and stay refused. + +use std::sync::Arc; + +use crate::atomic::ErrorCode; +use crate::atomic::*; +use atomic_repository::SealOptions; +use atomic_repository::StageOptions; +use tonic::{Request, Response, Status}; + +use super::services_agent::response_meta; +use super::state::{domain_status, repository_error, DaemonState}; + +pub struct SandboxImpl { + pub state: Arc, +} + +/// The local body's default destination: `/- +/// sandboxes/`. +fn default_sandbox_dest(repo_root: &std::path::Path, name: &str) -> std::path::PathBuf { + let repo_name = repo_root + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "repo".to_string()); + let parent = repo_root.parent().unwrap_or(repo_root); + parent.join(format!("{repo_name}-sandboxes")).join(name) +} + +#[tonic::async_trait] +impl sandbox_service_server::SandboxService for SandboxImpl { + async fn open_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("OpenSandbox lands with its slice")) + } + + async fn renew_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("RenewSandbox lands with its slice")) + } + + async fn close_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("CloseSandbox lands with its slice")) + } + + async fn create_sandbox_tree( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("CreateSandboxTree", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let name = request.name.clone(); + let from_view = request.from_view.clone(); + let view = request.view.clone(); + let dest = request.dest.clone(); + let root = handle.root.clone(); + let (resolved_dest, view, created_draft, files_cloned, repo_root) = + tokio::task::spawn_blocking(move || { + let mut repo = handle.repository()?; + let dest = dest + .map(std::path::PathBuf::from) + .unwrap_or_else(|| default_sandbox_dest(&root, &name)); + if dest.exists() { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!("destination already exists: {}", dest.display()), + )); + } + // --from creates a new draft named after the sandbox + // forked from ; --view uses the existing view; + // neither → the current view. + let (view, created_draft) = if let Some(from) = &from_view { + repo.create_view_from(&name, from) + .map_err(repository_error)?; + (name.clone(), true) + } else { + let view = view.unwrap_or_else(|| repo.current_view().to_string()); + (view, false) + }; + let count = repo + .provision_sandbox(&dest, &view) + .map_err(repository_error)?; + Ok::<_, Status>(( + dest.display().to_string(), + view, + created_draft, + count as u32, + root.display().to_string(), + )) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(CreateSandboxTreeResponse { + sandbox_path: resolved_dest.clone(), + resolved_dest, + view, + created_draft, + files_cloned, + repo_root, + meta: response_meta(&meta), + })) + } + + async fn stage_sandbox_image( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("StageSandboxImage", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let view = request.name.clone(); + let base_view = request.base_view.clone(); + let out = request.out_dir.clone(); + let (image_dir, manifest_digest, base_diff_id, delta_files) = + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let result = repo + .stage(StageOptions { + view, + base_view, + out: std::path::PathBuf::from(out), + }) + .map_err(repository_error)?; + Ok::<_, Status>(( + result.out.display().to_string(), + result.manifest_digest, + result.base_diff_id, + result.delta_files as u64, + )) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(StageSandboxImageResponse { + staged_ref: manifest_digest.clone(), + image_dir, + manifest_digest, + base_diff_id, + delta_files, + meta: response_meta(&meta), + })) + } + + async fn seal_sandbox_image( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("SealSandboxImage", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let view = request.name.clone(); + let out = request.out_dir.clone(); + let entrypoint = request.entrypoint.clone(); + let env = request.env.clone(); + let (image_dir, manifest_digest, files) = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let result = repo + .seal(SealOptions { + view, + out: std::path::PathBuf::from(out), + entrypoint, + env, + }) + .map_err(repository_error)?; + Ok::<_, Status>(( + result.out.display().to_string(), + result.manifest_digest, + result.files as u64, + )) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(SealSandboxImageResponse { + sealed_ref: manifest_digest.clone(), + image_dir, + manifest_digest, + files, + meta: response_meta(&meta), + })) + } + + type MaterializeStream = std::pin::Pin< + Box> + Send>, + >; + + async fn materialize( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("Materialize lands with its slice")) + } + + async fn get_file_states( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("GetFileStates lands with its slice")) + } + + async fn get_changes( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("GetChanges lands with its slice")) + } + + async fn publish_provenance( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "PublishProvenance lands with its slice", + )) + } + + async fn submit_change( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("SubmitChange lands with its slice")) + } +} diff --git a/libatomic/src/daemon/services_sync.rs b/libatomic/src/daemon/services_sync.rs new file mode 100644 index 00000000..4ff14280 --- /dev/null +++ b/libatomic/src/daemon/services_sync.rs @@ -0,0 +1,150 @@ +//! SyncService handlers — the remote registry (`atomic remote`), with the +//! push/pull remote-sync handlers riding the same service. +//! +//! The remote registry is persisted IN the repository database, so its CRUD +//! is service-layer work: `ListRemotes` serves the read-only listing and +//! `ManageRemotes` the mutating actions (add/remove/rename/set-url/set- +//! default — one action enum), each running the exact domain call the +//! local CLI body makes. + +use std::sync::Arc; + +use crate::atomic::ErrorCode; +use crate::atomic::*; +use atomic_repository::Repository; +use tonic::{Request, Response, Status}; + +use super::services_agent::response_meta; +use super::state::{domain_status, repository_error, DaemonState}; + +pub struct SyncImpl { + pub state: Arc, +} + +fn remote_info(name: &str, entry: &atomic_repository::remote::RemoteEntry) -> RemoteInfo { + RemoteInfo { + name: name.to_string(), + url: entry.url.clone(), + default: entry.default, + } +} + +#[tonic::async_trait] +impl sync_service_server::SyncService for SyncImpl { + async fn push_changes( + &self, + request: Request, + ) -> Result, Status> { + super::services_sync_remote::push_changes_impl(self.state.clone(), request).await + } + + async fn pull_changes( + &self, + request: Request, + ) -> Result, Status> { + super::services_sync_remote::pull_changes_impl(self.state.clone(), request).await + } + + async fn list_remotes( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ListRemotes", Some(&handle)); + let remotes = tokio::task::spawn_blocking(move || { + let repo = handle.repository_readonly()?; + repo.list_remotes().map_err(repository_error) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(ListRemotesResponse { + remotes: remotes + .iter() + .map(|(name, entry)| remote_info(name, entry)) + .collect(), + })) + } + + async fn manage_remotes( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ManageRemotes", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let action = RemoteAction::try_from(request.action) + .map_err(|_| domain_status(ErrorCode::InvalidArgument, "unknown remote action"))?; + let name = request.name.clone(); + let url = request.url.clone(); + let new_name = request.new_name.clone(); + let set_default = request.set_default; + let remotes = tokio::task::spawn_blocking(move || { + let repo: Repository = handle.repository()?; + let missing_name = || domain_status(ErrorCode::InvalidArgument, "remote name required"); + match action { + RemoteAction::Add => { + let name = name.as_ref().ok_or_else(missing_name)?; + let url = url.as_ref().ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "remote url required") + })?; + if set_default { + repo.add_remote_default(name, url) + .map_err(repository_error)?; + } else { + repo.add_remote(name, url).map_err(repository_error)?; + } + } + RemoteAction::Remove => { + let name = name.as_ref().ok_or_else(missing_name)?; + repo.remove_remote(name).map_err(repository_error)?; + } + RemoteAction::Rename => { + let name = name.as_ref().ok_or_else(missing_name)?; + let new_name = new_name.as_ref().ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "new remote name required") + })?; + repo.rename_remote(name, new_name) + .map_err(repository_error)?; + } + RemoteAction::SetUrl => { + let name = name.as_ref().ok_or_else(missing_name)?; + let url = url.as_ref().ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "remote url required") + })?; + repo.set_remote_url(name, url).map_err(repository_error)?; + } + RemoteAction::SetDefault => { + let name = name.as_ref().ok_or_else(missing_name)?; + repo.set_default_remote(name).map_err(repository_error)?; + } + RemoteAction::Unspecified => { + return Err(domain_status( + ErrorCode::InvalidArgument, + "remote action required", + )); + } + } + repo.list_remotes().map_err(repository_error) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(ManageRemotesResponse { + remotes: remotes + .iter() + .map(|(name, entry)| remote_info(name, entry)) + .collect(), + meta: response_meta(&meta), + })) + } + + async fn bind_remote_project( + &self, + request: Request, + ) -> Result, Status> { + super::services_sync_remote::bind_remote_project_impl(self.state.clone(), request).await + } +} diff --git a/libatomic/src/daemon/services_sync_remote.rs b/libatomic/src/daemon/services_sync_remote.rs new file mode 100644 index 00000000..4239044c --- /dev/null +++ b/libatomic/src/daemon/services_sync_remote.rs @@ -0,0 +1,1730 @@ +//! The remote-sync half of SyncService: `atomic push` / `atomic pull` +//! (the network sync IS domain — the handler performs it end to end and +//! returns the structured report the CLI renders with its local print +//! functions) and `BindRemoteProject` (`atomic project init`'s server-side +//! project creation + the remote bind). +//! +//! The ports are faithful: every domain step of the local bodies (chain +//! construction, manifest planning, the set-union membership model, +//! content-addressed stores, CAS ref moves, sidecar ingestion, set-id +//! verification) runs here against the repository handle; the CLI ships +//! the auth headers (resolved client-side from the identity store — a +//! config read, never redb) and renders the report. +//! +//! Network-phase failures carry the local bodies' exact composed texts +//! over a structured wire error (see the sync_error encoder) so the client raises +//! the identical CLI error after printing the same spinner-stage line. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use atomic_core::types::{Base32, Hash, Merkle, SetId}; +use atomic_objects::{ + ObjectFamily, ObjectRecord, RefRecord, SyncPack, SyncWants, ViewScopeLabel, ViewSnapshot, +}; +use atomic_remote::{HttpRemote, HttpRemoteConfig, RemoteError}; +use atomic_repository::{Repository, ViewManifest}; +use bytes::Bytes; +use serde_json::json; + +use crate::atomic::ErrorCode; +use crate::atomic::{ + BindRemoteProjectRequest, BindRemoteProjectResponse, ManifestState, PullChangesRequest, + PullChangesResponse, PushChange, PushChangesRequest, PushChangesResponse, PushConflict, + PushReport, PushSidecar, PushTag, PushViewReport, +}; +use tonic::{Request, Response, Status}; + +use super::convert::hash_proto; +use super::state::{domain_status, repository_error, DaemonState}; + +/// Default remote name / timeout when the request leaves them unset (the +/// local bodies' constants). +const DEFAULT_REMOTE: &str = "origin"; +const DEFAULT_TIMEOUT_SECS: u64 = 30; + +// --------------------------------------------------------------------------- +// the structured wire error (stage + variant + the local's message text) +// --------------------------------------------------------------------------- + +/// The wire-encoded sync failure: which spinner stage failed, which CLI +/// error shape the local body raised, the remote URL, and the exact +/// message text the local body composed. Encoded as JSON in the status +/// message so the client hook raises the identical CLI error. +fn sync_error(stage: &str, variant: &str, url: &str, message: String) -> Status { + domain_status( + ErrorCode::Remote, + json!({ + "stage": stage, + "variant": variant, + "url": url, + "message": message, + }) + .to_string(), + ) +} + +/// The local `convert_remote_error` mapping (push flavor), ported: it +/// produces the (variant, message, url) the CLI raised. +fn convert_remote_error_push(error: RemoteError, url: &str) -> (&'static str, String) { + match error { + RemoteError::ConnectionFailed { .. } => { + ("remote_error", format!("Failed to connect: {error}")) + } + RemoteError::AuthenticationFailed { .. } => ("authentication_failed", url.to_string()), + RemoteError::RepositoryNotFound { .. } => { + ("remote_error", not_found_push_message("the project")) + } + RemoteError::ViewNotFound { view } => ( + "remote_error", + not_found_push_message(&format!("project/view '{view}'")), + ), + RemoteError::ChangeNotFound { hash } => ("change_not_found", hash), + RemoteError::MissingDependencies { + count, + missing_hashes, + } => ( + "missing_dependency", + if missing_hashes.is_empty() { + format!("{count} dependencies") + } else { + missing_hashes.join(", ") + }, + ), + RemoteError::StateMismatch { + remote_state, + requested_state, + } => ( + "conflict", + format!("State mismatch: remote is at {remote_state}, requested {requested_state}"), + ), + RemoteError::Timeout { seconds } => ( + "remote_error", + format!("Request timed out after {seconds} seconds"), + ), + RemoteError::HttpError { status: 413, .. } => ( + "remote_error", + "Change too large for server (413 Payload Too Large). \ + The server's body size limit is too small. \ + Ask the server admin to increase MAX_BODY_SIZE_MB." + .to_string(), + ), + other => ("remote_error", other.to_string()), + } +} + +fn not_found_push_message(target: &str) -> String { + format!( + "Remote returned 'not found' for {target}. This means either it \ + doesn't exist, or you're not authenticated/authorized to push to it. \ + Make sure your identity is registered with the server \ + (`atomic identity register `) and that you have push access." + ) +} + +/// The pull body's `convert_remote_error` (pull/helpers.rs), ported. +fn convert_remote_error_pull(error: RemoteError, _url: &str) -> (&'static str, String) { + match error { + RemoteError::ConnectionFailed { .. } => { + ("remote_error", format!("Failed to connect: {error}")) + } + RemoteError::AuthenticationFailed { .. } => ("authentication_failed", String::new()), + other => ("remote_error", other.to_string()), + } +} + +// --------------------------------------------------------------------------- +// shared domain ports (the local push/pull helpers' logic, verbatim) +// --------------------------------------------------------------------------- + +/// The HTTP remote config from the request knobs + the client's auth +/// headers. +fn remote_config( + timeout_secs: u64, + insecure: bool, + auth_headers: &HashMap, +) -> HttpRemoteConfig { + let mut config = HttpRemoteConfig::new() + .with_timeout(Duration::from_secs(timeout_secs)) + .danger_accept_invalid_certs(insecure); + for (name, value) in auth_headers { + config = config.with_header(name.clone(), value.clone()); + } + config +} + +/// The local `build_view_chain` — the ancestor chain, root → leaf, with +/// the cycle guard. +fn build_view_chain(repo: &Repository, leaf: &str) -> Result, Status> { + let mut chain = vec![leaf.to_string()]; + let mut visited: HashSet = HashSet::new(); + visited.insert(leaf.to_string()); + let mut current = leaf.to_string(); + loop { + let parent = repo + .get_view_info(¤t) + .map_err(|_| domain_status(ErrorCode::View, format!("view '{current}' not found")))? + .parent_name; + let Some(parent) = parent else { break }; + if !visited.insert(parent.clone()) { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!("view parent chain contains a cycle at '{parent}'"), + )); + } + chain.push(parent.clone()); + current = parent; + } + chain.reverse(); + Ok(chain) +} + +/// The remote ref advertisement — the local `parse_advertisement`. +#[derive(Clone)] +struct Advertisement { + ref_targets: HashMap, + manifests: HashMap, +} + +fn parse_advertisement(pack: &SyncPack, url: &str, stage: &str) -> Result { + let mut snapshots: HashMap = HashMap::new(); + for object in &pack.objects { + if object.family == ObjectFamily::View { + if let Some(snapshot) = ViewSnapshot::from_bytes(&object.bytes) { + snapshots.insert(object.key.clone(), snapshot); + } + } + } + let mut ref_targets = HashMap::new(); + let mut manifests = HashMap::new(); + for reference in &pack.refs { + ref_targets.insert(reference.name.clone(), reference.new_target.clone()); + if let Some(snapshot) = snapshots.get(&reference.new_target) { + let manifest = parse_remote_manifest( + &reference.name, + &snapshot.to_manifest_text(&reference.name), + url, + stage, + )?; + manifests.insert(reference.name.clone(), manifest); + } + } + Ok(Advertisement { + ref_targets, + manifests, + }) +} + +/// The local `parse_remote_manifest` — parse + verify, mapping failures to +/// the remote error text. +fn parse_remote_manifest( + view: &str, + text: &str, + url: &str, + stage: &str, +) -> Result { + let manifest = ViewManifest::parse(text).map_err(|error| { + sync_error( + stage, + "remote_error", + url, + format!("Invalid manifest for view '{view}' from remote: {error}"), + ) + })?; + manifest.verify().map_err(|error| { + sync_error( + stage, + "remote_error", + url, + format!("Corrupt manifest for view '{view}' from remote: {error}"), + ) + })?; + Ok(manifest) +} + +/// The local `plan_view_sync` — the set-union membership model with the +/// identity checks and the declare decision. +struct ViewSyncPlan { + suffix: Vec, + declare: bool, + forced: bool, + shrink: bool, +} + +impl ViewSyncPlan { + fn is_noop(&self) -> bool { + self.suffix.is_empty() && !self.declare + } +} + +#[allow(dead_code)] +enum ViewSyncConflict { + Diverged { + first_mismatch: Option, + local_len: usize, + remote_len: usize, + }, + IdentityMismatch { + field: &'static str, + local: String, + remote: String, + }, +} + +impl ViewSyncConflict { + fn describe(&self) -> String { + match self { + ViewSyncConflict::Diverged { + first_mismatch, + local_len, + remote_len, + } => match first_mismatch { + Some(index) => format!( + "remote log ({remote_len} changes) is not a prefix of local log \ + ({local_len} changes): first mismatch at position {index}" + ), + None => format!( + "remote log ({remote_len} changes) is longer than local log \ + ({local_len} changes)" + ), + }, + ViewSyncConflict::IdentityMismatch { + field, + local, + remote, + } => { + format!("view {field} differs: local '{local}' vs remote '{remote}'") + } + } + } +} + +fn plan_view_sync( + local: &ViewManifest, + remote: Option<&ViewManifest>, +) -> Result { + let Some(remote) = remote else { + return Ok(ViewSyncPlan { + suffix: local.changes.clone(), + declare: true, + forced: false, + shrink: false, + }); + }; + if remote.scope != local.scope { + return Err(ViewSyncConflict::IdentityMismatch { + field: "scope", + local: local.scope.to_string(), + remote: remote.scope.to_string(), + }); + } + if remote.parent != local.parent { + let show = |parent: &Option| parent.clone().unwrap_or_else(|| "(none)".to_string()); + return Err(ViewSyncConflict::IdentityMismatch { + field: "parent", + local: show(&local.parent), + remote: show(&remote.parent), + }); + } + let remote_set: HashSet = remote.changes.iter().copied().collect(); + let local_set: HashSet = local.changes.iter().copied().collect(); + let suffix = local + .changes + .iter() + .filter(|hash| !remote_set.contains(hash)) + .copied() + .collect(); + Ok(ViewSyncPlan { + suffix, + declare: !local_set.is_subset(&remote_set), + forced: false, + shrink: false, + }) +} + +/// The local `mint_view_snapshot` — the content-addressed snapshot mint +/// (the own-set-id fold matches the server bridge). +fn mint_view_snapshot(manifest: &ViewManifest, prev: Option) -> ViewSnapshot { + let own_changes: Vec = manifest.changes.iter().map(|h| h.to_base32()).collect(); + let mut acc = SetId::ZERO; + for hash in &manifest.changes { + acc = acc.add(hash); + } + let own_set_id = acc.to_base32(); + let merkle_state = if manifest.changes.is_empty() { + None + } else { + Some(manifest.state.to_base32()) + }; + let scope = if manifest.scope.is_draft() { + ViewScopeLabel::Draft + } else { + ViewScopeLabel::Shared + }; + ViewSnapshot::new( + scope, + manifest.parent.clone(), + prev.into_iter().collect(), + own_changes, + own_set_id, + merkle_state, + ) +} + +/// The local `load_change_data` — the raw on-disk change bytes (the exact +/// bytes that produced the content hash), with the load+serialize fallback. +fn load_change_data(repo: &Repository, hash: &Hash) -> Result { + let change_path = repo.change_store().change_path(hash); + if change_path.exists() { + let data = std::fs::read(&change_path).map_err(|error| { + domain_status( + ErrorCode::Repository, + format!("Failed to read change file {:?}: {error}", change_path), + ) + })?; + return Ok(Bytes::from(data)); + } + let change = repo.load_change(hash).map_err(|_| { + domain_status( + ErrorCode::Changes, + format!("change not found: {}", hash.to_base32()), + ) + })?; + let mut buffer = Vec::new(); + change.serialize(&mut buffer).map_err(|error| { + domain_status(ErrorCode::Repository, format!("serialize change: {error}")) + })?; + Ok(Bytes::from(buffer)) +} + +fn load_change_message(repo: &Repository, hash: &Hash) -> Option { + repo.load_change(hash) + .ok() + .map(|change| change.hashed.header.message.clone()) +} + +fn manifest_state(view: &str, manifest: &ViewManifest) -> ManifestState { + ManifestState { + view: view.to_string(), + state: manifest.state.to_base32(), + change_count: manifest.changes.len() as u64, + } +} + +/// One planned view: the wire report plus the (possibly leaf-renamed) +/// local manifest the sync phase declares. +struct PlannedView { + report: PushViewReport, + manifest: ViewManifest, +} + +// --------------------------------------------------------------------------- +// PushChanges +// --------------------------------------------------------------------------- + +pub async fn push_changes_impl( + state: Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("PushChanges", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + + // Resolve the remote name + URL (the client may have resolved the URL + // through ListRemotes; otherwise the same domain lookup runs here). + let requested_remote = request.remote.clone(); + let client_url = request.remote_url.clone(); + let (remote_name, remote_url) = { + let handle = handle.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository_readonly()?; + let name = if requested_remote.is_empty() { + repo.get_default_remote() + .map(|(name, _)| name) + .unwrap_or_else(|_| DEFAULT_REMOTE.to_string()) + } else { + requested_remote + }; + let url = if let Some(url) = client_url { + url + } else if name.contains("://") { + name.clone() + } else { + repo.get_remote(&name) + .map(|entry| entry.url) + .map_err(|error| match error { + atomic_repository::RepositoryError::RemoteNotFound { .. } => { + domain_status(ErrorCode::NotFound, format!("remote '{name}' not found")) + } + other => repository_error(other), + })? + }; + Ok::<_, Status>((name, url)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + let to_view = request.to_view.clone(); + let from_view = request.from_view.clone(); + let dry_run = request.dry_run; + let all = request.all; + let insecure = request.insecure; + let timeout_secs = if request.timeout_secs == 0 { + DEFAULT_TIMEOUT_SECS + } else { + request.timeout_secs + }; + let auth_headers: HashMap = request.auth_headers.clone(); + + // The local view (the current-view default reads the working-copy + // marker — the same default every local command applies). + let local_view = match &from_view { + Some(view) => view.clone(), + None => handle.current_view(), + }; + let remote_view = to_view.clone().unwrap_or_else(|| local_view.clone()); + + // The ancestor chain, root → leaf. + let chain = { + let handle = handle.clone(); + let leaf = local_view.clone(); + tokio::task::spawn_blocking(move || build_view_chain(&handle.repository_readonly()?, &leaf)) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + // Connect (the auth headers ride the client-built config). + let config = remote_config(timeout_secs, insecure, &auth_headers); + let remote = HttpRemote::with_config(&remote_url, config).map_err(|error| { + let (variant, message) = convert_remote_error_push(error, &remote_url); + sync_error("connect", variant, &remote_url, message) + })?; + + // Fetch the remote advertisement. + let remote_names: Vec = chain + .iter() + .map(|name| { + if name == &local_view { + remote_view.clone() + } else { + name.clone() + } + }) + .collect(); + let adv_pack = remote + .sync_pull(&SyncWants::advertise(remote_names.clone())) + .await + .map_err(|error| { + let (variant, message) = convert_remote_error_push(error, &remote_url); + sync_error("advertise", variant, &remote_url, message) + })?; + let advertisement = parse_advertisement(&adv_pack, &remote_url, "advertise")?; + + // Plan phase: per-view sync plans (no mutation happens here — a + // conflict rides the response so the client renders the local block). + let (planned, plan_conflict): (Vec, Option) = { + let handle = handle.clone(); + let advertisement = advertisement.clone(); + let chain = chain.clone(); + let local_view = local_view.clone(); + let remote_view = remote_view.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository_readonly()?; + let mut known_on_remote: HashSet = HashSet::new(); + let mut planned = Vec::with_capacity(chain.len()); + let mut conflict: Option = None; + for name in &chain { + let is_leaf = name == &local_view; + let remote_view_name = if is_leaf { + remote_view.clone() + } else { + name.clone() + }; + let mut manifest = repo.view_manifest(name).map_err(repository_error)?; + if manifest.name != remote_view_name { + manifest.name = remote_view_name.clone(); + } + let remote_manifest = advertisement.manifests.get(&remote_view_name).cloned(); + if let Some(remote_manifest) = &remote_manifest { + known_on_remote.extend(remote_manifest.changes.iter().copied()); + } + let (noop, shrink, forced) = + match plan_view_sync(&manifest, remote_manifest.as_ref()) { + Ok(plan) => (plan.is_noop(), plan.shrink, plan.forced), + Err(view_conflict) => { + conflict = Some(PushConflict { + view: name.clone(), + identity_mismatch: matches!( + view_conflict, + ViewSyncConflict::IdentityMismatch { .. } + ), + description: view_conflict.describe(), + local: Some(manifest_state(name, &manifest)), + remote: remote_manifest + .as_ref() + .map(|manifest| manifest_state(&manifest.name, manifest)), + }); + break; + } + }; + // With --all, re-store the full log (content-addressed, + // idempotent); otherwise the suffix minus known-present. + let candidates: &[Hash] = if all { + &manifest.changes + } else { + &manifest + .changes + .iter() + .filter(|hash| !known_on_remote.contains(hash)) + .copied() + .collect::>() + }; + let to_store_details = candidates + .iter() + .map(|hash| PushChange { + hash: Some(hash_proto(hash)), + message: load_change_message(&repo, hash), + }) + .collect::>(); + planned.push(PlannedView { + report: PushViewReport { + remote_name: remote_view_name, + scope: manifest.scope.to_string(), + parent: manifest.parent.clone(), + exists_on_remote: remote_manifest.is_some(), + remote_change_count: remote_manifest + .as_ref() + .map(|manifest| manifest.changes.len() as u64) + .unwrap_or(0), + noop, + shrink, + forced, + to_store: to_store_details, + log_count: manifest.changes.len() as u64, + declared: false, + }, + manifest, + }); + } + Ok::<_, Status>((planned, conflict)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + if let Some(conflict) = plan_conflict { + return Ok(Response::new(PushChangesResponse { + pushed_changes: 0, + manifests: Vec::new(), + report: None, + conflict: Some(conflict), + meta: super::services_agent::response_meta(&meta), + })); + } + + // Dry run: the plan is the whole outcome. + if dry_run { + let report = PushReport { + remote_name, + remote_url, + views: planned.into_iter().map(|view| view.report).collect(), + total_stored: 0, + views_declared: 0, + attestations: Vec::new(), + provenance: Vec::new(), + tags: Vec::new(), + }; + return Ok(Response::new(PushChangesResponse { + pushed_changes: 0, + manifests: Vec::new(), + report: Some(report), + conflict: None, + meta: super::services_agent::response_meta(&meta), + })); + } + + // Sync phase: store the change files, mint the snapshots + ref CAS + // moves, queue attestations/provenance/tags. + let all_noop = planned.iter().all(|view| view.report.noop); + let sync_phase = { + let handle = handle.clone(); + let local_view = local_view.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository_readonly()?; + let mut total_stored = 0usize; + let mut views_declared = 0u64; + let mut stored_hashes: Vec = Vec::new(); + let mut declared_manifests: Vec = Vec::new(); + let mut pack = SyncPack::empty(); + // The published closure: the union of every view's full log + // (the local body's all_synced). + let mut all_synced: HashSet = HashSet::new(); + for view in &planned { + all_synced.extend(view.manifest.changes.iter().copied()); + } + let mut stored_reports = Vec::new(); + for view in &planned { + if view.report.noop { + stored_reports.push(view.report.clone()); + continue; + } + let to_store: Vec = view + .report + .to_store + .iter() + .filter_map(|change| { + change + .hash + .as_ref() + .and_then(|hash| hash.value.clone().try_into().ok()) + .map(Merkle) + }) + .collect(); + if !to_store.is_empty() { + for hash in &to_store { + let data = load_change_data(&repo, hash)?; + pack.objects.push(ObjectRecord::new( + ObjectFamily::Change, + hash.to_base32(), + data.to_vec(), + )); + } + total_stored += to_store.len(); + stored_hashes.extend(to_store.iter().copied()); + } + // The mint + ref CAS move. + let prev = advertisement + .ref_targets + .get(&view.report.remote_name) + .cloned(); + let snapshot = mint_view_snapshot(&view.manifest, prev.clone()); + let snap_key = snapshot.content_key(); + pack.objects.push(ObjectRecord::new( + ObjectFamily::View, + snap_key.clone(), + snapshot.to_canonical_bytes(), + )); + pack.refs.push(RefRecord { + name: view.report.remote_name.clone(), + expect_old: prev, + new_target: snap_key, + }); + views_declared += 1; + declared_manifests.push(view.manifest.clone()); + let mut report = view.report.clone(); + report.declared = true; + stored_reports.push(report); + } + // Upload attestations that cover only published changes. + let mut attest_reports = Vec::new(); + { + let mut seen: HashSet = HashSet::new(); + for pushed_hash in &stored_hashes { + for (attest_hash, attestation) in repo + .find_attestations_for_change(pushed_hash) + .unwrap_or_default() + { + if !seen.insert(attest_hash) { + continue; + } + let all_covered = attestation + .changes_covered + .iter() + .all(|hash| all_synced.contains(hash)); + if !all_covered { + continue; + } + let Ok(data) = attestation.serialize() else { + continue; + }; + pack.objects.push(ObjectRecord::new( + ObjectFamily::Attest, + attest_hash.to_base32(), + data, + )); + attest_reports.push(PushSidecar { + hash: Some(hash_proto(&attest_hash)), + cost: Some(attestation.cost_display()), + covered: attestation.change_count() as u64, + nodes: 0, + explained: 0, + }); + } + } + } + // Upload provenance graphs whose explained changes all travel. + let mut provenance_reports = Vec::new(); + { + let mut seen: HashSet = HashSet::new(); + for pushed_hash in &stored_hashes { + for (prov_hash, graph) in repo + .find_provenance_for_change(pushed_hash) + .unwrap_or_default() + { + if !seen.insert(prov_hash) { + continue; + } + let all_explained = graph + .changes_explained + .iter() + .all(|hash| all_synced.contains(hash)); + if !all_explained { + continue; + } + let Ok(data) = graph.serialize() else { + continue; + }; + pack.objects.push(ObjectRecord::new( + ObjectFamily::Provenance, + prov_hash.to_base32(), + data, + )); + provenance_reports.push(PushSidecar { + hash: Some(hash_proto(&prov_hash)), + cost: None, + covered: 0, + nodes: graph.node_count() as u64, + explained: graph.change_count() as u64, + }); + } + } + } + // Upload the pushed leaf view's tags (declared under the + // possibly-renamed remote view name). + let mut tag_reports = Vec::new(); + for tag in repo.list_tags_for_view(&local_view).unwrap_or_default() { + let tag_hash = tag.content_hash(); + let Ok(bytes) = atomic_repository::serialize_tag(&tag) else { + continue; + }; + pack.objects.push(ObjectRecord::new( + ObjectFamily::Tag, + tag_hash.to_base32(), + bytes, + )); + tag_reports.push(PushTag { + hash: Some(hash_proto(&tag_hash)), + name: tag.name.clone(), + kind: tag.kind.to_string(), + }); + } + Ok::<_, Status>(( + pack, + stored_reports, + declared_manifests, + total_stored as u64, + views_declared, + attest_reports, + provenance_reports, + tag_reports, + )) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + let ( + pack, + view_reports, + declared_manifests, + total_stored, + views_declared, + attestations, + provenance, + tags, + ) = sync_phase; + + // Send everything in one /code push, then verify the union: every + // declared manifest's patch must be present remotely. + if !pack.is_empty() { + remote.sync_push(&pack).await.map_err(|error| { + let (variant, message) = convert_remote_error_push(error, &remote_url); + sync_error("send", variant, &remote_url, message) + })?; + let final_pack = remote + .sync_pull(&SyncWants::advertise(remote_names.clone())) + .await + .map_err(|error| { + let (variant, message) = convert_remote_error_push(error, &remote_url); + sync_error("verify", variant, &remote_url, message) + })?; + let final_adv = parse_advertisement(&final_pack, &remote_url, "verify")?; + for manifest in &declared_manifests { + let Some(final_manifest) = final_adv.manifests.get(&manifest.name) else { + return Err(sync_error( + "verify-union", + "remote_error", + &remote_url, + format!("Remote did not advertise pushed view '{}'", manifest.name), + )); + }; + let final_set: HashSet = final_manifest.changes.iter().copied().collect(); + if let Some(missing) = manifest + .changes + .iter() + .find(|hash| !final_set.contains(hash)) + { + return Err(sync_error( + "verify-patch", + "conflict", + &remote_url, + format!( + "Remote view '{}' does not contain proposed patch {}", + manifest.name, + missing.to_base32() + ), + )); + } + } + } + let _ = all_noop; + + let report = PushReport { + remote_name, + remote_url, + views: view_reports, + total_stored, + views_declared, + attestations, + provenance, + tags, + }; + Ok(Response::new(PushChangesResponse { + pushed_changes: total_stored, + manifests: Vec::new(), + report: Some(report), + conflict: None, + meta: super::services_agent::response_meta(&meta), + })) +} + +// --------------------------------------------------------------------------- +// PullChanges +// --------------------------------------------------------------------------- + +/// The local `manifest_chain_from_pack` — the remote metadata chain in +/// root-to-leaf order, with the set-id cross-check warning. +fn manifest_chain_from_pack( + pack: &SyncPack, + leaf: &str, + url: &str, + warnings: &mut Vec, +) -> Result, Status> { + let mut by_name: HashMap = HashMap::new(); + for reference in &pack.refs { + if let Some(manifest) = manifest_from_pack(pack, &reference.name, url, warnings)? { + by_name.insert(reference.name.clone(), manifest); + } + } + let mut chain = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut cursor = Some(leaf.to_string()); + while let Some(name) = cursor { + if !seen.insert(name.clone()) { + return Err(sync_error( + "fetch", + "remote_error", + url, + format!("Remote view parent chain contains a cycle at '{name}'"), + )); + } + let manifest = by_name.remove(&name).ok_or_else(|| { + sync_error( + "fetch", + "remote_error", + url, + format!("Remote view metadata is missing '{name}'"), + ) + })?; + cursor = manifest.parent.clone(); + chain.push(manifest); + } + chain.reverse(); + Ok(chain) +} + +/// The local `manifest_from_pack` — one view's manifest reconstructed from +/// the pack's ref + snapshot, with the O(1) set-id cross-check. +fn manifest_from_pack( + pack: &SyncPack, + name: &str, + url: &str, + warnings: &mut Vec, +) -> Result, Status> { + let Some(target) = pack.refs.iter().find(|r| r.name == name) else { + return Ok(None); + }; + let target = target.new_target.clone(); + let snapshot = pack + .objects + .iter() + .find(|object| object.family == ObjectFamily::View && object.key == target) + .and_then(|object| ViewSnapshot::from_bytes(&object.bytes)); + let Some(snapshot) = snapshot else { + return Ok(None); + }; + let manifest = ViewManifest::parse(&snapshot.to_manifest_text(name)).map_err(|error| { + sync_error( + "fetch", + "remote_error", + url, + format!("Corrupt manifest for view '{name}': {error}"), + ) + })?; + let mut fold = SetId::ZERO; + for hash in &manifest.changes { + fold = fold.add(hash); + } + if fold.to_base32() != snapshot.own_set_id { + warnings.push(format!( + "Remote view '{name}' snapshot set-id disagrees with its change list; \ + the remote object may be inconsistent." + )); + } + Ok(Some(manifest)) +} + +/// The local `save_downloaded_change` — deserialize, verify the hash, save. +fn save_downloaded_change(repo: &Repository, hash: &Hash, data: Bytes) -> Result<(), String> { + let mut cursor = std::io::Cursor::new(&data[..]); + let (change, computed) = atomic_core::change::Change::deserialize(&mut cursor) + .map_err(|error| format!("Failed to deserialize change: {error}"))?; + if computed != *hash { + return Err(format!( + "Hash mismatch: expected {}, got {}", + hash.to_base32(), + computed.to_base32() + )); + } + repo.save_change(&change) + .map(|_hash| ()) + .map_err(|error| format!("Failed to save change: {error}")) +} + +/// The local `import_sidecars` (sidecars.rs) — ingest the pack's +/// provenance graphs and attestations; warnings collect for the client. +#[derive(Default)] +struct SidecarStats { + provenance: usize, + attestations: usize, +} + +fn import_sidecars(repo: &Repository, pack: &SyncPack, warnings: &mut Vec) -> SidecarStats { + let mut stats = SidecarStats::default(); + fn short(key: &str) -> &str { + &key[..12.min(key.len())] + } + for object in &pack.objects { + match object.family { + ObjectFamily::Provenance => { + let Some(hash) = Hash::from_base32(object.key.as_bytes()) else { + continue; + }; + let already_present = repo.has_provenance_graph(&hash); + match atomic_core::change::ProvenanceGraph::deserialize(&object.bytes) { + Ok((graph, computed)) => { + if computed != hash { + warnings.push(format!( + "Provenance {} failed hash verification — skipped", + short(&object.key) + )); + continue; + } + match repo.save_provenance_graph(&graph).map(|_hash| ()) { + Ok(()) if !already_present => stats.provenance += 1, + Ok(()) => {} + Err(error) => warnings.push(format!( + "Failed to register provenance {}: {error}", + short(&object.key) + )), + } + } + Err(error) => warnings.push(format!( + "Corrupt provenance {}: {error}", + short(&object.key) + )), + } + } + ObjectFamily::Attest => { + let Some(hash) = Hash::from_base32(object.key.as_bytes()) else { + continue; + }; + let already_present = repo.has_attestation(&hash); + match atomic_core::change::Attestation::deserialize(&object.bytes) { + Ok((attestation, computed)) => { + if computed != hash { + warnings.push(format!( + "Attestation {} failed hash verification — skipped", + short(&object.key) + )); + continue; + } + match repo.save_attestation(&attestation).map(|_hash| ()) { + Ok(()) if !already_present => stats.attestations += 1, + Ok(()) => {} + Err(error) => warnings.push(format!( + "Failed to register attestation {}: {error}", + short(&object.key) + )), + } + } + Err(error) => warnings.push(format!( + "Corrupt attestation {}: {error}", + short(&object.key) + )), + } + } + _ => {} + } + } + stats +} + +pub async fn pull_changes_impl( + state: Arc, + request: Request, +) -> Result, Status> { + use crate::atomic::{PullDownload, PullReport, PullSidecar, PullTag, PullWarning}; + let request = request.into_inner(); + let handle = state.resolve(request.repository.as_ref().unwrap())?; + state.log_rpc("PullChanges", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + + // Resolve the remote name + URL (same domain lookup as push; the + // client may have pre-resolved the URL through ListRemotes). + let requested_remote = request.remote.clone(); + let client_url = request.remote_url.clone(); + let (remote_name, remote_url) = { + let handle = handle.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let name = if requested_remote.is_empty() { + repo.get_default_remote() + .map(|(name, _)| name) + .unwrap_or_else(|_| DEFAULT_REMOTE.to_string()) + } else { + requested_remote + }; + let url = if let Some(url) = client_url { + url + } else if name.contains("://") { + name.clone() + } else { + repo.get_remote(&name) + .map(|entry| entry.url) + .map_err(|error| match error { + atomic_repository::RepositoryError::RemoteNotFound { .. } => { + domain_status(ErrorCode::NotFound, format!("remote '{name}' not found")) + } + other => repository_error(other), + })? + }; + Ok::<_, Status>((name, url)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + let to_view = request.to_view.clone(); + let from_view = request.from_view.clone(); + let dry_run = request.dry_run; + let insecure = request.insecure; + let timeout_secs = if request.timeout_secs == 0 { + DEFAULT_TIMEOUT_SECS + } else { + request.timeout_secs + }; + let download_only = request.download_only; + let auth_headers: HashMap = request.auth_headers.clone(); + + // Views: the remote view defaults to the current view; the local view + // defaults to the remote view being pulled. + let current_view = handle.current_view(); + let remote_view = from_view.clone().unwrap_or_else(|| current_view.clone()); + let local_view = to_view.clone().unwrap_or_else(|| remote_view.clone()); + + // The local view's state (loaded for display only) + the graph-wide + // haves. + let (local_view_exists, local_entries_len, local_tip, haves) = { + let handle = handle.clone(); + let local_view = local_view.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let exists = repo.view_exists(&local_view).map_err(repository_error)?; + let entries = if exists { + repo.log(atomic_repository::HistoryOptions::new().view(&local_view)) + .map_err(repository_error)? + } else { + Vec::new() + }; + let tip = entries.last().map(|entry| entry.hash.to_base32()); + let haves = repo + .registered_change_hashes() + .map_err(repository_error)? + .into_iter() + .map(|hash| hash.to_base32()) + .collect::>(); + Ok::<_, Status>((exists, entries.len(), tip, haves)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + // Connect. + let config = remote_config(timeout_secs, insecure, &auth_headers); + let remote = HttpRemote::with_config(&remote_url, config).map_err(|error| { + let (variant, message) = convert_remote_error_pull(error, &remote_url); + sync_error("connect", variant, &remote_url, message) + })?; + + // One /code pull: the remote view's metadata chain + missing objects. + let mut warnings: Vec = Vec::new(); + let pull_pack = remote + .sync_pull(&SyncWants { + refs: vec![remote_view.clone()], + haves, + refs_only: dry_run, + }) + .await + .map_err(|error| { + let (variant, message) = convert_remote_error_pull(error, &remote_url); + sync_error("fetch", variant, &remote_url, message) + })?; + let mut remote_chain = + match manifest_chain_from_pack(&pull_pack, &remote_view, &remote_url, &mut warnings) { + Ok(chain) if !chain.is_empty() => chain, + _ => { + return Err(sync_error( + "fetch-missing", + "remote_error", + &remote_url, + format!("Remote view '{remote_view}' does not exist"), + )); + } + }; + // --to-view renames only the requested leaf locally. + if let Some(leaf) = remote_chain.last_mut() { + leaf.name = local_view.clone(); + } + let remote_manifest = remote_chain.last().expect("non-empty remote chain").clone(); + let remote_entries = remote_manifest.changes.len() as u64; + let remote_state = if remote_manifest.changes.is_empty() { + String::new() + } else { + remote_manifest.state.to_base32() + }; + // The local body's delta: every change object in the pack is missing. + let change_objects: HashMap> = pull_pack + .objects + .iter() + .filter(|object| object.family == ObjectFamily::Change) + .map(|object| (object.key.clone(), object.bytes.clone())) + .collect(); + let to_download: Vec = change_objects + .keys() + .filter_map(|key| Hash::from_base32(key.as_bytes())) + .collect(); + + // Local-only changes (the diverged-history warning). + let local_only: Vec = { + let handle = handle.clone(); + let local_view = local_view.clone(); + let remote_manifest = remote_manifest.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + if !local_view_exists { + return Ok::, Status>(Vec::new()); + } + let local = repo + .log(atomic_repository::HistoryOptions::new().view(&local_view)) + .map_err(repository_error)?; + let remote_set: HashSet = remote_manifest + .changes + .iter() + .map(|hash| hash.to_base32()) + .collect(); + Ok(local + .iter() + .filter(|entry| !remote_set.contains(&entry.hash.to_base32())) + .map(|entry| entry.hash.to_base32()) + .collect()) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + // Dry run: the delta is the whole outcome. + if dry_run { + let downloads = to_download + .iter() + .map(|hash| PullDownload { + hash: Some(hash_proto(hash)), + message: None, + ok: true, + error: None, + index: 0, + total: to_download.len() as u64, + }) + .collect(); + let report = PullReport { + remote_name, + remote_url, + remote_view, + local_view, + local_view_changes: local_entries_len as u64, + graph_objects: 0, + remote_changes: remote_entries, + remote_state: Some(remote_state), + local_only, + downloads, + changes_downloaded: 0, + bytes_transferred: 0, + applied: false, + applied_changes: 0, + apply_errors: Vec::new(), + sidecars: Vec::new(), + set_id_verified: false, + set_id_warning: None, + materialized: false, + files_written: 0, + materialize_error: None, + tags: Vec::new(), + warnings: warnings + .iter() + .map(|text| PullWarning { text: text.clone() }) + .collect(), + local_tip, + vault_bootstrapped: false, + reconciled_views: 0, + download_only: false, + changes_failed: 0, + }; + return Ok(Response::new(PullChangesResponse { + applied_changes: 0, + updated_views: Vec::new(), + report: Some(report), + meta: super::services_agent::response_meta(&meta), + })); + } + + // Save the missing graph nodes. + let (downloads, changes_downloaded, bytes_transferred, changes_failed) = { + let handle = handle.clone(); + let change_objects = change_objects.clone(); + let to_download = to_download.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let mut changes_downloaded = 0u64; + let mut bytes_transferred = 0u64; + let mut changes_failed = 0u64; + let mut downloads = Vec::with_capacity(to_download.len()); + for (index, hash) in to_download.iter().enumerate() { + let hash_str = hash.to_base32(); + let message = repo + .load_change(hash) + .ok() + .map(|change| change.hashed.header.message.clone()); + // The local body prints the message from the downloaded + // bytes; pull it from the deserialized change instead of a + // second store read (identical text). + let entry = match change_objects.get(&hash_str) { + Some(data) => { + let data_len = data.len() as u64; + match save_downloaded_change(&repo, hash, Bytes::from(data.clone())) { + Ok(()) => { + changes_downloaded += 1; + bytes_transferred += data_len; + let message = message.or_else(|| { + let mut cursor = std::io::Cursor::new(&data[..]); + atomic_core::change::Change::deserialize(&mut cursor) + .ok() + .map(|(change, _)| change.hashed.header.message) + }); + PullDownload { + hash: Some(hash_proto(hash)), + message, + ok: true, + error: None, + index: index as u64, + total: to_download.len() as u64, + } + } + Err(error) => { + changes_failed += 1; + PullDownload { + hash: Some(hash_proto(hash)), + message, + ok: false, + error: Some(format!("save failed: {error}")), + index: index as u64, + total: to_download.len() as u64, + } + } + } + } + None => { + // The local body aborts the pull here. + return Err(sync_error( + "download", + "change_not_found", + &hash_str.clone(), + hash_str, + )); + } + }; + downloads.push(entry); + } + Ok::<_, Status>(( + downloads, + changes_downloaded, + bytes_transferred, + changes_failed, + )) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + // Download-only: sidecars still land, nothing is applied. + if download_only { + let (sidecar_reports, warnings) = { + let handle = handle.clone(); + let pull_pack = pull_pack.clone(); + let warnings = warnings.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let mut warnings = warnings; + let mut sidecar_reports = Vec::new(); + let stats = import_sidecars(&repo, &pull_pack, &mut warnings); + if stats.provenance > 0 { + sidecar_reports.push(PullSidecar { + kind: "provenance".to_string(), + count: stats.provenance as u64, + detail: None, + }); + } + if stats.attestations > 0 { + sidecar_reports.push(PullSidecar { + kind: "attestation".to_string(), + count: stats.attestations as u64, + detail: None, + }); + } + Ok::<_, Status>((sidecar_reports, warnings)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + let report = PullReport { + remote_name, + remote_url, + remote_view, + local_view, + local_view_changes: local_entries_len as u64, + graph_objects: 0, + remote_changes: remote_entries, + remote_state: Some(remote_state), + local_only, + downloads, + changes_downloaded, + bytes_transferred, + applied: false, + applied_changes: 0, + apply_errors: Vec::new(), + sidecars: sidecar_reports, + set_id_verified: false, + set_id_warning: None, + materialized: false, + files_written: 0, + materialize_error: None, + tags: Vec::new(), + warnings: warnings + .iter() + .map(|text| PullWarning { text: text.clone() }) + .collect(), + local_tip, + vault_bootstrapped: false, + reconciled_views: 0, + download_only: true, + changes_failed, + }; + return Ok(Response::new(PullChangesResponse { + applied_changes: 0, + updated_views: Vec::new(), + report: Some(report), + meta: super::services_agent::response_meta(&meta), + })); + } + + // Reconcile the view metadata root-to-leaf over the populated graph. + let (reconciled_views, applied, applied_changes, apply_errors) = { + let handle = handle.clone(); + let remote_chain = remote_chain.clone(); + tokio::task::spawn_blocking(move || { + let mut repo = handle.repository()?; + let mut applied_changes = 0u64; + let mut applied = false; + let mut apply_errors = Vec::new(); + for manifest in &remote_chain { + match repo.reconcile_view_manifest(manifest) { + Ok(outcome) => { + applied_changes += outcome.replayed as u64; + if outcome.replayed > 0 { + applied = true; + } + } + Err(error) => apply_errors.push(format!( + "Failed to reconcile view '{}': {error}", + manifest.name + )), + } + } + Ok::<_, Status>(( + remote_chain.len() as u64, + applied, + applied_changes, + apply_errors, + )) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + // Sidecars ingest AFTER reconciliation (the DEPS edges need the + // covered changes registered). + let (sidecar_reports, warnings) = { + let handle = handle.clone(); + let pull_pack = pull_pack.clone(); + let warnings = warnings.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let mut warnings = warnings; + let mut sidecar_reports = Vec::new(); + let stats = import_sidecars(&repo, &pull_pack, &mut warnings); + if stats.provenance > 0 { + sidecar_reports.push(PullSidecar { + kind: "provenance".to_string(), + count: stats.provenance as u64, + detail: None, + }); + } + if stats.attestations > 0 { + sidecar_reports.push(PullSidecar { + kind: "attestation".to_string(), + count: stats.attestations as u64, + detail: None, + }); + } + Ok::<_, Status>((sidecar_reports, warnings)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + // Set-id verification (the server's effective set-id vs the local). + let mut set_id_verified = false; + let mut set_id_warning: Option = None; + if applied { + let remote_view = remote_view.clone(); + let expected = remote.list_view_refs().await.ok().and_then(|inventory| { + inventory + .into_iter() + .find(|view| view.name == remote_view) + .and_then(|view| view.set_id) + }); + let handle = handle.clone(); + let local_view = local_view.clone(); + let (verified, warning) = { + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let mut set_id_verified = false; + let mut set_id_warning: Option = None; + match (repo.view_set_id(&local_view), expected) { + (Ok(local), Some(expected)) => { + if local.to_base32() == expected { + set_id_verified = true; + } else { + set_id_warning = Some(format!( + "Set-id mismatch after pull: local {} != remote {}. \ + The view may be divergent or incomplete.", + local.to_base32(), + expected + )); + } + } + (Ok(_), None) => { /* remote reported no set-id; skip */ } + (Err(error), _) => { + set_id_warning = Some(format!("Could not compute local set-id: {error}")); + } + } + Ok::<_, Status>((set_id_verified, set_id_warning)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + set_id_verified = verified; + set_id_warning = warning; + } + + // Materialize the working copy (only when pulling the current view). + let (materialized, files_written, materialize_error, vault_bootstrapped, warnings) = if applied + { + let handle = handle.clone(); + let local_view = local_view.clone(); + let current_view = current_view.clone(); + let warnings_in = warnings.clone(); + let materialize_outcome = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let mut warnings = warnings_in; + let mut materialized = false; + let mut files_written = 0u64; + let mut materialize_error: Option = None; + let mut vault_bootstrapped = false; + if local_view == current_view { + match repo.materialize() { + Ok(result) => { + materialized = true; + files_written = result.files_written as u64; + } + Err(error) => { + materialize_error = Some(error.to_string()); + } + } + } + // The pulled vault content deflates into redb (the log-only + // notes ride nothing; only failures warn). + if repo.vault_dir().exists() { + if repo.has_vault().unwrap_or(false) { + if let Err(error) = repo.vault_record_working_copy() { + warnings.push(format!( + "Failed to synchronize pulled vault content: {error}" + )); + } + } else { + match repo.bootstrap_vault_from_working_copy() { + Ok(()) => vault_bootstrapped = true, + Err(error) => { + warnings.push(format!("Failed to bootstrap vault: {error}")); + } + } + } + } + Ok::<_, Status>(( + materialized, + files_written, + materialize_error, + vault_bootstrapped, + warnings, + )) + }) + .await + .map_err(|error| Status::internal(error.to_string()))??; + materialize_outcome + } else { + (false, 0u64, None, false, warnings) + }; + + // Download the remote view's tags. + let (tags, warnings) = { + let handle = handle.clone(); + let pull_pack = pull_pack.clone(); + let local_view = local_view.clone(); + let warnings_in = warnings.clone(); + tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + let mut warnings = warnings_in; + let mut tags = Vec::new(); + for object in pull_pack + .objects + .iter() + .filter(|o| o.family == ObjectFamily::Tag) + { + match atomic_repository::deserialize_tag(&object.bytes) { + Ok(tag) => { + if let Ok(Some(_)) = repo.get_tag_from_view(&tag.name, &local_view) { + continue; + } + match repo.save_synced_tag(&tag) { + Ok(()) => tags.push(PullTag { + name: tag.name.clone(), + kind: tag.kind.to_string(), + }), + Err(error) => { + warnings.push(format!("Failed to save tag '{}': {error}", tag.name)) + } + } + } + Err(error) => { + warnings.push(format!("Failed to deserialize tag: {error}")); + } + } + } + Ok::<_, Status>((tags, warnings)) + }) + .await + .map_err(|error| Status::internal(error.to_string()))?? + }; + + let report = PullReport { + remote_name, + remote_url, + remote_view, + local_view, + local_view_changes: local_entries_len as u64, + graph_objects: 0, + remote_changes: remote_entries, + remote_state: Some(remote_state), + local_only, + downloads, + changes_downloaded, + bytes_transferred, + applied, + applied_changes, + apply_errors, + sidecars: sidecar_reports, + set_id_verified, + set_id_warning, + materialized, + files_written, + materialize_error, + tags, + warnings: warnings + .iter() + .map(|text| PullWarning { text: text.clone() }) + .collect(), + local_tip, + vault_bootstrapped, + reconciled_views, + download_only: false, + changes_failed, + }; + Ok(Response::new(PullChangesResponse { + applied_changes, + updated_views: remote_chain + .iter() + .map(|manifest| manifest.name.clone()) + .collect(), + report: Some(report), + meta: super::services_agent::response_meta(&meta), + })) +} + +// --------------------------------------------------------------------------- +// BindRemoteProject +// --------------------------------------------------------------------------- + +pub async fn bind_remote_project_impl( + state: Arc, + request: Request, +) -> Result, Status> { + let _ = state; + let _ = request; + Err(Status::unimplemented( + "BindRemoteProject lands with its slice", + )) +} diff --git a/libatomic/src/daemon/services_tag.rs b/libatomic/src/daemon/services_tag.rs new file mode 100644 index 00000000..474eb6e5 --- /dev/null +++ b/libatomic/src/daemon/services_tag.rs @@ -0,0 +1,222 @@ +//! TagService handlers: `atomic tag create|delete|list|show` — the domain +//! half (the exact repository calls the local CLI bodies make), with the +//! wire carrying the full tag record so the CLI renders the local reports. +//! +//! ONE implementation: the atomicd transport serves these handlers over the +//! socket and the CLI's local service mode calls them in-process. The +//! create flow reproduces the local body exactly — the existing-tag +//! pre-check, the `--force` delete, and the Release kind. + +use std::sync::Arc; + +use crate::atomic::ErrorCode; +use crate::atomic::*; +use atomic_core::types::Base32; +use atomic_core::types::Hash; +use atomic_repository::TagKind; +use atomic_repository::TagRecord; +use tonic::{Request, Response, Status}; + +use super::convert::{hash_proto, timestamp_proto}; +use super::services_agent::response_meta; +use super::state::{domain_status, repository_error, DaemonState}; + +pub struct TagImpl { + pub state: Arc, +} + +/// The domain tag record → the wire's full TagInfo (add-only fields ride +/// the wire so the CLI renders the local listing/show lines). +fn tag_info(tag: &TagRecord) -> TagInfo { + TagInfo { + name: tag.name.clone(), + state: Some(hash_proto(&tag.state)), + message: tag.message.clone(), + created_at: Some(timestamp_proto(tag.timestamp)), + view: tag.view.clone(), + sequence: tag.sequence, + kind: tag.kind.to_string(), + author: tag.author.as_ref().map(|author| Author { + name: author.name.clone(), + email: author.email.clone(), + }), + metadata: tag.metadata.as_ref().map(|value| value.to_string()), + } +} + +fn tag_already_exists(name: &str) -> Status { + domain_status( + ErrorCode::InvalidArgument, + format!("Tag '{name}' already exists. Use --force to overwrite."), + ) +} + +/// The create-tag domain error mapping — the local body's exact cases. +fn create_tag_error(error: atomic_repository::RepositoryError, name: &str) -> Status { + match error { + atomic_repository::RepositoryError::TagAlreadyExists { .. } => tag_already_exists(name), + atomic_repository::RepositoryError::InvalidTagName { name, reason } => domain_status( + ErrorCode::InvalidArgument, + format!("Invalid tag name '{name}': {reason}"), + ), + atomic_repository::RepositoryError::ViewNotFound { name } => { + domain_status(ErrorCode::View, format!("view '{name}' does not exist")) + } + other => repository_error(other), + } +} + +#[tonic::async_trait] +impl tag_service_server::TagService for TagImpl { + async fn create_tag( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("CreateTag", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let name = request.name.clone(); + let message = request.message.clone(); + let force = request.force; + let tag = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + // The local body's pre-check: refuse an existing tag unless + // --force deleted it first. + if let Ok(Some(_)) = repo.get_tag(&name) { + if force { + let _ = repo.delete_tag(&name); + } else { + return Err(tag_already_exists(&name)); + } + } + let tag = repo + .create_tag(&name, message.as_deref(), TagKind::Release) + .map_err(|error| create_tag_error(error, &name))?; + Ok::<_, Status>(tag) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(CreateTagResponse { + tag: Some(tag_info(&tag)), + meta: response_meta(&meta), + })) + } + + async fn delete_tag( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("DeleteTag", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let meta = request.meta.clone(); + let name = request.name.clone(); + let deleted = tokio::task::spawn_blocking(move || { + let repo = handle.repository()?; + repo.delete_tag(&name).map_err(repository_error) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(DeleteTagResponse { + deleted, + meta: response_meta(&meta), + })) + } + + async fn list_tags( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ListTags", Some(&handle)); + let view = request.view.clone(); + let pattern = request.pattern.clone(); + let annotated_only = request.annotated_only; + let tags = tokio::task::spawn_blocking(move || { + let repo = handle.repository_readonly()?; + // The local listing's domain call: a view filter switches to + // that view's tag list, otherwise every tag across views. + let mut tags = match &view { + Some(view) => repo.list_tags_for_view(view).map_err(repository_error)?, + None => repo.list_all_tags().map_err(repository_error)?, + }; + // The local body's filters, same semantics: the star-stripped + // substring pattern match, and the annotated-only keep. + if let Some(pattern) = &pattern { + let pat = pattern.replace('*', ""); + tags.retain(|tag| tag.name.contains(&pat)); + } + if annotated_only { + tags.retain(|tag| tag.is_annotated()); + } + Ok::<_, Status>(tags) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(ListTagsResponse { + tags: tags.iter().map(tag_info).collect(), + })) + } + + async fn get_tag( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("GetTag", Some(&handle)); + let name = request.name.clone(); + let (tag, records) = tokio::task::spawn_blocking(move || { + let repo = handle.repository_readonly()?; + let Some(tag) = repo.get_tag(&name).map_err(repository_error)? else { + return Ok::<_, Status>((None, Vec::new())); + }; + // The ReviewGate metadata render reads each record hash's + // presence and view membership — the same domain reads the + // local show body performs, computed here so the CLI renders. + let mut records = Vec::new(); + if tag.kind == TagKind::ReviewGate { + if let Some(metadata) = &tag.metadata { + if let Some(original) = metadata + .get("changes") + .and_then(|changes| changes.get("original_hashes")) + .and_then(|value| value.as_array()) + { + for entry in original { + let Some(text) = entry.as_str() else { continue }; + match Hash::from_base32(text.as_bytes()) { + Some(hash) => { + let present = repo.has_change(&hash); + let views = + repo.views_containing_change(&hash).unwrap_or_default(); + records.push(TagRecordPresence { + hash: text.to_string(), + present, + views, + parseable: true, + }); + } + None => records.push(TagRecordPresence { + hash: text.to_string(), + present: false, + views: Vec::new(), + parseable: false, + }), + } + } + } + } + } + Ok((Some(tag_info(&tag)), records)) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(GetTagResponse { tag, records })) + } +} diff --git a/libatomic/src/daemon/services_triage.rs b/libatomic/src/daemon/services_triage.rs new file mode 100644 index 00000000..7c57d963 --- /dev/null +++ b/libatomic/src/daemon/services_triage.rs @@ -0,0 +1,134 @@ +//! TriageService handlers — the promotion-review surface. +//! +//! `ListTriageCandidates` runs the repository's candidate-set domain call +//! (the view diff, the dependency-closure additions, and the baggage +//! classification) plus the view-pair resolution the local bodies +//! perform, and carries the domain JSON so the CLI renders the local +//! reports. `GenerateTriageReview` (the canonical report builder) is a +//! separate structural migration — see the guard note in the CLI's +//! `triage review` dispatch. + +use std::sync::Arc; + +use crate::atomic::ErrorCode; +use crate::atomic::*; +use atomic_repository::Repository; +use tonic::{Request, Response, Status}; + +use super::state::{domain_status, repository_error, DaemonState}; + +pub struct TriageImpl { + pub state: Arc, +} + +/// The local `resolve_views` semantics — the (feature, target) pair with +/// the same defaults (current view; its parent) and the same errors +/// (unknown view, root view without --into). +fn resolve_triage_views( + repo: &Repository, + feature: Option<&str>, + into: Option<&str>, +) -> Result<(String, String), Status> { + let source = match feature { + Some(name) => { + require_triage_view(repo, name, "")?; + name.to_string() + } + None => repo.current_view().to_string(), + }; + let target = match into { + Some(name) => { + require_triage_view(repo, name, "--into")?; + name.to_string() + } + None => match repo.parent_change_count(&source) { + Ok(Some((parent, _))) => parent, + Ok(None) => { + return Err(domain_status( + ErrorCode::InvalidArgument, + format!( + "'{source}' is a root view — it has no parent to promote into.\n \ + Pass --into to choose a target (see 'atomic view list')." + ), + )); + } + Err(atomic_repository::RepositoryError::ViewNotFound { name }) => { + return Err(unknown_triage_view(repo, &name, "")); + } + Err(error) => return Err(repository_error(error)), + }, + }; + Ok((source, target)) +} + +fn require_triage_view(repo: &Repository, name: &str, arg: &str) -> Result<(), Status> { + if repo.view_exists(name).map_err(repository_error)? { + Ok(()) + } else { + Err(unknown_triage_view(repo, name, arg)) + } +} + +fn unknown_triage_view(repo: &Repository, name: &str, arg: &str) -> Status { + domain_status( + ErrorCode::InvalidArgument, + format!( + "No view named '{name}' — {arg} takes a real view name, not a placeholder.\n \ + Current view is '{}'; omit {arg} to triage that instead, or run \ + 'atomic view list' to see all views.", + repo.current_view() + ), + ) +} + +#[tonic::async_trait] +impl triage_service_server::TriageService for TriageImpl { + async fn list_triage_candidates( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let handle = self.state.resolve(request.repository.as_ref().unwrap())?; + self.state.log_rpc("ListTriageCandidates", Some(&handle)); + let feature_arg = request.from_view.clone(); + let into_arg = request.to_view.clone(); + let (feature, target, payload) = tokio::task::spawn_blocking(move || { + let repo = handle.repository_readonly()?; + let (feature, target) = + resolve_triage_views(&repo, feature_arg.as_deref(), into_arg.as_deref())?; + let set = repo + .triage_candidate_set(&feature, &target) + .map_err(repository_error)?; + let payload = serde_json::to_vec(&set) + .map_err(|error| Status::internal(format!("candidate set encode: {error}")))?; + Ok::<_, Status>((feature, target, payload)) + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(ListTriageCandidatesResponse { + candidates: Vec::new(), + candidate_set: Some(VersionedBytes { + schema: "atomic.triage.candidates.v1".to_string(), + payload, + }), + feature, + target, + })) + } + + async fn generate_triage_review( + &self, + _request: Request, + ) -> Result, Status> { + // The canonical report builder (the CLI's triage/project.rs — the + // change → file → task → intent → acceptance-criterion join) is a + // 2000-line module entangled with CLI-only presentation helpers + // (hunk display summaries, the diff -c builder, the intent + // bridge); porting it is a separate structural migration. This + // stays an explicit refusal — never a silent local fallback. + Err(Status::unimplemented( + "GenerateTriageReview lands with its slice (the canonical report builder \ + migration — see the CLI's triage review dispatch note)", + )) + } +} From 736ca34da57356b1bf71d0d4a5910870675b10db Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 6 Oct 2026 14:17:32 -0500 Subject: [PATCH 12/23] chore: workspace lockfile for the libatomic service deps --- Cargo.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index de0a6941..3c100bfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1861,9 +1861,12 @@ dependencies = [ "atomic-canonical", "atomic-core", "atomic-identity", + "atomic-objects", + "atomic-remote", "atomic-repository", "atomic-semantic", "blake3", + "bytes", "chrono", "data-encoding", "futures-core", From 4ab7c084f65354ca7cdd1ba979b1017fe6fcf281 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 6 Oct 2026 15:08:50 -0500 Subject: [PATCH 13/23] feat(contract): adopt the #230 merged-database APIs in the service layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state.change_store: ensure_database (the legacy-layout merge) then open_existing on the merged atomic.redb — redb refuses a second concurrent handle, so the journal-port handlers keep the sequential open/use/drop discipline under the gate. - DirectJournalSink holds NO handle: each journal operation opens the merged database, runs, and drops it (the orchestrator opens the same file; the two can no longer coexist as concurrent handles). - atomic-repository: sync the #230 database module (pub database mod for the CLI's root-finder predicate) + the CandidateSet/BaggageEntry/ Coverage Deserialize derives for the triage wire. --- atomic-repository/src/repository/mod.rs | 2 +- libatomic/src/daemon/journal_sink.rs | 52 ++++++++++++++----------- libatomic/src/daemon/state.rs | 15 ++++--- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/atomic-repository/src/repository/mod.rs b/atomic-repository/src/repository/mod.rs index 25bb6211..e7fceb2a 100644 --- a/atomic-repository/src/repository/mod.rs +++ b/atomic-repository/src/repository/mod.rs @@ -77,7 +77,7 @@ use crate::RepositoryError; // ── Sub-modules (new) ─────────────────────────────────────────────────── -mod database; +pub mod database; mod deferred_tree; mod filter; mod materialize; diff --git a/libatomic/src/daemon/journal_sink.rs b/libatomic/src/daemon/journal_sink.rs index d4d9cc62..67af0114 100644 --- a/libatomic/src/daemon/journal_sink.rs +++ b/libatomic/src/daemon/journal_sink.rs @@ -8,8 +8,6 @@ //! handlers: generation fencing, request-ID echo and caller-supplied `now` //! live in the store layer itself. -use std::sync::Arc; - use atomic_agent::{ JournalAppendAck, JournalCheckpointAttempt, JournalCheckpointSource, JournalStopCause, JournalTurnLifecycle, JournalTurnReservation, JournalTurnStatus, ProvenanceJournalEnvelope, @@ -21,21 +19,29 @@ use atomic_repository::redb_change_store::{ FrozenProvenanceCursor, FrozenProvenancePage, ProvenanceCheckpointAttempt as StoreAttempt, ProvenanceCheckpointSource, ProvenanceId, StopCause, StopState, StoredProvenanceTurn, }; -use atomic_repository::Repository; +/// The sink holds NO database handle: post-#230 the change store lives in +/// the merged atomic.redb — the same file the orchestrator's repository +/// opens — and redb refuses a second concurrent handle. Each journal +/// operation opens the store, runs, and drops it (the sequential +/// open/close discipline #230's orchestrator applies). pub struct DirectJournalSink { - store: Arc, + root: std::path::PathBuf, } impl DirectJournalSink { pub fn open(root: &std::path::Path) -> Result { + // Resolve (and merge, when a legacy layout is present) the merged + // database path — WITHOUT holding a handle. + let dot_dir = root.join(".atomic"); let path = - Repository::canonical_change_store_path(root).map_err(|error| error.to_string())?; - let store = atomic_repository::redb_change_store::RedbChangeStore::open(&path) - .map_err(|error| format!("failed to open {}: {error}", path.display()))?; - Ok(Self { - store: Arc::new(store), - }) + atomic_repository::ensure_database(&dot_dir).map_err(|error| error.to_string())?; + Ok(Self { root: path }) + } + + fn store(&self) -> Result { + atomic_repository::redb_change_store::RedbChangeStore::open_existing(&self.root) + .map_err(|error| format!("failed to open {}: {error}", self.root.display())) } } @@ -137,7 +143,7 @@ impl ProvenanceJournalSink for DirectJournalSink { now: i64, ) -> Result { let turn = self - .store + .store()? .reserve_provenance_turn(session_id, turn_number, now) .map_err(|error| error.to_string())?; Ok(JournalTurnReservation { @@ -166,7 +172,7 @@ impl ProvenanceJournalSink for DirectJournalSink { .map(|(id, bytes)| (id.as_str(), bytes.as_slice())) .collect(); let stored = self - .store + .store()? .append_provenance_envelopes( ProvenanceId::new(reservation.provenance_id), reservation.generation, @@ -190,7 +196,7 @@ impl ProvenanceJournalSink for DirectJournalSink { now: i64, ) -> Result { let attempt = self - .store + .store()? .prepare_provenance_checkpoint( ProvenanceId::new(reservation.provenance_id), reservation.generation, @@ -211,7 +217,7 @@ impl ProvenanceJournalSink for DirectJournalSink { // The daemon is in-process with the store: no frame budgets, no // paging — one direct read. let stored = self - .store + .store()? .load_frozen_provenance_envelopes(ProvenanceId::new(checkpoint.provenance_id)) .map_err(|error| error.to_string())?; Ok(stored.into_iter().map(|entry| entry.envelope).collect()) @@ -225,7 +231,7 @@ impl ProvenanceJournalSink for DirectJournalSink { now: i64, ) -> Result { let attempt = self - .store + .store()? .bind_provenance_checkpoint_hash( ProvenanceId::new(checkpoint.provenance_id), checkpoint.attempt_generation, @@ -246,7 +252,7 @@ impl ProvenanceJournalSink for DirectJournalSink { manifest_hash: Hash, completed_at: i64, ) -> Result<(), String> { - self.store + self.store()? .acknowledge_provenance_checkpoint( ProvenanceId::new(checkpoint.provenance_id), checkpoint.attempt_generation, @@ -266,12 +272,12 @@ impl ProvenanceJournalSink for DirectJournalSink { observed_at: i64, ) -> Result, String> { let turn = self - .store + .store()? .get_provenance_turn_for(session_id, turn_number) .map_err(|error| error.to_string())?; let Some(turn) = turn else { return Ok(None) }; let stopped = self - .store + .store()? .stop_provenance_turn( turn.provenance_id, turn.generation, @@ -293,12 +299,12 @@ impl ProvenanceJournalSink for DirectJournalSink { now: i64, ) -> Result, String> { let turn = self - .store + .store()? .get_provenance_turn_for(session_id, turn_number) .map_err(|error| error.to_string())?; let Some(turn) = turn else { return Ok(None) }; let resumed = self - .store + .store()? .resume_provenance_turn(turn.provenance_id, turn.generation, now) .map_err(|error| error.to_string())?; Ok(Some(to_journal_turn_status(resumed))) @@ -311,12 +317,12 @@ impl ProvenanceJournalSink for DirectJournalSink { observed_at: i64, ) -> Result, String> { let turn = self - .store + .store()? .get_provenance_turn_for(session_id, turn_number) .map_err(|error| error.to_string())?; let Some(turn) = turn else { return Ok(None) }; let abandoned = self - .store + .store()? .abandon_provenance_turn( turn.provenance_id, turn.generation, @@ -337,7 +343,7 @@ impl ProvenanceJournalSink for DirectJournalSink { turn_number: u32, ) -> Result, String> { let turn = self - .store + .store()? .get_provenance_turn_for(session_id, turn_number) .map_err(|error| error.to_string())?; Ok(turn.map(to_journal_turn_status)) diff --git a/libatomic/src/daemon/state.rs b/libatomic/src/daemon/state.rs index 545c856d..bbde5255 100644 --- a/libatomic/src/daemon/state.rs +++ b/libatomic/src/daemon/state.rs @@ -117,14 +117,17 @@ impl RepoHandle { }) } - /// The redb-native change/provenance store (changes.redb) for this - /// request — opened by path WITHOUT opening the repository database. - /// The hosted turn orchestrator opens pristine itself; changes.redb is a - /// separate database file, so both can be open in-process together. + /// The redb-native change/provenance store (atomic.redb — the merged + /// database) for this request — opened by path WITHOUT opening the + /// repository handle. redb refuses a second concurrent handle on the + /// same file, so the journal-port handlers open, use, and drop this + /// sequentially under the gate (the same open/close-per-op discipline + /// #230's orchestrator applies). pub fn change_store(&self) -> Result { - let path = Repository::canonical_change_store_path(&self.root) + let dot_dir = self.root.join(".atomic"); + let path = atomic_repository::ensure_database(&dot_dir) .map_err(|error| domain_status(ErrorCode::Repository, error.to_string()))?; - RedbChangeStore::open(&path).map_err(|error| { + RedbChangeStore::open_existing(&path).map_err(|error| { domain_status( ErrorCode::ProvenanceStore, format!("failed to open {}: {error}", path.display()), From 8059551b115fef197ebd8341c2b1ae6f67029e71 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 6 Oct 2026 23:17:14 -0500 Subject: [PATCH 14/23] fix(cli): find #230 merged-database repositories with the root finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #230 merged pristine.redb and changes.redb into a single atomic.redb, so a fresh init no longer writes pristine.redb — but the CLI's root finder still required it and could not discover #230-created repos at all. The finder now applies the same predicate Repository::find_root uses (has_database: atomic.redb, or the legacy pristine.redb before the merge), and the discovery integration test asserts the merged database file. --- atomic-cli/src/commands/mod.rs | 8 +++++--- atomic-cli/tests/repository_discovery_integration_test.rs | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/atomic-cli/src/commands/mod.rs b/atomic-cli/src/commands/mod.rs index a63e90e9..ba10c13f 100644 --- a/atomic-cli/src/commands/mod.rs +++ b/atomic-cli/src/commands/mod.rs @@ -295,8 +295,10 @@ pub fn find_repository_root_from(start_path: &Path) -> CliResult { loop { let dot_dir = current.join(DOT_DIR); // A bare `.atomic/` may hold global configuration (e.g. ~/.atomic), - // not a repository. Require the repository's graph database too. - if dot_dir.is_dir() && dot_dir.join("pristine.redb").is_file() { + // not a repository. Require the repository's graph database too — + // the SAME predicate Repository::find_root applies (atomic.redb, + // or the legacy pristine.redb before the #230 database merge). + if dot_dir.is_dir() && atomic_repository::repository::database::has_database(&dot_dir) { return Ok(current); } @@ -639,7 +641,7 @@ mod tests { let mut check = temp.path().to_path_buf(); let mut found = false; loop { - if check.join(DOT_DIR).join("pristine.redb").is_file() + if atomic_repository::repository::database::has_database(&check.join(DOT_DIR)) || check.join(SANDBOX_POINTER).is_file() { found = true; diff --git a/atomic-cli/tests/repository_discovery_integration_test.rs b/atomic-cli/tests/repository_discovery_integration_test.rs index 21e23ef7..83436570 100644 --- a/atomic-cli/tests/repository_discovery_integration_test.rs +++ b/atomic-cli/tests/repository_discovery_integration_test.rs @@ -110,7 +110,9 @@ fn init_and_queries_from_repository_subdirectories_still_work() { let output = atomic(&project, &home, &["init"]); assert!(output.status.success(), "{output:?}"); - assert!(project.join(".atomic/pristine.redb").is_file()); + // #230 merged the databases: a fresh init writes the merged + // atomic.redb (the legacy pristine.redb no longer exists). + assert!(project.join(".atomic/atomic.redb").is_file()); let nested = project.join("src/deep"); std::fs::create_dir_all(nested.join(".atomic")).unwrap(); @@ -122,7 +124,7 @@ fn init_and_queries_from_repository_subdirectories_still_work() { let output = atomic(&nested, &home, args); assert!(output.status.success(), "{args:?}: {output:?}"); } - assert!(!nested.join(".atomic/pristine.redb").exists()); + assert!(!nested.join(".atomic/atomic.redb").exists()); } #[test] From 2277483079ceeebd18c5f785f5ea0f7fd056efb2 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 7 Oct 2026 11:09:12 -0500 Subject: [PATCH 15/23] feat(contract): route the full CLI command surface through the service layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The complete routing story the contract carries: every command family routes through the libatomic handlers (local in-process backend by default, reactor RPC when ATOMIC_SERVICE=reactor), with the render parity kept in the CLI over wire data. - atomic-cli: the rpc.rs hooks + src/service dispatch for every wired command (tags, provenance, remote registry, push/pull, sandbox, top-level split, agent explain, intent delete/link, memory write, vault materialize/summaries/init, vault goal lifecycle, triage candidates, session fork/rebuild, query KG extras, project init) - owner.rs and its integration suite removed: the database-owner command is dismantled by this port - routing parity tests for the newly wired surfaces - provenance_rpc_sink: resolve the Reactor checkout via ATOMIC_REACTOR_ROOT, and skip the suite when no Reactor checkout exists (CI runners) — the daemon binary is not buildable from this workspace alone - atomic-client: the socket-path crate the CLI service routing shares - workspace Cargo.toml/lock: the atomic-client member --- Cargo.lock | 20 + Cargo.toml | 6 +- atomic-cli/Cargo.toml | 6 + atomic-cli/src/commands/add.rs | 4 + atomic-cli/src/commands/agent/attest.rs | 344 +- atomic-cli/src/commands/agent/explain.rs | 21 +- atomic-cli/src/commands/agent/hooks.rs | 6 +- atomic-cli/src/commands/agent/lifecycle.rs | 15 +- atomic-cli/src/commands/agent/mod.rs | 33 +- atomic-cli/src/commands/agent/owner.rs | 2052 ----- .../src/commands/agent/provenance_rpc.rs | 1355 ++++ atomic-cli/src/commands/agent/receive.rs | 142 + atomic-cli/src/commands/change/command.rs | 106 +- atomic-cli/src/commands/change/mod.rs | 6 +- atomic-cli/src/commands/conflicts.rs | 6 + atomic-cli/src/commands/diff/command.rs | 16 + atomic-cli/src/commands/diff/helpers.rs | 227 +- atomic-cli/src/commands/diff/mod.rs | 10 +- atomic-cli/src/commands/diff/output.rs | 7 +- atomic-cli/src/commands/doctor.rs | 12 + atomic-cli/src/commands/insert.rs | 189 +- atomic-cli/src/commands/intent/attest.rs | 4 + atomic-cli/src/commands/intent/bridge.rs | 118 +- atomic-cli/src/commands/intent/delete.rs | 7 + atomic-cli/src/commands/intent/link.rs | 6 + atomic-cli/src/commands/intent/list.rs | 124 +- atomic-cli/src/commands/intent/new.rs | 77 +- atomic-cli/src/commands/intent/show.rs | 115 +- atomic-cli/src/commands/intent/update.rs | 11 +- atomic-cli/src/commands/intent/validate.rs | 4 + atomic-cli/src/commands/intent/verify.rs | 4 + atomic-cli/src/commands/log/command.rs | 4 + atomic-cli/src/commands/log/mod.rs | 4 +- atomic-cli/src/commands/memory/attest.rs | 4 + atomic-cli/src/commands/memory/bridge.rs | 114 +- atomic-cli/src/commands/memory/list.rs | 99 +- atomic-cli/src/commands/memory/new.rs | 4 + atomic-cli/src/commands/memory/show.rs | 107 +- atomic-cli/src/commands/memory/validate.rs | 4 + atomic-cli/src/commands/memory/verify.rs | 4 + atomic-cli/src/commands/memory/write.rs | 7 + atomic-cli/src/commands/mod.rs | 1 + atomic-cli/src/commands/mv.rs | 6 + atomic-cli/src/commands/project/init.rs | 10 + atomic-cli/src/commands/provenance/command.rs | 141 +- atomic-cli/src/commands/pull/command.rs | 7 + atomic-cli/src/commands/push/command.rs | 9 + atomic-cli/src/commands/push/mod.rs | 2 +- atomic-cli/src/commands/query/mod.rs | 53 +- atomic-cli/src/commands/record/builder.rs | 8 +- atomic-cli/src/commands/record/command.rs | 4 + atomic-cli/src/commands/record/format.rs | 78 +- atomic-cli/src/commands/record/mod.rs | 1 + atomic-cli/src/commands/remote/mod.rs | 7 + atomic-cli/src/commands/remove.rs | 6 + atomic-cli/src/commands/restore.rs | 31 +- atomic-cli/src/commands/revise.rs | 154 +- atomic-cli/src/commands/rpc.rs | 6763 +++++++++++++++++ atomic-cli/src/commands/sandbox.rs | 11 + atomic-cli/src/commands/session.rs | 117 +- atomic-cli/src/commands/split.rs | 10 +- atomic-cli/src/commands/stash.rs | 433 +- atomic-cli/src/commands/status.rs | 4 + atomic-cli/src/commands/tag/create.rs | 7 + atomic-cli/src/commands/tag/delete.rs | 6 + atomic-cli/src/commands/tag/list.rs | 7 + atomic-cli/src/commands/tag/show.rs | 104 +- atomic-cli/src/commands/triage/mod.rs | 90 +- atomic-cli/src/commands/unrecord.rs | 10 + atomic-cli/src/commands/vault/context.rs | 57 +- atomic-cli/src/commands/vault/goal.rs | 101 +- atomic-cli/src/commands/vault/init.rs | 6 + atomic-cli/src/commands/vault/list.rs | 93 +- atomic-cli/src/commands/vault/materialize.rs | 6 + atomic-cli/src/commands/vault/mod.rs | 2 +- atomic-cli/src/commands/vault/show.rs | 12 +- atomic-cli/src/commands/vault/summaries.rs | 6 + atomic-cli/src/commands/vault/sync.rs | 4 + atomic-cli/src/commands/view/delete.rs | 30 +- atomic-cli/src/commands/view/list.rs | 74 +- atomic-cli/src/commands/view/new.rs | 33 +- atomic-cli/src/commands/view/promote.rs | 5 + atomic-cli/src/commands/view/split.rs | 7 + atomic-cli/src/commands/view/switch.rs | 18 +- atomic-cli/src/error.rs | 13 + atomic-cli/src/main.rs | 1 + atomic-cli/src/service/mod.rs | 1634 ++++ .../tests/database_owner_integration_test.rs | 2248 ------ atomic-cli/tests/owner_wait_budget_test.rs | 166 - atomic-cli/tests/provenance_rpc_sink.rs | 425 ++ atomic-cli/tests/restore_integration_test.rs | 4 +- atomic-cli/tests/routing_parity_test.rs | 1362 ++++ atomic-cli/tests/unrecord_integration_test.rs | 8 +- atomic-client/Cargo.toml | 21 + atomic-client/src/lib.rs | 138 + 95 files changed, 14257 insertions(+), 5701 deletions(-) delete mode 100644 atomic-cli/src/commands/agent/owner.rs create mode 100644 atomic-cli/src/commands/agent/provenance_rpc.rs create mode 100644 atomic-cli/src/commands/agent/receive.rs create mode 100644 atomic-cli/src/commands/rpc.rs create mode 100644 atomic-cli/src/service/mod.rs delete mode 100644 atomic-cli/tests/database_owner_integration_test.rs delete mode 100644 atomic-cli/tests/owner_wait_budget_test.rs create mode 100644 atomic-cli/tests/provenance_rpc_sink.rs create mode 100644 atomic-cli/tests/routing_parity_test.rs create mode 100644 atomic-client/Cargo.toml create mode 100644 atomic-client/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 3c100bfe..358b0f79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,7 @@ dependencies = [ "anyhow", "atomic-agent", "atomic-canonical", + "atomic-client", "atomic-config", "atomic-core", "atomic-identity", @@ -234,10 +235,13 @@ dependencies = [ "dirs", "env_logger", "fs2", + "futures-core", "git2", "indicatif", + "libatomic", "log", "open", + "prost-types", "rayon", "reqwest", "serde", @@ -246,11 +250,26 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "tokio-stream", + "tonic", "url", "uuid", "windows-sys 0.61.2", ] +[[package]] +name = "atomic-client" +version = "0.19.1" +dependencies = [ + "atomic-repository", + "hyper-util", + "libatomic", + "libc", + "tokio", + "tonic", + "tower 0.4.13", +] + [[package]] name = "atomic-config" version = "0.19.1" @@ -3642,6 +3661,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index fd2ece5e..d50c46c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "atomic-agent", "atomic-canonical", "atomic-cli", + "atomic-client", "atomic-core", "atomic-config", "atomic-identity", @@ -28,6 +29,7 @@ rust-version = "1.90" # Core atomic-agent = { path = "atomic-agent" } atomic-canonical = { path = "atomic-canonical" } +atomic-client = { path = "atomic-client" } atomic-core = { path = "atomic-core" } atomic-config = { path = "atomic-config" } atomic-identity = { path = "atomic-identity" } @@ -45,7 +47,9 @@ prost-types = "0.13" tonic-build = "0.12" protox = "0.7" tokio-stream = "0.1" -futures-core = "0.3" +tower = "0.4" +libc = "0.2" +hyper-util = { version = "0.1", features = ["tokio"] } # Serialization serde = { version = "1.0", features = ["derive"] } diff --git a/atomic-cli/Cargo.toml b/atomic-cli/Cargo.toml index a60709a4..26ebfb9a 100644 --- a/atomic-cli/Cargo.toml +++ b/atomic-cli/Cargo.toml @@ -30,6 +30,10 @@ atomic-identity = { workspace = true } atomic-objects = { workspace = true } atomic-remote = { workspace = true } atomic-repository = { workspace = true } +libatomic = { workspace = true } +atomic-client = { workspace = true } +tonic = { workspace = true } +prost-types = { workspace = true } atomic-semantic = { workspace = true } atomic-teams = { workspace = true, optional = true } @@ -42,6 +46,8 @@ console = { workspace = true } # Async runtime tokio = { workspace = true } +tokio-stream = { workspace = true } +futures-core = "0.3" # Error handling anyhow = { workspace = true } diff --git a/atomic-cli/src/commands/add.rs b/atomic-cli/src/commands/add.rs index c76f3881..33504569 100644 --- a/atomic-cli/src/commands/add.rs +++ b/atomic-cli/src/commands/add.rs @@ -406,6 +406,10 @@ impl Command for Add { /// - A file is inside .atomic/ /// - A database error occurs fn run(&self) -> CliResult<()> { + if crate::commands::rpc::add(self)? { + return Ok(()); + } + // Find the repository root let repo_root = find_repository_root()?; diff --git a/atomic-cli/src/commands/agent/attest.rs b/atomic-cli/src/commands/agent/attest.rs index 4fb87733..1a8f5bf7 100644 --- a/atomic-cli/src/commands/agent/attest.rs +++ b/atomic-cli/src/commands/agent/attest.rs @@ -47,15 +47,15 @@ pub struct Attest { /// Cannot be combined with `--summary` / `--pending` (those are /// provenance-summary modes, not attestation lookups). #[arg(long, value_name = "HASH", conflicts_with_all = ["summary", "pending"])] - hash: Option, + pub(crate) hash: Option, /// Filter to attestations covering changes in this view. #[arg(long, value_name = "VIEW")] - view: Option, + pub(crate) view: Option, /// Show verbose output with model breakdown and coverage. #[arg(short, long)] - verbose: bool, + pub(crate) verbose: bool, /// Show a project-level AI provenance summary instead of the attestation list. /// @@ -68,100 +68,33 @@ pub struct Attest { /// use `--pending ` to summarize only the delta — otherwise /// inherited human/system changes will be counted in the denominator. #[arg(short, long)] - summary: bool, + pub(crate) summary: bool, /// Summarize the **pending delta** of `--view` relative to the given /// parent view (e.g., `--pending dev`). Only changes that are in /// `--view` but not in `` are classified. Implies `--summary` /// and requires `--view` to be set. #[arg(long, value_name = "PARENT_VIEW")] - pending: Option, + pub(crate) pending: Option, } impl Attest { - #[cfg(test)] - pub(crate) fn default_for_test() -> Self { - Self { - hash: None, - view: None, - verbose: false, - summary: false, - pending: None, - } - } -} - -impl Command for Attest { - fn run(&self) -> CliResult<()> { - let repo_root = find_repository_root()?; - let repo = Repository::open(&repo_root).map_err(|e| match e { - atomic_repository::RepositoryError::NotFound { path } => CliError::RepositoryNotFound { - searched_path: path.into(), - }, - other => CliError::Repository(other), - })?; - - // Project-level AI provenance summary (independent of attestations). - // `--pending ` implies summary mode. - if self.summary || self.pending.is_some() { - // `--pending` compares a specific agent view against its parent; - // defaulting the agent view to the current view would produce a - // meaningless "X relative to X" empty delta, so require it. - if self.pending.is_some() && self.view.is_none() { - return Err(CliError::InvalidArgument { - message: - "--pending requires --view (the forked view to summarize)" - .to_string(), - }); - } - return self.show_summary(&repo); - } - - // Show details for a specific attestation - if let Some(ref hash_prefix) = self.hash { - return self.show_detail(&repo, hash_prefix); - } - - // Filter by view - if let Some(ref view_name) = self.view { - return self.show_for_view(&repo, view_name); - } - - // List all attestations - self.list_all(&repo) - } -} - -impl Attest { - /// Print a project-level AI provenance summary. - /// - /// Reads each change's embedded provenance (independent of attestations) - /// and classifies as AI / Human / Needs-attention / System. The headline - /// metric is `ai_authored_pct = AI / (AI + Human)`. - /// - /// Default output is terse and product-facing: the headline %, the human - /// count, and the AI source / tool breakdown. Implementation detail - /// (system/bootstrap exclusions, model breakdown, zero-count data-quality - /// lines) is shown only under `--verbose`. Non-zero data-quality - /// conditions (needs-attention, unreadable) always surface as warnings. - fn show_summary(&self, repo: &Repository) -> CliResult<()> { - let view_name = self - .view - .clone() - .unwrap_or_else(|| repo.current_view().to_string()); - - let (summary, header_line) = if let Some(parent_view) = self.pending.as_deref() { - let s = repo - .provenance_summary_pending(&view_name, parent_view) - .map_err(CliError::Repository)?; - (s, format!("Pending work: {} → {}", view_name, parent_view)) - } else { - let s = repo - .provenance_summary(&view_name) - .map_err(CliError::Repository)?; - (s, format!("Project: {}", view_name)) + /// The `--summary`/`--pending` render, pure over the wire-carried + /// domain summary — the same lines the local body prints (headline + /// percentage, source/tool breakdown, the verbose internals, the + /// data-quality warnings, and the draft-view hint when the view has + /// a recorded parent). + pub(crate) fn render_summary( + summary: &atomic_repository::ProvenanceSummary, + view_name: &str, + pending_parent: Option<&str>, + view_parent: Option<&str>, + verbose: bool, + ) { + let header_line = match pending_parent { + Some(parent) => format!("Pending work: {} → {}", view_name, parent), + None => format!("Project: {}", view_name), }; - println!("{}", header_line); println!(); @@ -206,7 +139,7 @@ impl Attest { } // Verbose: internal accounting. - if self.verbose { + if verbose { println!(); println!( "System/bootstrap changes excluded: {}", @@ -241,21 +174,217 @@ impl Attest { } // On the canonical path, if the user actually selected a forked / - // draft view, point them at --pending. Detection is exact (via the - // view's recorded parent), so this never fires for canonical views. - if self.pending.is_none() { - if let Ok(info) = repo.get_view_info(&view_name) { - if let Some(parent) = info.parent_name { - println!(); - println!("This looks like a draft view. To summarize only pending work:"); - println!( - " atomic agent attest --pending {} --view {}", - parent, view_name, - ); - } + // draft view, point them at --pending. Detection is exact (via + // the view's recorded parent — repo-read locally, wire-carried on + // the routed path), so this never fires for canonical views. + if pending_parent.is_none() { + if let Some(parent) = view_parent { + println!(); + println!("This looks like a draft view. To summarize only pending work:"); + println!( + " atomic agent attest --pending {} --view {}", + parent, view_name + ); + } + } + } + + /// The `--hash` detail render, pure over the wire-carried domain + /// attestation and per-view coverage rows — the same lines the local + /// body prints. + pub(crate) fn render_detail( + hash: &atomic_core::types::Hash, + attest: &Attestation, + coverage: &[CoverageRow], + ) { + println!("Attestation {}", format_hash(hash, false)); + println!(); + + // Agent info + println!("Agent: {}", attest.agent); + println!("Session: {}", attest.session_id); + println!( + "Changes: {}", + format_count(attest.change_count(), "change") + ); + + if attest.duration_wall_ms > 0 { + println!("Wall time: {}", attest.wall_duration_display()); + } + if attest.duration_api_ms > 0 { + println!("API time: {}", attest.api_duration_display()); + } + if attest.cost_usd > 0.0 { + println!("Cost: {}", format_cost(attest.cost_usd)); + } + if attest.total_tokens() > 0 { + println!("Tokens: {}", format_tokens(attest.total_tokens())); + } + if !attest.code_changes.is_empty() { + println!( + "Code: +{} -{}", + attest.code_changes.lines_added, attest.code_changes.lines_removed, + ); + } + + if attest.cost_usd == 0.0 && attest.total_tokens() == 0 { + println!(); + println!("Note: Cost and token data pending."); + println!(" Claude Code does not expose this in the SessionEnd hook."); + println!(" Use 'atomic agent attest --enrich' when available."); + } + + if let Some(ref prev) = attest.previous_attestation { + println!("Previous: {}", format_hash(prev, false)); + } + + if let Some(ref notes) = attest.notes { + println!("Notes: {}", notes); + } + + println!(); + + // Model breakdown (only if there's data) + if !attest.models.is_empty() { + println!("Model Breakdown:"); + for model in &attest.models { + println!(" {}", model); + } + println!(); + } + + // Changes covered + if !attest.changes_covered.is_empty() { + println!("Changes Covered ({}):", attest.change_count(),); + for change_hash in &attest.changes_covered { + println!(" {}", format_hash(change_hash, false)); } } + println!(); + + // Coverage per view (the handler computed the rows; the bars and + // percentages render here). + let mut has_coverage = false; + for row in coverage { + if row.total == 0 || row.covered == 0 { + continue; + } + if !has_coverage { + println!("Coverage:"); + has_coverage = true; + } + let pct = (row.covered as f64 / row.total as f64) * 100.0; + let bar_width = 20; + let filled = ((pct / 100.0) * bar_width as f64) as usize; + let empty = bar_width - filled; + let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty),); + println!( + " {:<20} {} {}/{} ({:.0}%)", + row.view, bar, row.covered, row.total, pct, + ); + } + } + + #[cfg(test)] + pub(crate) fn default_for_test() -> Self { + Self { + hash: None, + view: None, + verbose: false, + summary: false, + pending: None, + } + } +} + +impl Command for Attest { + fn run(&self) -> CliResult<()> { + if crate::commands::rpc::agent_attest(self)? { + return Ok(()); + } + + let repo_root = find_repository_root()?; + let repo = Repository::open(&repo_root).map_err(|e| match e { + atomic_repository::RepositoryError::NotFound { path } => CliError::RepositoryNotFound { + searched_path: path.into(), + }, + other => CliError::Repository(other), + })?; + // Project-level AI provenance summary (independent of attestations). + // `--pending ` implies summary mode. + if self.summary || self.pending.is_some() { + // `--pending` compares a specific agent view against its parent; + // defaulting the agent view to the current view would produce a + // meaningless "X relative to X" empty delta, so require it. + if self.pending.is_some() && self.view.is_none() { + return Err(CliError::InvalidArgument { + message: + "--pending requires --view (the forked view to summarize)" + .to_string(), + }); + } + return self.show_summary(&repo); + } + + // Show details for a specific attestation + if let Some(ref hash_prefix) = self.hash { + return self.show_detail(&repo, hash_prefix); + } + + // Filter by view + if let Some(ref view_name) = self.view { + return self.show_for_view(&repo, view_name); + } + + // List all attestations + self.list_all(&repo) + } +} + +impl Attest { + /// Print a project-level AI provenance summary. + /// + /// Reads each change's embedded provenance (independent of attestations) + /// and classifies as AI / Human / Needs-attention / System. The headline + /// metric is `ai_authored_pct = AI / (AI + Human)`. + /// + /// Default output is terse and product-facing: the headline %, the human + /// count, and the AI source / tool breakdown. Implementation detail + /// (system/bootstrap exclusions, model breakdown, zero-count data-quality + /// lines) is shown only under `--verbose`. Non-zero data-quality + /// conditions (needs-attention, unreadable) always surface as warnings. + fn show_summary(&self, repo: &Repository) -> CliResult<()> { + let view_name = self + .view + .clone() + .unwrap_or_else(|| repo.current_view().to_string()); + + let (summary, pending_parent) = if let Some(parent_view) = self.pending.as_deref() { + let s = repo + .provenance_summary_pending(&view_name, parent_view) + .map_err(CliError::Repository)?; + (s, Some(parent_view.to_string())) + } else { + let s = repo + .provenance_summary(&view_name) + .map_err(CliError::Repository)?; + (s, None) + }; + // The draft-view hint source: the view's recorded parent, read + // the same way the routed path reads it off the wire's bundle. + let view_parent = repo + .get_view_info(&view_name) + .ok() + .and_then(|info| info.parent_name); + + Self::render_summary( + &summary, + &view_name, + pending_parent.as_deref(), + view_parent.as_deref(), + self.verbose, + ); Ok(()) } @@ -579,6 +708,21 @@ impl Attest { } } +/// One per-view coverage row off the wire's detail bundle. +pub(crate) struct CoverageRow { + pub view: String, + pub covered: usize, + pub total: usize, +} + +/// The wire's coverage row shape (deserialized from the detail bundle). +#[derive(serde::Deserialize)] +pub(crate) struct CoverageRowWire { + pub view: String, + pub covered: usize, + pub total: usize, +} + // Formatting Helpers /// Prettify a canonical vendor name (from `AIVendor::name()`, lowercase) into diff --git a/atomic-cli/src/commands/agent/explain.rs b/atomic-cli/src/commands/agent/explain.rs index 2dd7f00b..8d2a601d 100644 --- a/atomic-cli/src/commands/agent/explain.rs +++ b/atomic-cli/src/commands/agent/explain.rs @@ -70,17 +70,17 @@ pub struct Explain { /// /// The session must have at least one recorded turn on its agent view. /// Use `atomic agent status` to see available sessions. - session_id: String, + pub(crate) session_id: String, /// Explain a specific turn number (1-indexed). /// /// If not specified, explains the most recent turn. #[arg(long, value_name = "N")] - turn: Option, + pub(crate) turn: Option, /// Explain all turns in the session. #[arg(long)] - all: bool, + pub(crate) all: bool, /// Save the reasoning back into the change's unhashed section. /// @@ -88,14 +88,14 @@ pub struct Explain { /// included when you `atomic push`. Without this flag, reasoning /// is only displayed, not stored. #[arg(long)] - save: bool, + pub(crate) save: bool, /// Claude CLI model to use for reasoning generation. /// /// Defaults to "sonnet". Use "opus" for higher quality at higher cost, /// or "haiku" for faster/cheaper results. #[arg(long, default_value = "sonnet")] - model: String, + pub(crate) model: String, } impl Explain { @@ -114,6 +114,15 @@ impl Explain { impl Command for Explain { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the handler loads the + // session's turns, extracts the transcripts, generates the reasoning + // (Claude CLI), anchors the learnings, and performs the save-backs. + // The session record stays CLIENT-SIDE (the session store is a + // working-copy file read, not redb). + if crate::commands::rpc::agent_explain(self)? { + return Ok(()); + } + let repo_root = find_repository_root()?; // Load the session to get the view name @@ -421,7 +430,7 @@ fn get_condensed_text( // Display Reasoning /// Print a reasoning summary in a readable format. -fn print_reasoning(reasoning: &TurnReasoning) { +pub(crate) fn print_reasoning(reasoning: &TurnReasoning) { println!(" ├── {}: {}", emphasis("Intent"), info(&reasoning.intent)); println!( " ├── {}: {}", diff --git a/atomic-cli/src/commands/agent/hooks.rs b/atomic-cli/src/commands/agent/hooks.rs index 9ebf32a6..1d80536e 100644 --- a/atomic-cli/src/commands/agent/hooks.rs +++ b/atomic-cli/src/commands/agent/hooks.rs @@ -261,8 +261,10 @@ impl Command for Hooks { // Set the agent identity so new sessions get the correct name // (e.g., "claude-code" / "Claude Code" instead of "unknown") orchestrator.set_agent(&agent_name, &agent_display); - orchestrator - .set_journal_sink(Arc::new(super::owner::OwnerJournalSink::new(&repo_root))); + // The journal goes over the daemon's ProvenanceService — + // D4 start-or-retry: the sink starts the daemon when it is + // down (the daemon is the only journal writer). + orchestrator.set_journal_sink(super::provenance_rpc::sink_for(&repo_root)); // The delegated identity to sign recorded turns as, resolved // through the selection chain (env var > global setting > diff --git a/atomic-cli/src/commands/agent/lifecycle.rs b/atomic-cli/src/commands/agent/lifecycle.rs index 7582566d..93ab0631 100644 --- a/atomic-cli/src/commands/agent/lifecycle.rs +++ b/atomic-cli/src/commands/agent/lifecycle.rs @@ -16,6 +16,7 @@ use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use anyhow::anyhow; use clap::{Args, Subcommand, ValueEnum}; @@ -794,13 +795,15 @@ fn pending_turn_number(session: &AgentSession) -> u32 { } } -fn owner_sink(dot_dir: &Path) -> CliResult { +/// The journal sink for lifecycle transitions: the daemon's RPC sink +/// (D4 start-or-retry — the daemon is the only repository journal writer). +fn journal_sink(dot_dir: &Path) -> CliResult> { let root = dot_dir.parent().ok_or_else(|| { CliError::Internal(anyhow!( "canonical .atomic directory has no repository parent" )) })?; - Ok(super::owner::OwnerJournalSink::new(root)) + Ok(super::provenance_rpc::sink_for(root)) } fn map_stop_cause(cause: JournalStopCauseWire) -> JournalStopCause { @@ -821,7 +824,7 @@ fn stop_lifecycle_turns( resumable: bool, observed_at: i64, ) -> CliResult<()> { - let sink = owner_sink(dot_dir)?; + let sink = journal_sink(dot_dir)?; for session in sessions_for_run(dot_dir, &lifecycle.run_id) { sink.stop_turn( &session.session_id, @@ -836,7 +839,7 @@ fn stop_lifecycle_turns( } fn resume_lifecycle_turns(dot_dir: &Path, lifecycle: &ManagedLifecycle, now: i64) -> CliResult<()> { - let sink = owner_sink(dot_dir)?; + let sink = journal_sink(dot_dir)?; for session in sessions_for_run(dot_dir, &lifecycle.run_id) { sink.resume_turn(&session.session_id, pending_turn_number(&session), now) .map_err(|reason| CliError::Internal(anyhow!(reason)))?; @@ -849,7 +852,7 @@ fn abandon_lifecycle_turns( lifecycle: &ManagedLifecycle, now: i64, ) -> CliResult<()> { - let sink = owner_sink(dot_dir)?; + let sink = journal_sink(dot_dir)?; for session in sessions_for_run(dot_dir, &lifecycle.run_id) { sink.abandon_turn(&session.session_id, pending_turn_number(&session), now) .map_err(|reason| CliError::Internal(anyhow!(reason)))?; @@ -861,7 +864,7 @@ fn collect_lifecycle_turn_statuses( dot_dir: &Path, lifecycles: &[ManagedLifecycle], ) -> CliResult> { - let sink = owner_sink(dot_dir)?; + let sink = journal_sink(dot_dir)?; let mut result = Vec::new(); for lifecycle in lifecycles { for session in sessions_for_run(dot_dir, &lifecycle.run_id) { diff --git a/atomic-cli/src/commands/agent/mod.rs b/atomic-cli/src/commands/agent/mod.rs index dc4df1ea..67fa026a 100644 --- a/atomic-cli/src/commands/agent/mod.rs +++ b/atomic-cli/src/commands/agent/mod.rs @@ -37,14 +37,15 @@ //! hooks subcommand → parse JSON → TurnOrchestrator.dispatch() → Atomic change //! ``` -mod attest; +pub(crate) mod attest; mod disable; mod enable; -mod explain; +pub(crate) mod explain; mod hooks; mod identity; mod lifecycle; -mod owner; +mod provenance_rpc; +pub(crate) mod receive; mod status; use clap::{Args, Subcommand}; @@ -204,10 +205,6 @@ pub enum AgentCommands { /// nothing is suppressed; `lifecycle end --json` returns the run summary. Lifecycle(Lifecycle), - /// Run or inspect the repository-local redb database owner. - #[command(name = "database-owner", hide = true)] - DatabaseOwner(owner::DatabaseOwner), - /// Select or inspect the agent identity that recording hooks sign as. /// /// `set` makes every hooked agent on this machine record under a @@ -241,6 +238,25 @@ pub enum AgentCommands { /// invocation. It appears here for documentation purposes only. #[command(hide = true)] Hooks(hooks::Hooks), + + /// Drive the RPC provenance journal sink through its full trait flow + /// against a reachable daemon (the integration suite's harness). + /// + /// **This command is hidden** — it exists for the test suite, not for + /// users. + #[command(hide = true)] + JournalRpcSelftest(provenance_rpc::JournalRpcSelftest), + + /// Receive one typed `TurnEvent` from a verified dispatcher (Reactor). + /// + /// stdin carries an already-typed event (no agent hook parsing); the + /// result is the `agent hooks --json` shape so the dispatcher can mark + /// the delivery published. + /// + /// **This command is hidden** — it is called by the Atomic Reactor's + /// adapter, not by users. + #[command(hide = true)] + Receive(receive::Receive), } impl Command for Agent { @@ -252,9 +268,10 @@ impl Command for Agent { AgentCommands::Explain(cmd) => cmd.run(), AgentCommands::Attest(cmd) => cmd.run(), AgentCommands::Lifecycle(cmd) => cmd.run(), - AgentCommands::DatabaseOwner(cmd) => cmd.run(), AgentCommands::Identity(cmd) => cmd.run(), AgentCommands::Hooks(cmd) => cmd.run(), + AgentCommands::JournalRpcSelftest(cmd) => cmd.run(), + AgentCommands::Receive(cmd) => cmd.run(), } } } diff --git a/atomic-cli/src/commands/agent/owner.rs b/atomic-cli/src/commands/agent/owner.rs deleted file mode 100644 index e1ba1f16..00000000 --- a/atomic-cli/src/commands/agent/owner.rs +++ /dev/null @@ -1,2052 +0,0 @@ -//! Per-repository owner for the redb-native change and provenance store. -//! -//! redb permits one process at a time to open a database file. This service -//! elects one owner per repository with an OS-backed file lock and exposes a -//! small, versioned local protocol so concurrent agent hooks never race for -//! the provenance journal. The journal lives in the repository database, -//! `atomic.redb`, so the owner opens that file only while it serves a request -//! (see [`StoreLease`]); between requests other atomic processes can open the -//! repository. - -use std::fs::{File, OpenOptions}; -use std::ops::Deref; -use std::path::{Path, PathBuf}; -use std::process::{Command as ProcessCommand, Stdio}; -use std::sync::{Arc, Mutex, TryLockError, Weak}; -use std::time::{Duration, Instant}; - -use anyhow::{anyhow, Context}; -use atomic_agent::{ - JournalAppendAck, JournalCheckpointAttempt, JournalCheckpointSource, JournalStopCause, - JournalTurnLifecycle, JournalTurnReservation, JournalTurnStatus, ProvenanceJournalEnvelope, - ProvenanceJournalSink, -}; -use atomic_core::change::session::SessionTurn; -use atomic_core::types::Hash; -use atomic_repository::redb_change_store::{ - FrozenProvenanceCursor, FrozenProvenancePage, ProvenanceCheckpointAttempt, - ProvenanceCheckpointSource, ProvenanceId, ProvenanceTurnState, RedbChangeStore, StopCause, - StopState, StoredProvenanceTurn, MAX_FROZEN_PAGE_FRAGMENTS, -}; -use atomic_repository::{ensure_database, Repository, RepositoryError}; -use clap::{Args, Subcommand}; -use fs2::FileExt; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::sync::Notify; -use uuid::Uuid; - -use crate::commands::Command; -use crate::error::CliResult; - -const PROTOCOL_VERSION: u16 = 1; -// Local IPC only; a single oversized envelope (e.g. a huge recovered reasoning -// block) must still fit one frame even after client-side chunking. -const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; -// A JSON u8 takes at most four bytes including its comma. Reserve 256 bytes -// per fragment for metadata (including 64-bit cursors), plus the next cursor. -const FROZEN_PAGE_BYTES: usize = 1024 * 1024; -const FROZEN_PAGE_METADATA_BYTES: usize = 256 * MAX_FROZEN_PAGE_FRAGMENTS + 256; -const OWNER_LOCK_FILE: &str = "changes-owner.lock"; -const START_ATTEMPTS: usize = 80; -const START_RETRY_DELAY: Duration = Duration::from_millis(25); -const DATABASE_RETRY_DELAY: Duration = Duration::from_millis(10); - -#[derive(Debug, Args)] -#[command(arg_required_else_help = true)] -pub struct DatabaseOwner { - #[command(subcommand)] - command: OwnerCommand, -} - -#[derive(Debug, Subcommand)] -enum OwnerCommand { - /// Start the owner if needed, or reconnect to the existing owner. - Start(RepositoryPath), - /// Check owner health without starting it. - Ping(RepositoryPath), - /// Reserve an idempotent provenance turn through the owner. - Reserve(ReserveArgs), - /// Ask the owner to shut down after replying. - Shutdown(RepositoryPath), - /// Run the foreground owner process. - #[command(hide = true)] - Serve(RepositoryPath), -} - -#[derive(Clone, Debug, Args)] -struct RepositoryPath { - /// Path inside the repository to own. - #[arg(long, default_value = ".")] - repository: PathBuf, - - /// Emit a machine-readable response. - #[arg(long)] - json: bool, -} - -#[derive(Clone, Debug, Args)] -struct ReserveArgs { - /// Path inside the repository to own. - #[arg(long, default_value = ".")] - repository: PathBuf, - - /// Stable external agent session ID. - #[arg(long)] - session_id: String, - - /// Turn number within the session. - #[arg(long)] - turn: u32, - - /// Event timestamp in Unix seconds. - #[arg(long)] - now: i64, - - /// Emit a machine-readable response. - #[arg(long)] - json: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct RequestFrame { - version: u16, - request_id: String, - request: OwnerRequest, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -enum OwnerRequest { - Ping, - ReserveProvenanceTurn { - session_id: String, - turn_number: u32, - now: i64, - }, - AppendProvenanceEnvelopes { - provenance_id: u64, - expected_generation: u64, - envelopes: Vec, - now: i64, - }, - PrepareCheckpoint { - provenance_id: u64, - expected_generation: u64, - source: ProvenanceCheckpointSource, - #[serde(default)] - reuse_frozen_changes: bool, - now: i64, - }, - LoadFrozenEnvelopes { - provenance_id: u64, - }, - LoadFrozenEnvelopesPage { - provenance_id: u64, - attempt_generation: u64, - frozen_event_count: u64, - cursor: FrozenProvenanceCursor, - }, - BindCheckpointHash { - provenance_id: u64, - expected_generation: u64, - hash: Hash, - session_turn: SessionTurn, - now: i64, - }, - AcknowledgeCheckpoint { - provenance_id: u64, - expected_generation: u64, - manifest_hash: Hash, - completed_at: i64, - }, - StopTurn { - session_id: String, - turn_number: u32, - cause: StopCause, - resumable: bool, - observed_at: i64, - }, - ResumeTurn { - session_id: String, - turn_number: u32, - now: i64, - }, - AbandonTurn { - session_id: String, - turn_number: u32, - observed_at: i64, - }, - TurnStatus { - session_id: String, - turn_number: u32, - }, - Shutdown, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct WireEnvelope { - event_id: String, - bytes: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct ResponseFrame { - version: u16, - request_id: String, - response: OwnerResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub(crate) enum OwnerResponse { - Pong { - pid: u32, - // Additive v1 capability: old clients ignore it, old owners omit it. - #[serde(default)] - frozen_envelope_paging: bool, - }, - ProvenanceTurn { - turn: StoredProvenanceTurn, - committed: bool, - }, - ProvenanceEnvelopesCommitted { - acknowledgements: Vec, - }, - CheckpointPrepared { - attempt: ProvenanceCheckpointAttempt, - }, - FrozenEnvelopes { - envelopes: Vec>, - }, - FrozenEnvelopesPage { - page: FrozenProvenancePage, - }, - CheckpointAcknowledged, - TurnLifecycle { - turn: Option, - }, - ShuttingDown { - pid: u32, - }, - Error { - code: String, - message: String, - }, -} - -#[derive(Clone, Debug, Serialize)] -struct HealthOutput { - protocol_version: u16, - pid: u32, -} - -#[derive(Clone, Debug, Serialize)] -struct ReserveOutput { - protocol_version: u16, - committed: bool, - turn: StoredProvenanceTurn, -} - -/// Hook-facing client for committed provenance journal RPCs. -pub(crate) struct OwnerJournalSink { - repository: PathBuf, -} - -impl OwnerJournalSink { - pub(crate) fn new(repository: impl Into) -> Self { - Self { - repository: repository.into(), - } - } -} - -impl ProvenanceJournalSink for OwnerJournalSink { - fn reserve_turn( - &self, - session_id: &str, - turn_number: u32, - now: i64, - ) -> Result { - let repository = self.repository.clone(); - let session_id = session_id.to_string(); - run_outside_async_runtime(move || { - let response = request_with_reconnect( - &repository, - OwnerRequest::ReserveProvenanceTurn { - session_id, - turn_number, - now, - }, - )?; - match response { - OwnerResponse::ProvenanceTurn { - turn, - committed: true, - } => Ok(JournalTurnReservation { - provenance_id: turn.provenance_id.get(), - generation: turn.generation, - }), - other => Err(unexpected_response("reserve", other)), - } - }) - .map_err(|error| error.to_string()) - } - - fn append( - &self, - reservation: JournalTurnReservation, - envelopes: Vec, - now: i64, - ) -> Result, String> { - let repository = self.repository.clone(); - let envelopes = envelopes - .into_iter() - .map(|envelope| { - Ok(WireEnvelope { - event_id: envelope.event_id.clone(), - bytes: envelope.to_json_bytes()?, - }) - }) - .collect::, atomic_agent::ProvenanceJournalError>>() - .map_err(|error| error.to_string())?; - run_outside_async_runtime(move || { - let mut acknowledgements = Vec::new(); - for chunk in chunk_wire_envelopes(envelopes) { - let response = request_with_reconnect( - &repository, - OwnerRequest::AppendProvenanceEnvelopes { - provenance_id: reservation.provenance_id, - expected_generation: reservation.generation, - envelopes: chunk, - now, - }, - )?; - match response { - OwnerResponse::ProvenanceEnvelopesCommitted { - acknowledgements: acks, - } => acknowledgements.extend(acks), - other => return Err(unexpected_response("append provenance", other)), - } - } - Ok(acknowledgements) - }) - .map_err(|error| error.to_string()) - } - - fn prepare_checkpoint( - &self, - reservation: JournalTurnReservation, - source: JournalCheckpointSource, - now: i64, - ) -> Result { - let repository = self.repository.clone(); - run_outside_async_runtime(move || { - let response = request_with_reconnect( - &repository, - OwnerRequest::PrepareCheckpoint { - provenance_id: reservation.provenance_id, - expected_generation: reservation.generation, - reuse_frozen_changes: source.change_hashes.is_empty(), - source: to_store_checkpoint_source(source), - now, - }, - )?; - match response { - OwnerResponse::CheckpointPrepared { attempt } => Ok(to_journal_checkpoint_attempt( - reservation.provenance_id, - attempt, - )), - other => Err(unexpected_response("prepare checkpoint", other)), - } - }) - .map_err(|error| error.to_string()) - } - - fn load_frozen_envelopes( - &self, - checkpoint: &JournalCheckpointAttempt, - ) -> Result>, String> { - let repository = self.repository.clone(); - let provenance_id = checkpoint.provenance_id; - let attempt_generation = checkpoint.attempt_generation; - let frozen_event_count = checkpoint.frozen_event_count; - run_outside_async_runtime(move || { - require_frozen_paging(start_or_reconnect(&repository)?)?; - let mut cursor = FrozenProvenanceCursor::default(); - let mut envelopes = Vec::new(); - let mut partial = Vec::new(); - while cursor.seq < frozen_event_count { - let response = request_with_reconnect( - &repository, - OwnerRequest::LoadFrozenEnvelopesPage { - provenance_id, - attempt_generation, - frozen_event_count, - cursor, - }, - )?; - match response { - OwnerResponse::FrozenEnvelopesPage { page } => { - append_frozen_page( - &mut envelopes, - &mut partial, - &mut cursor, - frozen_event_count, - page, - )?; - } - other => return Err(unexpected_response("load frozen envelopes page", other)), - } - } - Ok(envelopes) - }) - .map_err(|error| error.to_string()) - } - - fn bind_checkpoint_hash( - &self, - checkpoint: &JournalCheckpointAttempt, - hash: Hash, - session_turn: SessionTurn, - now: i64, - ) -> Result { - let repository = self.repository.clone(); - let provenance_id = checkpoint.provenance_id; - let expected_generation = checkpoint.attempt_generation; - run_outside_async_runtime(move || { - match request_with_reconnect( - &repository, - OwnerRequest::BindCheckpointHash { - provenance_id, - expected_generation, - hash, - session_turn, - now, - }, - )? { - OwnerResponse::CheckpointPrepared { attempt } => { - Ok(to_journal_checkpoint_attempt(provenance_id, attempt)) - } - other => Err(unexpected_response("bind checkpoint hash", other)), - } - }) - .map_err(|error| error.to_string()) - } - - fn acknowledge_checkpoint( - &self, - checkpoint: &JournalCheckpointAttempt, - manifest_hash: Hash, - completed_at: i64, - ) -> Result<(), String> { - let repository = self.repository.clone(); - let provenance_id = checkpoint.provenance_id; - let expected_generation = checkpoint.attempt_generation; - run_outside_async_runtime(move || { - match request_with_reconnect( - &repository, - OwnerRequest::AcknowledgeCheckpoint { - provenance_id, - expected_generation, - manifest_hash, - completed_at, - }, - )? { - OwnerResponse::CheckpointAcknowledged => Ok(()), - other => Err(unexpected_response("acknowledge checkpoint", other)), - } - }) - .map_err(|error| error.to_string()) - } - - fn stop_turn( - &self, - session_id: &str, - turn_number: u32, - cause: JournalStopCause, - resumable: bool, - observed_at: i64, - ) -> Result, String> { - let request = OwnerRequest::StopTurn { - session_id: session_id.to_string(), - turn_number, - cause: to_store_stop_cause(cause), - resumable, - observed_at, - }; - request_turn_lifecycle(self.repository.clone(), request).map_err(|error| error.to_string()) - } - - fn resume_turn( - &self, - session_id: &str, - turn_number: u32, - now: i64, - ) -> Result, String> { - request_turn_lifecycle( - self.repository.clone(), - OwnerRequest::ResumeTurn { - session_id: session_id.to_string(), - turn_number, - now, - }, - ) - .map_err(|error| error.to_string()) - } - - fn abandon_turn( - &self, - session_id: &str, - turn_number: u32, - observed_at: i64, - ) -> Result, String> { - request_turn_lifecycle( - self.repository.clone(), - OwnerRequest::AbandonTurn { - session_id: session_id.to_string(), - turn_number, - observed_at, - }, - ) - .map_err(|error| error.to_string()) - } - - fn turn_status( - &self, - session_id: &str, - turn_number: u32, - ) -> Result, String> { - request_turn_lifecycle( - self.repository.clone(), - OwnerRequest::TurnStatus { - session_id: session_id.to_string(), - turn_number, - }, - ) - .map_err(|error| error.to_string()) - } -} - -fn request_turn_lifecycle( - repository: PathBuf, - request: OwnerRequest, -) -> anyhow::Result> { - run_outside_async_runtime( - move || match request_with_reconnect(&repository, request)? { - OwnerResponse::TurnLifecycle { turn } => Ok(turn.map(to_journal_turn_status)), - other => Err(unexpected_response("turn lifecycle", other)), - }, - ) -} - -fn to_store_stop_cause(cause: JournalStopCause) -> StopCause { - match cause { - JournalStopCause::UserRequested => StopCause::UserRequested, - JournalStopCause::LeaseExpired => StopCause::LeaseExpired, - JournalStopCause::ProcessExited => StopCause::ProcessExited, - JournalStopCause::HookFailure => StopCause::HookFailure, - JournalStopCause::SystemShutdown => StopCause::SystemShutdown, - JournalStopCause::Abandoned => StopCause::Abandoned, - } -} - -fn to_journal_stop_cause(cause: StopCause) -> JournalStopCause { - match cause { - StopCause::UserRequested => JournalStopCause::UserRequested, - StopCause::LeaseExpired => JournalStopCause::LeaseExpired, - StopCause::ProcessExited => JournalStopCause::ProcessExited, - StopCause::HookFailure => JournalStopCause::HookFailure, - StopCause::SystemShutdown => JournalStopCause::SystemShutdown, - StopCause::Abandoned => JournalStopCause::Abandoned, - } -} - -fn to_journal_turn_status(turn: StoredProvenanceTurn) -> JournalTurnStatus { - let lifecycle = match turn.state { - ProvenanceTurnState::Running => JournalTurnLifecycle::Running, - ProvenanceTurnState::Stopped(stop) => JournalTurnLifecycle::Stopped { - cause: to_journal_stop_cause(stop.cause), - observed_at: stop.observed_at, - last_event_seq: stop.last_event_seq, - resumable: stop.resumable, - }, - ProvenanceTurnState::Checkpointing => JournalTurnLifecycle::Checkpointing, - ProvenanceTurnState::Completed => JournalTurnLifecycle::Completed, - ProvenanceTurnState::Abandoned(stop) => JournalTurnLifecycle::Abandoned { - observed_at: stop.observed_at, - last_event_seq: stop.last_event_seq, - }, - }; - JournalTurnStatus { - provenance_id: turn.provenance_id.get(), - generation: turn.generation, - lifecycle, - } -} - -fn to_store_checkpoint_source(source: JournalCheckpointSource) -> ProvenanceCheckpointSource { - ProvenanceCheckpointSource { - agent_name: source.agent_name, - agent_display_name: source.agent_display_name, - agent_vendor: source.agent_vendor, - change_hashes: source.change_hashes, - previous_provenance: source.previous_provenance, - plan_id: source.plan_id, - ledger_turn_number: source.ledger_turn_number, - } -} - -fn to_journal_checkpoint_attempt( - provenance_id: u64, - attempt: ProvenanceCheckpointAttempt, -) -> JournalCheckpointAttempt { - JournalCheckpointAttempt { - provenance_id, - attempt_generation: attempt.attempt_generation, - frozen_event_count: attempt.frozen_event_count, - source: JournalCheckpointSource { - agent_name: attempt.source.agent_name, - agent_display_name: attempt.source.agent_display_name, - agent_vendor: attempt.source.agent_vendor, - change_hashes: attempt.source.change_hashes, - previous_provenance: attempt.source.previous_provenance, - plan_id: attempt.source.plan_id, - ledger_turn_number: attempt.source.ledger_turn_number, - }, - provenance_hash: attempt.provenance_hash, - session_turn: attempt.session_turn, - manifest_hash: attempt.manifest_hash, - } -} - -impl Command for DatabaseOwner { - fn run(&self) -> CliResult<()> { - match &self.command { - OwnerCommand::Start(args) => { - let response = start_or_reconnect(&args.repository)?; - print_health(response, args.json) - } - OwnerCommand::Ping(args) => { - let response = request(&args.repository, OwnerRequest::Ping)?; - print_health(response, args.json) - } - OwnerCommand::Reserve(args) => { - start_or_reconnect(&args.repository)?; - let response = request( - &args.repository, - OwnerRequest::ReserveProvenanceTurn { - session_id: args.session_id.clone(), - turn_number: args.turn, - now: args.now, - }, - )?; - print_reservation(response, args.json) - } - OwnerCommand::Shutdown(args) => { - let response = request(&args.repository, OwnerRequest::Shutdown)?; - let pid = match response { - OwnerResponse::ShuttingDown { pid } => pid, - other => return Err(unexpected_response("shutdown", other).into()), - }; - if args.json { - println!( - "{}", - serde_json::json!({ - "protocol_version": PROTOCOL_VERSION, - "pid": pid, - "status": "shutting-down" - }) - ); - } else { - println!("database owner {pid} is shutting down"); - } - Ok(()) - } - OwnerCommand::Serve(args) => serve(&args.repository), - } - } -} - -fn print_health(response: OwnerResponse, json: bool) -> CliResult<()> { - let pid = match response { - OwnerResponse::Pong { pid, .. } => pid, - other => return Err(unexpected_response("ping", other).into()), - }; - let output = HealthOutput { - protocol_version: PROTOCOL_VERSION, - pid, - }; - if json { - println!( - "{}", - serde_json::to_string(&output).map_err(anyhow::Error::from)? - ); - } else { - println!("database owner {pid} is healthy (protocol v{PROTOCOL_VERSION})"); - } - Ok(()) -} - -fn print_reservation(response: OwnerResponse, json: bool) -> CliResult<()> { - let (turn, committed) = match response { - OwnerResponse::ProvenanceTurn { turn, committed } => (turn, committed), - other => return Err(unexpected_response("reserve", other).into()), - }; - let output = ReserveOutput { - protocol_version: PROTOCOL_VERSION, - committed, - turn, - }; - if json { - println!( - "{}", - serde_json::to_string(&output).map_err(anyhow::Error::from)? - ); - } else { - println!( - "reserved provenance turn {} generation {} (committed)", - output.turn.provenance_id.get(), - output.turn.generation - ); - } - Ok(()) -} - -fn unexpected_response(operation: &str, response: OwnerResponse) -> anyhow::Error { - match response { - OwnerResponse::Error { code, message } => { - anyhow!("database owner {operation} failed [{code}]: {message}") - } - other => anyhow!("database owner returned an unexpected {operation} response: {other:?}"), - } -} - -fn require_frozen_paging(response: OwnerResponse) -> anyhow::Result<()> { - match response { - OwnerResponse::Pong { frozen_envelope_paging: true, .. } => Ok(()), - OwnerResponse::Pong { .. } => Err(anyhow!( - "database owner does not support frozen journal pagination; run `atomic agent database-owner shutdown --repository ` with the updated CLI, then retry the Stop hook" - )), - other => Err(unexpected_response("pagination capability", other)), - } -} - -/// Reassemble only complete envelopes, advancing the cursor after a successful -/// page. Retrying an RPC cannot append its bytes twice. -fn append_frozen_page( - envelopes: &mut Vec>, - partial: &mut Vec, - cursor: &mut FrozenProvenanceCursor, - cutoff: u64, - page: FrozenProvenancePage, -) -> anyhow::Result<()> { - let end = FrozenProvenanceCursor { - seq: cutoff, - offset: 0, - }; - if page.next <= *cursor || page.next > end { - return Err(anyhow!("invalid frozen journal page continuation")); - } - let mut position = *cursor; - for fragment in page.fragments { - // Legacy journal records can leave sequence gaps, but never inside an envelope. - if fragment.cursor.seq >= cutoff - || fragment.cursor < position - || (fragment.cursor != position - && (position.offset != 0 || fragment.cursor.offset != 0)) - || (!fragment.complete && fragment.bytes.is_empty()) - { - return Err(anyhow!("invalid frozen journal fragment cursor")); - } - partial.extend_from_slice(&fragment.bytes); - position = if fragment.complete { - envelopes.push(std::mem::take(partial)); - FrozenProvenanceCursor { - seq: fragment.cursor.seq + 1, - offset: 0, - } - } else { - FrozenProvenanceCursor { - seq: fragment.cursor.seq, - offset: partial.len(), - } - }; - } - if page.next < position - || (page.next != position && (position.offset != 0 || page.next.offset != 0)) - || (page.next == end && !partial.is_empty()) - { - return Err(anyhow!("incomplete frozen journal page")); - } - *cursor = page.next; - Ok(()) -} - -fn runtime() -> anyhow::Result { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .context("failed to create database-owner runtime") -} - -/// Start the repository owner or reconnect when another process won election. -pub(crate) fn start_or_reconnect(repository: &Path) -> anyhow::Result { - if let Ok(response) = request(repository, OwnerRequest::Ping) { - return Ok(response); - } - - let executable = std::env::current_exe().context("failed to locate atomic executable")?; - let mut command = ProcessCommand::new(executable); - command - .arg("agent") - .arg("database-owner") - .arg("serve") - .arg("--repository") - .arg(repository) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - configure_detached(&mut command)?; - let mut child = command - .spawn() - .context("failed to spawn repository database owner")?; - - for _ in 0..START_ATTEMPTS { - if let Ok(response) = request(repository, OwnerRequest::Ping) { - return Ok(response); - } - if let Some(status) = child - .try_wait() - .context("failed to inspect database-owner bootstrap")? - { - // A concurrent bootstrap may have won election. Give its endpoint - // the remainder of the retry window before reporting our exit. - if !status.success() { - for _ in 0..START_ATTEMPTS { - if let Ok(response) = request(repository, OwnerRequest::Ping) { - return Ok(response); - } - std::thread::sleep(START_RETRY_DELAY); - } - return Err(anyhow!("database owner exited during bootstrap: {status}")); - } - } - std::thread::sleep(START_RETRY_DELAY); - } - - Err(anyhow!( - "database owner did not become healthy before timeout" - )) -} - -fn request_with_reconnect( - repository: &Path, - request_body: OwnerRequest, -) -> anyhow::Result { - start_or_reconnect(repository)?; - let mut last_error = None; - for _ in 0..3 { - match request(repository, request_body.clone()) { - Ok(response) => return Ok(response), - Err(error) => { - last_error = Some(error); - start_or_reconnect(repository)?; - } - } - } - Err(last_error.unwrap_or_else(|| anyhow!("database owner request retry exhausted"))) -} - -fn run_outside_async_runtime(operation: F) -> anyhow::Result -where - T: Send + 'static, - F: FnOnce() -> anyhow::Result + Send + 'static, -{ - std::thread::spawn(operation) - .join() - .map_err(|_| anyhow!("database owner client thread panicked"))? -} - -/// Send one request to an already-running owner. -fn request(repository: &Path, request_body: OwnerRequest) -> anyhow::Result { - let dot_dir = Repository::canonical_dot_dir(repository)?; - let endpoint = endpoint_name(&dot_dir); - let frame = RequestFrame { - version: PROTOCOL_VERSION, - request_id: Uuid::new_v4().to_string(), - request: request_body, - }; - let response = runtime()?.block_on(exchange(&endpoint, &frame))?; - if response.version != PROTOCOL_VERSION { - return Err(anyhow!( - "database owner protocol mismatch: client {}, server {}", - PROTOCOL_VERSION, - response.version - )); - } - if response.request_id != frame.request_id { - return Err(anyhow!( - "database owner response request id mismatch: sent {}, received {}", - frame.request_id, - response.request_id - )); - } - match response.response { - OwnerResponse::Error { code, message } => { - Err(anyhow!("database owner request failed [{code}]: {message}")) - } - response => Ok(response), - } -} - -fn serve(repository: &Path) -> CliResult<()> { - let dot_dir = Repository::canonical_dot_dir(repository)?; - let owner_lock = acquire_owner_lock(&dot_dir)?; - // Bind before touching the database: clients give a starting owner only - // a short window to answer pings, while opening (or merging a legacy - // layout) may wait for another process. The first store request opens it. - let lease = Arc::new(StoreLease::new(dot_dir.clone())); - let endpoint = endpoint_name(&dot_dir); - runtime()?.block_on(run_server(&endpoint, lease))?; - FileExt::unlock(&owner_lock).context("failed to release database-owner lock")?; - Ok(()) -} - -/// Opens the repository database only while store requests are in flight. -/// -/// Graph state and the provenance journal share `atomic.redb`, and redb locks -/// the whole file per handle, so an owner that stayed open would lock every -/// other atomic process out of the repository. Concurrent requests share one -/// handle, which closes as soon as the last of them finishes. -struct StoreLease { - dot_dir: PathBuf, - current: Mutex>, -} - -impl StoreLease { - fn new(dot_dir: PathBuf) -> Self { - Self { - dot_dir, - current: Mutex::new(Weak::new()), - } - } - - fn acquire(&self, started: Instant) -> anyhow::Result> { - let timeout = atomic_agent::turn::orchestrator::wait_budget::database_wait(); - let mut current = loop { - match self.current.try_lock() { - Ok(guard) => break guard, - Err(TryLockError::Poisoned(_)) => { - return Err(anyhow!("database-owner store lease is poisoned")); - } - Err(TryLockError::WouldBlock) if started.elapsed() < timeout => { - std::thread::sleep( - DATABASE_RETRY_DELAY.min(timeout.saturating_sub(started.elapsed())), - ); - } - Err(TryLockError::WouldBlock) => { - return Err(anyhow!( - "database-owner store lease stayed busy for {timeout:?}" - )); - } - } - }; - if let Some(store) = current.upgrade() { - return Ok(store); - } - let store = Arc::new(self.open_waiting(started, timeout)?); - *current = Arc::downgrade(&store); - Ok(store) - } - - fn open_waiting(&self, started: Instant, timeout: Duration) -> anyhow::Result { - loop { - match self.open_once() { - Err(OpenFailure::Busy) if started.elapsed() < timeout => { - std::thread::sleep(DATABASE_RETRY_DELAY); - } - Err(OpenFailure::Busy) => { - return Err(anyhow!( - "repository database in {} stayed busy for {:?}", - self.dot_dir.display(), - timeout - )) - } - Err(OpenFailure::Other(error)) => return Err(error), - Ok(store) => return Ok(store), - } - } - } - - fn open_once(&self) -> Result { - let path = match ensure_database(&self.dot_dir) { - Ok(path) => path, - Err(RepositoryError::DatabaseBusy) => return Err(OpenFailure::Busy), - Err(error) => return Err(OpenFailure::Other(error.into())), - }; - RedbChangeStore::open_existing(&path).map_err(|error| { - if error.is_database_busy() { - OpenFailure::Busy - } else { - OpenFailure::Other( - anyhow::Error::new(error).context(format!("failed to open {}", path.display())), - ) - } - }) - } -} - -enum OpenFailure { - Busy, - Other(anyhow::Error), -} - -fn acquire_owner_lock(dot_dir: &Path) -> anyhow::Result { - let path = dot_dir.join(OWNER_LOCK_FILE); - let file = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&path) - .with_context(|| format!("failed to open owner lock {}", path.display()))?; - file.try_lock_exclusive().map_err(|error| { - anyhow!( - "another database owner already holds {}: {error}", - path.display() - ) - })?; - Ok(file) -} - -fn owner_failpoint(name: &str) { - if std::env::var("ATOMIC_OWNER_FAILPOINT").ok().as_deref() != Some(name) { - return; - } - let should_trip = match std::env::var_os("ATOMIC_OWNER_FAILPOINT_MARKER") { - Some(path) => OpenOptions::new() - .create_new(true) - .write(true) - .open(path) - .is_ok(), - None => true, - }; - if should_trip { - std::process::abort(); - } -} - -fn handle_request(store: &RedbChangeStore, frame: RequestFrame) -> (ResponseFrame, bool) { - handle_request_with(frame, || Ok(store)) -} - -/// Answer `Ping` and `Shutdown` without the database: a busy repository must -/// not make a live owner look dead, which would spawn a competing owner. -fn handle_request_with( - frame: RequestFrame, - store: impl FnOnce() -> anyhow::Result, -) -> (ResponseFrame, bool) -where - S: Deref, -{ - let request_id = frame.request_id; - if frame.version != PROTOCOL_VERSION { - return ( - ResponseFrame { - version: PROTOCOL_VERSION, - request_id, - response: OwnerResponse::Error { - code: "unsupported-version".to_string(), - message: format!( - "client requested protocol {}, owner supports {}", - frame.version, PROTOCOL_VERSION - ), - }, - }, - false, - ); - } - - let store = match frame.request { - OwnerRequest::Ping => return respond(request_id, pong(), false), - OwnerRequest::Shutdown => return respond(request_id, shutting_down(), true), - _ => match store() { - Ok(store) => store, - Err(error) => { - let response = OwnerResponse::Error { - code: "database-unavailable".to_string(), - message: format!("{error:#}"), - }; - return respond(request_id, response, false); - } - }, - }; - let (response, shutdown) = match frame.request { - OwnerRequest::Ping => (pong(), false), - OwnerRequest::ReserveProvenanceTurn { - session_id, - turn_number, - now, - } => match store.reserve_provenance_turn(&session_id, turn_number, now) { - Ok(turn) => ( - OwnerResponse::ProvenanceTurn { - turn, - // reserve_provenance_turn returns only after txn.commit(). - committed: true, - }, - false, - ), - Err(error) => ( - OwnerResponse::Error { - code: "provenance-store".to_string(), - message: error.to_string(), - }, - false, - ), - }, - OwnerRequest::AppendProvenanceEnvelopes { - provenance_id, - expected_generation, - envelopes, - now, - } => { - owner_failpoint("before-envelope-commit"); - let batch: Vec<_> = envelopes - .iter() - .map(|envelope| (envelope.event_id.as_str(), envelope.bytes.as_slice())) - .collect(); - match store.append_provenance_envelopes( - ProvenanceId::new(provenance_id), - expected_generation, - &batch, - now, - ) { - Err(error) => ( - OwnerResponse::Error { - code: "provenance-store".to_string(), - message: error.to_string(), - }, - false, - ), - Ok(stored) => { - owner_failpoint("after-envelope-commit"); - let acknowledgements = stored - .into_iter() - .map(|event| JournalAppendAck { - event_id: event.event_id, - sequence: event.seq, - }) - .collect(); - ( - OwnerResponse::ProvenanceEnvelopesCommitted { acknowledgements }, - false, - ) - } - } - } - OwnerRequest::PrepareCheckpoint { - provenance_id, - expected_generation, - mut source, - reuse_frozen_changes, - now, - } => match (|| { - let id = ProvenanceId::new(provenance_id); - // A previous Stop may have recorded the change before its journal - // read failed. The retry sees a clean working copy. Recover only - // those frozen hashes, then retain the store's source validation; - // a new nonempty change set must still conflict. - if reuse_frozen_changes && source.change_hashes.is_empty() { - if let Some(turn) = store.get_provenance_turn(id)? { - if turn.generation == expected_generation - && matches!(turn.state, ProvenanceTurnState::Checkpointing) - { - if let Some(attempt) = turn.checkpoint_attempt { - source.change_hashes = attempt.source.change_hashes; - } - } - } - } - store.prepare_provenance_checkpoint(id, expected_generation, source, now) - })() { - Ok(attempt) => { - owner_failpoint("after-checkpoint-prepare"); - (OwnerResponse::CheckpointPrepared { attempt }, false) - } - Err(error) => ( - OwnerResponse::Error { - code: "provenance-checkpoint".to_string(), - message: error.to_string(), - }, - false, - ), - }, - OwnerRequest::LoadFrozenEnvelopes { provenance_id } => { - match store.load_frozen_provenance_envelopes(ProvenanceId::new(provenance_id)) { - Ok(stored) => ( - OwnerResponse::FrozenEnvelopes { - envelopes: stored.into_iter().map(|entry| entry.envelope).collect(), - }, - false, - ), - Err(error) => ( - OwnerResponse::Error { - code: "provenance-checkpoint".to_string(), - message: error.to_string(), - }, - false, - ), - } - } - OwnerRequest::LoadFrozenEnvelopesPage { - provenance_id, - attempt_generation, - frozen_event_count, - cursor, - } => { - if cursor != FrozenProvenanceCursor::default() { - owner_failpoint("before-frozen-page-continuation"); - } - // Include the actual escaped request ID in the budget. Reserving the - // worst-case metadata and four bytes per payload byte bounds the - // encoded frame, not merely the event count or raw journal size. - let empty = ResponseFrame { - version: PROTOCOL_VERSION, - request_id: request_id.clone(), - response: OwnerResponse::FrozenEnvelopesPage { - page: FrozenProvenancePage { - fragments: Vec::new(), - next: FrozenProvenanceCursor::default(), - }, - }, - }; - let overhead = serde_json::to_vec(&empty) - .expect("serializable page frame") - .len(); - let budget = (MAX_FRAME_BYTES.saturating_sub(overhead + FROZEN_PAGE_METADATA_BYTES) - / 4) - .min(FROZEN_PAGE_BYTES); - match store.load_frozen_provenance_page( - ProvenanceId::new(provenance_id), - attempt_generation, - frozen_event_count, - cursor, - budget, - ) { - Ok(_) - if std::env::var("ATOMIC_OWNER_FAILPOINT").ok().as_deref() - == Some("frozen-page-unavailable") => - { - ( - OwnerResponse::Error { - code: "provenance-checkpoint".to_string(), - message: "injected frozen page read failure".to_string(), - }, - false, - ) - } - Ok(page) => (OwnerResponse::FrozenEnvelopesPage { page }, false), - Err(error) => ( - OwnerResponse::Error { - code: "provenance-checkpoint".to_string(), - message: error.to_string(), - }, - false, - ), - } - } - OwnerRequest::BindCheckpointHash { - provenance_id, - expected_generation, - hash, - session_turn, - now, - } => match store.bind_provenance_checkpoint_hash( - ProvenanceId::new(provenance_id), - expected_generation, - hash, - session_turn, - now, - ) { - Ok(attempt) => { - owner_failpoint("after-checkpoint-bind"); - (OwnerResponse::CheckpointPrepared { attempt }, false) - } - Err(error) => ( - OwnerResponse::Error { - code: "provenance-checkpoint".to_string(), - message: error.to_string(), - }, - false, - ), - }, - OwnerRequest::AcknowledgeCheckpoint { - provenance_id, - expected_generation, - manifest_hash, - completed_at, - } => match store.acknowledge_provenance_checkpoint( - ProvenanceId::new(provenance_id), - expected_generation, - manifest_hash, - completed_at, - ) { - Ok(_) => (OwnerResponse::CheckpointAcknowledged, false), - Err(error) => ( - OwnerResponse::Error { - code: "provenance-checkpoint".to_string(), - message: error.to_string(), - }, - false, - ), - }, - OwnerRequest::StopTurn { - session_id, - turn_number, - cause, - resumable, - observed_at, - } => match store.get_provenance_turn_for(&session_id, turn_number) { - Ok(None) => (OwnerResponse::TurnLifecycle { turn: None }, false), - Ok(Some(turn)) => match store.stop_provenance_turn( - turn.provenance_id, - turn.generation, - StopState { - cause, - observed_at, - last_event_seq: None, - resumable, - }, - ) { - Ok(turn) => (OwnerResponse::TurnLifecycle { turn: Some(turn) }, false), - Err(error) => ( - OwnerResponse::Error { - code: "provenance-lifecycle".to_string(), - message: error.to_string(), - }, - false, - ), - }, - Err(error) => ( - OwnerResponse::Error { - code: "provenance-lifecycle".to_string(), - message: error.to_string(), - }, - false, - ), - }, - OwnerRequest::ResumeTurn { - session_id, - turn_number, - now, - } => match store.get_provenance_turn_for(&session_id, turn_number) { - Ok(None) => (OwnerResponse::TurnLifecycle { turn: None }, false), - Ok(Some(turn)) => { - match store.resume_provenance_turn(turn.provenance_id, turn.generation, now) { - Ok(turn) => (OwnerResponse::TurnLifecycle { turn: Some(turn) }, false), - Err(error) => ( - OwnerResponse::Error { - code: "provenance-lifecycle".to_string(), - message: error.to_string(), - }, - false, - ), - } - } - Err(error) => ( - OwnerResponse::Error { - code: "provenance-lifecycle".to_string(), - message: error.to_string(), - }, - false, - ), - }, - OwnerRequest::AbandonTurn { - session_id, - turn_number, - observed_at, - } => match store.get_provenance_turn_for(&session_id, turn_number) { - Ok(None) => (OwnerResponse::TurnLifecycle { turn: None }, false), - Ok(Some(turn)) => match store.abandon_provenance_turn( - turn.provenance_id, - turn.generation, - StopState { - cause: StopCause::Abandoned, - observed_at, - last_event_seq: None, - resumable: false, - }, - ) { - Ok(turn) => (OwnerResponse::TurnLifecycle { turn: Some(turn) }, false), - Err(error) => ( - OwnerResponse::Error { - code: "provenance-lifecycle".to_string(), - message: error.to_string(), - }, - false, - ), - }, - Err(error) => ( - OwnerResponse::Error { - code: "provenance-lifecycle".to_string(), - message: error.to_string(), - }, - false, - ), - }, - OwnerRequest::TurnStatus { - session_id, - turn_number, - } => match store.get_provenance_turn_for(&session_id, turn_number) { - Ok(turn) => (OwnerResponse::TurnLifecycle { turn }, false), - Err(error) => ( - OwnerResponse::Error { - code: "provenance-lifecycle".to_string(), - message: error.to_string(), - }, - false, - ), - }, - OwnerRequest::Shutdown => (shutting_down(), true), - }; - respond(request_id, response, shutdown) -} - -fn respond(request_id: String, response: OwnerResponse, shutdown: bool) -> (ResponseFrame, bool) { - ( - ResponseFrame { - version: PROTOCOL_VERSION, - request_id, - response, - }, - shutdown, - ) -} - -fn pong() -> OwnerResponse { - OwnerResponse::Pong { - pid: std::process::id(), - frozen_envelope_paging: true, - } -} - -fn shutting_down() -> OwnerResponse { - OwnerResponse::ShuttingDown { - pid: std::process::id(), - } -} - -async fn handle_connection( - mut stream: S, - lease: Arc, - shutdown: Arc, -) -> anyhow::Result<()> -where - S: AsyncRead + AsyncWrite + Unpin, -{ - let request: RequestFrame = read_frame(&mut stream).await?; - let started = Instant::now(); - // Opening the database may wait for another process; keep that off the - // runtime threads that accept connections and answer pings. - let (response, should_shutdown) = tokio::task::spawn_blocking(move || { - handle_request_with(request, || lease.acquire(started)) - }) - .await - .context("database-owner request handler panicked")?; - write_frame(&mut stream, &response).await?; - stream.shutdown().await?; - if should_shutdown { - // `notify_one` retains a permit if the accept loop is between polls, - // preventing a shutdown request from being acknowledged but lost. - shutdown.notify_one(); - } - Ok(()) -} - -async fn read_frame(stream: &mut S) -> anyhow::Result -where - T: for<'de> Deserialize<'de>, - S: AsyncRead + Unpin, -{ - let length = stream.read_u32().await? as usize; - if length == 0 || length > MAX_FRAME_BYTES { - return Err(anyhow!("invalid database-owner frame length {length}")); - } - let mut payload = vec![0; length]; - stream.read_exact(&mut payload).await?; - serde_json::from_slice(&payload).context("invalid database-owner JSON frame") -} - -async fn write_frame(stream: &mut S, value: &T) -> anyhow::Result<()> -where - T: Serialize, - S: AsyncWrite + Unpin, -{ - let payload = serde_json::to_vec(value)?; - if payload.len() > MAX_FRAME_BYTES { - return Err(anyhow!("database-owner frame exceeds size limit")); - } - stream.write_u32(payload.len() as u32).await?; - stream.write_all(&payload).await?; - stream.flush().await?; - Ok(()) -} - -/// A turn's envelope batch can exceed `MAX_FRAME_BYTES` on its own (a heavy -/// agent turn recovers full tool output), so split it before framing. The -/// estimate is conservative: `bytes` serializes as a JSON number array, which -/// costs roughly four characters per byte. -const APPEND_CHUNK_BUDGET_BYTES: usize = 4 * 1024 * 1024; - -fn wire_envelope_frame_cost(envelope: &WireEnvelope) -> usize { - 4 * envelope.bytes.len() + 2 * envelope.event_id.len() + 128 -} - -fn chunk_wire_envelopes(envelopes: Vec) -> Vec> { - let mut chunks: Vec> = Vec::new(); - let mut current: Vec = Vec::new(); - let mut current_cost = 0usize; - for envelope in envelopes { - let cost = wire_envelope_frame_cost(&envelope); - if !current.is_empty() && current_cost + cost > APPEND_CHUNK_BUDGET_BYTES { - chunks.push(std::mem::take(&mut current)); - current_cost = 0; - } - current_cost += cost; - current.push(envelope); - } - if !current.is_empty() { - chunks.push(current); - } - chunks -} - -fn endpoint_name(dot_dir: &Path) -> String { - let canonical = std::fs::canonicalize(dot_dir).unwrap_or_else(|_| dot_dir.to_path_buf()); - let digest = blake3::hash(canonical.to_string_lossy().as_bytes()) - .to_hex() - .to_string(); - endpoint_for_digest(&digest[..24]) -} - -#[cfg(unix)] -fn endpoint_for_digest(digest: &str) -> String { - // macOS limits Unix-domain socket paths to roughly 104 bytes. Environment - // temp directories can be much longer, so use the stable short Unix temp - // root and keep repository identity in the canonical-path digest. - Path::new("/tmp") - .join(format!("atomic-owner-{digest}.sock")) - .to_string_lossy() - .into_owned() -} - -#[cfg(windows)] -fn endpoint_for_digest(digest: &str) -> String { - format!(r"\\.\pipe\atomic-owner-{digest}") -} - -#[cfg(unix)] -async fn exchange(endpoint: &str, frame: &RequestFrame) -> anyhow::Result { - let mut stream = tokio::net::UnixStream::connect(endpoint) - .await - .with_context(|| format!("database owner is not reachable at {endpoint}"))?; - write_frame(&mut stream, frame).await?; - read_frame(&mut stream).await -} - -#[cfg(windows)] -async fn exchange(endpoint: &str, frame: &RequestFrame) -> anyhow::Result { - let mut stream = connect_owner_pipe(endpoint, Duration::from_secs(5)) - .await - .with_context(|| format!("database owner is not reachable at {endpoint}"))?; - write_frame(&mut stream, frame).await?; - read_frame(&mut stream).await -} - -#[cfg(windows)] -async fn connect_owner_pipe( - endpoint: &str, - timeout: Duration, -) -> std::io::Result { - use tokio::net::windows::named_pipe::ClientOptions; - use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY; - - // A live server can temporarily have no listening instance when several - // hooks connect at once. Retry acquisition before sending any frame; a busy - // pipe is not evidence that the owner died or needs another bootstrap. - let started = tokio::time::Instant::now(); - loop { - match ClientOptions::new().open(endpoint) { - Err(error) - if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) - && started.elapsed() < timeout => - { - tokio::time::sleep( - START_RETRY_DELAY.min(timeout.saturating_sub(started.elapsed())), - ) - .await; - } - result => return result, - } - } -} - -#[cfg(unix)] -async fn run_server(endpoint: &str, lease: Arc) -> anyhow::Result<()> { - use tokio::net::UnixListener; - - let path = Path::new(endpoint); - if path.exists() { - std::fs::remove_file(path) - .with_context(|| format!("failed to remove stale endpoint {endpoint}"))?; - } - let listener = UnixListener::bind(path) - .with_context(|| format!("failed to bind database owner at {endpoint}"))?; - let shutdown = Arc::new(Notify::new()); - - loop { - tokio::select! { - accepted = listener.accept() => { - let (stream, _) = accepted?; - let lease = Arc::clone(&lease); - let shutdown = Arc::clone(&shutdown); - tokio::spawn(async move { - if let Err(error) = handle_connection(stream, lease, shutdown).await { - log::warn!("database-owner connection failed: {error}"); - } - }); - } - () = shutdown.notified() => break, - } - } - - drop(listener); - if path.exists() { - std::fs::remove_file(path) - .with_context(|| format!("failed to remove endpoint {endpoint}"))?; - } - Ok(()) -} - -#[cfg(windows)] -async fn run_server(endpoint: &str, lease: Arc) -> anyhow::Result<()> { - use tokio::net::windows::named_pipe::ServerOptions; - - let shutdown = Arc::new(Notify::new()); - let mut server = ServerOptions::new() - .first_pipe_instance(true) - .create(endpoint) - .with_context(|| format!("failed to create database owner pipe {endpoint}"))?; - loop { - tokio::select! { - connected = server.connect() => { - connected?; - // Keep an instance available before a fast handler can finish - // and close the connected pipe. Otherwise clients can observe - // a missing endpoint between two successful requests. - let next = ServerOptions::new() - .create(endpoint) - .with_context(|| format!("failed to create database owner pipe {endpoint}"))?; - let connected = std::mem::replace(&mut server, next); - let lease = Arc::clone(&lease); - let shutdown = Arc::clone(&shutdown); - tokio::spawn(async move { - if let Err(error) = handle_connection(connected, lease, shutdown).await { - log::warn!("database-owner connection failed: {error}"); - } - }); - } - () = shutdown.notified() => break, - } - } - Ok(()) -} - -#[cfg(unix)] -fn configure_detached(_command: &mut ProcessCommand) -> anyhow::Result<()> { - Ok(()) -} - -#[cfg(windows)] -fn configure_detached(command: &mut ProcessCommand) -> anyhow::Result<()> { - use std::os::windows::io::AsRawHandle; - use std::os::windows::process::CommandExt; - use windows_sys::Win32::Foundation::{ - SetHandleInformation, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, - }; - - // Rust's Windows spawn inherits every inheritable handle, even when the - // child's configured stdio is NUL. A hook's inherited output pipes would - // therefore stay open in the long-lived owner after the hook exits, making - // callers wait forever for EOF. Clear inheritance on the original handles; - // explicit Stdio::inherit() still works by duplicating them during spawn. - for handle in [ - std::io::stdin().as_raw_handle(), - std::io::stdout().as_raw_handle(), - std::io::stderr().as_raw_handle(), - ] { - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - continue; - } - // SAFETY: These are borrowed standard handles. This only clears an - // inheritance flag; it neither closes nor transfers ownership of them. - if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) } == 0 { - return Err(std::io::Error::last_os_error()) - .context("failed to prevent database owner from inheriting caller stdio"); - } - } - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - const DETACHED_PROCESS: u32 = 0x0000_0008; - command.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(windows)] - #[tokio::test] - async fn busy_owner_pipe_waits_for_a_new_listening_instance() { - use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions}; - use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY; - - let endpoint = format!(r"\\.\pipe\atomic-busy-test-{}", Uuid::new_v4()); - let occupied = ServerOptions::new() - .first_pipe_instance(true) - .create(&endpoint) - .unwrap(); - let _held_client = ClientOptions::new().open(&endpoint).unwrap(); - occupied.connect().await.unwrap(); - assert_eq!( - ClientOptions::new() - .open(&endpoint) - .unwrap_err() - .raw_os_error(), - Some(ERROR_PIPE_BUSY as i32) - ); - - let mut connecting = Box::pin(connect_owner_pipe(&endpoint, Duration::from_secs(2))); - assert!( - tokio::time::timeout(Duration::from_millis(50), &mut connecting) - .await - .is_err() - ); - let next = ServerOptions::new().create(&endpoint).unwrap(); - let _client = tokio::time::timeout(Duration::from_secs(2), connecting) - .await - .expect("client should recover when a listener is available") - .unwrap(); - next.connect().await.unwrap(); - } - - #[cfg(windows)] - #[tokio::test] - async fn busy_owner_pipe_wait_has_a_bounded_timeout() { - use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions}; - use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY; - - let endpoint = format!(r"\\.\pipe\atomic-busy-timeout-{}", Uuid::new_v4()); - let occupied = ServerOptions::new() - .first_pipe_instance(true) - .create(&endpoint) - .unwrap(); - let _held_client = ClientOptions::new().open(&endpoint).unwrap(); - occupied.connect().await.unwrap(); - let started = tokio::time::Instant::now(); - let error = tokio::time::timeout( - Duration::from_secs(2), - connect_owner_pipe(&endpoint, Duration::from_millis(60)), - ) - .await - .expect("busy pipe wait must not hang") - .unwrap_err(); - assert!(started.elapsed() >= Duration::from_millis(60)); - assert_eq!(error.raw_os_error(), Some(ERROR_PIPE_BUSY as i32)); - } - - #[cfg(windows)] - #[tokio::test] - async fn missing_owner_pipe_does_not_use_the_busy_retry_budget() { - let endpoint = format!(r"\\.\pipe\atomic-missing-test-{}", Uuid::new_v4()); - let error = tokio::time::timeout( - Duration::from_secs(2), - connect_owner_pipe(&endpoint, Duration::from_secs(5)), - ) - .await - .expect("a missing owner must return promptly for bootstrap") - .unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::NotFound); - } - - #[test] - fn frozen_pages_bound_encoded_frames_and_reassemble_large_envelopes() { - let dir = tempfile::tempdir().unwrap(); - let store = RedbChangeStore::open(dir.path().join("journal.redb")).unwrap(); - let turn = store.reserve_provenance_turn("paged", 1, 1).unwrap(); - // Exercise both the fragment-count limit and worst-case JSON expansion. - let mut expected = vec![Vec::new(); 257]; - expected.push(vec![255; 16 * FROZEN_PAGE_BYTES + 17]); - for (index, bytes) in expected.iter().enumerate() { - store - .append_provenance_envelope( - turn.provenance_id, - turn.generation, - &index.to_string(), - bytes, - 2, - ) - .unwrap(); - } - let attempt = store - .prepare_provenance_checkpoint( - turn.provenance_id, - turn.generation, - ProvenanceCheckpointSource { - agent_name: "codex".into(), - agent_display_name: "Codex".into(), - agent_vendor: "openai".into(), - change_hashes: vec![], - previous_provenance: None, - plan_id: None, - ledger_turn_number: 0, - }, - 3, - ) - .unwrap(); - assert!( - serde_json::to_vec(&OwnerResponse::FrozenEnvelopes { - envelopes: expected.clone() - }) - .unwrap() - .len() - > MAX_FRAME_BYTES - ); - for request_id in ["page".to_string(), "\\\"".repeat(1536 * 1024)] { - let mut cursor = FrozenProvenanceCursor::default(); - let mut actual = Vec::new(); - let mut partial = Vec::new(); - let mut pages = 0; - while cursor.seq < attempt.frozen_event_count { - let request = RequestFrame { - version: PROTOCOL_VERSION, - request_id: request_id.clone(), - request: OwnerRequest::LoadFrozenEnvelopesPage { - provenance_id: turn.provenance_id.get(), - attempt_generation: attempt.attempt_generation, - frozen_event_count: attempt.frozen_event_count, - cursor, - }, - }; - // The escaped ID must itself fit in an inbound request. - assert!(serde_json::to_vec(&request).unwrap().len() < MAX_FRAME_BYTES); - let (frame, shutdown) = handle_request(&store, request); - assert!(!shutdown); - assert!(serde_json::to_vec(&frame).unwrap().len() <= MAX_FRAME_BYTES); - let OwnerResponse::FrozenEnvelopesPage { page } = frame.response else { - panic!("expected page") - }; - append_frozen_page( - &mut actual, - &mut partial, - &mut cursor, - attempt.frozen_event_count, - page, - ) - .unwrap(); - pages += 1; - } - assert!(pages >= 4); - assert_eq!(actual, expected); - } - } - - #[test] - fn legacy_owner_requires_an_explicit_restart_for_pagination() { - let old: OwnerResponse = serde_json::from_str(r#"{"Pong":{"pid":42}}"#).unwrap(); - assert!(require_frozen_paging(old) - .unwrap_err() - .to_string() - .contains("database-owner shutdown")); - assert!(require_frozen_paging(OwnerResponse::Pong { - pid: 42, - frozen_envelope_paging: true - }) - .is_ok()); - } - - #[test] - fn checkpoint_read_retry_preserves_source_validation() { - let dir = tempfile::tempdir().unwrap(); - let store = RedbChangeStore::open(dir.path().join("journal.redb")).unwrap(); - let turn = store.reserve_provenance_turn("retry", 1, 1).unwrap(); - let original = ProvenanceCheckpointSource { - agent_name: "codex".into(), - agent_display_name: "Codex".into(), - agent_vendor: "openai".into(), - change_hashes: vec![Hash::of(b"original")], - previous_provenance: None, - plan_id: None, - ledger_turn_number: 0, - }; - let prepared = store - .prepare_provenance_checkpoint(turn.provenance_id, turn.generation, original.clone(), 2) - .unwrap(); - let request = |source, generation, reuse_frozen_changes| { - handle_request( - &store, - RequestFrame { - version: PROTOCOL_VERSION, - request_id: "retry".into(), - request: OwnerRequest::PrepareCheckpoint { - provenance_id: turn.provenance_id.get(), - expected_generation: generation, - source, - reuse_frozen_changes, - now: 3, - }, - }, - ) - .0 - .response - }; - let mut retry = original.clone(); - retry.change_hashes.clear(); - let OwnerResponse::CheckpointPrepared { attempt } = - request(retry.clone(), prepared.attempt_generation, true) - else { - panic!("expected recovery of prepared source") - }; - assert_eq!(attempt, prepared); - assert!(matches!( - request(retry.clone(), turn.generation, true), - OwnerResponse::Error { .. } - )); - assert!(matches!( - request(retry.clone(), prepared.attempt_generation, false), - OwnerResponse::Error { .. } - )); - retry.agent_name = "another-agent".into(); - assert!(matches!( - request(retry, prepared.attempt_generation, true), - OwnerResponse::Error { .. } - )); - let mut changed = original; - changed.change_hashes = vec![Hash::of(b"new change")]; - assert!(matches!( - request(changed, prepared.attempt_generation, true), - OwnerResponse::Error { .. } - )); - } - - #[tokio::test] - async fn framing_round_trips_request_id_and_version() { - let (mut client, mut server) = tokio::io::duplex(4096); - let expected = RequestFrame { - version: PROTOCOL_VERSION, - request_id: "request-1".to_string(), - request: OwnerRequest::Ping, - }; - let sent = expected.clone(); - let writer = tokio::spawn(async move { write_frame(&mut client, &sent).await.unwrap() }); - let actual: RequestFrame = read_frame(&mut server).await.unwrap(); - writer.await.unwrap(); - assert_eq!(actual.version, expected.version); - assert_eq!(actual.request_id, expected.request_id); - assert!(matches!(actual.request, OwnerRequest::Ping)); - } - - #[test] - fn owner_propagates_stale_generation_fencing() { - use atomic_repository::redb_change_store::{StopCause, StopState}; - - let dir = tempfile::tempdir().unwrap(); - let store = RedbChangeStore::open(dir.path().join("changes.redb")).unwrap(); - let running = store.reserve_provenance_turn("session", 1, 1).unwrap(); - store - .stop_provenance_turn( - running.provenance_id, - running.generation, - StopState { - cause: StopCause::ProcessExited, - observed_at: 2, - last_event_seq: None, - resumable: true, - }, - ) - .unwrap(); - let frame = RequestFrame { - version: PROTOCOL_VERSION, - request_id: "stale-request".to_string(), - request: OwnerRequest::AppendProvenanceEnvelopes { - provenance_id: running.provenance_id.get(), - expected_generation: running.generation, - envelopes: vec![WireEnvelope { - event_id: "event".to_string(), - bytes: b"envelope".to_vec(), - }], - now: 3, - }, - }; - let (response, shutdown) = handle_request(&store, frame); - assert!(!shutdown); - match response.response { - OwnerResponse::Error { code, message } => { - assert_eq!(code, "provenance-store"); - assert!(message.contains("generation is stale")); - } - other => panic!("expected fencing error, got {other:?}"), - } - } - - #[test] - fn endpoint_is_stable_and_bounded() { - let digest = "0123456789abcdef01234567"; - assert_eq!(endpoint_for_digest(digest), endpoint_for_digest(digest)); - assert!(endpoint_for_digest(digest).len() < 100); - } - - #[test] - fn chunking_splits_oversized_batches() { - let megabyte = 1024 * 1024; - let envelopes: Vec<_> = (0..6) - .map(|i| WireEnvelope { - event_id: format!("event-{i}"), - bytes: vec![0u8; 2 * megabyte], - }) - .collect(); - let chunks = chunk_wire_envelopes(envelopes); - assert_eq!(chunks.len(), 6); - for chunk in &chunks { - assert_eq!(chunk.len(), 1); - } - } - - #[test] - fn chunking_packs_small_batches() { - let envelopes: Vec<_> = (0..1_000) - .map(|i| WireEnvelope { - event_id: format!("event-{i}"), - bytes: b"small envelope".to_vec(), - }) - .collect(); - let chunks = chunk_wire_envelopes(envelopes); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].len(), 1_000); - } - - #[test] - fn chunking_preserves_all_envelopes_in_order() { - let envelopes: Vec<_> = (0..2_000) - .map(|i| WireEnvelope { - event_id: format!("event-{i}"), - bytes: vec![b'x'; 64 * 1024], - }) - .collect(); - let chunks = chunk_wire_envelopes(envelopes); - let flattened: Vec<_> = chunks.into_iter().flatten().collect(); - assert_eq!(flattened.len(), 2_000); - for (index, envelope) in flattened.iter().enumerate() { - assert_eq!(envelope.event_id, format!("event-{index}")); - } - } -} diff --git a/atomic-cli/src/commands/agent/provenance_rpc.rs b/atomic-cli/src/commands/agent/provenance_rpc.rs new file mode 100644 index 00000000..50c6bf50 --- /dev/null +++ b/atomic-cli/src/commands/agent/provenance_rpc.rs @@ -0,0 +1,1355 @@ +//! The RPC-based provenance journal sink — the daemon's +//! ProvenanceService spoken through the generated client. +//! +//! This is the journal transport, full stop: same trait +//! ([`atomic_agent::ProvenanceJournalSink`]), same semantics (generation +//! fencing, caller-supplied `now`, append idempotency by event ID, the +//! reuse_frozen_changes recovery, paged frozen reads with strict +//! continuity), with the transport being the `atomic` gRPC contract +//! over the daemon socket. The legacy owner protocol is deleted; the +//! daemon is the only writer of the repository databases, so every +//! journal consumer (hooks, the receive fallback, lifecycle) routes +//! here. +//! +//! # Transport notes +//! +//! * `run_outside_async_runtime` is a plain thread and a fresh +//! current-thread runtime per call — the sink may be invoked from +//! inside a tokio context, where `block_on` would panic. +//! * `request_with_reconnect` keeps the three-attempt retry; only +//! transport failures reconnect and retry — a domain refusal +//! (fenced generation, invalid cursor, ...) is terminal, because a +//! FailedPrecondition can never succeed on retry. Reconnects restart +//! the daemon per D4 (`connect_or_start`), mirroring the legacy +//! owner's per-request `start_or_reconnect`: a daemon that dies +//! mid-operation is brought back, and the retried operation converges +//! through the store's idempotency (append by event ID, rebind, +//! re-ack) exactly as the legacy owner's crash tests proved. +//! * `chunk_wire_envelopes`/`wire_envelope_frame_cost` keep the 1 MiB +//! wire chunk budget: protobuf encodes bytes near 1:1 and tonic's +//! default decode limit is 4 MiB, so the legacy 4 MiB JSON-frame +//! budget would exceed a default decode. A single envelope larger +//! than the budget still rides its own chunk (the daemon's provenance +//! decode limit is the contract's 64 MiB), and a full frozen page +//! (~1 MiB payload + proto overhead) always flows. +//! +//! # D4 policy +//! +//! The journal has no alternative writer, so [`sink_for`] always routes +//! here and `ATOMIC_RPC` does not apply to the journal transport (it +//! governs command routing in [`crate::commands::rpc`] only). The sink +//! starts the daemon when it is down (`connect_or_start`); if even that +//! fails (missing binary, unwritable runtime dir) `sink_for` returns a +//! [`RefusedJournalSink`] whose every operation returns a clear error — +//! the orchestrator fails the turn loudly rather than silently dropping +//! provenance. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{anyhow, Context}; +use atomic_agent::{ + JournalAppendAck, JournalCheckpointAttempt, JournalCheckpointSource, JournalStopCause, + JournalTurnLifecycle, JournalTurnReservation, JournalTurnStatus, ProvenanceJournalEnvelope, + ProvenanceJournalError, ProvenanceJournalSink, +}; +use atomic_client::proto as pb; +use atomic_client::AtomicClient; +use atomic_core::change::session::SessionTurn; +use atomic_core::types::Hash; +use clap::Args; +use libatomic::daemon::journal_sink::DirectJournalSink; +use uuid::Uuid; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; + +/// Wire append chunk budget (see the module doc for the deviation from +/// the legacy 4 MiB JSON-frame budget). +const APPEND_CHUNK_BUDGET_BYTES: usize = 1024 * 1024; + +/// The journal contract's frame ceiling — mirrors GetCapabilities +/// Limits.max_message_bytes and the daemon's provenance decode limit. +const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// the sink +// --------------------------------------------------------------------------- + +pub(crate) struct ProvenanceRpcSink { + /// The resolved persistent repository reference (path discovery + /// happened once, when the sink was opened). + reference: pb::RepositoryRef, +} + +impl ProvenanceRpcSink { + /// Connect to the daemon — starting it when it is down (D4) — and + /// resolve the repository. Returns `None` (never an error) only when + /// the daemon could not be started; the caller then uses the refusal + /// sink, whose operations carry the startup failure into the turn. + pub(crate) fn open(root: &Path) -> Option { + let root = root.to_path_buf(); + run_outside_async_runtime(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to create provenance rpc runtime")?; + rt.block_on(async { + let client = AtomicClient::connect_or_start() + .await + .map_err(|e| anyhow!(e))?; + let resolved = resolve_repository(&client, &root).await?; + Ok(Self { + reference: resolved, + }) + }) + }) + .ok() + } +} + +/// The journal sink every consumer installs, following the service mode +/// ([`crate::service`]): local journals through libatomic's +/// [`DirectJournalSink`] — the same in-process core the transport's own +/// dispatch handler uses; reactor journals over the daemon's +/// ProvenanceService (D4 start-or-retry). When even the chosen backend +/// cannot open, a [`RefusedJournalSink`] makes the failure loud — the +/// orchestrator fails the turn with the reason instead of silently +/// dropping provenance. +pub(crate) fn sink_for(root: &Path) -> Arc { + match crate::service::mode() { + crate::service::Mode::Local => match DirectJournalSink::open(root) { + Ok(sink) => Arc::new(sink), + Err(reason) => Arc::new(RefusedJournalSink::local(reason)), + }, + crate::service::Mode::Reactor => match ProvenanceRpcSink::open(root) { + Some(sink) => Arc::new(sink), + None => Arc::new(RefusedJournalSink::reactor()), + }, + } +} + +/// The no-backend sink: every operation refuses with the reason the chosen +/// backend could not provide a journal. Never silently drops provenance — +/// the orchestrator surfaces these as `ProvenanceJournalFailed`. +struct RefusedJournalSink { + reason: String, +} + +impl RefusedJournalSink { + /// The local service could not open the in-process journal core + /// (changes.redb) — e.g. a redb single-writer lock held by a + /// concurrent process on this machine. + fn local(reason: String) -> Self { + Self { + reason: format!("provenance journal unavailable (local service): {reason}"), + } + } + + /// The reactor transport could not be started (missing + /// `ATOMIC_DAEMON_BIN`, unwritable runtime dir). + fn reactor() -> Self { + Self { + reason: "provenance journal unavailable: the atomic daemon could not be started \ + (check ATOMIC_DAEMON_BIN and the socket runtime dir)" + .to_string(), + } + } +} + +async fn resolve_repository( + client: &AtomicClient, + root: &Path, +) -> anyhow::Result { + let mut daemon = pb::daemon_service_client::DaemonServiceClient::new(client.channel.clone()); + let resolved = daemon + .resolve_repository(pb::ResolveRepositoryRequest { + path: root.display().to_string(), + }) + .await + .context("resolve repository")? + .into_inner(); + resolved + .repository + .ok_or_else(|| anyhow!("daemon resolved no repository")) +} + +fn request_meta(now: i64) -> Option { + Some(pb::RequestMeta { + request_id: Uuid::new_v4().to_string(), + observed_at: Some(prost_types::Timestamp { + seconds: now, + nanos: 0, + }), + }) +} + +fn provenance_client( + client: AtomicClient, +) -> pb::provenance_service_client::ProvenanceServiceClient { + pb::provenance_service_client::ProvenanceServiceClient::new(client.channel.clone()) + .max_decoding_message_size(MAX_MESSAGE_BYTES) + .max_encoding_message_size(MAX_MESSAGE_BYTES) +} + +/// The legacy `request_with_reconnect`, ported: up to three attempts, +/// reconnecting through `connect_or_start` between transport failures — +/// a daemon that died mid-operation is restarted (D4), so a retried +/// idempotent operation (append by event ID, rebind, re-ack) converges +/// after a crash, mirroring the legacy owner's `start_or_reconnect`. +/// Domain refusals are terminal (see the module doc). +async fn request_with_reconnect(operation: &str, request: F) -> anyhow::Result +where + F: Fn(AtomicClient) -> Fut, + Fut: std::future::Future>, +{ + let mut last_error: Option = None; + for _ in 0..3 { + let client = match AtomicClient::connect_or_start().await { + Ok(client) => client, + Err(error) => { + last_error = Some(anyhow!("daemon rpc {operation} failed: {error}")); + continue; + } + }; + match request(client).await { + Ok(value) => return Ok(value), + Err(status) => { + if status.code() == tonic::Code::Unavailable { + last_error = Some(anyhow!( + "daemon rpc {operation} failed: {}", + status.message() + )); + continue; + } + // A domain refusal carries the store's verbatim message + // (e.g. "generation is stale", the injected page error). + return Err(anyhow!( + "daemon rpc {operation} failed: {}", + status.message() + )); + } + } + } + Err(last_error.unwrap_or_else(|| anyhow!("daemon rpc {operation} retry exhausted"))) +} + +fn run_outside_async_runtime(operation: F) -> anyhow::Result +where + T: Send + 'static, + F: FnOnce() -> anyhow::Result + Send + 'static, +{ + std::thread::spawn(operation) + .join() + .map_err(|_| anyhow!("provenance rpc client thread panicked"))? +} + +fn current_thread_runtime() -> anyhow::Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to create provenance rpc runtime") +} + +fn hash_proto(hash: &Hash) -> pb::Hash { + pb::Hash { + value: hash.as_bytes().to_vec(), + algorithm: pb::HashAlgorithm::Blake3 as i32, + } +} + +fn hash_domain(hash: &Option) -> anyhow::Result { + let bytes: [u8; 32] = hash + .as_ref() + .ok_or_else(|| anyhow!("hash missing in response"))? + .value + .clone() + .try_into() + .map_err(|_| anyhow!("hash is not 32 bytes"))?; + Ok(Hash::from_bytes(bytes)) +} + +fn checkpoint_source_proto(source: &JournalCheckpointSource) -> pb::CheckpointSource { + pb::CheckpointSource { + agent_name: source.agent_name.clone(), + agent_display_name: source.agent_display_name.clone(), + agent_vendor: source.agent_vendor.clone(), + change_hashes: source.change_hashes.iter().map(hash_proto).collect(), + previous_provenance: source.previous_provenance.as_ref().map(hash_proto), + plan_id: source.plan_id.clone(), + ledger_turn_number: source.ledger_turn_number, + } +} + +fn checkpoint_source_domain(source: &Option) -> JournalCheckpointSource { + let Some(source) = source else { + return JournalCheckpointSource { + agent_name: String::new(), + agent_display_name: String::new(), + agent_vendor: String::new(), + change_hashes: Vec::new(), + previous_provenance: None, + plan_id: None, + ledger_turn_number: 0, + }; + }; + JournalCheckpointSource { + agent_name: source.agent_name.clone(), + agent_display_name: source.agent_display_name.clone(), + agent_vendor: source.agent_vendor.clone(), + change_hashes: source + .change_hashes + .iter() + .filter_map(|hash| hash.value.clone().try_into().ok()) + .map(|bytes: [u8; 32]| Hash::from_bytes(bytes)) + .collect(), + previous_provenance: source.previous_provenance.as_ref().and_then(|hash| { + hash.value + .clone() + .try_into() + .ok() + .map(|bytes: [u8; 32]| Hash::from_bytes(bytes)) + }), + plan_id: source.plan_id.clone(), + ledger_turn_number: source.ledger_turn_number, + } +} + +/// Rebuild the journal attempt from the wire attempt. The wire session +/// turn carries only the identity pair; the provenance hash rides the +/// attempt itself, and the timestamp comes from the prepared-at stamp — +/// mirroring exactly what the daemon persisted (see the server's +/// `enriched_session_turn`). +fn journal_checkpoint_attempt( + provenance_id: u64, + attempt: &pb::ProvenanceCheckpointAttempt, +) -> JournalCheckpointAttempt { + JournalCheckpointAttempt { + provenance_id, + attempt_generation: attempt.attempt_generation, + frozen_event_count: attempt.frozen_event_count, + source: checkpoint_source_domain(&attempt.source), + provenance_hash: attempt.provenance_hash.as_ref().and_then(|h| { + h.value + .clone() + .try_into() + .ok() + .map(|bytes: [u8; 32]| Hash::from_bytes(bytes)) + }), + session_turn: attempt.session_turn.as_ref().map(|turn| SessionTurn { + session_id: turn.session_id.clone(), + turn_number: turn.turn_number, + goal: None, + provenance_hash: attempt + .provenance_hash + .as_ref() + .and_then(|hash| hash.value.clone().try_into().ok()) + .map(|bytes: [u8; 32]| Hash::from_bytes(bytes)) + .unwrap_or(atomic_core::types::Merkle::ZERO), + change_hashes: Vec::new(), + previous_provenance: None, + timestamp: attempt + .prepared_at + .as_ref() + .map(|stamp| stamp.seconds) + .unwrap_or(0), + plan_id: None, + todos: Vec::new(), + }), + manifest_hash: attempt.manifest_hash.as_ref().and_then(|hash| { + hash.value + .clone() + .try_into() + .ok() + .map(|bytes: [u8; 32]| Hash::from_bytes(bytes)) + }), + } +} + +fn journal_stop_cause(cause: i32) -> Option { + match pb::StopCause::try_from(cause) { + Ok(pb::StopCause::UserRequested) => Some(JournalStopCause::UserRequested), + Ok(pb::StopCause::LeaseExpired) => Some(JournalStopCause::LeaseExpired), + Ok(pb::StopCause::ProcessExited) => Some(JournalStopCause::ProcessExited), + Ok(pb::StopCause::HookFailure) => Some(JournalStopCause::HookFailure), + Ok(pb::StopCause::SystemShutdown) => Some(JournalStopCause::SystemShutdown), + Ok(pb::StopCause::Abandoned) => Some(JournalStopCause::Abandoned), + _ => None, + } +} + +fn journal_turn_status(turn: &pb::StoredProvenanceTurn) -> anyhow::Result { + use pb::ProvenanceTurnState as State; + let lifecycle = match State::try_from(turn.state) { + Ok(State::Running) => JournalTurnLifecycle::Running, + Ok(State::Checkpointing) => JournalTurnLifecycle::Checkpointing, + Ok(State::Completed) => JournalTurnLifecycle::Completed, + Ok(State::Stopped) => { + let stop = turn + .stop_state + .as_ref() + .ok_or_else(|| anyhow!("stopped turn without stop state"))?; + JournalTurnLifecycle::Stopped { + cause: journal_stop_cause(stop.cause) + .ok_or_else(|| anyhow!("unknown stop cause {}", stop.cause))?, + observed_at: stop.observed_at.as_ref().map(|t| t.seconds).unwrap_or(0), + last_event_seq: stop.last_event_seq, + resumable: stop.resumable, + } + } + Ok(State::Abandoned) => { + let stop = turn + .stop_state + .as_ref() + .ok_or_else(|| anyhow!("abandoned turn without stop state"))?; + JournalTurnLifecycle::Abandoned { + observed_at: stop.observed_at.as_ref().map(|t| t.seconds).unwrap_or(0), + last_event_seq: stop.last_event_seq, + } + } + _ => return Err(anyhow!("unknown turn state {}", turn.state)), + }; + Ok(JournalTurnStatus { + provenance_id: turn.provenance_id, + generation: turn.generation, + lifecycle, + }) +} + +/// The proto PageCursor carries no ordering; compare (seq, offset). +fn cursor_cmp(left: &pb::PageCursor, right: &pb::PageCursor) -> std::cmp::Ordering { + (left.seq, left.offset).cmp(&(right.seq, right.offset)) +} + +// --------------------------------------------------------------------------- +// append chunking (ported; see the module doc for the budget deviation) +// --------------------------------------------------------------------------- + +fn wire_envelope_frame_cost(envelope: &pb::WireEnvelope) -> usize { + envelope.envelope.len() + 2 * envelope.event_id.len() + 128 +} + +pub(crate) fn chunk_wire_envelopes(envelopes: Vec) -> Vec> { + let mut chunks: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut current_cost = 0usize; + for envelope in envelopes { + let cost = wire_envelope_frame_cost(&envelope); + if !current.is_empty() && current_cost + cost > APPEND_CHUNK_BUDGET_BYTES { + chunks.push(std::mem::take(&mut current)); + current_cost = 0; + } + current_cost += cost; + current.push(envelope); + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +// --------------------------------------------------------------------------- +// frozen page reassembly (ported verbatim from the legacy owner client) +// --------------------------------------------------------------------------- + +/// Reassemble only complete envelopes, advancing the cursor after a +/// successful page. Retrying an RPC cannot append its bytes twice. +fn append_frozen_page( + envelopes: &mut Vec>, + partial: &mut Vec, + cursor: &mut pb::PageCursor, + cutoff: u64, + page: pb::FrozenPage, +) -> anyhow::Result<()> { + let next = page + .next + .ok_or_else(|| anyhow!("frozen page without a continuation cursor"))?; + let end = pb::PageCursor { + seq: cutoff, + offset: 0, + }; + if cursor_cmp(&next, cursor) != std::cmp::Ordering::Greater + || cursor_cmp(&next, &end) == std::cmp::Ordering::Greater + { + return Err(anyhow!("invalid frozen journal page continuation")); + } + let mut position = *cursor; + for fragment in page.fragments { + let fragment_cursor = fragment + .cursor + .ok_or_else(|| anyhow!("frozen fragment without a cursor"))?; + // Legacy journal records can leave sequence gaps, but never inside an envelope. + if fragment_cursor.seq >= cutoff + || cursor_cmp(&fragment_cursor, &position) == std::cmp::Ordering::Less + || (fragment_cursor != position + && (position.offset != 0 || fragment_cursor.offset != 0)) + || (!fragment.complete && fragment.bytes.is_empty()) + { + return Err(anyhow!("invalid frozen journal fragment cursor")); + } + partial.extend_from_slice(&fragment.bytes); + position = if fragment.complete { + envelopes.push(std::mem::take(partial)); + pb::PageCursor { + seq: fragment_cursor.seq + 1, + offset: 0, + } + } else { + pb::PageCursor { + seq: fragment_cursor.seq, + offset: partial.len() as u64, + } + }; + } + if cursor_cmp(&next, &position) == std::cmp::Ordering::Less + || (next != position && (position.offset != 0 || next.offset != 0)) + || (next == end && !partial.is_empty()) + { + return Err(anyhow!("incomplete frozen journal page")); + } + *cursor = next; + Ok(()) +} + +// --------------------------------------------------------------------------- +// the trait +// --------------------------------------------------------------------------- + +impl ProvenanceJournalSink for ProvenanceRpcSink { + fn reserve_turn( + &self, + session_id: &str, + turn_number: u32, + now: i64, + ) -> Result { + let reference = self.reference.clone(); + let session_id = session_id.to_string(); + run_outside_async_runtime(move || { + current_thread_runtime()?.block_on(async { + let response = request_with_reconnect("reserve turn", |client| { + let reference = reference.clone(); + let session_id = session_id.clone(); + async move { + provenance_client(client) + .reserve_turn(pb::ReserveTurnRequest { + repository: Some(reference), + meta: request_meta(now), + session_id, + turn_number, + view: None, + }) + .await + .map(|response| response.into_inner()) + } + }) + .await?; + let turn = response + .turn + .ok_or_else(|| anyhow!("reserve returned no turn"))?; + Ok(JournalTurnReservation { + provenance_id: turn.provenance_id, + generation: turn.generation, + }) + }) + }) + .map_err(|error: anyhow::Error| error.to_string()) + } + + fn append( + &self, + reservation: JournalTurnReservation, + envelopes: Vec, + now: i64, + ) -> Result, String> { + let reference = self.reference.clone(); + let wire = envelopes + .iter() + .map(|envelope| { + Ok(pb::WireEnvelope { + event_id: envelope.event_id.clone(), + type_url: String::new(), + envelope: envelope.to_json_bytes()?, + }) + }) + .collect::, ProvenanceJournalError>>() + .map_err(|error| error.to_string())?; + let chunks = chunk_wire_envelopes(wire); + run_outside_async_runtime(move || { + current_thread_runtime()?.block_on(async { + let mut acknowledgements = Vec::new(); + for chunk in chunks { + let response = request_with_reconnect("append provenance", |client| { + let chunk = chunk.clone(); + let reference = reference.clone(); + async move { + provenance_client(client) + .append_envelopes(pb::AppendEnvelopesRequest { + repository: Some(reference.clone()), + meta: request_meta(now), + provenance_id: reservation.provenance_id, + expected_generation: reservation.generation, + envelopes: chunk, + }) + .await + .map(|response| response.into_inner()) + } + }) + .await?; + acknowledgements.extend(response.acknowledgements.into_iter().map(|ack| { + JournalAppendAck { + event_id: ack.event_id, + sequence: ack.sequence, + } + })); + } + Ok(acknowledgements) + }) + }) + .map_err(|error: anyhow::Error| error.to_string()) + } + + fn prepare_checkpoint( + &self, + reservation: JournalTurnReservation, + source: JournalCheckpointSource, + now: i64, + ) -> Result { + let reference = self.reference.clone(); + // The legacy client computed reuse the same way: an empty change + // set with a recorded change means a previous Stop's journal read + // failed after the change was recorded. + let reuse_frozen_changes = source.change_hashes.is_empty(); + let source = checkpoint_source_proto(&source); + let provenance_id = reservation.provenance_id; + run_outside_async_runtime(move || { + current_thread_runtime()?.block_on(async { + let response = request_with_reconnect("prepare checkpoint", |client| { + let reference = reference.clone(); + let source = source.clone(); + async move { + provenance_client(client) + .prepare_checkpoint(pb::PrepareCheckpointRequest { + repository: Some(reference), + meta: request_meta(now), + provenance_id, + expected_generation: reservation.generation, + source: Some(source), + reuse_frozen_changes, + }) + .await + .map(|response| response.into_inner()) + } + }) + .await?; + let attempt = response + .attempt + .ok_or_else(|| anyhow!("prepare returned no attempt"))?; + Ok(journal_checkpoint_attempt(provenance_id, &attempt)) + }) + }) + .map_err(|error: anyhow::Error| error.to_string()) + } + + fn load_frozen_envelopes( + &self, + checkpoint: &JournalCheckpointAttempt, + ) -> Result>, String> { + let reference = self.reference.clone(); + let provenance_id = checkpoint.provenance_id; + let attempt_generation = checkpoint.attempt_generation; + let frozen_event_count = checkpoint.frozen_event_count; + run_outside_async_runtime(move || { + current_thread_runtime()?.block_on(async { + let mut cursor = pb::PageCursor::default(); + let mut envelopes = Vec::new(); + let mut partial = Vec::new(); + let mut first = true; + while cursor.seq < frozen_event_count { + let response = request_with_reconnect("load frozen envelopes page", |client| { + let cursor = cursor; + let first = first; + let reference = reference.clone(); + async move { + provenance_client(client) + .load_frozen_envelopes(pb::LoadFrozenEnvelopesRequest { + repository: Some(reference.clone()), + provenance_id, + attempt_generation, + frozen_event_count, + cursor: if first { None } else { Some(cursor) }, + budget: None, + }) + .await + .map(|response| response.into_inner()) + } + }) + .await?; + first = false; + let page = response + .page + .ok_or_else(|| anyhow!("frozen read returned no page"))?; + append_frozen_page( + &mut envelopes, + &mut partial, + &mut cursor, + frozen_event_count, + page, + )?; + } + Ok(envelopes) + }) + }) + .map_err(|error: anyhow::Error| error.to_string()) + } + + fn bind_checkpoint_hash( + &self, + checkpoint: &JournalCheckpointAttempt, + hash: Hash, + session_turn: SessionTurn, + now: i64, + ) -> Result { + let reference = self.reference.clone(); + let provenance_id = checkpoint.provenance_id; + let expected_generation = checkpoint.attempt_generation; + let hash = hash_proto(&hash); + let wire_turn = pb::SessionTurn { + session_id: session_turn.session_id.clone(), + turn_number: session_turn.turn_number, + }; + run_outside_async_runtime(move || { + current_thread_runtime()?.block_on(async { + let response = request_with_reconnect("bind checkpoint hash", |client| { + let reference = reference.clone(); + let hash = hash.clone(); + let wire_turn = wire_turn.clone(); + async move { + provenance_client(client) + .bind_checkpoint_hash(pb::BindCheckpointHashRequest { + repository: Some(reference), + meta: request_meta(now), + provenance_id, + expected_generation, + hash: Some(hash), + session_turn: Some(wire_turn), + }) + .await + .map(|response| response.into_inner()) + } + }) + .await?; + let attempt = response + .attempt + .ok_or_else(|| anyhow!("bind returned no attempt"))?; + Ok(journal_checkpoint_attempt(provenance_id, &attempt)) + }) + }) + .map_err(|error: anyhow::Error| error.to_string()) + } + + fn acknowledge_checkpoint( + &self, + checkpoint: &JournalCheckpointAttempt, + manifest_hash: Hash, + completed_at: i64, + ) -> Result<(), String> { + let reference = self.reference.clone(); + let provenance_id = checkpoint.provenance_id; + let expected_generation = checkpoint.attempt_generation; + let manifest_hash = hash_proto(&manifest_hash); + run_outside_async_runtime(move || { + current_thread_runtime()?.block_on(async { + request_with_reconnect("acknowledge checkpoint", |client| { + let reference = reference.clone(); + let manifest_hash = manifest_hash.clone(); + async move { + provenance_client(client) + .acknowledge_checkpoint(pb::AcknowledgeCheckpointRequest { + repository: Some(reference), + meta: request_meta(completed_at), + provenance_id, + expected_generation, + manifest_hash: Some(manifest_hash), + completed_at: Some(prost_types::Timestamp { + seconds: completed_at, + nanos: 0, + }), + }) + .await + .map(|_| ()) + } + }) + .await + }) + }) + .map_err(|error: anyhow::Error| error.to_string()) + } + + fn stop_turn( + &self, + session_id: &str, + turn_number: u32, + cause: JournalStopCause, + resumable: bool, + observed_at: i64, + ) -> Result, String> { + let cause = match cause { + JournalStopCause::UserRequested => pb::StopCause::UserRequested, + JournalStopCause::LeaseExpired => pb::StopCause::LeaseExpired, + JournalStopCause::ProcessExited => pb::StopCause::ProcessExited, + JournalStopCause::HookFailure => pb::StopCause::HookFailure, + JournalStopCause::SystemShutdown => pb::StopCause::SystemShutdown, + JournalStopCause::Abandoned => pb::StopCause::Abandoned, + }; + update_turn( + self, + session_id, + turn_number, + pb::TurnAction::Stop, + Some(cause), + Some(resumable), + observed_at, + ) + } + + fn resume_turn( + &self, + session_id: &str, + turn_number: u32, + now: i64, + ) -> Result, String> { + update_turn( + self, + session_id, + turn_number, + pb::TurnAction::Resume, + None, + None, + now, + ) + } + + fn abandon_turn( + &self, + session_id: &str, + turn_number: u32, + observed_at: i64, + ) -> Result, String> { + update_turn( + self, + session_id, + turn_number, + pb::TurnAction::Abandon, + None, + None, + observed_at, + ) + } + + fn turn_status( + &self, + session_id: &str, + turn_number: u32, + ) -> Result, String> { + let reference = self.reference.clone(); + let session_id = session_id.to_string(); + run_outside_async_runtime(move || { + current_thread_runtime()?.block_on(async { + let response = request_with_reconnect("turn status", |client| { + let reference = reference.clone(); + let session_id = session_id.clone(); + async move { + provenance_client(client) + .get_turn(pb::GetTurnRequest { + repository: Some(reference), + session_id, + turn_number, + }) + .await + .map(|response| response.into_inner()) + } + }) + .await?; + match response.turn { + None => Ok(None), + Some(turn) => Ok(Some(journal_turn_status(&turn)?)), + } + }) + }) + .map_err(|error: anyhow::Error| error.to_string()) + } +} + +/// The lifecycle transitions: GetTurn first (a lookup never grants a +/// generation — the request's expected_generation is the turn's own +/// current one), then one UpdateTurn. An unknown turn is `Ok(None)`, +/// exactly like the legacy owner's TurnLifecycle{turn: None}. +fn update_turn( + sink: &ProvenanceRpcSink, + session_id: &str, + turn_number: u32, + action: pb::TurnAction, + cause: Option, + resumable: Option, + observed_at: i64, +) -> Result, String> { + let reference = sink.reference.clone(); + let session_id = session_id.to_string(); + run_outside_async_runtime(move || { + current_thread_runtime()?.block_on(async { + let stored = request_with_reconnect("turn lookup", |client| { + let session_id = session_id.clone(); + let reference = reference.clone(); + async move { + provenance_client(client) + .get_turn(pb::GetTurnRequest { + repository: Some(reference.clone()), + session_id, + turn_number, + }) + .await + .map(|response| response.into_inner()) + } + }) + .await?; + let Some(turn) = stored.turn else { + return Ok(None); + }; + let response = request_with_reconnect("update turn", |client| { + let reference = reference.clone(); + let turn = turn.clone(); + async move { + provenance_client(client) + .update_turn(pb::UpdateTurnRequest { + repository: Some(reference), + meta: request_meta(observed_at), + session_id: turn.session_id.clone(), + turn_number, + action: action as i32, + cause: cause.map(|cause| cause as i32), + resumable, + // The looked-up turn's own current generation — + // a lookup never grants one, so this is exactly + // what the legacy owner used internally. + expected_generation: turn.generation, + }) + .await + .map(|response| response.into_inner()) + } + }) + .await?; + match response.turn { + None => Ok(None), + Some(turn) => Ok(Some(journal_turn_status(&turn)?)), + } + }) + }) + .map_err(|error: anyhow::Error| error.to_string()) +} + +impl ProvenanceJournalSink for RefusedJournalSink { + fn reserve_turn( + &self, + _session_id: &str, + _turn_number: u32, + _now: i64, + ) -> Result { + Err(self.reason.clone()) + } + + fn append( + &self, + _reservation: JournalTurnReservation, + _envelopes: Vec, + _now: i64, + ) -> Result, String> { + Err(self.reason.clone()) + } + + fn prepare_checkpoint( + &self, + _reservation: JournalTurnReservation, + _source: JournalCheckpointSource, + _now: i64, + ) -> Result { + Err(self.reason.clone()) + } + + fn load_frozen_envelopes( + &self, + _checkpoint: &JournalCheckpointAttempt, + ) -> Result>, String> { + Err(self.reason.clone()) + } + + fn bind_checkpoint_hash( + &self, + _checkpoint: &JournalCheckpointAttempt, + _hash: Hash, + _session_turn: SessionTurn, + _now: i64, + ) -> Result { + Err(self.reason.clone()) + } + + fn acknowledge_checkpoint( + &self, + _checkpoint: &JournalCheckpointAttempt, + _manifest_hash: Hash, + _completed_at: i64, + ) -> Result<(), String> { + Err(self.reason.clone()) + } + + fn stop_turn( + &self, + _session_id: &str, + _turn_number: u32, + _cause: JournalStopCause, + _resumable: bool, + _observed_at: i64, + ) -> Result, String> { + Err(self.reason.clone()) + } + + fn resume_turn( + &self, + _session_id: &str, + _turn_number: u32, + _now: i64, + ) -> Result, String> { + Err(self.reason.clone()) + } + + fn abandon_turn( + &self, + _session_id: &str, + _turn_number: u32, + _observed_at: i64, + ) -> Result, String> { + Err(self.reason.clone()) + } + + fn turn_status( + &self, + _session_id: &str, + _turn_number: u32, + ) -> Result, String> { + Err(self.reason.clone()) + } +} + +// --------------------------------------------------------------------------- +// hidden self-test command (drives the real sink over the real socket) +// --------------------------------------------------------------------------- + +/// Drive `ProvenanceRpcSink` through the full trait flow against a +/// reachable daemon. Hidden (like the self-test commands generally are): +/// it exists for the integration suite, not for users. +#[derive(Debug, Args)] +#[command(hide = true)] +pub struct JournalRpcSelftest { + /// Path inside the repository to journal for. + #[arg(long, default_value = ".")] + repository: std::path::PathBuf, + + /// Stable external agent session ID. + #[arg(long, default_value = "rpc-selftest")] + session_id: String, + + /// Turn number within the session. + #[arg(long, default_value = "1")] + turn: u32, + + /// How many envelopes to append. + #[arg(long, default_value = "6")] + envelopes: usize, + + /// Payload bytes per envelope. + #[arg(long, default_value = "524288")] + envelope_bytes: usize, + + /// Emit a machine-readable JSON verdict. + #[arg(long)] + json: bool, +} + +impl Command for JournalRpcSelftest { + fn run(&self) -> CliResult<()> { + // This selftest exists to exercise the REACTOR journal transport + // (D4 start-or-retry, wire chunking) — it must never start a + // daemon in local mode. Refuse early, with the opt-in. + if !matches!(crate::service::mode(), crate::service::Mode::Reactor) { + return Err(CliError::InvalidArgument { + message: "the journal RPC selftest exercises the reactor transport; \ + set ATOMIC_SERVICE=reactor (it D4-starts the daemon when down)" + .to_string(), + }); + } + let root = crate::commands::find_repository_root_from(&self.repository)?; + let Some(sink) = ProvenanceRpcSink::open(&root) else { + return Err(CliError::InvalidArgument { + message: "no reachable atomic daemon; the selftest exercises the RPC sink" + .to_string(), + }); + }; + let sink = Arc::new(sink); + + let now = 1_700_000_000i64; + let reservation = sink + .reserve_turn(&self.session_id, self.turn, now) + .map_err(|reason| CliError::Internal(anyhow!("reserve failed: {reason}")))?; + + let mut envelopes = Vec::new(); + for index in 0..self.envelopes { + let event = atomic_agent::ProvenanceJournalEvent::Tool { + phase: atomic_agent::ProvenanceToolPhase::After, + tool_name: "selftest".to_string(), + tool_call_id: Some(format!("selftest-{index}")), + input: None, + output: Some(serde_json::Value::String("x".repeat(self.envelope_bytes))), + status: Some("completed".to_string()), + duration_ms: Some(4), + raw: None, + }; + envelopes.push(ProvenanceJournalEnvelope::new( + format!("selftest-{index}"), + &self.session_id, + self.turn, + reservation.generation, + now * 1000 + index as i64, + event, + )); + } + let expected: Vec> = envelopes + .iter() + .map(|envelope| envelope.to_json_bytes().unwrap()) + .collect(); + // The same chunking the sink performs — reported so the test can + // assert a multi-envelope batch really split. + let wire: Vec<_> = envelopes + .iter() + .map(|envelope| pb::WireEnvelope { + event_id: envelope.event_id.clone(), + type_url: String::new(), + envelope: envelope.to_json_bytes().unwrap(), + }) + .collect(); + let chunk_count = chunk_wire_envelopes(wire).len(); + + let acknowledgements = sink + .append(reservation, envelopes, now + 1) + .map_err(|reason| CliError::Internal(anyhow!("append failed: {reason}")))?; + + let source = JournalCheckpointSource { + agent_name: "selftest".to_string(), + agent_display_name: "Selftest".to_string(), + agent_vendor: "atomic".to_string(), + change_hashes: Vec::new(), + previous_provenance: None, + plan_id: None, + ledger_turn_number: self.turn, + }; + let checkpoint = sink + .prepare_checkpoint(reservation, source, now + 2) + .map_err(|reason| CliError::Internal(anyhow!("prepare failed: {reason}")))?; + + let frozen = sink + .load_frozen_envelopes(&checkpoint) + .map_err(|reason| CliError::Internal(anyhow!("load frozen failed: {reason}")))?; + let reassembled_equal = frozen == expected; + + let graph_hash = Hash::of(format!("selftest-graph-{}", self.session_id).as_bytes()); + let session_turn = SessionTurn { + session_id: self.session_id.clone(), + turn_number: self.turn, + goal: None, + provenance_hash: graph_hash, + change_hashes: Vec::new(), + previous_provenance: None, + timestamp: now + 3, + plan_id: None, + todos: Vec::new(), + }; + let bound = sink + .bind_checkpoint_hash(&checkpoint, graph_hash, session_turn, now + 3) + .map_err(|reason| CliError::Internal(anyhow!("bind failed: {reason}")))?; + let bound_hash = bound.provenance_hash == Some(graph_hash); + + let manifest_hash = Hash::of(format!("selftest-manifest-{}", self.session_id).as_bytes()); + sink.acknowledge_checkpoint(&bound, manifest_hash, now + 4) + .map_err(|reason| CliError::Internal(anyhow!("acknowledge failed: {reason}")))?; + + // Lifecycle on a second turn: stop then observe the status. + let second = sink + .reserve_turn(&self.session_id, self.turn + 1, now + 5) + .map_err(|reason| CliError::Internal(anyhow!("second reserve failed: {reason}")))?; + let _ = second; + let stopped = sink + .stop_turn( + &self.session_id, + self.turn + 1, + JournalStopCause::UserRequested, + true, + now + 6, + ) + .map_err(|reason| CliError::Internal(anyhow!("stop failed: {reason}")))?; + let status = sink + .turn_status(&self.session_id, self.turn + 1) + .map_err(|reason| CliError::Internal(anyhow!("status failed: {reason}")))?; + + let report = serde_json::json!({ + "reservation": { + "provenance_id": reservation.provenance_id, + "generation": reservation.generation, + }, + "acks": acknowledgements.len(), + "ack_sequences": acknowledgements.iter().map(|ack| ack.sequence).collect::>(), + "chunk_count": chunk_count, + "frozen_envelopes": frozen.len(), + "reassembled_equal": reassembled_equal, + "attempt_generation": checkpoint.attempt_generation, + "bound_hash": bound_hash, + "stopped": stopped.is_some(), + "status_lifecycle": format!("{:?}", status.map(|status| status.lifecycle)), + }); + if self.json { + println!("{report}"); + } else { + println!("provenance rpc selftest: {report}"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wire_envelope(index: usize, bytes: usize) -> pb::WireEnvelope { + pb::WireEnvelope { + event_id: format!("event-{index}"), + type_url: String::new(), + envelope: vec![0u8; bytes], + } + } + + #[test] + fn chunking_splits_oversized_batches() { + let envelopes: Vec<_> = (0..6) + .map(|i| wire_envelope(i, 2 * APPEND_CHUNK_BUDGET_BYTES)) + .collect(); + let chunks = chunk_wire_envelopes(envelopes); + assert_eq!(chunks.len(), 6); + for chunk in &chunks { + assert_eq!(chunk.len(), 1); + } + } + + #[test] + fn chunking_packs_small_batches() { + let envelopes: Vec<_> = (0..1_000).map(|i| wire_envelope(i, 16)).collect(); + let chunks = chunk_wire_envelopes(envelopes); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].len(), 1_000); + } + + #[test] + fn chunking_splits_a_multi_megabyte_tool_batch() { + // The selftest shape: six ~500 KiB wire envelopes against a 1 MiB + // budget — two per chunk, three chunks. + let envelopes: Vec<_> = (0..6).map(|i| wire_envelope(i, 500 * 1024)).collect(); + let chunks = chunk_wire_envelopes(envelopes); + assert_eq!(chunks.len(), 3); + for chunk in &chunks { + assert_eq!(chunk.len(), 2); + } + } + + #[test] + fn chunking_preserves_all_envelopes_in_order() { + let envelopes: Vec<_> = (0..2_000).map(|i| wire_envelope(i, 64 * 1024)).collect(); + let chunks = chunk_wire_envelopes(envelopes); + let flattened: Vec<_> = chunks.into_iter().flatten().collect(); + assert_eq!(flattened.len(), 2_000); + for (index, envelope) in flattened.iter().enumerate() { + assert_eq!(envelope.event_id, format!("event-{index}")); + } + } + + fn fragment(seq: u64, offset: u64, bytes: &[u8], complete: bool) -> pb::FrozenFragment { + pb::FrozenFragment { + cursor: Some(pb::PageCursor { seq, offset }), + bytes: bytes.to_vec(), + complete, + } + } + + #[test] + fn frozen_pages_reassemble_split_envelopes_and_reject_bad_continuations() { + let mut envelopes = Vec::new(); + let mut partial = Vec::new(); + let mut cursor = pb::PageCursor::default(); + + // One envelope split across two pages. + let first = pb::FrozenPage { + fragments: vec![fragment(0, 0, b"hel", false)], + next: Some(pb::PageCursor { seq: 0, offset: 3 }), + }; + append_frozen_page(&mut envelopes, &mut partial, &mut cursor, 2, first).unwrap(); + assert!(partial == b"hel"); + let second = pb::FrozenPage { + fragments: vec![fragment(0, 3, b"lo", true), fragment(1, 0, b"world", true)], + next: Some(pb::PageCursor { seq: 2, offset: 0 }), + }; + append_frozen_page(&mut envelopes, &mut partial, &mut cursor, 2, second).unwrap(); + assert!(partial.is_empty()); + assert_eq!(envelopes, vec![b"hello".to_vec(), b"world".to_vec()]); + assert_eq!(cursor.seq, 2); + + // A page that does not advance the cursor is refused. + let mut envelopes = Vec::new(); + let mut partial = Vec::new(); + let mut cursor = pb::PageCursor::default(); + let stuck = pb::FrozenPage { + fragments: vec![fragment(0, 0, b"hello", true)], + next: Some(pb::PageCursor::default()), + }; + assert!( + append_frozen_page(&mut envelopes, &mut partial, &mut cursor, 2, stuck) + .unwrap_err() + .to_string() + .contains("invalid frozen journal page continuation") + ); + + // A fragment that rewinds inside an envelope is refused. + let mut envelopes = Vec::new(); + let mut partial = Vec::new(); + let mut cursor = pb::PageCursor::default(); + let rewound = pb::FrozenPage { + fragments: vec![fragment(0, 0, b"hel", false), fragment(0, 1, b"lo", true)], + next: Some(pb::PageCursor { seq: 1, offset: 0 }), + }; + assert!( + append_frozen_page(&mut envelopes, &mut partial, &mut cursor, 2, rewound) + .unwrap_err() + .to_string() + .contains("invalid frozen journal fragment cursor") + ); + + // An incomplete fragment with no bytes is refused. + let empty = pb::FrozenPage { + fragments: vec![fragment(0, 0, b"", false)], + next: Some(pb::PageCursor { seq: 1, offset: 0 }), + }; + let mut envelopes = Vec::new(); + let mut partial = Vec::new(); + let mut cursor = pb::PageCursor::default(); + assert!( + append_frozen_page(&mut envelopes, &mut partial, &mut cursor, 2, empty) + .unwrap_err() + .to_string() + .contains("invalid frozen journal fragment cursor") + ); + } +} diff --git a/atomic-cli/src/commands/agent/receive.rs b/atomic-cli/src/commands/agent/receive.rs new file mode 100644 index 00000000..c96ce47d --- /dev/null +++ b/atomic-cli/src/commands/agent/receive.rs @@ -0,0 +1,142 @@ +//! `atomic agent receive` — typed TurnEvent entry point for the Reactor bridge. +//! +//! The Atomic Reactor (and other verified dispatchers) deliver agent events +//! as **already-typed** [`TurnEvent`] JSON on stdin — no agent-specific hook +//! parsing happens here. The reactor's adapter validated and projected the +//! native event; this command is the trusted typed boundary between the +//! Reactor's durable inbox and Atomic's turn orchestrator. +//! +//! # Invocation +//! +//! ```text +//! atomic agent receive --repository --agent --json +//! ``` +//! +//! stdin: one JSON object matching `atomic_agent::event::TurnEvent` +//! (snake_case `event_type`: `session_start`, `turn_start`, `turn_end`, +//! `session_end`, `pre_tool_use`, `post_tool_use`). +//! +//! stdout (`--json`): one line matching the `agent hooks --json` contract — +//! `{session_id, recorded, change_hash?, view?, files, run_id?}` — so the +//! Reactor can mark the delivery published and surface the recorded change. +//! +//! # Routing +//! +//! While the daemon is reachable this command is a thin RPC client: the +//! typed event travels to `ProvenanceService.DispatchTurnEvent`, where the +//! daemon hosts the turn orchestrator against the repository databases it +//! owns — this process never opens redb. When the daemon is down the +//! in-process orchestrator fallback runs (auto mode only; `ATOMIC_RPC=1` +//! is strict and never falls back); its journal sink starts the daemon +//! per D4, so even the fallback's provenance flows over the service +//! layer. + +use std::io::Read; +use std::sync::Arc; + +use anyhow::anyhow; +use clap::Args; + +use atomic_agent::event::TurnEvent; +use atomic_agent::hooks::AgentRegistry; +use atomic_core::types::Base32; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; + +/// Typed event receiver (called by the Reactor, not by users). +#[derive(Debug, Args)] +pub struct Receive { + /// Path inside the repository the event belongs to. + #[arg(long, default_value = ".")] + repository: std::path::PathBuf, + + /// Agent registry key the dispatcher verified (e.g. "opencode"). + #[arg(long)] + agent: String, + + /// Print one JSON result line for the dispatcher. + #[arg(long)] + json: bool, +} + +/// The dispatch outcome, shared by the RPC path and the local fallback. +pub struct ReceiveResult { + pub session_id: String, + pub recorded: bool, + pub change_hash: Option, + pub view: Option, + pub files: Vec, + pub warnings: Vec, +} + +impl Command for Receive { + fn run(&self) -> CliResult<()> { + // The typed event arrives on stdin — no agent hook parsing here. + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).map_err(|e| { + CliError::Io(std::io::Error::new( + e.kind(), + format!("Failed to read typed event from stdin: {}", e), + )) + })?; + let event: TurnEvent = + serde_json::from_slice(&input).map_err(|e| CliError::InvalidArgument { + message: format!("Invalid typed TurnEvent JSON: {}", e), + })?; + + let repo_root = crate::commands::find_repository_root_from(&self.repository)?; + + let registry = AgentRegistry::with_defaults(); + let agent = registry + .require(&self.agent) + .map_err(|e| CliError::InvalidArgument { + message: format!("Unknown agent '{}': {}", self.agent, e), + })?; + let agent_name = agent.name().to_string(); + let agent_display = agent.display_name().to_string(); + + // The identity selection chain resolves client-side (env > global + // setting > server profile) and rides the request. + let agent_identity = + super::identity::resolve_effective_agent_identity().map(|(name, _)| name); + + // The service area dispatches the SAME handler both transports + // serve: in-process over libatomic (local mode, the default) or + // over the daemon socket (reactor mode, D4). There is no second + // local dispatch implementation to fall back to. + let result = crate::commands::rpc::dispatch_turn_event( + &repo_root, + &event, + &agent_name, + &agent_display, + agent_identity, + )?; + + for warning in &result.warnings { + log::warn!("{}", warning); + } + + if !self.json { + println!( + "{} recorded={} ({} files)", + result.session_id, + result.recorded, + result.files.len() + ); + return Ok(()); + } + + println!( + "{}", + serde_json::json!({ + "session_id": result.session_id, + "recorded": result.recorded, + "change_hash": result.change_hash, + "view": result.view, + "files": result.files, + }) + ); + Ok(()) + } +} diff --git a/atomic-cli/src/commands/change/command.rs b/atomic-cli/src/commands/change/command.rs index 770b87b9..c3696577 100644 --- a/atomic-cli/src/commands/change/command.rs +++ b/atomic-cli/src/commands/change/command.rs @@ -75,6 +75,26 @@ pub struct ChangeCmd { pub full_hash: bool, } +/// Everything the change renders need beyond the change itself: the causal +/// decision graph(s) and (for `--show-deps`) dependency messages. The local +/// path fetches these from the repository; the routed path gets them from +/// the wire (GetChange's bundle + provenance ledger). One render either way. +pub(crate) struct ChangeRenderData { + pub ledger_graphs: Vec<(Hash, ProvenanceGraph)>, + pub dep_messages: std::collections::HashMap, +} + +impl ChangeRenderData { + /// The local repository path. Dependency messages (for `--show-deps`) + /// are filled by the caller once the change's dependency list is known. + pub(crate) fn from_repo(repo: &Repository, hash: &Hash) -> Self { + Self { + ledger_graphs: repo.find_provenance_for_change(hash).unwrap_or_default(), + dep_messages: std::collections::HashMap::new(), + } + } +} + impl ChangeCmd { /// Create a new ChangeCmd with default settings. pub fn new() -> Self { @@ -291,7 +311,7 @@ impl ChangeCmd { change: &Change, hash: &Hash, sequence: Option, - repo: &Repository, + data: &ChangeRenderData, ) -> String { let mut output = String::new(); let hash_len = self.get_hash_length(); @@ -343,17 +363,9 @@ impl ChangeCmd { for dep_hash in &change.hashed.dependencies { let dep_hash_str = format_hash_with_length(dep_hash, 12); let dep_msg = if self.show_deps { - repo.load_change(dep_hash) - .ok() - .map(|c| { - c.hashed - .header - .message - .lines() - .next() - .unwrap_or("") - .to_string() - }) + data.dep_messages + .get(dep_hash) + .cloned() .unwrap_or_else(|| "[unable to load]".to_string()) } else { String::new() @@ -433,7 +445,7 @@ impl ChangeCmd { } // Change ledger (causal decision DAG from .provenance file) - output.push_str(&self.format_change_ledger(hash, repo)); + output.push_str(&self.format_change_ledger(&data.ledger_graphs)); output } @@ -564,29 +576,20 @@ impl ChangeCmd { /// /// Displays the structured provenance graph stored in the `.provenance` /// file: goals, tool executions, explorations, commitments, and patch - /// proposals that led to this change. - fn format_change_ledger(&self, change_hash: &Hash, repo: &Repository) -> String { + /// proposals that led to this change. The graphs arrive from the render + /// data (repository or wire — one render either way). + fn format_change_ledger(&self, graphs: &[(Hash, ProvenanceGraph)]) -> String { let mut output = String::new(); - let graphs = match repo.find_provenance_for_change(change_hash) { - Ok(g) if !g.is_empty() => g, - Ok(_) => { - output.push_str(&format!( - "{}\n", - hint("No provenance graph found for this change.") - )); - return output; - } - Err(e) => { - output.push_str(&format!( - "{}\n", - hint(&format!("Failed to load provenance: {}", e)) - )); - return output; - } - }; + if graphs.is_empty() { + output.push_str(&format!( + "{}\n", + hint("No provenance graph found for this change.") + )); + return output; + } - for (_graph_hash, graph) in &graphs { + for (_graph_hash, graph) in graphs { output.push_str(&format!("{}\n", emphasis("=== Change Ledger ==="))); output.push_str(&format!(" Session: {}\n", info(&graph.session_id))); output.push_str(&format!( @@ -659,21 +662,26 @@ impl ChangeCmd { /// `.provenance` file — for JSON output. Mirrors the `=== Change Ledger ===` /// section of the default text format. Missing/corrupt provenance is /// non-fatal: the ledger simply stays empty (and is omitted from the JSON). - fn load_json_ledger(&self, hash: &Hash, repo: &Repository) -> Vec { - repo.find_provenance_for_change(hash) - .unwrap_or_default() + fn load_json_ledger(&self, graphs: &[(Hash, ProvenanceGraph)]) -> Vec { + graphs .iter() .map(|(graph_hash, graph)| JsonChangeLedger::from_graph(graph_hash, graph)) .collect() } /// Print the change in the configured format. - fn print_change(&self, change: &Change, hash: &Hash, sequence: Option, repo: &Repository) { + pub(crate) fn print_change( + &self, + change: &Change, + hash: &Hash, + sequence: Option, + data: &ChangeRenderData, + ) { let output = match self.format { - ChangeFormat::Default => self.format_default(change, hash, sequence, repo), + ChangeFormat::Default => self.format_default(change, hash, sequence, data), ChangeFormat::Short => self.format_short(change, hash, sequence), ChangeFormat::Json => { - let ledger = self.load_json_ledger(hash, repo); + let ledger = self.load_json_ledger(&data.ledger_graphs); self.format_json(change, hash, sequence, ledger) } }; @@ -738,6 +746,10 @@ impl Command for ChangeCmd { /// - The change is not found /// - The hash prefix is ambiguous fn run(&self) -> CliResult<()> { + if crate::commands::rpc::change(self)? { + return Ok(()); + } + // Find and open repository let repo_root = find_repository_root()?; let repo = crate::commands::open_readonly_repository(&repo_root).map_err(|e| match e { @@ -761,8 +773,22 @@ impl Command for ChangeCmd { other => CliError::Internal(anyhow::anyhow!("{}", other)), })?; + // The render data from the repository (dep messages for --show-deps) + let mut data = ChangeRenderData::from_repo(&repo, &hash); + if self.show_deps { + for dep_hash in &change.hashed.dependencies { + let message = repo + .load_change(dep_hash) + .ok() + .and_then(|c| c.hashed.header.message.lines().next().map(str::to_string)); + if let Some(message) = message { + data.dep_messages.insert(*dep_hash, message); + } + } + } + // Print the change - self.print_change(&change, &hash, sequence, &repo); + self.print_change(&change, &hash, sequence, &data); Ok(()) } diff --git a/atomic-cli/src/commands/change/mod.rs b/atomic-cli/src/commands/change/mod.rs index c57dd065..f660e456 100644 --- a/atomic-cli/src/commands/change/mod.rs +++ b/atomic-cli/src/commands/change/mod.rs @@ -128,7 +128,7 @@ use clap::{Parser, ValueEnum}; use serde::Serialize; -use atomic_core::change::{Author, Change, GraphOp, Provenance}; +use atomic_core::change::{Author, Change, GraphOp, Provenance, ProvenanceGraph}; use atomic_core::pristine::ViewTxnT; use atomic_core::types::{Base32, Hash}; use atomic_repository::history::{find_change_sequence, get_change_at_sequence}; @@ -146,8 +146,8 @@ use console::style; // Output Format -mod command; -mod types; +pub(crate) mod command; +pub(crate) mod types; pub use command::*; pub use types::*; diff --git a/atomic-cli/src/commands/conflicts.rs b/atomic-cli/src/commands/conflicts.rs index 7aeb3a57..680b2fc7 100644 --- a/atomic-cli/src/commands/conflicts.rs +++ b/atomic-cli/src/commands/conflicts.rs @@ -43,6 +43,12 @@ impl Conflicts { impl Command for Conflicts { fn run(&self) -> CliResult<()> { + // Route through the daemon when reachable (a read; the render + // comes from the wire's ConflictInfo kind/line/sides). + if crate::commands::rpc::conflicts(self.short)? { + return Ok(()); + } + let repo_root = find_repository_root()?; let repo = crate::commands::open_readonly_repository(&repo_root).map_err(|e| { CliError::InvalidRepository { diff --git a/atomic-cli/src/commands/diff/command.rs b/atomic-cli/src/commands/diff/command.rs index 5a67eed7..59e80711 100644 --- a/atomic-cli/src/commands/diff/command.rs +++ b/atomic-cli/src/commands/diff/command.rs @@ -81,6 +81,16 @@ pub struct Diff { #[arg(long, add = ArgValueCompleter::new(complete_view_names))] pub view: Option, + /// View-pair diff: diff FROM this view (requires --to). + /// + /// Routes through the daemon when reachable. + #[arg(long, requires = "to", add = ArgValueCompleter::new(complete_view_names))] + pub from: Option, + + /// View-pair diff: diff TO this view (requires --from). + #[arg(long, requires = "from", add = ArgValueCompleter::new(complete_view_names))] + pub to: Option, + /// Enable token-level diff highlighting (CRDT-powered). /// /// Shows exactly which tokens changed within a line, not just @@ -109,6 +119,8 @@ impl Diff { untracked: false, cached: false, view: None, + from: None, + to: None, word_diff: false, json: false, } @@ -238,6 +250,10 @@ impl Command for Diff { /// 3. Computes diffs for modified files /// 4. Displays the diffs in the requested format fn run(&self) -> CliResult<()> { + if crate::commands::rpc::diff(self)? { + return Ok(()); + } + // Find the repository root let repo_root = find_repository_root()?; diff --git a/atomic-cli/src/commands/diff/helpers.rs b/atomic-cli/src/commands/diff/helpers.rs index ad7a1b95..fef81b97 100644 --- a/atomic-cli/src/commands/diff/helpers.rs +++ b/atomic-cli/src/commands/diff/helpers.rs @@ -185,7 +185,7 @@ impl Diff { /// Returns `true` when no files were specified (no filtering) or when /// the path exactly matches one of the given files. A leading `./` on /// either side is tolerated. - pub(super) fn file_matches_filter(&self, path: &str) -> bool { + pub(crate) fn file_matches_filter(&self, path: &str) -> bool { if self.files.is_empty() { return true; } @@ -200,7 +200,7 @@ impl Diff { /// line, rebuilding the aggregate stats from the surviving entries. /// /// This is a no-op when no positional files were specified. - pub(super) fn filter_file_diffs( + pub(crate) fn filter_file_diffs( &self, file_diffs: Vec, stats: DiffStats, @@ -453,7 +453,11 @@ impl Diff { self.print_change_file_diffs(change, change_hash, config, file_diffs, stats) } - fn print_change_file_diffs( + /// Render a recorded change's computed file diffs — the header for the + /// human unified form, then the configured format. Shared by the local + /// `-c` body and the service-layer hook (one render, two data sources: + /// the file diffs arrive from the graph or the wire). + pub(crate) fn print_change_file_diffs( &self, change: &Change, change_hash: &Hash, @@ -512,7 +516,7 @@ impl Diff { .is_some() } - pub(super) fn build_git_import_file_diffs( + pub(crate) fn build_git_import_file_diffs( change: &Change, ) -> Option<(Vec, DiffStats)> { let diff_files_value = change @@ -767,99 +771,23 @@ impl Diff { // Parse algorithm for diffing let algorithm = self.parse_algorithm()?; - // Compute diffs for each file using state-based content retrieval - let mut file_diffs = Vec::new(); - let mut stats = DiffStats::new(); - + // Compute the per-file before/after content from the graph, then + // build the diffs with the SAME builder the routed path runs over + // wire-carried content (one computation, two data sources). + let mut entries = Vec::with_capacity(modified_files.len()); for file_path in &modified_files { - // Get content BEFORE the change was applied let before_content = match repo.get_file_content_before_change(file_path, hash) { Ok(content) => content.unwrap_or_default(), Err(_) => Vec::new(), }; - - // Get content AFTER the change was applied let after_content = match repo.get_file_content_after_change(file_path, hash) { Ok(content) => content.unwrap_or_default(), Err(_) => Vec::new(), }; - - // Determine the type of change based on before/after content - let file_diff = match (before_content.is_empty(), after_content.is_empty()) { - // File was added (no content before, has content after) - (true, false) => { - let mut diff = FileDiff::added(file_path); - let lines: Vec<_> = after_content.split(|&b| b == b'\n').collect(); - let line_count = lines.len(); - - if !after_content.is_empty() { - let mut graph_op = DiffHunk::new(0, 0, 1, line_count); - for (i, line_bytes) in lines.iter().enumerate() { - let line_content = String::from_utf8_lossy(line_bytes).into_owned(); - graph_op.add_line(HunkLine::added(line_content, i + 1)); - } - diff.add_hunk(graph_op); - } - - diff.stats = FileDiffStats::added(file_path, line_count); - diff - } - - // File was deleted (has content before, no content after) - (false, true) => { - let mut diff = FileDiff::deleted(file_path); - let lines: Vec<_> = before_content.split(|&b| b == b'\n').collect(); - let line_count = lines.len(); - - if !before_content.is_empty() { - let mut graph_op = DiffHunk::new(1, line_count, 0, 0); - for (i, line_bytes) in lines.iter().enumerate() { - let line_content = String::from_utf8_lossy(line_bytes).into_owned(); - graph_op.add_line(HunkLine::removed(line_content, i + 1)); - } - diff.add_hunk(graph_op); - } - - diff.stats = FileDiffStats::deleted(file_path, line_count); - diff - } - - // File was modified (has content both before and after) - (false, false) => { - let mut diff = FileDiff::modified(file_path); - - // Compute diff between old (before) and new (after) content - let diff_result = diff_text(&before_content, &after_content, algorithm); - - if !diff_result.is_unchanged() { - let old_lines: Vec<_> = before_content.split(|&b| b == b'\n').collect(); - let new_lines: Vec<_> = after_content.split(|&b| b == b'\n').collect(); - - // Build hunks with context - let hunks = build_hunks_from_diff( - &diff_result, - &old_lines, - &new_lines, - config.context_lines, - ); - for graph_op in hunks { - diff.add_hunk(graph_op); - } - } - - diff.compute_stats(); - diff - } - - // No content at all (shouldn't happen for files in change) - (true, true) => { - continue; // Skip files with no content - } - }; - - stats.add_file(file_diff.stats.clone()); - file_diffs.push(file_diff); + entries.push((file_path.clone(), before_content, after_content)); } + let (file_diffs, stats) = + legacy_content_file_diffs(&entries, algorithm, config.context_lines); if file_diffs.is_empty() { self.print_no_changes(); @@ -995,6 +923,119 @@ pub(crate) fn change_file_diffs( change_hash: &Hash, config: &DiffOutputConfig, ) -> CliResult<(Vec, DiffStats)> { + // The before-content fetch: the file's recorded state prior to the + // change, read from the graph. The wire path has no such fetch — it + // passes a `None` resolver and the hunks render zero-context, the + // same degradation this path applies when the read fails. + change_file_diffs_with(change, config, |path| { + repo.get_file_content_before_change(path, change_hash) + .ok() + .flatten() + }) +} + +/// Build the legacy (no-file_ops) change's file diffs from per-file +/// before/after content — the computation `atomic diff -c` runs for +/// legacy changes, factored out so the local body (graph-read content) +/// and the routed hook (wire-carried content) run the SAME code: +/// added (no before), deleted (no after), or modified (diffed with the +/// configured algorithm and context). +pub(crate) fn legacy_content_file_diffs( + entries: &[(String, Vec, Vec)], + algorithm: Algorithm, + context_lines: usize, +) -> (Vec, DiffStats) { + let mut file_diffs = Vec::new(); + let mut stats = DiffStats::new(); + + for (file_path, before_content, after_content) in entries { + // Determine the type of change based on before/after content + let file_diff = match (before_content.is_empty(), after_content.is_empty()) { + // File was added (no content before, has content after) + (true, false) => { + let mut diff = FileDiff::added(file_path); + let lines: Vec<_> = after_content.split(|&b| b == b'\n').collect(); + let line_count = lines.len(); + + if !after_content.is_empty() { + let mut graph_op = DiffHunk::new(0, 0, 1, line_count); + for (i, line_bytes) in lines.iter().enumerate() { + let line_content = String::from_utf8_lossy(line_bytes).into_owned(); + graph_op.add_line(HunkLine::added(line_content, i + 1)); + } + diff.add_hunk(graph_op); + } + + diff.stats = FileDiffStats::added(file_path, line_count); + diff + } + + // File was deleted (has content before, no content after) + (false, true) => { + let mut diff = FileDiff::deleted(file_path); + let lines: Vec<_> = before_content.split(|&b| b == b'\n').collect(); + let line_count = lines.len(); + + if !before_content.is_empty() { + let mut graph_op = DiffHunk::new(1, line_count, 0, 0); + for (i, line_bytes) in lines.iter().enumerate() { + let line_content = String::from_utf8_lossy(line_bytes).into_owned(); + graph_op.add_line(HunkLine::removed(line_content, i + 1)); + } + diff.add_hunk(graph_op); + } + + diff.stats = FileDiffStats::deleted(file_path, line_count); + diff + } + + // File was modified (has content both before and after) + (false, false) => { + let mut diff = FileDiff::modified(file_path); + + // Compute diff between old (before) and new (after) content + let diff_result = diff_text(before_content, after_content, algorithm); + + if !diff_result.is_unchanged() { + let old_lines: Vec<_> = before_content.split(|&b| b == b'\n').collect(); + let new_lines: Vec<_> = after_content.split(|&b| b == b'\n').collect(); + + // Build hunks with context + let hunks = + build_hunks_from_diff(&diff_result, &old_lines, &new_lines, context_lines); + for graph_op in hunks { + diff.add_hunk(graph_op); + } + } + + diff.compute_stats(); + diff + } + + // No content at all (shouldn't happen for files in change) + (true, true) => { + continue; // Skip files with no content + } + }; + + stats.add_file(file_diff.stats.clone()); + file_diffs.push(file_diff); + } + + (file_diffs, stats) +} + +/// The resolver-parameterized core of [`change_file_diffs`]: `before` +/// supplies a file's pre-change content for context padding (a `None` +/// answer pads nothing — zero-context hunks, still at true offsets). +pub(crate) fn change_file_diffs_with( + change: &Change, + config: &DiffOutputConfig, + before: F, +) -> CliResult<(Vec, DiffStats)> +where + F: Fn(&str) -> Option>, +{ // Git-imported changes carry Git's captured +/- lines in unhashed metadata. if let Some((file_diffs, stats)) = Diff::build_git_import_file_diffs(change) { return Ok((file_diffs, stats)); @@ -1099,17 +1140,13 @@ pub(crate) fn change_file_diffs( } } - // Fetch the file's recorded before-content so hunks can be padded with + // Fetch the file's before-content so hunks can be padded with // context lines. Only needed for unified output with a non-zero - // --context; failures degrade to zero context. + // --context; a `None` answer degrades to zero context. let before_lines: Option>> = if config.format == DiffFormat::Unified && config.context_lines > 0 { - match repo.get_file_content_before_change(file_path, change_hash) { - Ok(Some(content)) => { - Some(content.split(|&b| b == b'\n').map(|l| l.to_vec()).collect()) - } - _ => None, - } + before(file_path) + .map(|content| content.split(|&b| b == b'\n').map(|l| l.to_vec()).collect()) } else { None }; diff --git a/atomic-cli/src/commands/diff/mod.rs b/atomic-cli/src/commands/diff/mod.rs index 724c9769..fe45920d 100644 --- a/atomic-cli/src/commands/diff/mod.rs +++ b/atomic-cli/src/commands/diff/mod.rs @@ -232,19 +232,21 @@ use crate::output::{ added, deleted, emphasis, hash, info, modified, path as style_path, print_hint, print_info, }; -mod command; +pub(crate) mod command; mod format; mod helpers; mod json; mod output; -mod types; +pub(crate) mod types; /// How many recent changes the no-pending-changes hint offers to inspect. const RECENT_CHANGE_SUGGESTIONS: usize = 3; pub use command::*; -pub(crate) use helpers::change_file_diffs; -pub(crate) use output::{build_hunks_from_diff, format_stat_graph}; +pub(crate) use helpers::{change_file_diffs, change_file_diffs_with, legacy_content_file_diffs}; +pub(crate) use output::{ + build_hunks_from_diff, format_stat_graph, print_semantic_word_diff_line, print_word_diff_line, +}; pub use types::*; #[cfg(test)] diff --git a/atomic-cli/src/commands/diff/output.rs b/atomic-cli/src/commands/diff/output.rs index a0318165..d3977e2d 100644 --- a/atomic-cli/src/commands/diff/output.rs +++ b/atomic-cli/src/commands/diff/output.rs @@ -244,7 +244,10 @@ pub(crate) fn build_hunks_from_diff( /// Uses ANSI escape codes to highlight changed tokens: /// - Deletions: bright red text on light red background /// - Insertions: bright green text on light green background -pub(super) fn print_word_diff_line( +/// +/// Shared by the local unified renderer and the service-layer hook's +/// word-diff render (one render, two data sources). +pub(crate) fn print_word_diff_line( content: &[u8], hunks: &[atomic_core::diff::ChangeHunk], is_deletion: bool, @@ -289,7 +292,7 @@ pub(super) fn print_word_diff_line( /// + const result = calculateSum(a, b, c); <- light green background /// ^^^^ <- dark green: ", c" added /// ``` -pub(super) fn print_semantic_word_diff_line(token_changes: &[TokenChange<'_>], is_deletion: bool) { +pub(crate) fn print_semantic_word_diff_line(token_changes: &[TokenChange<'_>], is_deletion: bool) { for tc in token_changes { match tc { TokenChange::Unchanged { token, .. } => { diff --git a/atomic-cli/src/commands/doctor.rs b/atomic-cli/src/commands/doctor.rs index b47c2898..45ab73a5 100644 --- a/atomic-cli/src/commands/doctor.rs +++ b/atomic-cli/src/commands/doctor.rs @@ -82,6 +82,10 @@ impl Command for Doctor { impl Command for Check { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::doctor_check(self)? { + return Ok(()); + } + let repo = require_repository(None)?; print_info("Verifying working-copy consistency against the graph..."); @@ -117,6 +121,10 @@ impl Command for Check { impl Command for RepairDependencyIndex { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::doctor_repair_dependency_index(self)? { + return Ok(()); + } + let repo = require_repository(None)?; print_info("Repairing change dependency index..."); @@ -145,6 +153,10 @@ impl Command for RepairDependencyIndex { impl Command for MaterializeCrdt { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::doctor_materialize_crdt(self)? { + return Ok(()); + } + let repo = require_repository(None)?; let view = self .view diff --git a/atomic-cli/src/commands/insert.rs b/atomic-cli/src/commands/insert.rs index bce3d78b..fb5da26d 100644 --- a/atomic-cli/src/commands/insert.rs +++ b/atomic-cli/src/commands/insert.rs @@ -15,7 +15,7 @@ use atomic_repository::{ }; use crate::commands::complete::{complete_change_hashes, complete_view_names}; -use crate::commands::{format_hash, require_repository}; +use crate::commands::{find_repository_root, format_hash, require_repository}; use crate::error::{CliError, CliResult}; use crate::output; @@ -51,23 +51,24 @@ pub struct Insert { /// into (default: current view). For the bare `atomic insert` promotion /// it overrides the target (default: the current view's parent). #[arg(long, visible_alias = "to", add = ArgValueCompleter::new(complete_view_names))] - view: Option, + pub(crate) view: Option, - /// Insert dependencies automatically. - #[arg(long, default_value = "true")] - deps: bool, + /// Insert dependencies automatically. Pass `--deps=false` to skip the + /// dependency closure (the explicit opt-out the wire carries). + #[arg(long, action = clap::ArgAction::Set, num_args = 0..=1, default_value = "true", default_missing_value = "true")] + pub(crate) deps: bool, /// Allow conflicts during insert. #[arg(long)] - allow_conflicts: bool, + pub(crate) allow_conflicts: bool, /// Preview the promotion without inserting anything. #[arg(short = 'n', long)] - dry_run: bool, + pub(crate) dry_run: bool, /// Skip the confirmation prompt when promoting between two shared views. #[arg(long)] - confirm: bool, + pub(crate) confirm: bool, /// Repository path. #[arg(short = 'R', long)] @@ -109,8 +110,9 @@ pub struct ViewArgs { #[arg(long, visible_alias = "to", add = ArgValueCompleter::new(complete_view_names))] to_view: Option, - /// Insert dependencies automatically. - #[arg(long, default_value = "true")] + /// Insert dependencies automatically. Pass `--deps=false` to skip the + /// dependency closure (the explicit opt-out the wire carries). + #[arg(long, action = clap::ArgAction::Set, num_args = 0..=1, default_value = "true", default_missing_value = "true")] deps: bool, /// Allow conflicts during insert. @@ -137,8 +139,9 @@ pub struct TagArgs { #[arg(long, visible_alias = "to", add = ArgValueCompleter::new(complete_view_names))] to_view: Option, - /// Insert dependencies automatically. - #[arg(long, default_value = "true")] + /// Insert dependencies automatically. Pass `--deps=false` to skip the + /// dependency closure (the explicit opt-out the wire carries). + #[arg(long, action = clap::ArgAction::Set, num_args = 0..=1, default_value = "true", default_missing_value = "true")] deps: bool, /// Allow conflicts during insert. @@ -161,8 +164,9 @@ pub struct ChangeArgs { #[arg(long, visible_alias = "to", add = ArgValueCompleter::new(complete_view_names))] to_view: Option, - /// Insert dependencies automatically. - #[arg(long, default_value = "true")] + /// Insert dependencies automatically. Pass `--deps=false` to skip the + /// dependency closure (the explicit opt-out the wire carries). + #[arg(long, action = clap::ArgAction::Set, num_args = 0..=1, default_value = "true", default_missing_value = "true")] deps: bool, /// Allow conflicts during insert. @@ -190,22 +194,85 @@ pub struct PreviewArgs { impl crate::commands::Command for Insert { fn run(&self) -> CliResult<()> { - let repo_path = self.repository.as_ref().map(std::path::Path::new); - let repo = require_repository(repo_path)?; + // Route EVERY form through the service layer — BEFORE any local + // repository open (the routed path must never touch redb). The + // --deps escape hatch rides apply_dependencies (the handler + // applies or skips the closure server-side); the dry-run previews + // ride the request's dry_run flag (the handler computes the plan + // with the same domain call); the tag --from-view override rides + // tag_from_view; multi-pick references resolve handler-side with + // the resolver's semantics; the bare promotion resolves source + // and parent through the promote arm (the pre-flight read and the + // confirmed insert are the same RPC's two phases). + let route_ok = match &self.command { + Some(InsertSubcommand::View(args)) => crate::commands::rpc::insert_from_view( + &args.from_view, + args.to_view.clone(), + args.allow_conflicts, + args.deps, + args.dry_run, + )?, + Some(InsertSubcommand::Tag(args)) => crate::commands::rpc::insert_up_to_tag( + &args.tag_name, + args.from_view.clone(), + args.to_view.clone(), + args.allow_conflicts, + args.deps, + args.dry_run, + )?, + Some(InsertSubcommand::Change(args)) => crate::commands::rpc::insert_changes( + &args.changes, + args.to_view.clone(), + args.allow_conflicts, + )?, + None if self.change.is_some() => crate::commands::rpc::insert_single( + self.change.as_deref().expect("guarded above"), + self.view.clone(), + self.allow_conflicts, + self.deps, + )?, + None => crate::commands::rpc::insert_promote(self)?, + Some(InsertSubcommand::Preview(args)) => false, // dispatched below (a read) + }; + if route_ok { + return Ok(()); + } + // The fallback (an unreachable daemon / outside a repository) + // opens the repository LAZILY per arm; the bare promotion manages + // its own handles. + let repo_path = self.repository.as_ref().map(std::path::Path::new); match &self.command { - Some(InsertSubcommand::View(args)) => run_view_insert(&repo, args), - Some(InsertSubcommand::Tag(args)) => run_tag(&repo, args), - Some(InsertSubcommand::Change(args)) => run_change_insert(&repo, args), - Some(InsertSubcommand::Preview(args)) => run_preview(&repo, args), + Some(InsertSubcommand::View(args)) => { + run_view_insert(&require_repository(repo_path)?, args) + } + Some(InsertSubcommand::Tag(args)) => run_tag(&require_repository(repo_path)?, args), + Some(InsertSubcommand::Change(args)) => { + run_change_insert(&require_repository(repo_path)?, args) + } + Some(InsertSubcommand::Preview(args)) => { + // A read: route through the daemon when reachable (the + // current view resolves client-side). + if crate::commands::rpc::insert_preview( + &args.from_view, + args.to_view.clone(), + args.up_to_tag.clone(), + )? { + Ok(()) + } else { + run_preview(&require_repository(repo_path)?, args) + } + } None => { if let Some(ref change_str) = self.change { // Insert a single change into the current (or --view) view. - run_single_insert(&repo, change_str, self) + run_single_insert(&require_repository(repo_path)?, change_str, self) } else { // No change and no subcommand: promote the current view's - // changes into its parent view. - run_promote_to_parent(&repo, self) + // changes into its parent view (the local fallback — the + // routed path resolves source and parent through the + // promote arm). + run_promote_to_parent(&find_repository_root()?, self) } } } @@ -269,7 +336,19 @@ fn run_single_insert(repo: &Repository, change_str: &str, args: &Insert) -> CliR /// /// The working copy is intentionally NOT rematerialized: the target view is /// not checked out, so the current view's on-disk state is unchanged. -fn run_promote_to_parent(repo: &Repository, args: &Insert) -> CliResult<()> { +/// +/// The NOT-A-REPOSITORY FALLBACK: the routed path (`rpc::insert_promote`) +/// resolves the source, the parent, the missing set, and the +/// shared-to-shared gate through the promote arm server-side — this +/// body runs only when the service layer is unreachable (outside a +/// repository, where the root lookup below reports the same refusal +/// without opening redb). +fn run_promote_to_parent(repo_root: &std::path::Path, args: &Insert) -> CliResult<()> { + // The pre-flight reads through a READ-ONLY handle: it coexists with + // the daemon, so the routed path never collides on the redb lock. + let repo = Repository::open_readonly(repo_root).map_err(|e| CliError::InvalidRepository { + reason: e.to_string(), + })?; let source = repo.current_view().to_string(); let source_info = repo .get_view_info(&source) @@ -361,6 +440,23 @@ fn run_promote_to_parent(repo: &Repository, args: &Insert) -> CliResult<()> { } } + // Release the read-only handle before routing (the daemon opens the + // repository for the insert). + drop(repo); + // The confirmed promotion routes through the daemon when reachable; + // the local path is the fallback. The --deps escape hatch rides the + // request's apply_dependencies. + if crate::commands::rpc::insert_from_view( + &source, + Some(target.clone()), + args.allow_conflicts, + args.deps, + false, + )? { + return Ok(()); + } + + let repo = Repository::open(repo_root).map_err(CliError::Repository)?; let options = CrossViewInsertOptions::new(&source, &target) .with_dependencies(args.deps) .allow_conflicts(args.allow_conflicts); @@ -649,7 +745,7 @@ fn parse_change_hash(repo: &Repository, hash_str: &str) -> CliResult { } /// Print the outcome of a single insert operation. -fn print_insert_outcome( +pub(crate) fn print_insert_outcome( applied: &[Hash], new_state: atomic_core::types::Merkle, has_conflicts: bool, @@ -678,33 +774,50 @@ fn print_conflict_summary(repo: &Repository) { Ok(c) => c, Err(_) => return, }; - if conflicts.is_empty() { + let entries = conflicts + .iter() + .map(|(path, records)| ConflictSummaryEntry { + path: path.clone(), + line: records.first().and_then(|c| c.line), + }) + .collect::>(); + print_conflict_summary_entries(&entries); +} + +/// One still-conflicted file for the inline summary: the path plus the +/// first record's line — the two things the summary line prints (the +/// wire carries exactly these). +pub(crate) struct ConflictSummaryEntry { + pub path: String, + pub line: Option, +} + +/// The inline conflicted-file listing over however the caller obtained +/// the entries (the local repository read, or the wire's conflict +/// listing) — one render, byte-identical by construction. +pub(crate) fn print_conflict_summary_entries(entries: &[ConflictSummaryEntry]) { + if entries.is_empty() { return; } println!(); - let file_word = if conflicts.len() == 1 { - "file" - } else { - "files" - }; + let file_word = if entries.len() == 1 { "file" } else { "files" }; output::print_warning(&format!( "{} conflicted {} — resolve markers, then record:", - conflicts.len(), + entries.len(), file_word )); - for (path, records) in &conflicts { - let where_ = records - .first() - .and_then(|c| c.line) + for entry in entries { + let where_ = entry + .line .map(|l| format!(" (line {})", l)) .unwrap_or_default(); - println!(" {}{}", path, where_); + println!(" {}{}", entry.path, where_); } output::print_hint("See 'atomic conflicts' for details."); } /// Print the outcome of a cross-view insert operation. -fn print_cross_view_outcome(outcome: &CrossViewInsertOutcome, dry_run: bool) { +pub(crate) fn print_cross_view_outcome(outcome: &CrossViewInsertOutcome, dry_run: bool) { println!(); if dry_run { diff --git a/atomic-cli/src/commands/intent/attest.rs b/atomic-cli/src/commands/intent/attest.rs index d879adc0..1dd67db7 100644 --- a/atomic-cli/src/commands/intent/attest.rs +++ b/atomic-cli/src/commands/intent/attest.rs @@ -32,6 +32,10 @@ pub struct IntentAttest { impl Command for IntentAttest { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::intent_attest(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/intent/bridge.rs b/atomic-cli/src/commands/intent/bridge.rs index d6999657..0a649f7d 100644 --- a/atomic-cli/src/commands/intent/bridge.rs +++ b/atomic-cli/src/commands/intent/bridge.rs @@ -195,6 +195,33 @@ pub enum Attestation { Stale(Box), } +/// The RAW attestation sources the dual-read consults — the tracked vault +/// entry's bytes plus the legacy sidecar candidate's text. The wire +/// carries exactly these (the GetVaultEntry bundle), so the classification +/// below runs in ONE place over identical inputs whether they were read +/// in-process or fetched over the service layer. +pub struct AttestationSources { + /// The tracked `attestations//attested.md` entry, with + /// its vault path (the malformed-entry warning names it). + pub tracked: Option, + /// The first EXISTING legacy sidecar candidate's path and text. + pub sidecar: Option, +} + +/// The tracked attestation entry's raw bytes. +pub struct TrackedAttestation { + pub vault_path: String, + pub frontmatter_json: String, + pub content: Vec, +} + +/// The legacy sidecar candidate's raw text, with its path (the +/// unreadable-sidecar warnings name it). +pub struct SidecarArtifact { + pub path: String, + pub text: String, +} + /// Load an intent's attestation sidecar and classify it against the current /// source (`inputs`). A missing sidecar is `None`; a corrupt/malformed one is /// treated as `None` with a warning (so a read still works on the raw node). @@ -205,16 +232,55 @@ pub fn load_attestation( id: &str, inputs: &LiftInputs, ) -> CliResult { - // 1) Tracked vault entry (new authoritative source). The body is the pretty - // JSON-LD node (+ a trailing '\n'); parse it straight to a CanonicalNode. + // 1) Tracked vault entry (new authoritative source). The body is the + // pretty JSON-LD node (+ a trailing '\n'); the classification parses + // it straight to a CanonicalNode. let vpath = attestation_vault_path(repo, id)?; - if let Some(entry) = repo.vault_retrieve(&vpath).map_err(CliError::Repository)? { - let raw = String::from_utf8_lossy(&entry.content_bytes); + let tracked = repo + .vault_retrieve(&vpath) + .map_err(CliError::Repository)? + .map(|entry| TrackedAttestation { + vault_path: vpath.clone(), + frontmatter_json: entry.frontmatter_json, + content: entry.content_bytes, + }); + // 2) Legacy sidecar fallback (pre-upgrade attestations). Probe the + // normalized-id path first (what the current `attest` writes), then + // the raw-arg path an M1a-era build used (it sanitized the RAW CLI + // arg without normalizing), so a sidecar written via `attest 1` / + // `attest pimo-1` is still found after upgrade regardless of the id + // form (critic #4). + let sidecar = attested_sidecar_candidates(repo, id)? + .into_iter() + .find(|candidate| candidate.exists()) + .and_then(|path| { + std::fs::read_to_string(&path) + .ok() + .map(|text| SidecarArtifact { + path: path.display().to_string(), + text, + }) + }); + Ok(classify_attestation( + AttestationSources { tracked, sidecar }, + inputs, + )) +} + +/// The dual-read classification, pure over its inputs: the tracked entry +/// parses to a `CanonicalNode` and its `sourceContentHash` anchor decides +/// fresh vs stale; a malformed tracked entry warns and falls through to +/// the sidecar; a missing or unreadable sidecar degrades to `None` with a +/// warning. Both the local body (repo-read sources) and the routed path +/// (wire-carried sources) run this SAME code. +pub fn classify_attestation(sources: AttestationSources, inputs: &LiftInputs) -> Attestation { + if let Some(tracked) = sources.tracked { + let raw = String::from_utf8_lossy(&tracked.content); match serde_json::from_str::(raw.trim_end()) { Ok(node) => { // Staleness: prefer the frontmatter anchor `sourceContentHash`; // frontmatter_json round-trips as a flat-scalar JSON object. - let recorded = serde_json::from_str::(&entry.frontmatter_json) + let recorded = serde_json::from_str::(&tracked.frontmatter_json) .ok() .and_then(|v| { v.get("sourceContentHash") @@ -222,39 +288,31 @@ pub fn load_attestation( .map(str::to_owned) }); let current = source_content_hash(inputs); - return Ok(match recorded { + return match recorded { Some(h) if h == current => Attestation::Fresh(Box::new(node)), _ => Attestation::Stale(Box::new(node)), - }); + }; } Err(e) => { - eprintln!("warning: ignoring malformed tracked attestation {vpath}: {e}"); + eprintln!( + "warning: ignoring malformed tracked attestation {}: {e}", + tracked.vault_path + ); // fall through to the legacy sidecar } } } - - // 2) Legacy sidecar fallback (pre-upgrade attestations). Probe the - // normalized-id path first (what the current `attest` writes), then the - // raw-arg path an M1a-era build used (it sanitized the RAW CLI arg without - // normalizing), so a sidecar written via `attest 1` / `attest pimo-1` is - // still found after upgrade regardless of the id form (critic #4). - let path = match attested_sidecar_candidates(repo, id)? - .into_iter() - .find(|p| p.exists()) - { - Some(p) => p, - None => return Ok(Attestation::None), + let Some(sidecar) = sources.sidecar else { + return Attestation::None; }; - let raw = std::fs::read_to_string(&path).map_err(CliError::Io)?; - let artifact: Value = match serde_json::from_str(&raw) { + let artifact: Value = match serde_json::from_str(&sidecar.text) { Ok(v) => v, Err(e) => { eprintln!( "warning: ignoring unreadable attestation sidecar {}: {e}", - path.display() + sidecar.path ); - return Ok(Attestation::None); + return Attestation::None; } }; let node_val = match artifact.get("node") { @@ -262,9 +320,9 @@ pub fn load_attestation( None => { eprintln!( "warning: attestation sidecar {} has no 'node' — ignoring", - path.display() + sidecar.path ); - return Ok(Attestation::None); + return Attestation::None; } }; let node: CanonicalNode = match serde_json::from_value(node_val) { @@ -272,9 +330,9 @@ pub fn load_attestation( Err(e) => { eprintln!( "warning: ignoring malformed attestation node in {}: {e}", - path.display() + sidecar.path ); - return Ok(Attestation::None); + return Attestation::None; } }; let recorded = artifact @@ -283,8 +341,8 @@ pub fn load_attestation( .and_then(Value::as_str); let current = source_content_hash(inputs); match recorded { - Some(h) if h == current => Ok(Attestation::Fresh(Box::new(node))), - _ => Ok(Attestation::Stale(Box::new(node))), + Some(h) if h == current => Attestation::Fresh(Box::new(node)), + _ => Attestation::Stale(Box::new(node)), } } diff --git a/atomic-cli/src/commands/intent/delete.rs b/atomic-cli/src/commands/intent/delete.rs index 6acad684..d07e4463 100644 --- a/atomic-cli/src/commands/intent/delete.rs +++ b/atomic-cli/src/commands/intent/delete.rs @@ -34,6 +34,13 @@ pub struct IntentDelete { impl Command for IntentDelete { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the existence + // pre-check rides a read, the prompt stays client-side, and the + // handler enforces the same unstarted-backlog guard. + if crate::commands::rpc::intent_delete(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/intent/link.rs b/atomic-cli/src/commands/intent/link.rs index 7dc70364..009a9130 100644 --- a/atomic-cli/src/commands/intent/link.rs +++ b/atomic-cli/src/commands/intent/link.rs @@ -28,6 +28,12 @@ pub struct IntentLink { impl Command for IntentLink { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the handler runs the + // same vault_intent_link domain call). + if crate::commands::rpc::intent_link(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/intent/list.rs b/atomic-cli/src/commands/intent/list.rs index 9a79c17e..10a71e74 100644 --- a/atomic-cli/src/commands/intent/list.rs +++ b/atomic-cli/src/commands/intent/list.rs @@ -70,7 +70,7 @@ pub struct IntentList { impl IntentList { /// The effective `kind` filter: `--review` is sugar for `--kind review`. - fn kind_filter(&self) -> Option { + pub(crate) fn kind_filter(&self) -> Option { if self.review { Some("review".to_string()) } else { @@ -81,7 +81,7 @@ impl IntentList { /// The classification of one intent's attestation, for a table/JSON row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Attested { +pub(crate) enum Attested { Fresh, Stale, None, @@ -109,7 +109,7 @@ impl Attested { /// The result of the DID-match-then-verify rule for one row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Verifies { +pub(crate) enum Verifies { /// Same-signer, fresh attestation that cryptographically verified. Yes, /// Same-signer, fresh attestation whose hash/signature FAILED. @@ -139,16 +139,16 @@ impl Verifies { } /// A fully-computed intent row. -struct Row { - human_key: String, +pub(crate) struct Row { + pub(crate) human_key: String, /// The intent's title, carried into `--json` so machine consumers can /// name an intent without a second lookup. The table stays id-only. - title: String, - status: String, + pub(crate) title: String, + pub(crate) status: String, /// Classification tag, read from the manifest `IntentSummary.kind` (no lift). - kind: String, - attested: Attested, - verifies: Verifies, + pub(crate) kind: String, + pub(crate) attested: Attested, + pub(crate) verifies: Verifies, } /// The verifying identity resolved ONCE for the whole list: its public key and @@ -288,8 +288,112 @@ fn build_rows( Ok(rows) } +impl IntentList { + /// The effective `kind` filter is validated and applied client-side + /// (an unknown kind is a clean argument error, not a silent empty + /// list); the classification itself rides the wire's VaultEntry. + pub(crate) fn render(rows: &[Row], json: bool) { + if json { + let json: Vec = rows + .iter() + .map(|r| { + serde_json::json!({ + "id": r.human_key, + // Omitted entirely before, so every API consumer + // reported an intent's title as null however well + // the frontmatter named it. + "title": r.title, + "status": r.status, + "kind": r.kind, + "attested": r.attested.json(), + "verifies": r.verifies.json(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + return; + } + if rows.is_empty() { + println!("No intents found."); + return; + } + // Fixed-width, left-aligned columns with a header row. + let key_w = rows + .iter() + .map(|r| r.human_key.chars().count()) + .chain(std::iter::once("humanKey".chars().count())) + .max() + .unwrap_or(8); + let status_w = rows + .iter() + .map(|r| r.status.chars().count()) + .chain(std::iter::once("status".chars().count())) + .max() + .unwrap_or(6); + // The kind column renders as a `[tag]`, so size on the bracketed width. + let kind_w = rows + .iter() + .map(|r| r.kind.chars().count() + 2) + .chain(std::iter::once("kind".chars().count())) + .max() + .unwrap_or(4); + + println!( + " {: Row { + let attested = match entry.attested.as_deref() { + Some("fresh") => Attested::Fresh, + Some("stale") => Attested::Stale, + _ => Attested::None, + }; + let verifies = match entry.verifies.as_deref() { + Some("yes") => Verifies::Yes, + Some("no") => Verifies::No, + _ => Verifies::Na, + }; + Row { + human_key: entry.id.clone(), + title: entry.title.clone(), + status: entry + .status + .and_then(crate::commands::rpc::entry_status_label) + .unwrap_or_else(|| entry.status_label.clone().unwrap_or_default()), + kind: entry + .manifest_kind + .clone() + .unwrap_or_else(|| "feature".to_string()), + attested, + verifies, + } +} + impl Command for IntentList { fn run(&self) -> CliResult<()> { + // Every form routes (the --json columns ride the wire; the kind + // filter validates and applies client-side). + if crate::commands::rpc::intent_list(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = crate::commands::open_readonly_repository(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/intent/new.rs b/atomic-cli/src/commands/intent/new.rs index 97714519..147558d2 100644 --- a/atomic-cli/src/commands/intent/new.rs +++ b/atomic-cli/src/commands/intent/new.rs @@ -8,6 +8,7 @@ use atomic_repository::{IntentCreateOptions, IntentCreateResult, IntentUpdateOpt use crate::commands::{find_repository_root, Command}; use crate::error::{CliError, CliResult}; +use atomic_repository::{FEATURE_SCAFFOLD, REVIEW_SCAFFOLD}; /// The directive scaffold emitted for a new intent. /// @@ -24,78 +25,6 @@ use crate::error::{CliError, CliResult}; /// hard-requires a non-empty `why`), and because it emits a `:::scope-in` it /// must also emit a `:::scope-out` (the gate requires the out-of-scope boundary /// whenever scope is declared). -const FEATURE_SCAFFOLD: &str = "\ -:::why - -::: - -:::acceptance-criterion{#{id}-ac-1 status=unmet} - -::: - -:::task{#{id}-1 status=open criteria={id}-ac-1} - -::file-ref{path=path/to/file} -::: - -:::scope-in - -::: - -:::scope-out - -::: - -:::constraint - -::: -"; - -/// The directive scaffold emitted for a `--review ` intent. -/// -/// Identical in shape to [`FEATURE_SCAFFOLD`] but carries a -/// `:::ref{to= edge=reviews}` leaf. The gate's `ReviewShape` couples the -/// `review` kind to the `reviews` edge (kind==review ⟺ exactly-a-reviews-edge), -/// so seeding the ref here makes a `--review` intent conform out of the box. The -/// acceptance-criterion stub is phrased as a review verdict. -/// -/// `{id}` is replaced with the intent's ULID (child-id namespacing) and -/// `{target}` with the reviewed intent reference. -const REVIEW_SCAFFOLD: &str = "\ -:::why - -::: - -:::acceptance-criterion{#{id}-ac-1 status=unmet} - -::: - -:::task{#{id}-1 status=open criteria={id}-ac-1} - -::file-ref{path=path/to/file} -::: - -:::ref{to={target} edge=reviews} -::: - -:::scope-in - -::: - -:::scope-out - -::: - -:::constraint - -::: -"; - /// Scaffold a new directive-based intent into the vault. #[derive(Parser, Debug)] #[command(name = "new")] @@ -212,6 +141,10 @@ fn create_intent( impl Command for IntentNew { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::intent_new(self)? { + return Ok(()); + } + if self.template != "feature" { return Err(CliError::InvalidArgument { message: format!( diff --git a/atomic-cli/src/commands/intent/show.rs b/atomic-cli/src/commands/intent/show.rs index 2e2943e5..7979a2ba 100644 --- a/atomic-cli/src/commands/intent/show.rs +++ b/atomic-cli/src/commands/intent/show.rs @@ -23,61 +23,78 @@ pub struct IntentShow { pub json: bool, } +/// The pure read-time projection: lift then render. No gate, no proof +/// requirement — this must work on a plain (un-attested) intent. The +/// status line is regenerated from the spine by the renderer. If a FRESH +/// attestation sidecar exists we project that (so `show` reflects the +/// attested author/proof); a stale one warns and falls back to the raw +/// node. Shared by the local body (repo-read inputs) and the routed hook +/// (wire-carried inputs) — one render, two data sources. +pub(crate) fn project( + id: &str, + inputs: &bridge::LiftInputs, + attestation: &bridge::Attestation, + json: bool, +) -> CliResult<()> { + let node = match attestation { + bridge::Attestation::Fresh(node) => (**node).clone(), + bridge::Attestation::Stale(_) => { + eprintln!( + "warning: the attestation for {id} is stale; showing the current \ + (un-attested) intent." + ); + bridge::lift(inputs)? + } + bridge::Attestation::None => bridge::lift(inputs)?, + }; + + if json { + // The canonical JSON already carries `kind` (when non-default) and the + // `reviews` refs under `dependsOn`, so no supplement is needed here. + println!( + "{}", + serde_json::to_string_pretty(&node.to_value()).unwrap() + ); + } else { + print!("{}", render(&node, Target::Cli)); + // The renderer has no `kind` field of its own, so surface the + // classification (and, for a review, what it reviews) here. + println!("Kind: {}", node.kind); + if node.kind == "review" { + let targets: Vec<&str> = node + .depends_on + .iter() + .filter(|r| r.edge == "reviews") + .map(|r| r.to.as_str()) + .collect(); + if targets.is_empty() { + println!("Reviews: (none declared)"); + } else { + println!("Reviews:"); + for t in targets { + println!(" • {t}"); + } + } + } + } + Ok(()) +} + impl Command for IntentShow { fn run(&self) -> CliResult<()> { + // Every form routes: the wire carries the lift inputs plus the raw + // attestation sources, so the projection runs client-side with + // the SAME bridge code over wire data. + if crate::commands::rpc::intent_show(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = crate::commands::open_readonly_repository(&root).map_err(CliError::Repository)?; - // Pure read-time projection: lift then render. No gate, no proof - // requirement — this must work on a plain (un-attested) intent. The - // status line is regenerated from the spine by the renderer. If a FRESH - // attestation sidecar exists we project that (so `show` reflects the - // attested author/proof); a stale one warns and falls back to the raw node. let inputs = bridge::read_intent(&repo, &self.id)?; - let node = match bridge::load_attestation(&repo, &self.id, &inputs)? { - bridge::Attestation::Fresh(node) => *node, - bridge::Attestation::Stale(_) => { - eprintln!( - "warning: the attestation for {} is stale; showing the current \ - (un-attested) intent.", - self.id - ); - bridge::lift(&inputs)? - } - bridge::Attestation::None => bridge::lift(&inputs)?, - }; - - if self.json { - // The canonical JSON already carries `kind` (when non-default) and the - // `reviews` refs under `dependsOn`, so no supplement is needed here. - println!( - "{}", - serde_json::to_string_pretty(&node.to_value()).unwrap() - ); - } else { - print!("{}", render(&node, Target::Cli)); - // The renderer has no `kind` field of its own, so surface the - // classification (and, for a review, what it reviews) here. - println!("Kind: {}", node.kind); - if node.kind == "review" { - let targets: Vec<&str> = node - .depends_on - .iter() - .filter(|r| r.edge == "reviews") - .map(|r| r.to.as_str()) - .collect(); - if targets.is_empty() { - println!("Reviews: (none declared)"); - } else { - println!("Reviews:"); - for t in targets { - println!(" • {t}"); - } - } - } - } - - Ok(()) + let attestation = bridge::load_attestation(&repo, &self.id, &inputs)?; + project(&self.id, &inputs, &attestation, self.json) } } diff --git a/atomic-cli/src/commands/intent/update.rs b/atomic-cli/src/commands/intent/update.rs index cbb0779f..b15c59b3 100644 --- a/atomic-cli/src/commands/intent/update.rs +++ b/atomic-cli/src/commands/intent/update.rs @@ -63,7 +63,12 @@ pub struct IntentUpdate { } impl IntentUpdate { - fn resolve_body(&self) -> CliResult> { + /// Resolve the body override: inline `--body` or `--body-stdin`. + /// + /// Shared by the local body and the service-layer hook — reading the + /// client's stdin is client-side by design, so the routed path resolves + /// the body with this SAME code and sends it on the wire. + pub(crate) fn resolve_body(&self) -> CliResult> { if let Some(ref body) = self.body { return Ok(Some(body.clone())); } @@ -81,6 +86,10 @@ impl IntentUpdate { impl Command for IntentUpdate { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::intent_update(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/intent/validate.rs b/atomic-cli/src/commands/intent/validate.rs index 154b9692..78e223f3 100644 --- a/atomic-cli/src/commands/intent/validate.rs +++ b/atomic-cli/src/commands/intent/validate.rs @@ -29,6 +29,10 @@ pub struct IntentValidate { impl Command for IntentValidate { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::intent_validate(self)? { + return Ok(()); + } + // Resolve the argument. A path-shaped argument (ends in `.md` or // contains a separator) is loaded as a file — and if it doesn't exist // we report FileNotFound rather than misreporting it as a bad ID. diff --git a/atomic-cli/src/commands/intent/verify.rs b/atomic-cli/src/commands/intent/verify.rs index 7689814e..5be735da 100644 --- a/atomic-cli/src/commands/intent/verify.rs +++ b/atomic-cli/src/commands/intent/verify.rs @@ -63,6 +63,10 @@ pub struct IntentVerify { impl Command for IntentVerify { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::intent_verify(self)? { + return Ok(()); + } + // Remote resolution (identities/resolve) needs async. Build a // one-shot runtime rather than depending on the caller being inside // tokio, exactly like `identity register`. diff --git a/atomic-cli/src/commands/log/command.rs b/atomic-cli/src/commands/log/command.rs index 0741d8ae..b085e2a0 100644 --- a/atomic-cli/src/commands/log/command.rs +++ b/atomic-cli/src/commands/log/command.rs @@ -497,6 +497,10 @@ impl Command for Log { /// - The specified view doesn't exist /// - Database errors occur fn run(&self) -> CliResult<()> { + if crate::commands::rpc::log(self)? { + return Ok(()); + } + // Find and open repository let repo_root = find_repository_root()?; let repo = crate::commands::open_readonly_repository(&repo_root).map_err(|e| match e { diff --git a/atomic-cli/src/commands/log/mod.rs b/atomic-cli/src/commands/log/mod.rs index 9b80009e..db31315a 100644 --- a/atomic-cli/src/commands/log/mod.rs +++ b/atomic-cli/src/commands/log/mod.rs @@ -124,8 +124,8 @@ use crate::output::{ // Output Format -mod command; -mod types; +pub(crate) mod command; +pub(crate) mod types; pub use command::*; pub use types::*; diff --git a/atomic-cli/src/commands/memory/attest.rs b/atomic-cli/src/commands/memory/attest.rs index 38fccdb9..3e51bb9e 100644 --- a/atomic-cli/src/commands/memory/attest.rs +++ b/atomic-cli/src/commands/memory/attest.rs @@ -33,6 +33,10 @@ pub struct MemoryAttest { impl Command for MemoryAttest { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::memory_attest(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/memory/bridge.rs b/atomic-cli/src/commands/memory/bridge.rs index 84d7b49f..d2a744a4 100644 --- a/atomic-cli/src/commands/memory/bridge.rs +++ b/atomic-cli/src/commands/memory/bridge.rs @@ -194,24 +194,84 @@ pub enum Attestation { Stale(Box), } +/// The RAW attestation sources the dual-read consults — the tracked vault +/// entry's bytes plus the legacy sidecar's text. The wire carries exactly +/// these (the GetVaultEntry bundle), so the classification below runs in +/// ONE place over identical inputs whether they were read in-process or +/// fetched over the service layer. +pub struct AttestationSources { + /// The tracked `attestations/memory//attested.md` entry, with its + /// vault path (the malformed-entry warning names it). + pub tracked: Option, + /// The legacy sidecar candidate's path and text. + pub sidecar: Option, +} + +/// The tracked attestation entry's raw bytes. +pub struct TrackedAttestation { + pub vault_path: String, + pub frontmatter_json: String, + pub content: Vec, +} + +/// The legacy sidecar candidate's raw text, with its path (the +/// unreadable-sidecar warnings name it). +pub struct SidecarArtifact { + pub path: String, + pub text: String, +} + /// Load a memory's attestation and classify it against the current source /// (`inputs`). Prefers the tracked vault entry, falling back to the legacy -/// sidecar. A missing attestation is `None`; a corrupt/malformed one is treated -/// as `None`/skipped with a warning (fail-open, so a read still works on the -/// raw node). Same mechanism as the intent bridge. +/// sidecar. A missing attestation is `None`; a corrupt/malformed one is +/// treated as `None`/skipped with a warning (fail-open, so a read still +/// works on the raw node). Same mechanism as the intent bridge. pub fn load_attestation( repo: &Repository, id_or_path: &str, inputs: &MemLiftInputs, ) -> CliResult { - // 1) Tracked vault entry (authoritative). The body is the pretty JSON-LD - // node (+ trailing '\n'); parse it straight to a MemoryNode. + // 1) Tracked vault entry (authoritative). The body is the pretty + // JSON-LD node (+ trailing '\n'); the classification parses it + // straight to a MemoryNode. let vpath = attestation_vault_path(id_or_path); - if let Some(entry) = repo.vault_retrieve(&vpath).map_err(CliError::Repository)? { - let raw = String::from_utf8_lossy(&entry.content_bytes); + let tracked = repo + .vault_retrieve(&vpath) + .map_err(CliError::Repository)? + .map(|entry| TrackedAttestation { + vault_path: vpath.clone(), + frontmatter_json: entry.frontmatter_json, + content: entry.content_bytes, + }); + // 2) Legacy sidecar fallback (pre-upgrade attestations). Memory has + // no PREFIX-N ambiguity, so there is exactly ONE candidate path. + let sidecar_path = attested_sidecar_path(repo, id_or_path); + let sidecar = if sidecar_path.exists() { + std::fs::read_to_string(&sidecar_path) + .ok() + .map(|text| SidecarArtifact { + path: sidecar_path.display().to_string(), + text, + }) + } else { + None + }; + Ok(classify_attestation( + AttestationSources { tracked, sidecar }, + inputs, + )) +} + +/// The dual-read classification, pure over its inputs — the same code the +/// intent bridge runs, over the memory node shape. Both the local body +/// (repo-read sources) and the routed path (wire-carried sources) run +/// this SAME function. +pub fn classify_attestation(sources: AttestationSources, inputs: &MemLiftInputs) -> Attestation { + if let Some(tracked) = sources.tracked { + let raw = String::from_utf8_lossy(&tracked.content); match serde_json::from_str::(raw.trim_end()) { Ok(node) => { - let recorded = serde_json::from_str::(&entry.frontmatter_json) + let recorded = serde_json::from_str::(&tracked.frontmatter_json) .ok() .and_then(|v| { v.get("sourceContentHash") @@ -219,33 +279,31 @@ pub fn load_attestation( .map(str::to_owned) }); let current = source_content_hash(inputs); - return Ok(match recorded { + return match recorded { Some(h) if h == current => Attestation::Fresh(Box::new(node)), _ => Attestation::Stale(Box::new(node)), - }); + }; } Err(e) => { - eprintln!("warning: ignoring malformed tracked attestation {vpath}: {e}"); + eprintln!( + "warning: ignoring malformed tracked attestation {}: {e}", + tracked.vault_path + ); // fall through to the legacy sidecar } } } - - // 2) Legacy sidecar fallback (pre-upgrade attestations). Memory has no - // PREFIX-N ambiguity, so there is exactly ONE candidate path. - let path = attested_sidecar_path(repo, id_or_path); - if !path.exists() { - return Ok(Attestation::None); - } - let raw = std::fs::read_to_string(&path).map_err(CliError::Io)?; - let artifact: Value = match serde_json::from_str(&raw) { + let Some(sidecar) = sources.sidecar else { + return Attestation::None; + }; + let artifact: Value = match serde_json::from_str(&sidecar.text) { Ok(v) => v, Err(e) => { eprintln!( "warning: ignoring unreadable attestation sidecar {}: {e}", - path.display() + sidecar.path ); - return Ok(Attestation::None); + return Attestation::None; } }; let node_val = match artifact.get("node") { @@ -253,9 +311,9 @@ pub fn load_attestation( None => { eprintln!( "warning: attestation sidecar {} has no 'node' — ignoring", - path.display() + sidecar.path ); - return Ok(Attestation::None); + return Attestation::None; } }; let node: MemoryNode = match serde_json::from_value(node_val) { @@ -263,9 +321,9 @@ pub fn load_attestation( Err(e) => { eprintln!( "warning: ignoring malformed attestation node in {}: {e}", - path.display() + sidecar.path ); - return Ok(Attestation::None); + return Attestation::None; } }; let recorded = artifact @@ -274,8 +332,8 @@ pub fn load_attestation( .and_then(Value::as_str); let current = source_content_hash(inputs); match recorded { - Some(h) if h == current => Ok(Attestation::Fresh(Box::new(node))), - _ => Ok(Attestation::Stale(Box::new(node))), + Some(h) if h == current => Attestation::Fresh(Box::new(node)), + _ => Attestation::Stale(Box::new(node)), } } diff --git a/atomic-cli/src/commands/memory/list.rs b/atomic-cli/src/commands/memory/list.rs index 85ae971e..98dfd0a4 100644 --- a/atomic-cli/src/commands/memory/list.rs +++ b/atomic-cli/src/commands/memory/list.rs @@ -65,7 +65,7 @@ pub struct MemoryList { /// The classification of one memory's attestation, for a table/JSON row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Attested { +pub(crate) enum Attested { Fresh, Stale, None, @@ -91,7 +91,7 @@ impl Attested { /// The result of the DID-match-then-verify rule for one row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Verifies { +pub(crate) enum Verifies { Yes, No, Na, @@ -116,13 +116,13 @@ impl Verifies { } /// A fully-computed memory row. -struct Row { - id: String, - kind: String, - status: String, - about: usize, - attested: Attested, - verifies: Verifies, +pub(crate) struct Row { + pub(crate) id: String, + pub(crate) kind: String, + pub(crate) status: String, + pub(crate) about: usize, + pub(crate) attested: Attested, + pub(crate) verifies: Verifies, } /// The verifying identity resolved ONCE for the whole list. @@ -265,8 +265,89 @@ fn compute_row(repo: &Repository, id: &str, verifier: Option<&Verifier>) -> Row } } +impl MemoryList { + /// The shared render — the exact local body's table and JSON shapes, + /// driven by the rows (computed in-process by the local body or + /// reconstructed from the wire's per-entry classification). + pub(crate) fn render(rows: &[Row], json: bool) { + if json { + let json: Vec = rows + .iter() + .map(|r| { + serde_json::json!({ + "id": r.id, + "kind": r.kind, + "status": r.status, + "about": r.about, + "attested": r.attested.json(), + "verifies": r.verifies.json(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + return; + } + if rows.is_empty() { + println!("No memories found."); + return; + } + // Fixed-width, left-aligned columns with a header row. + let id_w = col_width(rows.iter().map(|r| r.id.chars().count()), "id"); + let kind_w = col_width(rows.iter().map(|r| r.kind.chars().count()), "kind"); + let status_w = col_width(rows.iter().map(|r| r.status.chars().count()), "status"); + + println!( + " {:5} {:<8} verifies", + "id", "kind", "status", "about", "attested", + ); + for r in rows { + println!( + " {:5} {:<8} {}", + r.id, + r.kind, + r.status, + r.about, + r.attested.table(), + r.verifies.table(), + ); + } + } +} + +/// One row reconstructed from the wire's per-entry classification — the +/// routed twin of [`compute_row`]: the handler computed the +/// kind/status/about columns and the attestation/verifies state with the +/// same domain logic, so the row maps straight off the wire columns. +pub(crate) fn wire_row(entry: &atomic_client::proto::VaultEntry) -> Row { + let attested = match entry.attested.as_deref() { + Some("fresh") => Attested::Fresh, + Some("stale") => Attested::Stale, + _ => Attested::None, + }; + let verifies = match entry.verifies.as_deref() { + Some("yes") => Verifies::Yes, + Some("no") => Verifies::No, + _ => Verifies::Na, + }; + Row { + id: entry.id.clone(), + kind: entry.memory_kind.clone().unwrap_or_else(|| NA.to_string()), + status: entry + .memory_status + .clone() + .unwrap_or_else(|| NA.to_string()), + about: entry.about_count.unwrap_or(0) as usize, + attested, + verifies, + } +} + impl Command for MemoryList { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::memory_list(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/memory/new.rs b/atomic-cli/src/commands/memory/new.rs index 1b89a572..1639baec 100644 --- a/atomic-cli/src/commands/memory/new.rs +++ b/atomic-cli/src/commands/memory/new.rs @@ -75,6 +75,10 @@ pub struct MemoryNew { impl Command for MemoryNew { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::memory_new(self)? { + return Ok(()); + } + // Reject a bad --kind EARLY (before touching the vault) so `new` never // writes a non-liftable / non-conforming spine. if !MEMORY_KIND.contains(&self.kind.as_str()) { diff --git a/atomic-cli/src/commands/memory/show.rs b/atomic-cli/src/commands/memory/show.rs index f643ae18..4f2c92d2 100644 --- a/atomic-cli/src/commands/memory/show.rs +++ b/atomic-cli/src/commands/memory/show.rs @@ -22,56 +22,73 @@ pub struct MemoryShow { pub json: bool, } -impl Command for MemoryShow { - fn run(&self) -> CliResult<()> { - let root = find_repository_root()?; - let repo = Repository::open(&root).map_err(CliError::Repository)?; - - // Pure read-time projection: lift then render. No gate, no proof - // requirement — this must work on a plain (un-attested) memory. If a - // FRESH attestation exists we project that (so `show` reflects the - // attested author/proof); a stale one warns and falls back to the raw - // node. - let inputs = bridge::read_memory(&repo, &self.id)?; - if bridge::is_freeform_memory(&inputs) { - if self.json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "path": bridge::normalize_memory_path(&self.id), - "content": inputs.body, - "frontmatter": inputs.frontmatter, - })) - .unwrap() - ); - } else { - print!("{}", inputs.body); - } - return Ok(()); - } - - let node = match bridge::load_attestation(&repo, &self.id, &inputs)? { - bridge::Attestation::Fresh(node) => *node, - bridge::Attestation::Stale(_) => { - eprintln!( - "warning: the attestation for {} is stale; showing the current \ - (un-attested) memory.", - self.id - ); - bridge::lift(&inputs)? - } - bridge::Attestation::None => bridge::lift(&inputs)?, - }; - - if self.json { +/// The pure read-time projection: lift then render. No gate, no proof +/// requirement — this must work on a plain (un-attested) memory; a +/// freeform memory renders its stored body. If a FRESH attestation +/// exists we project that (so `show` reflects the attested +/// author/proof); a stale one warns and falls back to the raw node. +/// Shared by the local body (repo-read inputs) and the routed hook +/// (wire-carried inputs) — one render, two data sources. +pub(crate) fn project( + id: &str, + inputs: &bridge::MemLiftInputs, + attestation: &bridge::Attestation, + json: bool, +) -> CliResult<()> { + if bridge::is_freeform_memory(inputs) { + if json { println!( "{}", - serde_json::to_string_pretty(&node.to_value()).unwrap() + serde_json::to_string_pretty(&serde_json::json!({ + "path": bridge::normalize_memory_path(id), + "content": inputs.body, + "frontmatter": inputs.frontmatter, + })) + .unwrap() ); } else { - print!("{}", render_memory(&node, Target::Cli)); + print!("{}", inputs.body); + } + return Ok(()); + } + + let node = match attestation { + bridge::Attestation::Fresh(node) => (**node).clone(), + bridge::Attestation::Stale(_) => { + eprintln!( + "warning: the attestation for {id} is stale; showing the current \ + (un-attested) memory." + ); + bridge::lift(inputs)? } + bridge::Attestation::None => bridge::lift(inputs)?, + }; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&node.to_value()).unwrap() + ); + } else { + print!("{}", render_memory(&node, Target::Cli)); + } + Ok(()) +} - Ok(()) +impl Command for MemoryShow { + fn run(&self) -> CliResult<()> { + // Every form routes: the wire carries the lift inputs plus the raw + // attestation sources, so the projection runs client-side with + // the SAME bridge code over wire data. + if crate::commands::rpc::memory_show(self)? { + return Ok(()); + } + + let root = find_repository_root()?; + let repo = Repository::open(&root).map_err(CliError::Repository)?; + + let inputs = bridge::read_memory(&repo, &self.id)?; + let attestation = bridge::load_attestation(&repo, &self.id, &inputs)?; + project(&self.id, &inputs, &attestation, self.json) } } diff --git a/atomic-cli/src/commands/memory/validate.rs b/atomic-cli/src/commands/memory/validate.rs index 50ebbe7a..59ad658b 100644 --- a/atomic-cli/src/commands/memory/validate.rs +++ b/atomic-cli/src/commands/memory/validate.rs @@ -29,6 +29,10 @@ pub struct MemoryValidate { impl Command for MemoryValidate { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::memory_validate(self)? { + return Ok(()); + } + // A path-shaped argument (ends in `.md` or contains a separator) is // loaded as a file; a missing file is reported as FileNotFound rather // than misreported as a bad id. Otherwise it is a memory id read diff --git a/atomic-cli/src/commands/memory/verify.rs b/atomic-cli/src/commands/memory/verify.rs index ffdc64f4..a0e9f01c 100644 --- a/atomic-cli/src/commands/memory/verify.rs +++ b/atomic-cli/src/commands/memory/verify.rs @@ -29,6 +29,10 @@ pub struct MemoryVerify { impl Command for MemoryVerify { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::memory_verify(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/memory/write.rs b/atomic-cli/src/commands/memory/write.rs index 446e4b6f..be0576d8 100644 --- a/atomic-cli/src/commands/memory/write.rs +++ b/atomic-cli/src/commands/memory/write.rs @@ -37,6 +37,13 @@ pub struct MemoryWrite { impl Command for MemoryWrite { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: stdin + frontmatter + // stay client-side (interactive/file-only); the store + materialize + // run handler-side with the hash riding the response. + if crate::commands::rpc::memory_write(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/mod.rs b/atomic-cli/src/commands/mod.rs index ba10c13f..4e758131 100644 --- a/atomic-cli/src/commands/mod.rs +++ b/atomic-cli/src/commands/mod.rs @@ -85,6 +85,7 @@ pub mod record; pub mod remove; pub mod restore; pub mod revise; +pub(crate) mod rpc; pub mod sandbox; pub mod session; pub mod split; diff --git a/atomic-cli/src/commands/mv.rs b/atomic-cli/src/commands/mv.rs index 131921f9..63b90e20 100644 --- a/atomic-cli/src/commands/mv.rs +++ b/atomic-cli/src/commands/mv.rs @@ -195,6 +195,12 @@ impl Command for Move { /// b. Move the actual file on disk /// 5. Display results fn run(&self) -> CliResult<()> { + // Route through the daemon when reachable (plain form only — the + // wire carries no dry-run/force); the local path is unchanged. + if crate::commands::rpc::mv(self)? { + return Ok(()); + } + // Find repository let repo_root = find_repository_root()?; let repo = Repository::open(&repo_root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/project/init.rs b/atomic-cli/src/commands/project/init.rs index 4d595dae..5fb102a5 100644 --- a/atomic-cli/src/commands/project/init.rs +++ b/atomic-cli/src/commands/project/init.rs @@ -94,6 +94,16 @@ pub struct ProjectInit { impl Command for ProjectInit { fn run(&self) -> CliResult<()> { + // Route the repository mutation through the service layer: the + // server-side project creation stays client-side (the management + // StorageClient is built from the global config + the identity + // store — config/auth reads, not redb, per the push/pull ruling), + // and the ONE repository write (add_remote origin) rides + // ManageRemotes so this command never opens redb directly. + if crate::commands::rpc::project_init(self)? { + return Ok(()); + } + let rt = tokio::runtime::Runtime::new().map_err(|e| { CliError::Internal(anyhow::anyhow!("Failed to create async runtime: {}", e)) })?; diff --git a/atomic-cli/src/commands/provenance/command.rs b/atomic-cli/src/commands/provenance/command.rs index b4cc53e7..7a5a2c7a 100644 --- a/atomic-cli/src/commands/provenance/command.rs +++ b/atomic-cli/src/commands/provenance/command.rs @@ -76,16 +76,35 @@ pub struct ProvenanceShow { impl Command for ProvenanceTrace { fn run(&self) -> CliResult<()> { - let root = find_repository_root()?; - let repo = Repository::open(&root).map_err(CliError::Repository)?; - let change_hash = resolve_change_target(&repo, &self.target)?; - let graphs = load_graphs(&repo, &change_hash)?; - + // Route every form through the service layer: the handler resolves + // the target, loads the explaining graphs, maps the projection + // inputs, and pre-walks the prior-turn chain. Identity resolution + // stays CLIENT-SIDE (the identity store is a config read, not + // redb); the projection/signing runs over wire-carried inputs with + // the SAME canonical functions. if self.json { - // Emit the JSON-LD, exactly like `show` (unsigned by default; `--sign` - // for the signable artifact). let (identity, keypair) = resolve_person(self.identity.as_deref())?; let person_did = did_for_public_key(&identity.public_key); + if let Some((change_hash, graphs)) = + crate::commands::rpc::provenance_export(&self.target, &person_did)? + { + let values = graphs + .iter() + .map(|wire| { + if self.sign { + attest_prov(&wire.input, &identity, &keypair) + } else { + project(&wire.input) + } + }) + .collect::>(); + print_value(&values, self.sign); + return Ok(()); + } + let root = find_repository_root()?; + let repo = Repository::open(&root).map_err(CliError::Repository)?; + let change_hash = resolve_change_target(&repo, &self.target)?; + let graphs = load_graphs(&repo, &change_hash)?; let values = graphs .iter() .map(|(_, g)| { @@ -110,21 +129,51 @@ impl Command for ProvenanceTrace { .map(|(id, _)| did_for_public_key(&id.public_key)) .unwrap_or_else(|| "(no signing identity)".to_string()); - print_trace(&repo, &graphs, &change_hash, &person_did); + if let Some((change_hash, graphs)) = + crate::commands::rpc::provenance_export(&self.target, &person_did)? + { + print_trace(&graphs, &change_hash, &person_did); + return Ok(()); + } + + let root = find_repository_root()?; + let repo = Repository::open(&root).map_err(CliError::Repository)?; + let change_hash = resolve_change_target(&repo, &self.target)?; + let graphs = load_graphs(&repo, &change_hash)?; + print_trace_local(&repo, &graphs, &change_hash, &person_did); Ok(()) } } impl Command for ProvenanceShow { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (same wire as trace; + // show always needs the signing identity, resolved client-side). + let (identity, keypair) = resolve_person(self.identity.as_deref())?; + let person_did = did_for_public_key(&identity.public_key); + + if let Some((change_hash, graphs)) = + crate::commands::rpc::provenance_export(&self.target, &person_did)? + { + let values = graphs + .iter() + .map(|wire| { + if self.sign { + attest_prov(&wire.input, &identity, &keypair) + } else { + project(&wire.input) + } + }) + .collect::>(); + print_value(&values, self.sign); + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; let change_hash = resolve_change_target(&repo, &self.target)?; let graphs = load_graphs(&repo, &change_hash)?; - let (identity, keypair) = resolve_person(self.identity.as_deref())?; - let person_did = did_for_public_key(&identity.public_key); - let values = graphs .iter() .map(|(_, g)| { @@ -264,9 +313,34 @@ fn print_value(values: &[serde_json::Value], signed: bool) { } } -/// Print the human-readable flywheel chain: change -> activity -> generated -> -/// agent -> person -> turnParent -> ... (walking `previous`). +/// Print the human-readable flywheel chain — the routed render: change -> +/// activity -> generated -> agent -> person -> turnParent -> ... (the +/// prior-turn chain arrives pre-walked on the wire). fn print_trace( + graphs: &[crate::commands::rpc::ProvenanceWireGraph], + change_hash: &Hash, + person_did: &str, +) { + println!( + "{} {}", + emphasis("Provenance for change"), + info(&change_hash.to_base32()) + ); + for (i, wire) in graphs.iter().enumerate() { + if graphs.len() > 1 { + println!( + "{}", + hint(&format!(" [graph {} of {}]", i + 1, graphs.len())) + ); + } + print_activity_chain(&wire.input, &wire.prior_activities, wire.chain_truncated); + } + let _ = person_did; +} + +/// The local fallback render (outside a routed repository): the same +/// chain with the graphs walked live from the repository handle. +fn print_trace_local( repo: &Repository, graphs: &[(Hash, ProvenanceGraph)], change_hash: &Hash, @@ -285,12 +359,14 @@ fn print_trace( ); } let input = map_graph_to_input(repo, graph, change_hash, person_did); - print_activity_chain(repo, &input, graph); + print_activity_chain_local(repo, &input, graph); } } -/// Print one activity and walk its `turnParent` chain via `previous`. -fn print_activity_chain(repo: &Repository, input: &ProvActivityInput, graph: &ProvenanceGraph) { +/// Print one activity and its prior-turn chain — the routed render: the +/// activity block projects from the wire-carried input, and the chain +/// renders the handler's pre-walked activity URNs. +fn print_activity_chain(input: &ProvActivityInput, priors: &[String], truncated: bool) { // The projected (unsigned) value is the source of truth for the shape. let value = project(input); let activity = value @@ -349,28 +425,45 @@ fn print_activity_chain(repo: &Repository, input: &ProvActivityInput, graph: &Pr } } - // Walk `previous` to render the prior turn (one level; recursion via loop). + for prior in priors { + if prior.is_empty() { + println!("{indent} {}", hint("↑ prior turn (graph unavailable)")); + } else { + println!("{indent} {} activity {}", hint("↑ prior turn"), prior); + } + } + if truncated { + println!("{indent} {}", hint("(chain truncated)")); + } +} + +/// The local fallback's live chain walk (the same lines the pre-walked +/// render prints). +fn print_activity_chain_local( + repo: &Repository, + input: &ProvActivityInput, + graph: &ProvenanceGraph, +) { + let mut priors = Vec::new(); + let mut truncated = false; let mut cursor = graph.previous; let mut depth = 0usize; while let Some(prev_hash) = cursor { depth += 1; if depth > 64 { - println!("{indent} {}", hint("(chain truncated)")); + truncated = true; break; } match repo.load_provenance_graph(&prev_hash) { Ok(prev) => { - println!( - "{indent} {} activity {}", - hint("↑ prior turn"), - activity_urn(&activity_id_for(&prev)), - ); + priors.push(activity_urn(&activity_id_for(&prev))); cursor = prev.previous; } Err(_) => { - println!("{indent} {}", hint("↑ prior turn (graph unavailable)")); + priors.push(String::new()); break; } } } + print_activity_chain(input, &priors, truncated); } diff --git a/atomic-cli/src/commands/pull/command.rs b/atomic-cli/src/commands/pull/command.rs index d8c1142d..7c75b932 100644 --- a/atomic-cli/src/commands/pull/command.rs +++ b/atomic-cli/src/commands/pull/command.rs @@ -989,6 +989,13 @@ impl Command for Pull { /// - Network operations fail /// - Changes fail to download or save fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the handler performs + // the network sync, the applies, and the verification; the CLI + // renders the local pull body's lines from the report. + if crate::commands::rpc::pull(self)? { + return Ok(()); + } + // Create a runtime for async operations let rt = tokio::runtime::Runtime::new().map_err(|e| { CliError::Internal(anyhow::anyhow!("Failed to create async runtime: {}", e)) diff --git a/atomic-cli/src/commands/push/command.rs b/atomic-cli/src/commands/push/command.rs index f850f50d..d69a6d50 100644 --- a/atomic-cli/src/commands/push/command.rs +++ b/atomic-cli/src/commands/push/command.rs @@ -1010,6 +1010,15 @@ impl Command for Push { /// - `CliError::RemoteError` - Network/server error (including servers /// that predate view-manifest support) fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the network sync IS + // domain — the handler performs it end to end and returns the + // report this render prints. The client-side pieces (remote-name + // resolution read, the credential check, the auth headers) are + // config/identity reads, never redb. + if crate::commands::rpc::push(self)? { + return Ok(()); + } + // Create async runtime for HTTP operations let runtime = tokio::runtime::Runtime::new().map_err(|e| { CliError::Internal(anyhow::anyhow!("Failed to create async runtime: {}", e)) diff --git a/atomic-cli/src/commands/push/mod.rs b/atomic-cli/src/commands/push/mod.rs index 099154a4..197ac4bc 100644 --- a/atomic-cli/src/commands/push/mod.rs +++ b/atomic-cli/src/commands/push/mod.rs @@ -149,7 +149,7 @@ // Submodules mod command; -mod helpers; +pub(crate) mod helpers; pub mod types; // Re-exports diff --git a/atomic-cli/src/commands/query/mod.rs b/atomic-cli/src/commands/query/mod.rs index ea3b1d48..e77b0dbc 100644 --- a/atomic-cli/src/commands/query/mod.rs +++ b/atomic-cli/src/commands/query/mod.rs @@ -276,6 +276,12 @@ const MAX_GRAPH_NODES_HTML: usize = 5000; impl Command for QueryGraph { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the seed/expand/ + // filter/cap build runs handler-side; the DOT/HTML/JSON renders + // stay client-side over the wire-carried subgraph. + if crate::commands::rpc::query_graph(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -493,7 +499,7 @@ impl Command for QueryGraph { } /// Generate a DOT (Graphviz) representation of a subgraph. -fn emit_dot(nodes: &[KgNode], edges: &[KgEdge]) -> String { +pub(crate) fn emit_dot(nodes: &[KgNode], edges: &[KgEdge]) -> String { let mut out = String::new(); out.push_str("digraph query {\n"); out.push_str(" rankdir=LR;\n"); @@ -619,7 +625,7 @@ fn dot_escape_label(s: &str) -> String { } /// Generate a self-contained HTML page with a D3.js force-directed graph. -fn emit_html(query: &str, nodes: &[KgNode], edges: &[KgEdge]) -> String { +pub(crate) fn emit_html(query: &str, nodes: &[KgNode], edges: &[KgEdge]) -> String { // Build JSON data for embedding let graph_nodes: Vec = nodes .iter() @@ -1028,7 +1034,7 @@ fn emit_html(query: &str, nodes: &[KgNode], edges: &[KgEdge]) -> String { } /// Truncate display text on character boundaries and append `...`. -fn truncate_display(s: &str, max_chars: usize) -> String { +pub(crate) fn truncate_display(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { s.to_string() } else { @@ -1070,6 +1076,10 @@ pub struct QueryNodes { impl Command for QueryNodes { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::query_search(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -1143,6 +1153,10 @@ pub struct QueryNeighbors { impl Command for QueryNeighbors { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::query_neighbors(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -1198,6 +1212,11 @@ pub struct QueryCallers { impl Command for QueryCallers { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the handler filters + // the subgraph to CALLS edges at the entity). + if crate::commands::rpc::query_callers(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -1285,7 +1304,6 @@ pub struct QueryEntities { impl Command for QueryEntities { fn run(&self) -> CliResult<()> { let root = find_repository_root()?; - let _repo = Repository::open(&root).map_err(CliError::Repository)?; if !atomic_semantic::is_supported(&self.path) { println!("Unsupported file type: {}", self.path); @@ -1587,6 +1605,10 @@ mod enrich_tests { impl Command for QueryEnrich { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::query_enrich(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -1723,6 +1745,11 @@ pub struct Reindex; impl Command for Reindex { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the handler runs + // the same vault_reindex_kg domain call). + if crate::commands::rpc::query_reindex()? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -1743,6 +1770,12 @@ pub struct QueryEmbed { impl Command for QueryEmbed { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the provider + // resolves handler-side from the same environment/config; the CLI + // prints the local report from the wire-carried outcome. + if crate::commands::rpc::query_embed(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -1800,6 +1833,11 @@ pub struct PlanExec { impl Command for PlanExec { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (stdin stays + // client-side; the plan executes handler-side against the repo). + if crate::commands::rpc::query_plan(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -1931,6 +1969,13 @@ The knowledge graph contains: impl Command for QueryAsk { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the agentic tool + // loop runs handler-side over the repository. --verbose's live + // tool-call trace is the unary-RPC limit: the trace rides the + // result (rendered in --json), not streamed live. + if crate::commands::rpc::query_ask(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/record/builder.rs b/atomic-cli/src/commands/record/builder.rs index 372315a4..ca7593a0 100644 --- a/atomic-cli/src/commands/record/builder.rs +++ b/atomic-cli/src/commands/record/builder.rs @@ -44,7 +44,7 @@ impl Record { } /// Parse the author string into an Author struct. - pub(super) fn parse_author(&self) -> Option { + pub(crate) fn parse_author(&self) -> Option { self.author.as_ref().map(|s| { // Try to parse "Name " format if let Some(bracket_start) = s.find('<') { @@ -162,7 +162,11 @@ impl Record { } /// Get the commit message, potentially from editor. - pub(super) fn get_message(&self) -> CliResult { + /// + /// Shared by the local body and the service-layer hook: the editor and + /// the interactive prompt are client-side by design, so the routed path + /// composes the message with this SAME code and sends it on the wire. + pub(crate) fn get_message(&self) -> CliResult { // If message was provided, use it if let Some(ref msg) = self.message { return Ok(msg.clone()); diff --git a/atomic-cli/src/commands/record/command.rs b/atomic-cli/src/commands/record/command.rs index 081eef68..6c02d4ae 100644 --- a/atomic-cli/src/commands/record/command.rs +++ b/atomic-cli/src/commands/record/command.rs @@ -15,6 +15,10 @@ impl Command for Record { /// 8. Apply the change to the current view /// 9. Display the result fn run(&self) -> CliResult<()> { + if crate::commands::rpc::record(self)? { + return Ok(()); + } + // Find repository let repo_root = find_repository_root()?; let repo = Repository::open(&repo_root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/record/format.rs b/atomic-cli/src/commands/record/format.rs index 8099bbee..66f504fa 100644 --- a/atomic-cli/src/commands/record/format.rs +++ b/atomic-cli/src/commands/record/format.rs @@ -81,49 +81,57 @@ impl Record { .status(StatusOptions::default()) .map_err(CliError::Repository)?; - let mut has_changes = false; - - println!("Would record:"); + let entries = status + .entries() + .iter() + .map(|entry| (entry.status(), entry.path().to_string_lossy().into_owned())) + .collect::>(); + render_dry_run(&entries, self.all, &self.files); + Ok(()) + } +} - for entry in status.entries() { - // Skip untracked unless --all - if matches!(entry.status(), FileStatus::Untracked) && !self.all { - continue; - } +/// The dry-run preview, pure over its inputs — the same lines the local +/// body prints, shared with the routed path (the wire's Status entries +/// drive it; the status vocabulary maps to the local descriptions). +pub(crate) fn render_dry_run(entries: &[(FileStatus, String)], all: bool, files: &[String]) { + let mut has_changes = false; - // Skip clean files - if matches!(entry.status(), FileStatus::Clean) { - continue; - } + println!("Would record:"); - // Filter by specified files if any - if !self.files.is_empty() { - let path_str = entry.path().to_string_lossy(); - if !self.files.iter().any(|f| path_str.contains(f)) { - continue; - } - } + for (status, path) in entries { + // Skip untracked unless --all + if matches!(status, FileStatus::Untracked) && !all { + continue; + } - has_changes = true; - let status_desc = match entry.status() { - FileStatus::Added => "new file:", - FileStatus::Modified => "modified:", - FileStatus::Deleted => "deleted: ", - FileStatus::Untracked => "new file:", - FileStatus::TypeChanged => "typechange:", - FileStatus::PermissionsChanged => "permissions:", - FileStatus::Conflicted => "conflicted:", - FileStatus::Clean => continue, - }; - - println!(" {} {}", status_desc, entry.path().to_string_lossy()); + // Skip clean files + if matches!(status, FileStatus::Clean) { + continue; } - if !has_changes { - println!(" (no changes to record)"); + // Filter by specified files if any + if !files.is_empty() && !files.iter().any(|f| path.contains(f)) { + continue; } - Ok(()) + has_changes = true; + let status_desc = match status { + FileStatus::Added => "new file:", + FileStatus::Modified => "modified:", + FileStatus::Deleted => "deleted: ", + FileStatus::Untracked => "new file:", + FileStatus::TypeChanged => "typechange:", + FileStatus::PermissionsChanged => "permissions:", + FileStatus::Conflicted => "conflicted:", + FileStatus::Clean => continue, + }; + + println!(" {} {}", status_desc, path); + } + + if !has_changes { + println!(" (no changes to record)"); } } diff --git a/atomic-cli/src/commands/record/mod.rs b/atomic-cli/src/commands/record/mod.rs index 5ee62873..cadb29ca 100644 --- a/atomic-cli/src/commands/record/mod.rs +++ b/atomic-cli/src/commands/record/mod.rs @@ -410,6 +410,7 @@ mod format; mod provenance; pub(super) use command::*; +pub(crate) use format::render_dry_run; #[cfg(test)] mod tests; diff --git a/atomic-cli/src/commands/remote/mod.rs b/atomic-cli/src/commands/remote/mod.rs index c54d61c3..1fe3ee17 100644 --- a/atomic-cli/src/commands/remote/mod.rs +++ b/atomic-cli/src/commands/remote/mod.rs @@ -289,6 +289,13 @@ impl Default for Remote { impl Command for Remote { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the registry lives in + // the repository database, and the handler runs the exact domain + // call each local body makes. + if crate::commands::rpc::remote(self)? { + return Ok(()); + } + match &self.command { None => self.list_remotes(), Some(RemoteSubcommand::Add(add)) => self.add_remote(add), diff --git a/atomic-cli/src/commands/remove.rs b/atomic-cli/src/commands/remove.rs index f7de0f1b..c23e3645 100644 --- a/atomic-cli/src/commands/remove.rs +++ b/atomic-cli/src/commands/remove.rs @@ -232,6 +232,12 @@ impl Command for Remove { /// b. If not --keep, delete from disk /// 3. Display results fn run(&self) -> CliResult<()> { + // Route through the daemon when reachable (the plain, non-dry-run, + // non-force forms); the local path below stays byte-identical. + if crate::commands::rpc::remove(self)? { + return Ok(()); + } + // Find repository let repo_root = find_repository_root()?; let repo = Repository::open(&repo_root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/restore.rs b/atomic-cli/src/commands/restore.rs index 00b41d9c..1d5fb50c 100644 --- a/atomic-cli/src/commands/restore.rs +++ b/atomic-cli/src/commands/restore.rs @@ -329,6 +329,35 @@ impl Command for Restore { /// 4. If `--dry-run`, preview changes /// 5. Otherwise, restore files to pristine state fn run(&self) -> CliResult<()> { + // Route every form through the daemon when reachable — the + // non-dry-run forms over Restore; the dry-run previews over + // PreviewRestore (the listing form, and the single-file + // pristine-bytes dump through the content arm). The single-file + // shape check stats the working copy only — never a redb open. + if crate::commands::rpc::restore(self)? { + return Ok(()); + } + if self.dry_run { + // The single-file dump applies only when the argument is + // exactly one concrete file (the local body's rule: a + // directory or trailing-slash filter has no pristine bytes + // of its own and previews as a listing). + let single_file_arg = self.files.len() == 1 + && !self.files[0].ends_with('/') + && find_repository_root() + .map(|root| !root.join(&self.files[0]).is_dir()) + .unwrap_or(true); + if single_file_arg && crate::commands::rpc::restore_single_dry_run(&self.files[0])? { + return Ok(()); + } + if crate::commands::rpc::restore_preview(self.files.clone())? { + return Ok(()); + } + } + + // The NOT-A-REPOSITORY FALLBACK (and the unreachable-daemon + // fallback): the root lookup below reports the same refusal + // without opening redb when no repository surrounds the cwd. // Find repository let repo_root = find_repository_root()?; let repo = Repository::open(&repo_root).map_err(CliError::Repository)?; @@ -447,7 +476,7 @@ impl Command for Restore { } /// Format a count with singular/plural word. -fn format_count(count: usize, word: &str) -> String { +pub(crate) fn format_count(count: usize, word: &str) -> String { if count == 1 { format!("{} {}", count, word) } else { diff --git a/atomic-cli/src/commands/revise.rs b/atomic-cli/src/commands/revise.rs index 2e2a62f0..eb38e917 100644 --- a/atomic-cli/src/commands/revise.rs +++ b/atomic-cli/src/commands/revise.rs @@ -355,7 +355,12 @@ impl Revise { } /// Get the message for the revised change. - fn get_message(&self, original_message: &str) -> CliResult { + /// + /// Shared by the local body and the service-layer hook: the editor and + /// the no-edit fallback are client-side by design, so the routed reword + /// path composes the message with this SAME code (seeded with the + /// change's current message fetched over the wire) and sends it. + pub(crate) fn get_message(&self, original_message: &str) -> CliResult { // If explicit message provided, use it if let Some(ref msg) = self.message { return Ok(msg.clone()); @@ -430,7 +435,7 @@ impl Revise { } /// Parse author string into name and email. - fn parse_author(&self) -> Option<(String, Option)> { + pub(crate) fn parse_author(&self) -> Option<(String, Option)> { self.author.as_ref().map(|author_str| { // Try to parse "Name " format if let Some(start) = author_str.find('<') { @@ -584,12 +589,6 @@ impl Revise { original_change.hashed.header.authors.first().cloned() }; - // Handle --reword mode differently: create a new change with same content - // but different header, rather than going through working copy - if self.reword { - return self.execute_reword(repo, &original_change, &message, author, &pending_changes); - } - // Build header for content modification mode let mut header_builder = ChangeHeader::builder().message(&message); if let Some(author) = author { @@ -655,89 +654,6 @@ impl Revise { Ok(new_hash) } - - /// Execute reword operation - creates new change with same content but new header. - /// - /// This is cleaner than going through working copy because: - /// 1. We preserve exact same hunks/content - /// 2. No risk of capturing unintended working copy changes - /// 3. Works correctly even with pending changes to re-apply - fn execute_reword( - &self, - repo: &Repository, - original_change: &Change, - message: &str, - author: Option, - pending_changes: &[Hash], - ) -> CliResult { - // Build new header with updated message - let mut new_header = original_change.hashed.header.clone(); - new_header.message = message.to_string(); - - // Update author if provided - if let Some(new_author) = author { - new_header.authors = vec![new_author]; - } - - // Create new change with same hunks but new header - let new_change = Change::with_file_ops( - new_header, - original_change.hashed.hunks.clone(), - original_change.hashed.file_ops.clone(), - original_change.contents.clone(), - original_change.hashed.dependencies.clone(), - ); - - // Sign the reworded change with the current identity. Reword bypasses - // the record pipeline (it rebuilds the change directly), so it must - // sign here — a revised change is signed like any new record. - let mut new_change = new_change; - if let Ok(store) = IdentityStore::open_default() { - let signing_identity = store.get_default().ok().flatten(); - if let Some(identity) = signing_identity { - if let Ok(keypair) = store.load_keypair(&identity.id, None) { - let _ = new_change.sign_with( - &atomic_canonical::did::did_for_public_key(&identity.public_key), - keypair.secret.as_bytes(), - chrono::Utc::now().timestamp(), - ); - } - } - } - - // Save the new change - let new_hash = repo.save_change(&new_change).map_err(|e| { - CliError::Internal(anyhow::anyhow!("Failed to save reworded change: {}", e)) - })?; - - // Insert the new change into the view (it replaces the unrecorded one) - repo.insert_change(&new_hash, Default::default()) - .map_err(|e| { - CliError::Internal(anyhow::anyhow!("Failed to apply reworded change: {}", e)) - })?; - - // Re-apply pending changes - if !pending_changes.is_empty() { - print_hint(&format!("Re-applying {} changes...", pending_changes.len())); - - for hash in pending_changes { - repo.reinsert_change(hash, None).map_err(|e| { - print_warning(&format!( - "Failed to re-apply change {}: {}", - format_hash(hash, false), - e - )); - CliError::Internal(anyhow::anyhow!( - "Failed to re-apply change {}: {}", - format_hash(hash, false), - e - )) - })?; - } - } - - Ok(new_hash) - } } impl Default for Revise { @@ -758,9 +674,21 @@ impl Command for Revise { /// 5. Record the revised change /// 6. Re-apply subsequent changes fn run(&self) -> CliResult<()> { + // Route EVERY form through the service layer (BEFORE any local + // repository open): the reference resolves over the wire's Log, + // the message composes client-side (an explicit -m, or the SAME + // editor flow the local body runs, seeded with the change's + // current message fetched over the wire), the dry-run preview + // renders over the wire's log entries, and the stack surgery — + // reword or content re-capture — is the domain's, applied + // atomically by the handler. + if crate::commands::rpc::revise(self)? { + return Ok(()); + } + // Find and open repository let repo_root = find_repository_root()?; - let repo = Repository::open(&repo_root).map_err(CliError::Repository)?; + let mut repo = Repository::open(&repo_root).map_err(CliError::Repository)?; // Resolve the reference let (sequence, entry) = self.resolve_reference(&repo)?; @@ -770,6 +698,48 @@ impl Command for Revise { return self.display_dry_run(&repo, sequence, &entry); } + // Reword mode: the whole stack surgery is the domain's (the same + // code path the daemon's Revise RPC calls). The message resolves + // client-side (arg, editor, or original); the author too. + if self.reword { + let original = repo + .load_change(&entry.hash) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load change: {}", e)))?; + let message = self.get_message(&original.hashed.header.message.clone())?; + let author = self + .parse_author() + .map(|(name, email)| atomic_core::change::Author { + name, + email, + identity: None, + }); + + let change_ref = self.parse_reference(); + println!( + "Revising change {} (sequence #{})...", + change_ref.description(), + sequence + ); + + let outcome = repo + .reword_change(&entry.hash, &message, author) + .map_err(CliError::Repository)?; + + println!(); + print_success(&format!( + "Revised {} → {}", + format_hash(&entry.hash, false), + format_hash(&outcome.new_hash, false) + )); + if !outcome.reinserted.is_empty() { + print_hint(&format!( + "Re-applied {} pending change(s).", + outcome.reinserted.len() + )); + } + return Ok(()); + } + // Check for uncommitted changes if not in reword mode if !self.reword { let status = repo diff --git a/atomic-cli/src/commands/rpc.rs b/atomic-cli/src/commands/rpc.rs new file mode 100644 index 00000000..f3a872d7 --- /dev/null +++ b/atomic-cli/src/commands/rpc.rs @@ -0,0 +1,6763 @@ +//! The CLI's service-layer hooks: the presentation half of each wired +//! command (D6 — terminal formatting stays in the CLI; the domain +//! executes in the service layer). +//! +//! Routing policy lives in the service area ([`crate::service`]): +//! `ATOMIC_SERVICE=local` (the default) runs the same libatomic handlers +//! atomicd serves, in-process — no socket, no daemon, no probing; +//! `ATOMIC_SERVICE=reactor` routes over the daemon socket (D4 +//! start-or-retry); `ATOMIC_RPC=1` is the legacy reactor alias. +//! Every wired command routes every flag-form through the service +//! layer; the remaining `Ok(false)` exits are the file-only forms (no +//! repository database), the documented no-op, and the scope-out +//! surfaces: `view list --remote` (the remote listing), the git interop +//! family (import/parallel/push keep their today-bodies), and +//! `triage review` (the canonical report builder is a CLI-entangled +//! module whose handler migration is a separate slice — see its +//! dispatch guard). Push/pull keep auth/config resolution CLIENT-SIDE +//! (identity store + config reads, never redb) and ship the resolved +//! headers; the network sync itself runs handler-side. + +use std::path::{Path, PathBuf}; + +use atomic_client::proto as pb; +use atomic_core::types::{Base32, Merkle}; +use libatomic::atomic::attestation_target::Kind as TargetKind; +use libatomic::atomic::update_vault_entity_request::Update as UpdateKind; +use libatomic::atomic::verify_attestation_request::Target as VerifyTarget; +use libatomic::atomic::VaultEntityKind as Kind; +use libatomic::atomic::VaultEntityStatus as Status; + +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_info, print_success, print_warning}; +use crate::service::{status_message, Service}; + +/// The raw status label for a wire entry's status enum — the label the +/// update report and the listing rows print (falls back to the entry's +/// carried `status_label` for non-lifecycle statuses). +pub(crate) fn entry_status_label(status: i32) -> Option { + Status::try_from(status) + .ok() + .map(status_str) + .map(str::to_string) +} + +// --------------------------------------------------------------------------- +// vault entry bundles — the wire-carried lift/classification inputs +// --------------------------------------------------------------------------- + +/// The wire's entry bundle (schema "atomic.vault.entry.bundle.v1"): the +/// stored entry's full domain serialization plus the RAW attestation +/// sources, deserialized into the domain shapes the CLI's existing +/// bridge code consumes. +#[derive(serde::Deserialize)] +struct EntryBundle { + entry: atomic_core::pristine::VaultEntry, + #[serde(default)] + attestation: Option, +} + +#[derive(serde::Deserialize)] +struct BundleAttestationSources { + #[serde(default)] + tracked: Option, + #[serde(default)] + sidecar: Option, +} + +#[derive(serde::Deserialize)] +struct BundleTrackedAttestation { + path: String, + frontmatter_json: String, + content: Vec, +} + +#[derive(serde::Deserialize)] +struct BundleSidecar { + path: String, + text: String, +} + +/// Deserialize one entry bundle, refusing an unexpected schema. +fn parse_entry_bundle(bundle: &pb::VersionedBytes) -> CliResult { + if bundle.schema != "atomic.vault.entry.bundle.v1" { + return Err(CliError::Internal(anyhow::anyhow!( + "unknown entry bundle schema '{}'", + bundle.schema + ))); + } + serde_json::from_slice(&bundle.payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("entry bundle: {error}"))) +} + +/// The intent bridge's sources off one bundle. +fn intent_sources(bundle: &EntryBundle) -> crate::commands::intent::bridge::AttestationSources { + use crate::commands::intent::bridge::{ + AttestationSources, SidecarArtifact, TrackedAttestation, + }; + let attestation = bundle.attestation.as_ref(); + AttestationSources { + tracked: attestation + .and_then(|sources| sources.tracked.as_ref()) + .map(|tracked| TrackedAttestation { + vault_path: tracked.path.clone(), + frontmatter_json: tracked.frontmatter_json.clone(), + content: tracked.content.clone(), + }), + sidecar: attestation + .and_then(|sources| sources.sidecar.as_ref()) + .map(|sidecar| SidecarArtifact { + path: sidecar.path.clone(), + text: sidecar.text.clone(), + }), + } +} + +/// The memory bridge's sources off one bundle. +fn memory_sources(bundle: &EntryBundle) -> crate::commands::memory::bridge::AttestationSources { + use crate::commands::memory::bridge::{ + AttestationSources, SidecarArtifact, TrackedAttestation, + }; + let attestation = bundle.attestation.as_ref(); + AttestationSources { + tracked: attestation + .and_then(|sources| sources.tracked.as_ref()) + .map(|tracked| TrackedAttestation { + vault_path: tracked.path.clone(), + frontmatter_json: tracked.frontmatter_json.clone(), + content: tracked.content.clone(), + }), + sidecar: attestation + .and_then(|sources| sources.sidecar.as_ref()) + .map(|sidecar| SidecarArtifact { + path: sidecar.path.clone(), + text: sidecar.text.clone(), + }), + } +} + +// --------------------------------------------------------------------------- +// KG wire reconstruction — the domain nodes/edges the JSON renders emit +// --------------------------------------------------------------------------- + +/// Wire KGNode → the domain node (kind/label/summary/source/metadata), +/// the shape the local JSON serializes. +fn kg_node_domain(node: &pb::KgNode) -> atomic_core::pristine::vault::KgNode { + atomic_core::pristine::vault::KgNode { + id: node.id.clone(), + kind: node.node_type.clone(), + label: node.name.clone().unwrap_or_default(), + summary: node + .data + .as_ref() + .map(|data| String::from_utf8_lossy(data).into_owned()), + source: node.source.clone().unwrap_or_default(), + metadata: node + .metadata + .as_ref() + .and_then(|metadata| serde_json::from_slice(metadata).ok()), + } +} + +/// Wire KGEdge → the domain edge. +fn kg_edge_domain(edge: &pb::KgEdge) -> atomic_core::pristine::vault::KgEdge { + atomic_core::pristine::vault::KgEdge { + from_id: edge.source.clone(), + to_id: edge.target.clone(), + kind: edge.relation.clone(), + metadata: edge + .metadata + .as_ref() + .and_then(|metadata| serde_json::from_slice(metadata).ok()), + } +} + +fn request_meta() -> Option { + Some(pb::RequestMeta { + request_id: uuid::Uuid::new_v4().to_string(), + observed_at: None, + }) +} + +fn short_hash(hash: &Option) -> String { + hash.as_ref() + .and_then(|h| { + h.value + .clone() + .try_into() + .ok() + .map(|bytes: [u8; 32]| Merkle(bytes).to_base32()[..12].to_string()) + }) + .unwrap_or_else(|| "-".to_string()) +} + +fn full_hash(hash: &Option) -> String { + hash.as_ref() + .and_then(|h| { + h.value + .clone() + .try_into() + .ok() + .map(|bytes: [u8; 32]| Merkle(bytes).to_base32()) + }) + .unwrap_or_else(|| "-".to_string()) +} + +// --------------------------------------------------------------------------- +// status +// --------------------------------------------------------------------------- + +/// `atomic status` over Status — including `--json`: the IDE contract +/// (schema_version, repository_root, entries) renders client-side from +/// the wire's file entries, and `--reindex` composes the wire's +/// Repair(ReindexWorkingCopy) before the status read. Only +/// `--debug-ignore` stays local: it reads ignore files (`.atomicignore` +/// plus the user's global ignore list) without touching the repository +/// databases, so it needs no wire. +pub fn status(args: &super::status::Status) -> CliResult { + if args.debug_ignore { + return Ok(false); + } + let Some(session) = Service::open()? else { + return Ok(false); + }; + // `--reindex` rebuilds the working-copy index through the wire's + // repair action, then falls through to the status read — the same + // compose the local body performs. The line renders from the wire's + // reindexed count with a client-side clock, byte-for-byte with the + // local reindex report. + if args.reindex { + let start = std::time::Instant::now(); + let repair = session.repair(pb::RepairRequest { + repository: Some(session.reference.clone()), + meta: None, + action: pb::RepairAction::ReindexWorkingCopy as i32, + force: false, + view: String::new(), + }); + match repair { + Ok(response) => { + if !args.json { + print_info(&format!( + "Reindexed {} files in {:.1}s", + response.reindexed, + start.elapsed().as_secs_f64() + )); + } + } + Err(error) if args.json => return Err(error), + Err(error) => { + print_warning(&format!("Reindex failed: {error}")); + } + } + } + let request = pb::StatusRequest { + repository: Some(session.reference.clone()), + }; + let response = session.status(request)?; + + if args.json { + // The IDE JSON contract — schema_version/repository_root are the + // client's own; everything else rides StatusResponse. + let root = crate::commands::find_repository_root()?; + let entries: Vec = response + .files + .iter() + .map(|file| { + let name = match pb::FileStatus::try_from(file.status) { + Ok(pb::FileStatus::Modified) => "modified", + Ok(pb::FileStatus::Deleted) => "deleted", + Ok(pb::FileStatus::Untracked) => "untracked", + Ok(pb::FileStatus::Added) => "added", + Ok(pb::FileStatus::Conflicted) => "conflicted", + _ => "clean", + }; + let code = match pb::FileStatus::try_from(file.status) { + Ok(pb::FileStatus::Modified) => "M", + Ok(pb::FileStatus::Deleted) => "D", + Ok(pb::FileStatus::Untracked) => "?", + Ok(pb::FileStatus::Added) => "A", + Ok(pb::FileStatus::Conflicted) => "C", + _ => "-", + }; + serde_json::json!({ + "path": file.path, + "status": name, + "code": code, + "details": file.details, + }) + }) + .collect(); + let clean = entries.is_empty(); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "schema_version": 1, + "repository_root": root.display().to_string(), + "view": response.view, + "state": short_hash(&response.view_merkle), + "clean": clean, + "needs_reindex": response.needs_reindex, + "stale_index_count": response.stale_index_count, + "entries": entries, + })) + .map_err(|e| CliError::Internal(e.into()))? + ); + return Ok(true); + } + + if args.short { + for file in &response.files { + let code = match pb::FileStatus::try_from(file.status) { + Ok(pb::FileStatus::Modified) => "M", + Ok(pb::FileStatus::Deleted) => "D", + Ok(pb::FileStatus::Untracked) => "?", + Ok(pb::FileStatus::Added) => "A", + Ok(pb::FileStatus::Conflicted) => "C", + _ => " ", + }; + println!("{code} {}", file.path); + } + return Ok(true); + } + + println!("On view {}", response.view); + if response.view_merkle.is_some() { + println!("State: {}...", short_hash(&response.view_merkle)); + } + println!(); + let dirty: Vec<_> = response + .files + .iter() + .filter(|f| { + f.status != pb::FileStatus::Unspecified as i32 + && f.status != pb::FileStatus::Untracked as i32 + }) + .collect(); + if !dirty.is_empty() { + println!("Changes to be recorded:"); + println!(); + for file in &dirty { + let label = match pb::FileStatus::try_from(file.status) { + Ok(pb::FileStatus::Added) => "new file", + Ok(pb::FileStatus::Deleted) => "deleted", + Ok(pb::FileStatus::Conflicted) => "conflicted", + _ => "modified", + }; + println!("\t{label}: {}", file.path); + } + } + let untracked: Vec<_> = response + .files + .iter() + .filter(|f| f.status == pb::FileStatus::Untracked as i32) + .collect(); + if !untracked.is_empty() { + if !dirty.is_empty() { + println!(); + } + println!("Untracked files:"); + println!(" (use \"atomic add ...\" to include in what will be recorded)"); + println!(); + for file in &untracked { + println!("\t{}", file.path); + } + println!(); + println!("Use \"atomic add ...\" to track files"); + } else if dirty.is_empty() { + println!("nothing else to record"); + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// log +// --------------------------------------------------------------------------- + +pub fn log(args: &crate::commands::log::command::Log) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::LogRequest { + repository: Some(session.reference.clone()), + all_views: args.all, + view: args.view.clone(), + cursor: None, + budget: args.count.map(|count| pb::PageBudget { + max_items: Some(count as u32), + max_bytes: None, + }), + path_filter: args.path.clone().into_iter().collect(), + tags_only: args.tags_only, + }; + let response = session.log(request)?; + + let mut entries = response.entries; // the wire carries newest-first + if args.reverse { + entries.reverse(); // --reverse: oldest first + } + for entry in &entries { + let hash = short_hash(&entry.hash); + match args.format { + crate::commands::log::types::LogFormat::Json => { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "hash": hash, + "sequence": entry.sequence, + "message": entry.message, + "view": entry.view, + })) + .map_err(|e| CliError::Internal(e.into()))? + ); + } + _ => { + println!("#{} === {} ===", entry.sequence, hash); + for author in &entry.authors { + let email = author + .email + .as_ref() + .map(|e| format!(" <{e}>")) + .unwrap_or_default(); + println!("Author: {}{}", author.name, email); + } + if let Some(recorded_at) = &entry.recorded_at { + let time = chrono::DateTime::from_timestamp( + recorded_at.seconds, + recorded_at.nanos.max(0) as u32, + ); + if let Some(time) = time { + println!("Date: {}", time.format("%Y-%m-%d %H:%M:%S")); + } + } + println!(); + if let Some(message) = &entry.message { + for line in message.lines() { + println!(" {line}"); + } + } + if let Some(description) = &entry.description { + for line in description.lines() { + println!(" {line}"); + } + } + println!(); + } + } + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// add / record +// --------------------------------------------------------------------------- + +/// `atomic add` over AddFiles — every form: dry-run/force/directory/ +/// no-recursive ride the wire, and internal `.atomic` paths route too +/// (the repository's ignore rules treat them like any other ignored +/// path — the tolerant per-path report carries the skip). +pub fn add(args: &super::add::Add) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let root = crate::commands::find_repository_root()?; + let paths = if args.all { + // `add --all` composes an untracked read first — the same read the + // CLI does today, over the service layer. + let request = pb::StatusRequest { + repository: Some(session.reference.clone()), + }; + let status = session.status(request)?; + status + .files + .iter() + .filter(|file| file.status == pb::FileStatus::Untracked as i32) + .map(|file| file.path.clone()) + .collect() + } else { + args.files.clone() + }; + for path in &paths { + println!("Adding: {path}"); + } + let request = pb::AddFilesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + paths: paths.iter().map(|path| relativize(path, &root)).collect(), + expected: None, + dry_run: args.dry_run, + force: args.force, + directory: args.directory, + no_recursive: args.no_recursive, + }; + let response = session.add_files(request)?; + if response.dry_run { + for path in &response.would_add { + println!("Would add: {path}"); + } + for path in &response.skipped_tracked { + println!("Skipping: {path} (already tracked)"); + } + for path in &response.skipped_ignored { + println!("Skipping: {path} (ignored)"); + } + for path in &response.failed_paths { + println!("Error: {path} - could not be added"); + } + println!("Would add {} file(s)", response.would_add.len()); + return Ok(true); + } + println!("✓ Added {} file(s)", response.tracked); + Ok(true) +} + +fn relativize(path: &str, root: &Path) -> String { + let candidate = Path::new(path); + if candidate.is_absolute() { + if let Ok(relative) = candidate.strip_prefix(root) { + return relative.display().to_string(); + } + return path.to_string(); + } + path.to_string() +} + +/// `atomic record` over Record — every form. The message composes +/// client-side with the SAME editor/prompt flow the local body uses +/// (`get_message` — editors are interactive, client-side by design), +/// `--author` parses into the wire author exactly like the local body, +/// `--identity` names an identity the handler resolves host-side, and +/// `--dry-run` is the Status read's preview — the same "Would record" +/// lines the local body prints, rendered from the wire's file entries. +pub fn record(args: &super::record::Record) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + if args.dry_run { + let response = session.status(pb::StatusRequest { + repository: Some(session.reference.clone()), + })?; + let entries = response + .files + .iter() + .map(|file| { + let status = match pb::FileStatus::try_from(file.status) { + Ok(pb::FileStatus::Modified) => atomic_repository::status::FileStatus::Modified, + Ok(pb::FileStatus::Deleted) => atomic_repository::status::FileStatus::Deleted, + Ok(pb::FileStatus::Untracked) => { + atomic_repository::status::FileStatus::Untracked + } + Ok(pb::FileStatus::Added) => atomic_repository::status::FileStatus::Added, + Ok(pb::FileStatus::Conflicted) => { + atomic_repository::status::FileStatus::Conflicted + } + _ => atomic_repository::status::FileStatus::Clean, + }; + (status, file.path.clone()) + }) + .collect::>(); + crate::commands::record::render_dry_run(&entries, args.all, &args.files); + return Ok(true); + } + // The message composes BEFORE any staging so a cancelled editor leaves + // the working copy untouched (the same ordering the local body uses). + let message = args.get_message()?; + // Authorship mirrors the local precedence: --identity wins over + // --author; the handler turns the named identity into an author and + // signing credentials host-side. + let (author, identity_name) = if let Some(name) = args.identity.clone() { + (None, Some(name)) + } else { + // The local parse: "Name " or a bare name. + let author = args.parse_author().map(|parsed| pb::Author { + name: parsed.name, + email: parsed.email, + }); + (author, None) + }; + let root = crate::commands::find_repository_root()?; + // `--all` stages untracked files first — the same client-side + // composition `atomic add --all` performs (a Status read + AddFiles), + // silently like the local body's pre-record staging. + if args.all { + let status = session.status(pb::StatusRequest { + repository: Some(session.reference.clone()), + })?; + let untracked: Vec = status + .files + .iter() + .filter(|file| file.status == pb::FileStatus::Untracked as i32) + .map(|file| file.path.clone()) + .collect(); + if !untracked.is_empty() { + session.add_files(pb::AddFilesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + paths: untracked, + expected: None, + dry_run: false, + force: false, + directory: false, + no_recursive: false, + })?; + } + } + let request = pb::RecordRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + message, + paths: args + .files + .iter() + .map(|path| relativize(path, &root)) + .collect(), + expected: None, + author, + identity_name, + }; + let response = session.record(request)?; + let Some(change) = response.change else { + return Err(CliError::Internal(anyhow::anyhow!( + "record produced no change" + ))); + }; + let view = response.view.unwrap_or_default(); + println!( + "[{} {}/{}] {}", + view, + change + .recorded_at + .as_ref() + .map(|_| "1".to_string()) + .unwrap_or_default(), + short_hash(&change.hash), + change.message.clone().unwrap_or_default() + ); + if !args.files.is_empty() { + for file in &args.files { + println!(" {file}"); + } + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// intent +// --------------------------------------------------------------------------- + +pub fn intent_new(args: &super::intent::new::IntentNew) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::CreateVaultEntityRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kind: Kind::Intent as i32, + title: args.title.clone(), + body: None, + memory_kind: None, + derived_from: Vec::new(), + about: Vec::new(), + developer: None, + intent: None, + model: None, + view: None, + expected_snapshot: None, + }; + let response = session.create_vault_entity(request)?; + let entry = response.entry.expect("created entry"); + println!("Created intent: {}", entry.id); + println!(" file: {}", entry.vault_path.clone().unwrap_or_default()); + println!(" template: {}", args.template); + println!(" kind: {}", args.kind); + Ok(true) +} + +/// `atomic intent update` over UpdateVaultEntity — the FULL surface. +/// The wire's composed-update arm carries every field in ONE request +/// (status, assignee, priority, title, reason, informed-by, body, and +/// the --force rewrite guard), so the handler applies them atomically +/// through the same domain update the local body performs. The body +/// still composes client-side (inline or stdin — reading the client's +/// stdin is client-side by design); the no-op form errors client-side +/// with the local body's exact message. +pub fn intent_update(args: &super::intent::update::IntentUpdate) -> CliResult { + let body_requested = args.body.is_some() || args.body_stdin; + if !body_requested + && args.status.is_none() + && args.assignee.is_none() + && args.priority.is_none() + && args.title.is_none() + && args.reason.is_none() + && args.informed_by.is_empty() + && !args.force + { + return Err(CliError::InvalidArgument { + message: "Nothing to update. Provide at least one of \ + --status, --assignee, --priority, --title, --reason, \ + --informed-by, \ + --body, or --body-stdin." + .to_string(), + }); + } + let Some(session) = Service::open()? else { + return Ok(false); + }; + // The pure status/body forms ride their original wire arms; anything + // else composes the full field set into ONE request. + let update = if args.assignee.is_none() + && args.priority.is_none() + && args.title.is_none() + && args.reason.is_none() + && args.informed_by.is_empty() + && !args.force + { + if body_requested { + let body = args + .resolve_body()? + .expect("--body/--body-stdin always resolves a body"); + UpdateKind::Body(body) + } else { + let status = args.status.clone().expect("checked above"); + let proto_status = match status.as_str() { + "backlog" => Status::Backlog, + "in_progress" | "in-progress" => Status::InProgress, + "needs-review" => Status::NeedsReview, + "done" | "completed" => Status::Done, + "planned" => Status::Planned, + "icebox" => Status::Suspended, + other => { + return Err(CliError::InvalidArgument { + message: format!("unknown intent status '{other}'"), + }) + } + }; + UpdateKind::Status(proto_status as i32) + } + } else { + // The mixed/multi-field update: ONE request, applied atomically. + UpdateKind::Fields(pb::IntentUpdateFields { + status: args.status.clone(), + assignee: args.assignee.clone(), + priority: args.priority.clone(), + title: args.title.clone(), + reason: args.reason.clone(), + informed_by: args.informed_by.clone(), + body: args.resolve_body()?, + force: args.force, + }) + }; + let body_updated = body_requested; + let request = pb::UpdateVaultEntityRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kind: Kind::Intent as i32, + id: args.id.clone(), + update: Some(update), + expected: None, + view: None, + }; + let response = session.update_vault_entity(request)?; + let entry = response.entry.expect("updated entry"); + // Render parity with the local body's update report. + println!("Updated intent: {}", entry.id); + let status = entry + .status_label + .clone() + .or_else(|| entry.status.and_then(entry_status_label)); + if let Some(status) = status { + println!(" status: {status}"); + } + if let Some(priority) = &entry.priority { + println!(" priority: {priority}"); + } + if let Some(assignee) = &entry.assignee { + println!(" assignee: {assignee}"); + } + if body_updated { + println!(" body: updated"); + } + if !args.informed_by.is_empty() { + println!(" informed by: {} source(s)", args.informed_by.len()); + } + Ok(true) +} + +fn status_str(status: Status) -> &'static str { + match status { + Status::Backlog => "backlog", + Status::InProgress => "in_progress", + Status::NeedsReview => "needs-review", + Status::Done => "done", + Status::Planned => "planned", + Status::Active => "active", + Status::Suspended => "icebox", + Status::Completed => "completed", + Status::Superseded => "superseded", + Status::Retracted => "retracted", + _ => "unknown", + } +} + +pub fn intent_attest(args: &super::intent::attest::IntentAttest) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::RecordAttestationRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + target: Some(pb::AttestationTarget { + kind: Some(TargetKind::IntentId(args.id.clone())), + }), + identity_did: args.identity.clone().unwrap_or_default(), + signature: Vec::new(), + view: None, + expected_snapshot: None, + }; + let response = session.record_attestation(request)?; + let info = response.attestation.expect("recorded attestation"); + println!("Attested intent: {}", info.id); + println!( + " vault: {}", + info.vault_path.clone().unwrap_or_default() + ); + println!( + " sidecar: {}", + info.sidecar_path.clone().unwrap_or_default() + ); + println!(" author: {}", info.identity_did); + println!("conforms: yes"); + Ok(true) +} + +pub fn intent_validate(args: &super::intent::validate::IntentValidate) -> CliResult { + // Path-arg form reads a file, no redb — stays local. + if args.id_or_path.ends_with(".md") || args.id_or_path.contains('/') { + return Ok(false); + } + let Some(session) = Service::open()? else { + return Ok(false); + }; + validate_entity(&session, Kind::Intent, &args.id_or_path) +} + +pub fn intent_verify(args: &super::intent::verify::IntentVerify) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::VerifyAttestationRequest { + repository: Some(session.reference.clone()), + target: Some(VerifyTarget::Subject(pb::AttestationTarget { + kind: Some(TargetKind::IntentId(args.id.clone())), + })), + view: None, + identity_name: args.identity.clone(), + }; + let response = session.verify_attestation(request)?; + if !response.valid { + return Err(CliError::InvalidArgument { + message: response + .reason + .unwrap_or_else(|| "verification failed".into()), + }); + } + println!("Verified intent: {}", args.id); + println!(" author: {}", response.reason.unwrap_or_default()); + Ok(true) +} + +/// `atomic intent list` over ListVaultEntries — every form. The handler +/// computes the per-intent manifest kind and the attestation/verifies +/// state with the same domain logic the local body uses; the CLI renders +/// with the SAME table/JSON code (one render, two data sources). The +/// `--kind`/`--review` filter validates client-side (an unknown kind is +/// a clean argument error) and applies over the wire's classification. +pub fn intent_list(args: &super::intent::list::IntentList) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + // Resolve + validate the kind filter (`--review` ⇒ `--kind review`) + // BEFORE the request, so the argument error never round-trips. + let filter = args.kind_filter(); + if let Some(kind) = &filter { + if !atomic_canonical::vocab::is_known_intent_kind(kind) { + return Err(CliError::InvalidArgument { + message: format!( + "unknown intent kind '{}' (expected one of {:?})", + kind, + atomic_canonical::vocab::INTENT_KIND + ), + }); + } + } + let request = pb::ListVaultEntriesRequest { + repository: Some(session.reference.clone()), + kind: Some(Kind::Intent as i32), + status: None, + summaries_only: false, + cursor: None, + budget: None, + view: None, + identity: args.identity.clone(), + path_prefix: None, + entry_type: None, + status_filter: None, + tool_result_previews: false, + }; + let response = session.list_vault_entries(request)?; + let mut rows = response + .entries + .iter() + .map(super::intent::list::wire_row) + .collect::>(); + if let Some(kind) = &filter { + rows.retain(|row| &row.kind == kind); + } + super::intent::list::IntentList::render(&rows, args.json); + Ok(true) +} + +/// `atomic intent show` over GetVaultEntry — every form. The wire +/// carries the lift inputs (the stored entry) plus the RAW attestation +/// sources (the tracked entry and the legacy sidecar), so the CLI runs +/// its EXISTING bridge::lift / bridge::load_attestation code unchanged +/// over wire-carried inputs and projects with the same render the local +/// body uses — byte-parity for the JSON-LD projection. +pub fn intent_show(args: &super::intent::show::IntentShow) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::GetVaultEntryRequest { + repository: Some(session.reference.clone()), + kind: Kind::Intent as i32, + id: args.id.clone(), + path: None, + include_bundle: true, + view: None, + }; + let response = session.get_vault_entry(request)?; + let Some(bundle) = response + .entry_bundle + .as_ref() + .map(parse_entry_bundle) + .transpose()? + else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no entry bundle (include_bundle required)" + ))); + }; + let inputs = crate::commands::intent::bridge::inputs_from_entry(&bundle.entry)?; + let attestation = + crate::commands::intent::bridge::classify_attestation(intent_sources(&bundle), &inputs); + super::intent::show::project(&args.id, &inputs, &attestation, args.json)?; + Ok(true) +} + +fn validate_entity(session: &Service, kind: Kind, id: &str) -> CliResult { + let request = pb::ValidateVaultEntityRequest { + repository: Some(session.reference.clone()), + kind: kind as i32, + subject: Some(pb::validate_vault_entity_request::Subject::Id( + id.to_string(), + )), + content_hash: None, + view: None, + }; + let response = session.validate_vault_entity(request)?; + if response.valid { + println!("conforms: yes"); + } else { + println!("conforms: no ({} violation(s))", response.issues.len()); + for issue in &response.issues { + println!(" ✗ {issue}"); + } + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// memory +// --------------------------------------------------------------------------- + +pub fn memory_new(args: &super::memory::new::MemoryNew) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::CreateVaultEntityRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kind: Kind::Memory as i32, + title: String::new(), + body: args.text.clone(), + memory_kind: Some(args.kind.clone()), + derived_from: args.derived_from.clone(), + about: args.about.clone(), + developer: None, + intent: None, + model: None, + view: None, + expected_snapshot: None, + }; + let response = session.create_vault_entity(request)?; + let entry = response.entry.expect("created entry"); + println!("Created memory: {}", entry.id); + println!(" file: {}", entry.vault_path.clone().unwrap_or_default()); + println!(" kind: {}", args.kind); + println!(" status: {}", args.status); + Ok(true) +} + +pub fn memory_attest(args: &super::memory::attest::MemoryAttest) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::RecordAttestationRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + target: Some(pb::AttestationTarget { + kind: Some(TargetKind::MemoryId(args.id.clone())), + }), + identity_did: args.identity.clone().unwrap_or_default(), + signature: Vec::new(), + view: None, + expected_snapshot: None, + }; + let response = session.record_attestation(request)?; + let info = response.attestation.expect("recorded attestation"); + println!("Attested memory: {}", info.id); + println!( + " vault: {}", + info.vault_path.clone().unwrap_or_default() + ); + println!( + " sidecar: {}", + info.sidecar_path.clone().unwrap_or_default() + ); + println!(" author: {}", info.identity_did); + println!("conforms: yes"); + println!("note: signing keys are stored unencrypted on disk; treat this attestation as a non-production dev signature until key-at-rest encryption lands."); + Ok(true) +} + +pub fn memory_validate(args: &super::memory::validate::MemoryValidate) -> CliResult { + if args.id_or_path.ends_with(".md") { + return Ok(false); + } + let Some(session) = Service::open()? else { + return Ok(false); + }; + validate_entity(&session, Kind::Memory, &args.id_or_path) +} + +pub fn memory_verify(args: &super::memory::verify::MemoryVerify) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::VerifyAttestationRequest { + repository: Some(session.reference.clone()), + target: Some(VerifyTarget::Subject(pb::AttestationTarget { + kind: Some(TargetKind::MemoryId(args.id.clone())), + })), + view: None, + identity_name: args.identity.clone(), + }; + let response = session.verify_attestation(request)?; + if !response.valid { + return Err(CliError::InvalidArgument { + message: response + .reason + .unwrap_or_else(|| "verification failed".into()), + }); + } + println!("Verified memory: {}", args.id); + println!(" author: {}", response.reason.unwrap_or_default()); + Ok(true) +} + +/// `atomic memory list` over ListVaultEntries — every form. The handler +/// enumerates the canonical memories (no attestation entries, no index +/// scaffold, most-recent first, the budget truncation) and computes the +/// kind/status/about columns plus the attestation/verifies state with +/// the same domain logic the local body uses; `--identity` names the +/// verifying identity (the `verifies` column's resolver — soft-fail to +/// "no identity", hard error for a named-but-missing identity). +pub fn memory_list(args: &super::memory::list::MemoryList) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::ListVaultEntriesRequest { + repository: Some(session.reference.clone()), + kind: Some(Kind::Memory as i32), + status: None, + summaries_only: false, + cursor: None, + budget: args.limit.map(|limit| pb::PageBudget { + max_items: Some(limit as u32), + max_bytes: None, + }), + view: None, + identity: args.identity.clone(), + path_prefix: None, + entry_type: None, + status_filter: None, + tool_result_previews: false, + }; + let response = session.list_vault_entries(request)?; + let rows = response + .entries + .iter() + .map(super::memory::list::wire_row) + .collect::>(); + super::memory::list::MemoryList::render(&rows, args.json); + Ok(true) +} + +/// `atomic memory show` over GetVaultEntry — every form. The wire +/// carries the lift inputs (the stored entry) plus the RAW attestation +/// sources, so the CLI runs its EXISTING bridge code unchanged over +/// wire-carried inputs and projects with the same render the local body +/// uses — byte-parity for the canonical JSON-LD projection and the +/// `atomic vault query search` over QueryGraph (KgSearch) — every form: +/// the wire's KGNode now carries the full domain fields (source, +/// metadata), so the JSON serializes the same shape the local body +/// emits; `--kind` rides the request (the handler filters the candidate +/// pool server-side, taking the limit after the filter — the local +/// post-filter order) and `--pool` names the candidate pool. The plain +/// render reads the same kind/id/summary columns. +pub fn query_search(args: &super::query::QueryNodes) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::QueryGraphRequest { + repository: Some(session.reference.clone()), + limit: args.limit as u32, + query: Some(pb::query_graph_request::Query::KgSearch( + pb::KgSearchQuery { + query: args.query.clone(), + kind: args.kind.clone(), + pool: args.pool.map(|pool| pool as u32), + }, + )), + view: None, + }; + let response = session.query_graph(request)?; + let nodes = response + .nodes + .iter() + .map(kg_node_domain) + .collect::>(); + if args.json { + println!("{}", serde_json::to_string_pretty(&nodes).unwrap()); + return Ok(true); + } + if nodes.is_empty() { + println!("No results."); + return Ok(true); + } + for node in &nodes { + let summary = node.summary.as_deref().unwrap_or(""); + let summary_display = if summary.is_empty() { + String::new() + } else { + format!(" {}", super::query::truncate_display(summary, 60)) + }; + println!(" [{}] {}{}", node.kind, node.id, summary_display); + } + println!("\n{} result(s).", nodes.len()); + Ok(true) +} + +/// freeform body. +pub fn memory_show(args: &super::memory::show::MemoryShow) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::GetVaultEntryRequest { + repository: Some(session.reference.clone()), + kind: Kind::Memory as i32, + id: args.id.clone(), + path: None, + include_bundle: true, + view: None, + }; + let response = session.get_vault_entry(request)?; + let Some(bundle) = response + .entry_bundle + .as_ref() + .map(parse_entry_bundle) + .transpose()? + else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no entry bundle (include_bundle required)" + ))); + }; + let inputs = crate::commands::memory::bridge::inputs_from_entry(&bundle.entry)?; + let attestation = + crate::commands::memory::bridge::classify_attestation(memory_sources(&bundle), &inputs); + super::memory::show::project(&args.id, &inputs, &attestation, args.json)?; + Ok(true) +} + +// --------------------------------------------------------------------------- +// vault sync + query +// --------------------------------------------------------------------------- + +pub fn vault_sync() -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::SyncVaultRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + view: None, + expected_snapshot: None, + }; + let response = session.sync_vault(request)?; + if response.entities_synced == 0 { + println!("Vault is up to date."); + } else { + println!("Synced {} vault file(s).", response.entities_synced); + } + Ok(true) +} + +pub fn query_enrich(args: &super::query::QueryEnrich) -> CliResult { + let session = match Service::open()? { + Some(session) => session, + None => return Ok(false), + }; + // Per-change enrichment: each change reference resolves to its full + // hash through the service layer's Log (a read), then rides the + // request's repeated changes — the handler enriches exactly those. + let changes = args + .changes + .iter() + .map(|spec| { + resolve_change_hash(&session, spec).map(|hash| pb::Hash { + value: hash.0.to_vec(), + algorithm: pb::HashAlgorithm::Blake3 as i32, + }) + }) + .collect::>>()?; + let request = pb::MaintainKnowledgeGraphRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::KnowledgeMaintainAction::Enrich as i32, + scope: args.rebuild.then(|| "rebuild".to_string()), + changes, + }; + let response = session.maintain_knowledge_graph(request)?; + if !args.changes.is_empty() { + // The targeted form's report, as the local body prints it. + println!("Enriched {} change(s).", response.processed); + } else { + println!( + "Enriched knowledge graph: {} item(s) processed.", + response.processed + ); + } + Ok(true) +} + +/// `atomic vault context` over GetVaultContext — every form. The +/// gather+rank (seeds from query terms, `--intent`, `--files`; the +/// memory-body scan; neighbor bonuses; recency; the body budget) runs in +/// the handler; the CLI renders the SAME markdown/JSON contracts with +/// the SAME render functions the local body uses (one render, two data +/// `atomic vault context` over GetVaultContext — every form. The +/// gather+rank (seeds from query terms, `--intent`, `--files`; the +/// memory-body scan; neighbor bonuses; recency; the body budget) runs in +/// the handler; the CLI renders the SAME markdown/JSON contracts with +/// the SAME render functions the local body uses (one render, two data +/// sources). +pub fn vault_context(args: &super::vault::context::Context) -> CliResult { + use super::vault::context::{ + render_candidates_json, render_candidates_md, render_json, render_md, wire_items, + }; + let session = match Service::open()? { + Some(session) => session, + None => return Ok(false), + }; + let as_json = args.json || args.format.eq_ignore_ascii_case("json"); + let request = pb::GetVaultContextRequest { + repository: Some(session.reference.clone()), + max_entries: Some(args.limit as u32), + kinds: Vec::new(), + query: args.query.clone(), + intent: args.intent.clone(), + files: args.files.clone(), + budget_chars: Some(args.budget_chars as u32), + include_body: Some(!args.candidates_only), + view: None, + }; + let response = session.get_vault_context(request)?; + let items = wire_items(&response.context); + + if args.candidates_only { + if as_json { + println!("{}", render_candidates_json(&items, args, args.limit)); + } else { + let md = render_candidates_md(&items); + if !md.is_empty() { + print!("{md}"); + } + } + return Ok(true); + } + + let md = render_md(&items); + if as_json { + println!("{}", render_json(&items, &md, args, args.limit)); + } else if !md.is_empty() { + print!("{md}"); + } + Ok(true) +} + +/// `atomic agent attest` over ListAttestations — every form. The +/// listing rides the view filter as before; `--hash ` resolves +/// ONE attestation server-side (the hash names an attestation, which the +/// change-oriented Log cannot resolve) and returns the domain payload +/// plus the per-view coverage as versioned-opaque bytes; `--summary` / +/// `--pending ` return the AI provenance summary the same way. +/// The CLI renders with the SAME code the local bodies run. +pub fn agent_attest(args: &super::agent::attest::Attest) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + // The provenance-summary modes: `--pending ` requires --view + // (validated client-side, the local body's exact error). + if args.summary || args.pending.is_some() { + if args.pending.is_some() && args.view.is_none() { + return Err(CliError::InvalidArgument { + message: + "--pending requires --view (the forked view to summarize)" + .to_string(), + }); + } + // The summary's view defaults to the current view — resolved + // client-side from the working-copy marker (the same default the + // local body applies). + let summary_view = args + .view + .clone() + .unwrap_or_else(|| current_view_name().unwrap_or_default()); + let request = pb::ListAttestationsRequest { + repository: Some(session.reference.clone()), + filter: None, + cursor: None, + budget: None, + view: None, + hash_prefix: None, + summary_view: Some(summary_view), + pending_parent: args.pending.clone(), + }; + let response = session.list_attestations(request)?; + let Some(bundle) = response.summary_bundle.as_ref() else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no summary bundle (summary_view required)" + ))); + }; + if bundle.schema != "atomic.provenance.summary.v1" { + return Err(CliError::Internal(anyhow::anyhow!( + "unknown summary bundle schema '{}'", + bundle.schema + ))); + } + #[derive(serde::Deserialize)] + struct SummaryBundle { + summary: atomic_repository::ProvenanceSummary, + parent: Option, + } + let parsed: SummaryBundle = serde_json::from_slice(&bundle.payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("summary bundle: {error}")))?; + super::agent::attest::Attest::render_summary( + &parsed.summary, + &args + .view + .clone() + .unwrap_or_else(|| current_view_name().unwrap_or_default()), + args.pending.as_deref(), + parsed.parent.as_deref(), + args.verbose, + ); + return Ok(true); + } + // The `--hash ` detail: handler-side resolution (exact match + // first, then the prefix scan), the domain payload + per-view + // coverage over the wire. + if let Some(prefix) = args.hash.clone() { + let request = pb::ListAttestationsRequest { + repository: Some(session.reference.clone()), + filter: None, + cursor: None, + budget: None, + view: None, + hash_prefix: Some(prefix), + summary_view: None, + pending_parent: None, + }; + let response = session.list_attestations(request)?; + let Some(bundle) = response.detail_bundle.as_ref() else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no detail bundle (hash_prefix required)" + ))); + }; + if bundle.schema != "atomic.attestation.detail.v1" { + return Err(CliError::Internal(anyhow::anyhow!( + "unknown detail bundle schema '{}'", + bundle.schema + ))); + } + #[derive(serde::Deserialize)] + struct DetailBundle { + hash: String, + attestation: atomic_core::change::attestation::Attestation, + coverage: Vec, + } + let parsed: DetailBundle = serde_json::from_slice(&bundle.payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("detail bundle: {error}")))?; + let hash = atomic_core::types::Hash::from_base32(parsed.hash.as_bytes()) + .ok_or_else(|| CliError::Internal(anyhow::anyhow!("malformed attestation hash")))?; + let coverage = parsed + .coverage + .iter() + .map(|row| super::agent::attest::CoverageRow { + view: row.view.clone(), + covered: row.covered, + total: row.total, + }) + .collect::>(); + super::agent::attest::Attest::render_detail(&hash, &parsed.attestation, &coverage); + return Ok(true); + } + // The listing: attestations covering the view's changes. + let request = pb::ListAttestationsRequest { + repository: Some(session.reference.clone()), + filter: Some(pb::AttestationTarget { + kind: Some(TargetKind::GraphStats(pb::GraphStatsRef { + session_id: args.view.clone(), + turn_number: None, + change_hash: None, + })), + }), + cursor: None, + budget: None, + view: None, + hash_prefix: None, + summary_view: None, + pending_parent: None, + }; + let response = session.list_attestations(request)?; + if response.attestations.is_empty() { + println!("No attestations cover changes in this view."); + return Ok(true); + } + println!( + "{} attestation(s) covering changes in this view", + response.attestations.len() + ); + for attestation in &response.attestations { + println!(" {}", attestation.id); + } + Ok(true) +} + +/// `atomic vault list` over ListVaultEntries — every form. kind absent +/// is the WHOLE-VAULT listing: every entry with its type, content size, +/// and updated-at date, filtered by the request's path prefix and entry +/// type; the CLI renders with the same table/JSON code the local body +/// uses. +pub fn vault_list(args: &super::vault::list::List) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + // The local parse: an unknown --type value parses to no filter (the + // local body lists everything), so only a PARSED type rides the wire. + let type_filter = args + .r#type + .as_deref() + .and_then(atomic_core::pristine::VaultEntryType::parse); + let request = pb::ListVaultEntriesRequest { + repository: Some(session.reference.clone()), + kind: None, + status: None, + summaries_only: true, + cursor: None, + budget: None, + view: None, + identity: None, + path_prefix: args.prefix.clone(), + entry_type: type_filter.map(|kind| kind.to_string()), + status_filter: None, + tool_result_previews: false, + }; + let response = session.list_vault_entries(request)?; + let rows = response + .entries + .iter() + .map(|entry| super::vault::list::EntryRow { + path: entry.vault_path.clone().unwrap_or_default(), + entry_type: entry.entry_type.clone().unwrap_or_default(), + size: entry.content_size.unwrap_or(0), + updated_at: entry.updated_at_label.clone().unwrap_or_default(), + }) + .collect::>(); + super::vault::list::render_rows(&rows, args.json); + Ok(true) +} + +/// `atomic vault show ` over GetVaultEntry — every form. The +/// wire resolves by vault-relative path (any entry type) and carries +/// the stored entry's full domain bytes, so the revision hash, the JSON +/// projection (entry type, frontmatter, created/updated dates), and the +/// `--revision` guard all run client-side over wire-carried data with +/// the SAME functions the local body uses. +pub fn vault_show(args: &super::vault::show::Show) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + // The raw argument names the entry exactly as the local body's + // vault_retrieve does (vault-relative; a `.vault/`-prefixed path + // fails the same lookup both ways). + let path = args.path.clone(); + let request = pb::GetVaultEntryRequest { + repository: Some(session.reference.clone()), + kind: Kind::Unspecified as i32, + id: String::new(), + path: Some(path.clone()), + include_bundle: true, + view: None, + }; + let response = session.get_vault_entry(request)?; + let Some(bundle) = response + .entry_bundle + .as_ref() + .map(parse_entry_bundle) + .transpose()? + else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no entry bundle (include_bundle required)" + ))); + }; + let entry = &bundle.entry; + let revision_hash = (args.json || args.revision.is_some()) + .then(|| super::vault::vault_entry_revision_hash(entry)); + if let Some(actual) = revision_hash.as_deref() { + super::vault::show::require_revision(&path, args.revision.as_deref(), actual)?; + } + if args.json { + let json = super::vault::show::entry_json( + &path, + entry, + revision_hash + .as_deref() + .expect("JSON output always computes a revision"), + ); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + } else { + print!("{}", String::from_utf8_lossy(&entry.content_bytes)); + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// diff / change / session / view list / goals / neighbors +// --------------------------------------------------------------------------- + +/// `atomic diff` over DiffWorkingCopy / Diff / GetChange. The working-copy +/// and view-pair forms render the wire's DiffChunk patches client-side — +/// including `--json` (the versioned document shape the local body emits) +/// and `--word-diff` (the same pairing + token-level highlighting the local +/// unified renderer applies, over the parsed patch lines). `-c ` loads +/// the change bundle over GetChange and runs the local render flow over +/// the deserialized domain Change. `--cached` is a documented no-op +/// ("reserved for future use") with no local behavior and no wire shape, +/// so it keeps the local body. +pub fn diff(args: &super::diff::command::Diff) -> CliResult { + // The view-pair form (--from/--to) is the Diff RPC's RefPairScope arm. + if let (Some(from), Some(to)) = (&args.from, &args.to) { + return diff_view_pair(args, from, to); + } + // The `-c ` form: a recorded change's content, over GetChange. + if let Some(change_ref) = args.change.clone() { + return diff_change(args, &change_ref); + } + if args.cached { + return Ok(false); // reserved for future use: a no-op flag needs no wire + } + let session = match Service::open()? { + Some(session) => session, + None => return Ok(false), + }; + let (stat_only, name_only, include_untracked, files) = ( + args.stat, + args.name_only, + args.untracked, + args.files.clone(), + ); + let response = session.diff_working_copy(pb::DiffWorkingCopyRequest { + repository: Some(session.reference.clone()), + word_diff: args.word_diff, + working_copy: Some(pb::WorkingCopyScope { + include_untracked, + paths: files, + }), + stat_only, + })?; + + if args.json { + print_diff_json(&response, Some(¤t_view_name()?))?; + return Ok(true); + } + if response.is_empty() { + println!("No pending changes."); + return Ok(true); + } + for chunk in &response { + if name_only { + println!("{}", chunk.path); + continue; + } + if stat_only { + println!( + " {} | +{} -{}", + chunk.path, chunk.additions, chunk.deletions + ); + continue; + } + if chunk.binary { + println!("Binary file {} differs", chunk.path); + continue; + } + if let Some(patch) = &chunk.patch { + println!("--- {}", chunk.path); + println!("+++ {}", chunk.path); + if args.word_diff { + print_word_diff_patch(&String::from_utf8_lossy(patch)); + } else { + print!("{}", String::from_utf8_lossy(patch)); + } + } + } + Ok(true) +} + +/// `atomic change` over GetChange (includes.metadata: the versioned +/// change bundle + the provenance ledger). The full-detail renders — the +/// Change Ledger, hunks, JSON — run client-side over the wire data with +/// the SAME formatters the local path uses: one render, two data sources. +pub fn change(args: &super::change::command::ChangeCmd) -> CliResult { + use super::change::command::ChangeRenderData; + use super::change::types::ChangeIdentifier; + let Some(session) = Service::open()? else { + return Ok(false); + }; + + // Resolve the identifier: None = "latest" (the view's newest change, + // via Log); a full hash or a view sequence ride the ChangeRef; a + // prefix rides the wire's prefix arm (whole-store resolution). + let reference = match args.identifier.as_deref() { + None => { + let entries = session.log(pb::LogRequest { + repository: Some(session.reference.clone()), + all_views: false, + view: args.view.clone(), + cursor: None, + budget: Some(pb::PageBudget { + max_items: Some(1), + max_bytes: None, + }), + path_filter: Vec::new(), + tags_only: false, + })?; + let Some(entry) = entries.entries.first() else { + return Err(CliError::ChangeNotFound { + hash: "latest".to_string(), + }); + }; + let hash = entry + .hash + .clone() + .ok_or_else(|| CliError::Internal(anyhow::anyhow!("log entry carries no hash")))?; + pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Hash(hash)), + } + } + Some(identifier) => { + let parsed = ChangeIdentifier::parse(Some(identifier)) + .map_err(|error| CliError::InvalidArgument { message: error })?; + match parsed { + ChangeIdentifier::FullHash(hash) => { + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&hash.0); + pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Hash(pb::Hash { + value: bytes.to_vec(), + algorithm: pb::HashAlgorithm::Blake3 as i32, + })), + } + } + ChangeIdentifier::Sequence(sequence) => pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Sequence(sequence)), + }, + _ => pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Prefix(identifier.to_string())), + }, + } + } + }; + + let response = session.get_change(pb::GetChangeRequest { + repository: Some(session.reference.clone()), + r#ref: Some(reference), + includes: Some(pb::ChangeIncludes { + metadata: true, + ..Default::default() + }), + view: args.view.as_ref().map(|view| pb::ViewRef { + view_id: Vec::new(), + name: Some(view.clone()), + }), + include_file_contents: false, + })?; + + // The versioned change bundle: V3 bytes → the domain Change. + let Some(bundle) = response.change_bundle else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no change bundle (metadata includes required)" + ))); + }; + let mut payload = bundle.payload.as_slice(); + let (change, _hash) = atomic_core::change::Change::deserialize(&mut payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("change bundle: {error}")))?; + let Some(info) = response.change else { + return Err(CliError::Internal(anyhow::anyhow!("change not found"))); + }; + let hash = info + .hash + .and_then(|h| h.value.clone().try_into().ok()) + .map(atomic_core::types::Merkle) + .ok_or_else(|| CliError::Internal(anyhow::anyhow!("malformed hash")))?; + + // The causal decision graph(s): JSON bytes → the domain ProvenanceGraph. + let ledger_graphs = response + .provenance_ledger + .iter() + .filter_map(|bytes| { + let graph: atomic_core::change::ProvenanceGraph = + serde_json::from_slice(&bytes.payload).ok()?; + let graph_hash = atomic_core::types::Merkle([0u8; 32]); + Some((graph_hash, graph)) + }) + .collect::>(); + + // Dependency messages for --show-deps: per-dep metadata lookups. + let mut dep_messages = std::collections::HashMap::new(); + if args.show_deps { + for dep in &info.dependencies { + let value = dep.value.clone(); + let Ok(bytes) = TryInto::<[u8; 32]>::try_into(value) else { + continue; + }; + let result = session.get_change(pb::GetChangeRequest { + repository: Some(session.reference.clone()), + r#ref: Some(pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Hash(pb::Hash { + value: bytes.to_vec(), + algorithm: pb::HashAlgorithm::Blake3 as i32, + })), + }), + includes: None, + view: None, + include_file_contents: false, + }); + if let Ok(dep_response) = result { + if let Some(dep_info) = dep_response.change { + if let Some(message) = dep_info.message { + dep_messages.insert( + atomic_core::types::Merkle(bytes), + message.lines().next().unwrap_or("").to_string(), + ); + } + } + } + } + } + + let data = ChangeRenderData { + ledger_graphs, + dep_messages, + }; + args.print_change(&change, &hash, response.sequence, &data); + Ok(true) +} + +/// `atomic diff --from X --to Y` over the Diff RPC's RefPairScope arm — +/// the same client-side renders as the working-copy form (`--json`, the +/// word-diff highlight, the plain patch text). +fn diff_view_pair(args: &super::diff::command::Diff, from: &str, to: &str) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let stat_only = args.stat; + let name_only = args.name_only || args.name_status || args.short; + let from = from.to_string(); + let to = to.to_string(); + let chunks = session.diff(pb::DiffRequest { + repository: Some(session.reference.clone()), + word_diff: args.word_diff, + scope: Some(pb::diff_request::Scope::Refs(pb::RefPairScope { + from_view: from, + to_view: to, + })), + stat_only, + })?; + + if args.json { + // A view-pair diff is not scoped against a single view — the + // document's `view` field stays null (and `change` too: no `-c`). + print_diff_json(&chunks, None)?; + return Ok(true); + } + if chunks.is_empty() { + println!("No differences between the views."); + return Ok(true); + } + for chunk in &chunks { + if name_only { + println!("{}", chunk.path); + continue; + } + if stat_only { + println!( + " {} | +{} -{}", + chunk.path, chunk.additions, chunk.deletions + ); + continue; + } + if chunk.binary { + println!("Binary file {} differs", chunk.path); + continue; + } + if let Some(patch) = &chunk.patch { + println!("--- a/{}", chunk.path); + println!("+++ b/{}", chunk.path); + if args.word_diff { + print_word_diff_patch(&String::from_utf8_lossy(patch)); + } else { + print!("{}", String::from_utf8_lossy(patch)); + } + } + } + Ok(true) +} + +/// `atomic diff -c ` over GetChange — every form: the change +/// resolves (full hash, `#N` sequence, or prefix — the same ChangeRef +/// arms `atomic change` uses), the versioned change bundle deserializes +/// into the domain Change, and the SAME render flow the local `-c` body +/// runs prints it. The request's file-content reconstruction carries +/// each touched file's pre/post-change bytes: the FileOps hunks pad +/// their context from the true before-content (no degradation), and +/// legacy no-file_ops changes build their diffs from the reconstructed +/// content — the exception is gone. +fn diff_change(args: &super::diff::command::Diff, change_ref: &str) -> CliResult { + use super::change::types::ChangeIdentifier; + let Some(session) = Service::open()? else { + return Ok(false); + }; + let parsed = ChangeIdentifier::parse(Some(change_ref)) + .map_err(|error| CliError::InvalidArgument { message: error })?; + let reference = match parsed { + ChangeIdentifier::FullHash(hash) => { + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&hash.0); + pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Hash(pb::Hash { + value: bytes.to_vec(), + algorithm: pb::HashAlgorithm::Blake3 as i32, + })), + } + } + ChangeIdentifier::Sequence(sequence) => pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Sequence(sequence)), + }, + // "Latest" (`@`/empty) resolves through the daemon's Log like the + // `change` hook does: the view's newest change. + ChangeIdentifier::Latest => { + let entries = session.log(pb::LogRequest { + repository: Some(session.reference.clone()), + all_views: false, + view: args.view.clone(), + cursor: None, + budget: Some(pb::PageBudget { + max_items: Some(1), + max_bytes: None, + }), + path_filter: Vec::new(), + tags_only: false, + })?; + let Some(entry) = entries.entries.first() else { + return Err(CliError::ChangeNotFound { + hash: "latest".to_string(), + }); + }; + let hash = entry + .hash + .clone() + .ok_or_else(|| CliError::Internal(anyhow::anyhow!("log entry carries no hash")))?; + pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Hash(hash)), + } + } + _ => pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Prefix(change_ref.to_string())), + }, + }; + + let response = session.get_change(pb::GetChangeRequest { + repository: Some(session.reference.clone()), + r#ref: Some(reference), + includes: Some(pb::ChangeIncludes { + metadata: true, + ..Default::default() + }), + view: args.view.as_ref().map(|view| pb::ViewRef { + view_id: Vec::new(), + name: Some(view.clone()), + }), + include_file_contents: true, + })?; + let Some(bundle) = response.change_bundle else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no change bundle (metadata includes required)" + ))); + }; + let mut payload = bundle.payload.as_slice(); + let (change, _hash) = atomic_core::change::Change::deserialize(&mut payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("change bundle: {error}")))?; + let Some(info) = response.change else { + return Err(CliError::Internal(anyhow::anyhow!("change not found"))); + }; + let hash = info + .hash + .and_then(|h| h.value.clone().try_into().ok()) + .map(atomic_core::types::Merkle) + .ok_or_else(|| CliError::Internal(anyhow::anyhow!("malformed hash")))?; + + // The wire's per-file content reconstruction: before (the context + // padding's source) and after (the legacy fallback's diff input). + let mut before_content = std::collections::HashMap::new(); + let mut after_content = std::collections::HashMap::new(); + for file in &response.file_contents { + if let Some(before) = &file.before { + before_content.insert(file.path.clone(), before.clone()); + } + if let Some(after) = &file.after { + after_content.insert(file.path.clone(), after.clone()); + } + } + + let config = args.get_output_config(); + let (file_diffs, stats) = if let Some((file_diffs, stats)) = + super::diff::Diff::build_git_import_file_diffs(&change) + { + // Git-imported changes carry Git's captured +/- lines in their + // unhashed metadata — a pure render over the deserialized + // change, no content reads. + (file_diffs, stats) + } else if change.has_file_ops() { + // The FileOps hunks pad their context from the wire-carried + // before-content — the same context the local path reads from + // the graph. + super::diff::change_file_diffs_with(&change, &config, |path| { + before_content.get(path).cloned() + })? + } else { + // Legacy changes carry no file_ops: the diff computes from the + // reconstructed before/after content — the SAME builder the + // local body runs over graph-read content. + use atomic_repository::get_files_in_change; + let modified_files: Vec = get_files_in_change(&change) + .into_iter() + .filter(|path| args.file_matches_filter(path)) + .collect(); + let entries = modified_files + .iter() + .map(|path| { + ( + path.clone(), + before_content.get(path).cloned().unwrap_or_default(), + after_content.get(path).cloned().unwrap_or_default(), + ) + }) + .collect::>(); + super::diff::legacy_content_file_diffs( + &entries, + args.parse_algorithm()?, + config.context_lines, + ) + }; + let (file_diffs, stats) = args.filter_file_diffs(file_diffs, stats); + args.print_change_file_diffs(&change, &hash, &config, file_diffs, stats)?; + Ok(true) +} + +// --------------------------------------------------------------------------- +// wire diff rendering (DiffChunk → text/JSON/word-diff) +// --------------------------------------------------------------------------- + +/// One hunk parsed from a wire DiffChunk's unified patch text. +struct WireHunk { + old_start: usize, + old_count: usize, + new_start: usize, + new_count: usize, + lines: Vec<(char, String, Option, Option)>, +} + +/// Parse a unified-diff patch into hunks. Each line is +/// `(marker, content, old_line, new_line)` with the markers ' ', '-', '+'; +/// line numbers follow the unified header's running counters, exactly as +/// the local renderer numbers its computed hunks. +fn parse_patch_hunks(patch: &str) -> Vec { + let mut hunks = Vec::new(); + let mut current: Option = None; + let mut old_counter = 0usize; + let mut new_counter = 0usize; + for line in patch.lines() { + if let Some(header) = line.strip_prefix("@@") { + // "@@ -a,b +c,d @@ ..." — the ranges ride before the closing @@. + let body = header.split("@@").next().unwrap_or(""); + let mut parts = body.split_whitespace(); + let parse = |spec: &str| -> (usize, usize) { + let stripped = spec + .strip_prefix('-') + .or_else(|| spec.strip_prefix('+')) + .unwrap_or(spec); + let (start, count) = stripped.split_once(',').unwrap_or((stripped, "1")); + (start.parse().unwrap_or(0), count.parse().unwrap_or(1)) + }; + let (old_start, old_count) = parse(parts.next().unwrap_or("-0,0")); + let (new_start, new_count) = parse(parts.next().unwrap_or("+0,0")); + old_counter = old_start; + new_counter = new_start; + if let Some(previous) = current.take() { + hunks.push(previous); + } + current = Some(WireHunk { + old_start, + old_count, + new_start, + new_count, + lines: Vec::new(), + }); + continue; + } + if line.starts_with("---") || line.starts_with("+++") || line.starts_with("diff ") { + continue; // defensive: file headers never carry change lines + } + let Some(hunk) = current.as_mut() else { + continue; // preamble before the first @@ header + }; + let marker = line.chars().next().unwrap_or(' '); + let content = line.get(1..).unwrap_or_default().to_string(); + match marker { + '-' => { + hunk.lines.push(('-', content, Some(old_counter), None)); + old_counter = old_counter.saturating_add(1); + } + '+' => { + hunk.lines.push(('+', content, None, Some(new_counter))); + new_counter = new_counter.saturating_add(1); + } + _ => { + hunk.lines + .push((' ', content, Some(old_counter), Some(new_counter))); + old_counter = old_counter.saturating_add(1); + new_counter = new_counter.saturating_add(1); + } + } + } + if let Some(last) = current.take() { + hunks.push(last); + } + hunks +} + +/// Print a wire patch with word-level highlighting — the same pairing and +/// token-level highlighting the local unified renderer (`diff/format.rs`) +/// applies to its computed hunks: consecutive removed/added lines pair up +/// and diff at token level (the semantic engine first, the inline engine +/// as fallback); unpaired and context lines render plain. +fn print_word_diff_patch(patch: &str) { + use crate::output::{added, deleted}; + use atomic_core::diff::compute_inline_diff; + use atomic_core::diff::{semantic_diff, LineChange}; + + for hunk in parse_patch_hunks(patch) { + println!( + "@@ -{},{} +{},{} @@", + hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count + ); + let lines = hunk.lines; + let mut index = 0; + while index < lines.len() { + if lines[index].0 != '-' { + let (marker, content, ..) = &lines[index]; + match marker { + '+' => println!("{}", added(&format!("{marker}{content}"))), + _ => println!("{marker}{content}"), + } + index += 1; + continue; + } + // Collect the consecutive removed block, then the added block + // that follows — the local renderer's pairing window. + let removed: Vec<&(char, String, Option, Option)> = lines[index..] + .iter() + .take_while(|line| line.0 == '-') + .collect(); + let mut cursor = index + removed.len(); + let added_lines: Vec<&(char, String, Option, Option)> = lines[cursor..] + .iter() + .take_while(|line| line.0 == '+') + .collect(); + cursor += added_lines.len(); + if added_lines.is_empty() { + for (marker, content, ..) in &removed { + println!("{}", deleted(&format!("{marker}{content}"))); + } + index += removed.len(); + continue; + } + let pairs = removed.len().min(added_lines.len()); + for pair in 0..pairs { + let (removed_line, added_line) = + (removed[pair].1.as_str(), added_lines[pair].1.as_str()); + let old_content = removed_line.as_bytes(); + let new_content = added_line.as_bytes(); + // Semantic token diff first; the inline diff is the fallback. + let sem_diff = semantic_diff(old_content, new_content); + let mut used_semantic = false; + #[allow(clippy::collapsible_match)] + if let Some(change) = sem_diff.changes().first() { + if let LineChange::Modified { token_changes, .. } = change { + print!("{}", deleted(&format!("-{removed_line}"))); + super::diff::print_semantic_word_diff_line(token_changes, true); + println!(); + print!("{}", added(&format!("+{added_line}"))); + super::diff::print_semantic_word_diff_line(token_changes, false); + println!(); + used_semantic = true; + } + } + if !used_semantic { + let inline_diff = compute_inline_diff(old_content, new_content); + print!("{}", deleted(&format!("-{removed_line}"))); + super::diff::print_word_diff_line(old_content, inline_diff.old_hunks(), true); + println!(); + print!("{}", added(&format!("+{added_line}"))); + super::diff::print_word_diff_line(new_content, inline_diff.new_hunks(), false); + println!(); + } + } + for (marker, content, ..) in removed.iter().skip(pairs) { + println!("{}", deleted(&format!("{marker}{content}"))); + } + for (marker, content, ..) in added_lines.iter().skip(pairs) { + println!("{}", added(&format!("{marker}{content}"))); + } + index = cursor; + } + } +} + +/// Render the wire's DiffChunks as the local `diff --json` document — the +/// same versioned shape (`schema_version` 1, `view`, null `change`, per-file +/// hunks/lines with true numbers, aggregate stats), built from the chunk +/// fields. Per-file status derives from the hunks' old/new counts (a +/// pure insertion reads `added`, a pure deletion `deleted`, else +/// `modified`), the same classification the local FileDiff carries. +fn print_diff_json(chunks: &[pb::DiffChunk], view: Option<&str>) -> CliResult<()> { + let mut files = Vec::new(); + let mut insertions = 0usize; + let mut deletions = 0usize; + for chunk in chunks { + insertions += chunk.additions as usize; + deletions += chunk.deletions as usize; + let hunks = parse_patch_hunks(&String::from_utf8_lossy( + chunk.patch.as_deref().unwrap_or(&[]), + )); + let old_side_empty = hunks.iter().all(|hunk| hunk.old_count == 0); + let new_side_empty = hunks.iter().all(|hunk| hunk.new_count == 0); + let (status, code, old_path, new_path) = if chunk.binary { + ("binary", 'B', chunk.path.clone(), chunk.path.clone()) + } else if old_side_empty && !new_side_empty { + ("added", 'A', "/dev/null".to_string(), chunk.path.clone()) + } else if new_side_empty && !old_side_empty { + ("deleted", 'D', chunk.path.clone(), "/dev/null".to_string()) + } else { + ("modified", 'M', chunk.path.clone(), chunk.path.clone()) + }; + let json_hunks: Vec = hunks + .iter() + .map(|hunk| { + serde_json::json!({ + "old_start": hunk.old_start, + "old_count": hunk.old_count, + "new_start": hunk.new_start, + "new_count": hunk.new_count, + "lines": hunk.lines.iter().map(|(marker, content, old_line, new_line)| { + serde_json::json!({ + "status": match marker { + '-' => "removed", + '+' => "added", + _ => "context", + }, + "content": content, + "old_line": old_line, + "new_line": new_line, + }) + }).collect::>(), + }) + }) + .collect(); + files.push(serde_json::json!({ + "path": chunk.path, + "old_path": old_path, + "new_path": new_path, + "status": status, + "code": code, + "binary": chunk.binary, + "insertions": chunk.additions, + "deletions": chunk.deletions, + "hunks": json_hunks, + })); + } + let document = serde_json::json!({ + "schema_version": 1, + "view": view, + "change": serde_json::Value::Null, + "files": files, + "stats": { + "files": chunks.len(), + "insertions": insertions, + "deletions": deletions, + }, + }); + println!( + "{}", + serde_json::to_string_pretty(&document).map_err(|e| CliError::Internal(e.into()))? + ); + Ok(()) +} + +/// `atomic session show` over the session RPCs. The listing rides +/// ListSessions (with its ledgers bundle + vault-derived intent counts); +/// the detail rides GetSession (ledger bundle, turn→intent resolution, +/// and the session manifest data). Both renders are the SAME functions +/// the local path uses — the wire carries the domain data. +pub fn session_show(args: &super::session::SessionShow) -> CliResult { + use atomic_core::change::session::{SessionRecord, SessionTurn}; + + let Some(session) = Service::open()? else { + return Ok(false); + }; + let target = args.session_id.clone(); + if target.is_none() { + // The recent-session listing. + let response = session.list_sessions(pb::ListSessionsRequest { + repository: Some(session.reference.clone()), + limit: args.limit as u32, + })?; + let Some(bundle) = response.ledgers_bundle else { + // The same-build service always carries the bundle; a missing + // one is a protocol violation, not a fallback path. + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no ledgers bundle" + ))); + }; + if args.json { + // The bundle IS the domain serialization of the ledgers — + // print it verbatim (byte-parity with the local JSON). + print!("{}", String::from_utf8_lossy(&bundle.payload)); + return Ok(true); + } + let ledgers: Vec<(SessionRecord, Vec)> = + serde_json::from_slice(&bundle.payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("ledgers bundle: {error}")))?; + let intent_counts = response + .intent_counts + .into_iter() + .map(|count| count as usize) + .collect(); + super::session::render_recent_sessions(ledgers, intent_counts, false)?; + return Ok(true); + } + let session_id = target.expect("checked"); + let response = session.get_session(pb::GetSessionRequest { + repository: Some(session.reference.clone()), + session_id, + from_turn: None, + to_turn: None, + })?; + let Some(bundle) = response.ledger_bundle else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no ledger bundle" + ))); + }; + let (record, turns): (SessionRecord, Vec) = + serde_json::from_slice(&bundle.payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("ledger bundle: {error}")))?; + if args.json { + // Byte-parity: the bundle is the domain serialization of the pair. + print!("{}", String::from_utf8_lossy(&bundle.payload)); + return Ok(true); + } + let turn_intents = response + .turn_intents + .into_iter() + .map(|entry| (entry.turn, entry.intent)) + .collect::>(); + let manifest_head = response + .manifest_head + .and_then(|h| h.value.clone().try_into().ok()) + .map(atomic_core::types::Merkle); + let parent_manifest = response + .parent_manifest + .and_then(|h| h.value.clone().try_into().ok()) + .map(atomic_core::types::Merkle); + super::session::render_session_detail( + record, + turns, + turn_intents, + manifest_head, + parent_manifest, + response.fork_turn, + false, + )?; + Ok(true) +} + +/// `atomic view list` over ListViews — every local form (`--remote` is +/// the scope-out remote surface). The wire's ViewInfo carries each view's +/// own/inherited change-count split and head state, so the versioned JSON +/// document renders client-side with the same JsonView shape the local +/// body emits; the plain form keeps its metadata rendering. +pub fn view_list(args: &super::view::List) -> CliResult { + if args.remote.is_some() { + return Ok(false); // the remote listing (scope-out remote surface) + } + let Some(session) = Service::open()? else { + return Ok(false); + }; + let response = session.list_views(pb::ListViewsRequest { + repository: Some(session.reference.clone()), + include_details: false, + })?; + if args.json { + let mut json_views = response + .views + .iter() + .map(super::view::list::JsonView::from_wire) + .collect::>(); + json_views.sort_by(|left, right| left.name.cmp(&right.name)); + let current = response + .views + .iter() + .find(|view| view.current) + .map(|view| view.name.clone()); + super::view::list::print_local_json(&json_views, current)?; + return Ok(true); + } + for view in &response.views { + // Scope + parent annotations mirror the local listing's metadata. + let kind = match pb::ViewScope::try_from(view.scope) { + Ok(pb::ViewScope::Draft) => " [draft]", + Ok(pb::ViewScope::Shared) => " [shared]", + _ => "", + }; + let parent = view + .parent + .as_ref() + .map(|parent| format!(" (parent: {parent})")) + .unwrap_or_default(); + let marker = if view.current { " (current)" } else { "" }; + println!("{}{}{}{}", view.name, kind, parent, marker); + } + Ok(true) +} + +/// `atomic vault goal list` over ListVaultEntries (kind=GOAL) — every +/// form: the raw `--status` filter rides the request verbatim ("all" is +/// no filter), and the wire carries each goal's developer/status/intent +/// plus the started_at/turns columns the JSON prints. +pub fn vault_goal_list(args: &super::vault::goal::GoalList) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::ListVaultEntriesRequest { + repository: Some(session.reference.clone()), + kind: Some(Kind::Goal as i32), + status: None, + summaries_only: true, + cursor: None, + budget: None, + view: None, + identity: None, + path_prefix: None, + entry_type: None, + status_filter: Some(args.status.clone()), + tool_result_previews: false, + }; + let response = session.list_vault_entries(request)?; + let rows = response + .entries + .iter() + .map(|entry| super::vault::goal::GoalRow { + name: entry.id.clone(), + developer: entry.title.clone(), + status: entry.status_label.clone().unwrap_or_default(), + intent: entry.linked.first().cloned(), + started_at: entry.started_at.clone().unwrap_or_default(), + turns: entry.turns.unwrap_or(0), + }) + .collect::>(); + super::vault::goal::render_goal_rows(&rows, args.json); + Ok(true) +} + +/// `atomic vault query neighbors` over QueryGraph (Neighbors) — every +/// form: `-d` rides the request's depth, the wire's KGNode/KGEdge carry +/// the full domain fields, and the JSON serializes the same subgraph +/// shape the local body emits. The plain render matches the local +/// listing (the Nodes/Edges sections and their counts). +pub fn query_neighbors(args: &super::query::QueryNeighbors) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let (node_id, depth) = (args.node_id.clone(), args.depth); + let response = session.query_graph(pb::QueryGraphRequest { + repository: Some(session.reference.clone()), + limit: 0, + query: Some(pb::query_graph_request::Query::Neighbors( + pb::NeighborsQuery { + node_id, + incoming: None, + depth: Some(depth as u32), + }, + )), + view: None, + })?; + let nodes = response + .nodes + .iter() + .map(kg_node_domain) + .collect::>(); + let edges = response + .edges + .iter() + .map(kg_edge_domain) + .collect::>(); + if args.json { + let subgraph = atomic_core::pristine::vault::KgSubgraph { nodes, edges }; + println!("{}", serde_json::to_string_pretty(&subgraph).unwrap()); + return Ok(true); + } + if nodes.is_empty() { + println!("No neighbors found for '{}'.", args.node_id); + return Ok(true); + } + println!("Nodes ({}):", nodes.len()); + for node in &nodes { + let summary = node.summary.as_deref().unwrap_or(""); + let summary_display = if summary.is_empty() { + String::new() + } else { + format!(" {}", super::query::truncate_display(summary, 50)) + }; + println!(" [{}] {}{}", node.kind, node.id, summary_display); + } + println!("\nEdges ({}):", edges.len()); + for edge in &edges { + println!( + " {} \u{2192}[{}]\u{2192} {}", + edge.from_id, edge.kind, edge.to_id + ); + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// doctor (MaintenanceService) +// --------------------------------------------------------------------------- + +/// `atomic doctor check` over CheckRepository — the read-only consistency +/// check. Rendered to mirror the in-process path exactly (non-zero exit +/// when problems are found). +pub fn doctor_check(_args: &super::doctor::Check) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::CheckRepositoryRequest { + repository: Some(session.reference.clone()), + }; + let response = session.check_repository(request)?; + + println!("Verifying working-copy consistency against the graph..."); + print_info(&format!( + "Checked {} clean file(s); {} with uncommitted edits skipped; {} conflicted.", + response.clean_files_checked, response.uncommitted_skipped, response.conflicted_files + )); + + if response.consistent { + print_success("Working copy is consistent with the graph."); + return Ok(true); + } + + print_warning(&format!("{} problem(s) found:", response.findings.len())); + for finding in &response.findings { + println!(" ✗ {finding}"); + } + print_hint( + "Materialization drift can often be repaired by re-materializing \ + (e.g. `atomic view switch `); conflict-state \ + disagreements indicate a bug worth reporting.", + ); + Err(CliError::Internal(anyhow::anyhow!( + "working-copy verification found {} problem(s)", + response.findings.len() + ))) +} + +/// `atomic doctor repair-dependency-index` over MaintenanceService.Repair. +pub fn doctor_repair_dependency_index( + args: &super::doctor::RepairDependencyIndex, +) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::RepairRequest { + repository: Some(session.reference.clone()), + meta: None, + action: pb::RepairAction::RebuildDependencyIndex as i32, + force: args.force, + view: String::new(), + }; + let response = session.repair(request)?; + println!("Repairing change dependency index..."); + if args.force { + print_warning("--force enabled: existing dependency index rows will be replaced"); + } + print_success(&format!( + "Dependency index repair complete: {} indexed, {} skipped, {} failed", + response.indexed, response.skipped, response.failed + )); + if response.failed > 0 { + print_hint( + "Some changes could not be loaded. Run with verbose logging to identify corrupted or missing change files.", + ); + } else if response.indexed > 0 { + print_hint("View filter setup for status/diff/content paths can now use pristine indexes instead of scanning .change files."); + } + Ok(true) +} + +/// `atomic doctor materialize-crdt` over MaintenanceService.Repair. +pub fn doctor_materialize_crdt(args: &super::doctor::MaterializeCrdt) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + // The current view is resolved client-side (same default the + // in-process path uses); the daemon materializes it server-side. + let view = match &args.view { + Some(view) => view.clone(), + None => { + let marker = crate::commands::find_repository_root()? + .join(".atomic") + .join("current_view"); + std::fs::read_to_string(&marker) + .map(|content| content.trim().to_string()) + .unwrap_or_else(|_| "dev".to_string()) + } + }; + let request = pb::RepairRequest { + repository: Some(session.reference.clone()), + meta: None, + action: pb::RepairAction::MaterializeCrdt as i32, + force: args.force, + view, + }; + let response = session.repair(request.clone())?; + println!("Materializing CRDT tables for view '{}'...", request.view); + if args.force { + print_warning("--force enabled: existing CRDT trunk rows may be overwritten"); + } + print_success(&format!( + "CRDT materialization complete in {:.1}s: {} changes scanned, {} changes applied, {} FileOps applied, {} already materialized, {} skipped", + response.elapsed_ms as f64 / 1000.0, + response.changes_scanned, + response.changes_applied, + response.file_ops_applied, + response.file_ops_already_materialized, + response.file_ops_skipped + )); + print_hint(&format!( + "CRDT rows: trunks +{}, branches +{}, leaves +{}", + response.trunks_created, response.branches_created, response.leaves_created + )); + if response.file_ops_skipped > 0 { + let skip = response.skip_stats; + if let Some(skip) = skip { + print_hint(&format!( + "Skipped FileOps: non_create={}, unresolved_path={}, unresolved_line={}, missing_range={}, non_create_trunk={}, non_create_leaf={}", + skip.non_create_trunk, + skip.unresolved_path, + skip.unresolved_line, + skip.missing_content_range, + skip.non_insert_branch, + skip.non_insert_leaf + )); + } + if !response.skip_samples.is_empty() { + print_hint(&format!( + "Skip samples: {}", + response.skip_samples.join(", ") + )); + } + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// working-copy mutations (RepositoryMutationService) +// --------------------------------------------------------------------------- + +/// `atomic remove` over RemoveFiles — every form (--dry-run and --force +/// ride the request; the handler reports the tolerant dry-run preview). +pub fn remove(args: &super::remove::Remove) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::RemoveFilesRequest { + repository: Some(session.reference.clone()), + meta: None, + paths: args.paths.clone(), + keep_working_copy: args.keep, + dry_run: args.dry_run, + force: args.force, + }; + let response = session.remove_files(request)?; + if args.dry_run { + println!("Would remove {} file(s) from tracking", response.removed); + return Ok(true); + } + println!("Removed {} file(s) from tracking", response.removed); + Ok(true) +} + +/// `atomic mv` over MoveFile — every form (--dry-run/--force ride the +/// request). +pub fn mv(args: &super::mv::Move) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::MoveFileRequest { + repository: Some(session.reference.clone()), + meta: None, + source: args.source.clone(), + destination: args.destination.clone(), + dry_run: args.dry_run, + force: args.force, + }; + session.move_file(request)?; + if args.dry_run { + println!("Would move: {} → {}", args.source, args.destination); + return Ok(true); + } + println!("Moving: {} → {}", args.source, args.destination); + println!(); + print_success("Moved 1 file"); + println!(); + print_hint("Run 'atomic record' to capture this move (inode/history preserved)"); + Ok(true) +} + +/// `atomic unrecord` over Unrecord — the non-dry-run forms, bare or +/// with a change argument: the prefix rides the wire's ChangeRef prefix +/// arm (whole-store resolution, the same resolver the local body uses; +/// the membership/dependency refusals surface from the handler). +/// `--dry-run` declines here and routes through the PreviewMutation arm +/// the command dispatches next. +pub fn unrecord(args: &super::unrecord::Unrecord) -> CliResult { + if args.dry_run { + return Ok(false); // the preview rides unrecord_preview below + } + let Some(session) = Service::open()? else { + return Ok(false); + }; + + // A bare hash prefix needs the full 32-byte hash before the wire call: + // resolve it through the service layer (a read, no redb here). + let target = args.change.as_ref().map(|prefix| pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Prefix(prefix.clone())), + }); + let request = pb::UnrecordRequest { + repository: Some(session.reference.clone()), + meta: None, + expected: None, + view: None, + target, + }; + let response = session.unrecord(request)?; + + if let Some(change) = &response.removed { + let hash = change + .hash + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(|bytes: [u8; 32]| Merkle(bytes).to_base32()) + .unwrap_or_else(|| "-".to_string()); + print_success(&format!("Unrecorded: {hash}")); + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// view mutations (ViewService) +// --------------------------------------------------------------------------- + +/// `atomic view create` over CreateView — every form. `--from` seeds on +/// the source view, `--parent` anchors on the parent WITHOUT seeding +/// (the draft workspace form, the wire's Parent arm), `--empty`/plain +/// anchor on the nearest Shared ancestor; `--switch` chains the +/// SwitchView RPC. +pub fn view_create(args: &super::view::New) -> CliResult { + let Some(name) = args.name.clone() else { + return Err(CliError::InvalidArgument { + message: "View name is required".to_string(), + }); + }; + // The local body's name validation stays client-side — the same + // messages, checked before any wire round-trip. + super::view::new::validate_view_name(&name) + .map_err(|message| CliError::InvalidArgument { message })?; + let Some(session) = Service::open()? else { + return Ok(false); + }; + let base = if let Some(from) = &args.from { + Some(pb::create_view_request::Base::FromView(from.clone())) + } else if let Some(parent) = &args.parent { + // --parent: anchor on the parent WITHOUT seeding (draft workspace). + Some(pb::create_view_request::Base::Parent(parent.clone())) + } else if args.empty { + Some(pb::create_view_request::Base::Empty(true)) + } else { + None + }; + let request = pb::CreateViewRequest { + repository: Some(session.reference.clone()), + meta: None, + name: name.clone(), + base, + scope: pb::ViewScope::Unspecified as i32, + base_snapshot: None, + }; + let response = session.create_view(request)?; + let view = response.view.expect("created view info"); + if args.switch { + view_switch_plain(&session, &name)?; + } else { + print_hint(&format!( + "Use 'atomic view switch {name}' to switch to the new view" + )); + } + let seeded = view.change_count > 0; + if seeded { + print_success(&format!( + "Created view: {name} (seeded - {} changes)", + view.change_count + )); + } else { + print_success(&format!("Created view: {name}")); + } + Ok(true) +} + +/// `atomic view switch` over SwitchView — every form: --force/--stash +/// ride the request's explicit dirty-copy bypass (the caller's informed +/// decision; the server refusal stays the default without it). +pub fn view_switch(args: &super::view::Switch) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let Some(name) = args.name.clone() else { + return Err(CliError::InvalidArgument { + message: "View name is required".to_string(), + }); + }; + // --force/--stash are the caller's informed decision to leave the + // dirty working copy behind; the server refusal stays the default. + let request = pb::SwitchViewRequest { + repository: Some(session.reference.clone()), + meta: None, + view: name.clone(), + bypass_dirty_check: Some(args.force || args.stash), + }; + let response = session.switch_view(request)?; + print_success(&format!( + "Switched to view: {} ({} files updated)", + name, response.files_written + )); + Ok(true) +} + +fn view_switch_plain(session: &Service, name: &str) -> CliResult<()> { + let request = pb::SwitchViewRequest { + repository: Some(session.reference.clone()), + meta: None, + view: name.to_string(), + bypass_dirty_check: None, + }; + let response = session.switch_view(request)?; + print_success(&format!( + "Switched to view: {} ({} files updated)", + name, response.files_written + )); + Ok(()) +} + +/// `atomic view delete` over DeleteView (--force is presentational only). +pub fn view_delete(args: &super::view::Delete) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let Some(name) = args.name.clone() else { + return Err(CliError::InvalidArgument { + message: "View name is required".to_string(), + }); + }; + let request = pb::DeleteViewRequest { + repository: Some(session.reference.clone()), + meta: None, + view: name.clone(), + expected: None, + }; + session.delete_view(request)?; + print_success(&format!("Deleted view: {name}")); + Ok(true) +} + +/// `atomic view promote` over SetViewScope (SHARED). +pub fn view_promote(args: &super::view::Promote) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + // No name = promote the current view; resolve it from the marker. + let name = match &args.name { + Some(name) => name.clone(), + None => std::fs::read_to_string( + crate::commands::find_repository_root()? + .join(".atomic") + .join("current_view"), + ) + .map(|s| s.trim().to_string()) + .map_err(|_| CliError::InvalidArgument { + message: "View name is required".to_string(), + })?, + }; + let request = pb::SetViewScopeRequest { + repository: Some(session.reference.clone()), + meta: None, + view: name.clone(), + scope: pb::ViewScope::Shared as i32, + expected_snapshot: None, + }; + session.set_view_scope(request)?; + print_success(&format!("Promoted '{name}' to shared root view")); + Ok(true) +} + +/// `atomic restore` over Restore. The non-dry-run forms route; every +/// dry-run form routes through the PreviewRestore arm the command +/// dispatches next — the listing form (no paths, or more than one) via +/// `restore_preview`, the single-file pristine-bytes dump via +/// `restore_single_dry_run` (the wire's content arm). The whole-copy +/// refusal surfaces with the daemon's message. +pub fn restore(args: &super::restore::Restore) -> CliResult { + if args.dry_run { + return Ok(false); + } + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::RestoreRequest { + repository: Some(session.reference.clone()), + meta: None, + paths: args.files.clone(), + force: args.force, + }; + let response = session.restore(request)?; + + if response.restored.is_empty() { + println!("Nothing to restore - working copy is clean"); + return Ok(true); + } + println!("Restoring working copy..."); + for path in &response.restored { + println!(" Restored: {path}"); + } + println!(); + print_success(&format!("Restored {} file(s)", response.restored.len())); + Ok(true) +} + +// --------------------------------------------------------------------------- +// stash (RepositoryMutationService) +// --------------------------------------------------------------------------- + +/// `atomic stash push` over CreateStash. Returns Ok(false) when the call +/// should fall back (unreachable daemon). An empty stash_id in the +/// response means a clean working copy. +pub fn stash_push(message: Option, include_untracked: bool, keep: bool) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::CreateStashRequest { + repository: Some(session.reference.clone()), + meta: None, + message, + include_untracked, + keep, + }; + let response = session.create_stash(request)?; + if response.stash_id.is_empty() { + print_warning("No local changes to save"); + return Ok(true); + } + print_success(&format!("Saved working copy to {}", response.stash_id)); + if !keep { + print_success("Working copy restored to clean state"); + } + Ok(true) +} + +/// `atomic stash apply` over ApplyStash. Renders the applied-file count. +pub fn stash_apply(stash: Option) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::ApplyStashRequest { + repository: Some(session.reference.clone()), + meta: None, + stash_id: stash.clone().unwrap_or_default(), + }; + let response = session.apply_stash(request)?; + if response.applied.is_empty() { + print_warning("Stash was already applied or is empty"); + } else { + print_success(&format!( + "Applied {} file(s) to working copy", + response.applied.len() + )); + } + Ok(true) +} + +/// `atomic stash pop` composes ApplyStash then DropStash (the wire's +/// reserved field 4 documents exactly this client-side composition). +pub fn stash_pop(stash: Option) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let stash_id = match stash { + Some(stash) => stash, + // Pop's default target is stash@{0}: resolve it through the list. + None => { + let stashes = session + .list_stashes(pb::ListStashesRequest { + repository: Some(session.reference.clone()), + })? + .stashes; + match stashes.first() { + Some(first) => first.id.clone(), + None => { + return Err(CliError::InvalidArgument { + message: "No stashes found".to_string(), + }) + } + } + } + }; + stash_apply(Some(stash_id.clone()))?; + let request = pb::DropStashRequest { + repository: Some(session.reference.clone()), + meta: None, + stash_id: Some(stash_id.clone()), + all: false, + }; + session.drop_stash(request)?; + print_success(&format!("Dropped {stash_id}")); + Ok(true) +} + +/// `atomic stash list` over ListStashes. +pub fn stash_list() -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let stashes = session + .list_stashes(pb::ListStashesRequest { + repository: Some(session.reference.clone()), + })? + .stashes; + if stashes.is_empty() { + println!("No stashes found"); + return Ok(true); + } + for (index, stash) in stashes.iter().enumerate() { + let created = stash + .created_at + .as_ref() + .map(|ts| { + chrono::DateTime::from_timestamp(ts.seconds, ts.nanos as u32) + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + println!( + "stash@{{{index}}}: {} ({created})", + stash.message.as_deref().unwrap_or("WIP") + ); + } + Ok(true) +} + +/// `atomic stash drop` over DropStash (all=true covers `stash clear`, +/// which routes without its confirmation prompt — the prompt is the +/// client's job). +pub fn stash_drop(stash: Option, all: bool) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + // Resolve a bare drop to the newest stash for the display reference. + let (stash_id, display) = match stash { + Some(stash) => (stash.clone(), stash), + None => { + let stashes = list_stashes_raw(&session)?; + match stashes.first() { + Some(first) => (first.id.clone(), "stash@{0}".to_string()), + None => { + return Err(CliError::InvalidArgument { + message: "No stashes found".to_string(), + }) + } + } + } + }; + let request = pb::DropStashRequest { + repository: Some(session.reference.clone()), + meta: None, + stash_id: Some(stash_id), + all, + }; + session.drop_stash(request)?; + print_success(&format!("Dropped {display}")); + Ok(true) +} + +fn list_stashes_raw(session: &Service) -> CliResult> { + let request = pb::ListStashesRequest { + repository: Some(session.reference.clone()), + }; + Ok(session.list_stashes(request)?.stashes) +} + +// --------------------------------------------------------------------------- +// insert (RepositoryMutationService) +// --------------------------------------------------------------------------- + +fn insert_request( + session: &Service, + source: Option, + target_view: Option, + allow_conflicts: bool, + deps: bool, + dry_run: bool, +) -> pb::InsertChangesRequest { + // Dependency closure is the wire's default; the CLI's --deps=false + // escape hatch rides apply_dependencies explicitly (the handler + // applies or skips the closure server-side); dry_run previews the + // plan server-side without inserting. + pb::InsertChangesRequest { + repository: Some(session.reference.clone()), + meta: None, + apply_dependencies: Some(deps), + source, + target_view, + allow_conflicts, + target: None, + expected_snapshot: None, + source_snapshot: None, + dry_run: Some(dry_run), + tag_from_view: None, + } +} + +fn insert_rpc( + session: &Service, + request: pb::InsertChangesRequest, +) -> CliResult { + session.insert_changes(request) +} + +// The wire-carried report the local insert bodies render: the outcome +// reassembled from the response, the conflicted-file entries for the +// inline summary, and the resolved names the leading lines print. + +/// The applied hashes off the response's inserted entries. +fn insert_applied_hashes(response: &pb::InsertChangesResponse) -> Vec { + response + .inserted + .iter() + .filter_map(|change| { + change + .hash + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(Merkle) + }) + .collect() +} + +/// The response's cross-view outcome — the shape the local body's +/// `print_cross_view_outcome` renders (the skipped placeholder hashes +/// exist only for the count the line prints). +fn insert_cross_view_outcome( + response: &pb::InsertChangesResponse, +) -> atomic_repository::CrossViewInsertOutcome { + atomic_repository::CrossViewInsertOutcome { + changes_applied: response.inserted.len(), + applied_hashes: insert_applied_hashes(response), + skipped_hashes: vec![Merkle::ZERO; response.skipped_count as usize], + new_state: response + .new_state + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(Merkle) + .unwrap_or_default(), + sequence: 0, + has_conflicts: response.has_conflicts, + was_dry_run: false, + } +} + +/// The inline conflicted-file entries off the response's listing (the +/// first record's line per file, in listing order). +fn insert_conflict_entries( + response: &pb::InsertChangesResponse, +) -> Vec { + let mut entries: Vec = Vec::new(); + for conflict in &response.conflicts { + if entries.iter().any(|entry| entry.path == conflict.path) { + continue; + } + entries.push(super::insert::ConflictSummaryEntry { + path: conflict.path.clone(), + line: conflict.line.map(|line| line as u32), + }); + } + entries +} + +/// The local materialization report line + inline conflict summary over +/// the wire-carried counts — rendered with the same output functions the +/// local bodies use (the view/tag bodies finish a spinner; the +/// single/multi bodies print success), byte-identical by construction. +fn print_insert_refresh(response: &pb::InsertChangesResponse, via_spinner: bool) { + if let (Some(files), Some(directories)) = (response.files_updated, response.directories_created) + { + if via_spinner { + let spinner = crate::output::create_spinner("Materializing files for view..."); + crate::output::finish_success( + &spinner, + &format!("{files} files updated, {directories} directories"), + ); + } else { + print_success(&format!("{files} files updated, {directories} directories")); + } + super::insert::print_conflict_summary_entries(&insert_conflict_entries(response)); + } +} + +/// `atomic insert ` over InsertChanges — the raw reference rides the +/// single-ref arm (the handler resolves it with the local resolver's +/// semantics); both --deps forms (the escape hatch rides +/// apply_dependencies). Rendered with the local single-insert body's +/// exact print functions. +pub fn insert_single( + change: &str, + view: Option, + allow_conflicts: bool, + deps: bool, +) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = insert_request( + &session, + Some(pb::insert_changes_request::Source::SingleRef( + change.to_string(), + )), + view, + allow_conflicts, + deps, + false, + ); + let response = insert_rpc(&session, request)?; + let hash = response + .resolved_change + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(Merkle); + if let Some(hash) = hash { + print_info(&format!( + "Inserting change {}...", + crate::commands::format_hash(&hash, true) + )); + } + let applied = insert_applied_hashes(&response); + let new_state = response + .new_state + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(Merkle) + .unwrap_or_default(); + super::insert::print_insert_outcome(&applied, new_state, response.has_conflicts); + print_insert_refresh(&response, false); + Ok(true) +} + +/// `atomic insert view [--to ] [-n]` over InsertChanges — +/// both --deps forms and the dry-run preview (the handler computes the +/// plan with the same domain call the local body makes). Rendered with +/// the local view-insert body's exact print functions. +pub fn insert_from_view( + source: &str, + target: Option, + allow_conflicts: bool, + deps: bool, + dry_run: bool, +) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = insert_request( + &session, + Some(pb::insert_changes_request::Source::FromView( + source.to_string(), + )), + target, + allow_conflicts, + deps, + dry_run, + ); + let response = insert_rpc(&session, request)?; + let resolved_target = response.resolved_target_view.clone().unwrap_or_default(); + print_info(&format!( + "Inserting changes from '{source}' to '{resolved_target}'..." + )); + let outcome = insert_cross_view_outcome(&response); + super::insert::print_cross_view_outcome(&outcome, dry_run); + if !dry_run { + print_insert_refresh(&response, true); + } + Ok(true) +} + +/// `atomic insert tag [--from-view ] [--to ] [-n]` +/// over InsertChanges — the --from-view override rides tag_from_view (the +/// handler composes the same cross-view options the local body makes), +/// both --deps forms and the dry-run preview ride the request. Rendered +/// with the local tag body's exact print functions. +pub fn insert_up_to_tag( + tag: &str, + from_view: Option, + target: Option, + allow_conflicts: bool, + deps: bool, + dry_run: bool, +) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let mut request = insert_request( + &session, + Some(pb::insert_changes_request::Source::UpToTag(tag.to_string())), + target, + allow_conflicts, + deps, + dry_run, + ); + request.tag_from_view = from_view; + let response = insert_rpc(&session, request)?; + let from_view = response + .resolved_source_view + .clone() + .unwrap_or_else(|| "dev".to_string()); + let resolved_target = response.resolved_target_view.clone().unwrap_or_default(); + print_info(&format!( + "Inserting changes up to tag '{tag}' from '{from_view}' to '{resolved_target}'..." + )); + let outcome = insert_cross_view_outcome(&response); + super::insert::print_cross_view_outcome(&outcome, dry_run); + if !dry_run { + print_insert_refresh(&response, true); + } + Ok(true) +} + +/// `atomic insert change ...` (multi-pick) over InsertChanges — every +/// reference rides the change-set arm raw (the handler resolves each with +/// the local resolver's semantics and applies them with the cherry-pick +/// call the local body makes). Rendered with the local multi-insert +/// body's exact print functions. +pub fn insert_changes( + changes: &[String], + target: Option, + allow_conflicts: bool, +) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let set = pb::ChangeSet { + changes: changes.to_vec(), + }; + let request = insert_request( + &session, + Some(pb::insert_changes_request::Source::ChangeSet(set)), + target, + allow_conflicts, + true, + false, + ); + let response = insert_rpc(&session, request)?; + let resolved_target = response.resolved_target_view.clone().unwrap_or_default(); + print_info(&format!( + "Cherry-picking {} change(s) to '{resolved_target}'...", + changes.len() + )); + let outcome = insert_cross_view_outcome(&response); + super::insert::print_cross_view_outcome(&outcome, false); + print_insert_refresh(&response, false); + Ok(true) +} + +/// The bare `atomic insert` promotion over InsertChanges — the promote +/// arm resolves the current view, its parent, and the missing set +/// server-side (the CLI holds no repository handle). The dry-run form +/// renders the local preview from the pre-flight response; the real form +/// pre-flights, runs the shared→shared confirmation client-side, then +/// inserts through the same arm. Rendered with the local body's exact +/// print functions. +pub fn insert_promote(args: &super::insert::Insert) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let mut request = insert_request( + &session, + Some(pb::insert_changes_request::Source::PromoteCurrentView( + pb::PromoteCurrentView {}, + )), + args.view.clone(), + args.allow_conflicts, + args.deps, + true, + ); + // The pre-flight read: source, parent, the missing set, and the + // confirm-gate scopes — the same domain reads the local pre-flight + // makes. The request is kept for the insert phase (the same arm + // without the preview flag). + let response = insert_rpc(&session, request.clone())?; + let source = response.resolved_source_view.clone().unwrap_or_default(); + let target = response.resolved_target_view.clone().unwrap_or_default(); + let missing = response.inserted.len(); + if missing == 0 { + print_success(&format!( + "Already even with '{target}' — nothing to insert." + )); + return Ok(true); + } + print_info(&format!( + "Inserting {missing} change(s): {source} → {target}" + )); + if args.dry_run { + // The local dry-run branch: the numbered missing list (full hash + // plus the 50-byte-truncated message), then the dry-run notice. + println!(); + for (i, change) in response.inserted.iter().enumerate() { + let hash = short_hash_full(&change.hash); + match change.message.as_deref() { + Some(message) if !message.is_empty() => println!( + " {}. {} {}", + i + 1, + hash, + crate::output::truncate_bytes(message, 50) + ), + _ => println!(" {}. {}", i + 1, hash), + } + } + println!(); + print_info("Dry run: no changes inserted. Re-run without --dry-run to insert."); + return Ok(true); + } + // The shared→shared gate: the prompt runs client-side (interactive); + // the wire carries the scopes the local pre-flight reads. + if response.both_views_shared.unwrap_or(false) && !args.confirm { + let prompt = format!( + "Insert {missing} change(s) from shared view '{source}' into shared view '{target}'?" + ); + let confirmed = dialoguer::Confirm::new() + .with_prompt(&prompt) + .default(false) + .interact() + .map_err(|_| CliError::InvalidArgument { + message: "Refusing to insert between two shared views without \ + confirmation. Re-run with --confirm to proceed \ + non-interactively." + .to_string(), + })?; + if !confirmed { + print_info("Aborted."); + return Ok(true); + } + } + // The insert itself: the same promote arm, now without the preview + // flag — the handler runs the same domain call the local body makes + // after its confirmation. + request.dry_run = Some(false); + let response = insert_rpc(&session, request)?; + let outcome = insert_cross_view_outcome(&response); + super::insert::print_cross_view_outcome(&outcome, false); + Ok(true) +} + +/// A wire hash in the full base32 form the local listings print +/// (`format_hash(hash, true)`). +fn short_hash_full(hash: &Option) -> String { + hash.as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(|bytes: [u8; 32]| Merkle(bytes).to_base32()) + .unwrap_or_else(|| "-".to_string()) +} + +/// `atomic view split` over SplitView. Change specs and --last N resolve +/// through the daemon's Log RPC; --switch composes SwitchView after. +/// Returns Ok(false) when the call should fall back to the local path. +pub fn view_split(args: &super::view::Split) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let from_view = args.from.clone().unwrap_or_else(|| { + std::fs::read_to_string( + crate::commands::find_repository_root() + .expect("session resolved a repository") + .join(".atomic") + .join("current_view"), + ) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "dev".to_string()) + }); + + // Resolve the change set through the daemon's own Log: newest-first + // entries carry the full hashes (--last takes the first N of that + // list; prefixes match against the base32 form). + let entries = log_entries(&session, &from_view)?; + let mut hashes: Vec<[u8; 32]> = Vec::new(); + if let Some(n) = args.last { + if n == 0 { + return Err(CliError::InvalidArgument { + message: "--last must be greater than zero".to_string(), + }); + } + if entries.len() < n { + return Err(CliError::InvalidArgument { + message: format!( + "view '{from_view}' has only {} change(s); cannot split the last {n}", + entries.len() + ), + }); + } + hashes.extend(entries.iter().take(n).filter_map(|e| { + e.hash.as_ref().and_then(|h| { + let bytes: [u8; 32] = h.value.clone().try_into().ok()?; + Some(bytes) + }) + })); + } else { + if args.changes.is_empty() { + return Err(CliError::InvalidArgument { + message: "specify one or more changes to split, or use --last ".to_string(), + }); + } + for spec in &args.changes { + let hash = if let Some(found) = entries.iter().find(|e| { + e.hash + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(|bytes: [u8; 32]| Merkle(bytes).to_base32().starts_with(spec)) + .unwrap_or(false) + }) { + let bytes: [u8; 32] = found + .hash + .as_ref() + .expect("matched entry has a hash") + .value + .clone() + .try_into() + .map_err(|_| CliError::Internal(anyhow::anyhow!("malformed hash")))?; + bytes + } else { + return Err(CliError::ChangeNotFound { hash: spec.clone() }); + }; + hashes.push(hash); + } + } + + let request = pb::SplitViewRequest { + repository: Some(session.reference.clone()), + meta: None, + dry_run: args.dry_run, + name: args.name.clone(), + from_view, + changes: hashes + .into_iter() + .map(|bytes| pb::Hash { + value: bytes.to_vec(), + algorithm: pb::HashAlgorithm::Blake3 as i32, + }) + .collect(), + cascade: args.cascade, + // When switching, the draft is materialized by the switch below; + // otherwise the daemon reconciles the source's working copy. + materialize: !args.switch, + }; + let from_view_display = request.from_view.clone(); + let response = session.split_view(request)?; + + let moved_list = |items: &[pb::SplitChangeInfo]| -> Vec { + items + .iter() + .filter_map(|c| { + c.hash + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(|bytes: [u8; 32]| Merkle(bytes).to_base32()[..12].to_string()) + }) + .collect() + }; + if response.dry_run { + print_success(&format!( + "Would split {} change(s) out of {} into draft {}", + response.moved.len(), + from_view_display, + args.name + )); + } else { + print_success(&format!( + "Split {} change(s) out of {} into draft {}", + response.moved.len(), + from_view_display, + args.name + )); + } + let moved = moved_list(&response.moved); + for hash in &moved { + println!(" moved {hash}"); + } + print_info(&format!( + "'{}' now has {} change(s); draft '{}' has {} own change(s).", + from_view_display, response.source_change_count, args.name, response.target_change_count + )); + if response.working_copy_updated && (response.files_written > 0 || response.files_removed > 0) { + print_info(&format!( + "Working copy updated: {} file(s) refreshed, {} removed.", + response.files_written, response.files_removed + )); + } + if args.switch { + view_switch_plain(&session, &args.name)?; + } + Ok(true) +} + +fn log_entries(session: &Service, view: &str) -> CliResult> { + let request = pb::LogRequest { + repository: Some(session.reference.clone()), + all_views: false, + view: if view.is_empty() { + None + } else { + Some(view.to_string()) + }, + cursor: None, + budget: None, + path_filter: Vec::new(), + tags_only: false, + }; + Ok(session.log(request)?.entries) +} + +/// `atomic revise` over Revise — every form. The reference (@/@~N/hash +/// prefix) resolves through the daemon's Log exactly as the local +/// `resolve_reference` does (newest-first: "@" is the last change, +/// "@~N" N back, a prefix case-insensitively with an ambiguity +/// refusal). The message composes client-side — an explicit `-m`, or +/// the SAME editor flow the local body runs, seeded with the change's +/// current message from the wire — and the author parses client-side. +/// `--reword` rides the reword arm; the content-modification mode rides +/// the content arm (the stack surgery — unrecord → record from the +/// working copy → re-apply — is the domain's, applied atomically); +/// `--dry-run` renders the same preview the local body prints, over the +/// wire's log entries. +pub fn revise(args: &super::revise::Revise) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + + // Resolve the reference through the daemon's Log (newest first) to + // the full hash plus the change's current message — the editor's + // seed — and its sequence. + let parsed = super::revise::ChangeRef::parse(&args.reference); + let entries = log_entries(&session, "")?; + let target = match &parsed { + super::revise::ChangeRef::Last => { + if entries.is_empty() { + return Err(CliError::Internal(anyhow::anyhow!( + "Cannot revise: view '{}' is empty", + current_view_name()? + ))); + } + entries.first().cloned() + } + super::revise::ChangeRef::Relative(offset) => { + let offset = *offset as usize; + match entries.get(offset) { + Some(entry) => Some(entry.clone()), + None => { + return Err(CliError::Internal(anyhow::anyhow!( + "Reference @~{} is out of range (view has {} changes)", + offset, + entries.len() + ))) + } + } + } + super::revise::ChangeRef::Hash(prefix) => { + let prefix_lower = prefix.to_lowercase(); + let matches: Vec<_> = entries + .iter() + .filter(|e| { + e.hash + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(|bytes: [u8; 32]| { + Merkle(bytes) + .to_base32() + .to_lowercase() + .starts_with(&prefix_lower) + }) + .unwrap_or(false) + }) + .collect(); + match matches.as_slice() { + [] => { + return Err(CliError::ChangeNotFound { + hash: prefix.to_string(), + }) + } + [single] => Some((*single).clone()), + _ => { + return Err(CliError::AmbiguousHash { + hash: prefix.to_string(), + }) + } + } + } + }; + let Some(entry) = target else { + return Err(CliError::Internal(anyhow::anyhow!( + "the log entry carries no hash" + ))); + }; + let (target_hash, original_message, sequence) = ( + entry.hash.clone(), + entry.message.clone().unwrap_or_default(), + entry.sequence, + ); + let Some(target_hash) = target_hash else { + return Err(CliError::Internal(anyhow::anyhow!( + "the log entry carries no hash" + ))); + }; + + // Dry-run: the same preview the local body prints (the entries to + // unrecord, the target marker, the re-application count), over the + // wire's log entries. + if args.dry_run { + println!( + "Would revise change {} (sequence #{})", + full_hash(&Some(target_hash.clone())), + sequence + ); + println!(); + let changes_to_unrecord = entries.len().saturating_sub(sequence as usize); + if changes_to_unrecord > 1 { + println!( + "This will temporarily unrecord {} changes:", + changes_to_unrecord + ); + for e in entries.iter().filter(|e| e.sequence >= sequence) { + let marker = if e.sequence == sequence { + " (target)" + } else { + "" + }; + println!(" #{}: {}{}", e.sequence, full_hash(&e.hash), marker); + } + println!(); + println!( + "After revision, {} changes will be re-applied.", + changes_to_unrecord - 1 + ); + } else { + println!("This is the last change - no re-application needed."); + } + if args.reword { + println!(); + println!("Mode: --reword (only change message, preserve file changes)"); + } + return Ok(true); + } + + // The message composes with the local body's own flow: -m wins, else + // the editor (seeded with the current message), else the original. + let message = args.get_message(&original_message)?; + let author = args + .parse_author() + .map(|(name, email)| pb::Author { name, email }); + + // The content-modification mode's gate: nothing to re-capture. + if !args.reword { + let status = session.status(pb::StatusRequest { + repository: Some(session.reference.clone()), + })?; + let clean = status.files.iter().all(|file| { + !matches!( + pb::FileStatus::try_from(file.status), + Ok(pb::FileStatus::Modified) + | Ok(pb::FileStatus::Deleted) + | Ok(pb::FileStatus::Added) + | Ok(pb::FileStatus::Conflicted) + ) + }); + if clean && args.message.is_none() { + print_warning("No changes to revise. Use --reword to only change the message."); + return Ok(true); + } + } + + let request = pb::ReviseRequest { + repository: Some(session.reference.clone()), + meta: None, + target: Some(pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Hash(target_hash.clone())), + }), + reword_message: args.reword.then(|| message.clone()), + author: author.clone(), + content: if args.reword { + None + } else { + Some(pb::ContentRevise { + message, + author, + paths: args + .files + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(), + }) + }, + expected: None, + }; + // The surgery hints, per mode — the local bodies' exact behavior: + // the content mode narrates the unrecord/re-apply steps; the reword + // form performs the same surgery silently and reports the re-applied + // pending changes. + let above = entries.len().saturating_sub(sequence as usize + 1); + println!( + "Revising change {} (sequence #{})...", + parsed.description(), + sequence + ); + if !args.reword && above > 0 { + print_hint(&format!( + "Temporarily unrecording {} changes after target...", + above + )); + } + let response = session.revise(request)?; + let change = response.change.expect("revised change info"); + if args.reword { + if above > 0 { + print_hint(&format!("Re-applied {} pending change(s).", above)); + } + } else if above > 0 { + print_hint(&format!("Re-applying {above} changes...")); + } + println!(); + print_success(&format!( + "Revised {} → {}", + full_hash(&Some(target_hash)), + full_hash(&change.hash) + )); + Ok(true) +} + +/// The current view name, from the working-copy marker file (a client-side +/// read — the same default every local command uses). +fn current_view_name() -> CliResult { + Ok(std::fs::read_to_string( + crate::commands::find_repository_root()? + .join(".atomic") + .join("current_view"), + ) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "dev".to_string())) +} + +// --------------------------------------------------------------------------- +// preview reads (RepositoryQueryService) +// --------------------------------------------------------------------------- + +/// `atomic conflicts` over ListConflicts — render parity: kind, line, +/// sides ride the add-only ConflictInfo fields. +pub fn conflicts(short: bool) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::ListConflictsRequest { + repository: Some(session.reference.clone()), + }; + let response = session.list_conflicts(request)?; + + if short { + for conflict in &response.conflicts { + let line = conflict + .line + .map(|l| l.to_string()) + .unwrap_or_else(|| "-".into()); + println!( + "{}:{}:{}", + conflict.path, + line, + conflict.kind.as_deref().unwrap_or("-") + ); + } + return Ok(true); + } + if response.conflicts.is_empty() { + print_success("No conflicts."); + return Ok(true); + } + let file_word = if response.conflicts.len() == 1 { + "file" + } else { + "files" + }; + // Group by path for the render (the wire is per-record). + let mut by_path: std::collections::BTreeMap<&str, Vec<&pb::ConflictInfo>> = + std::collections::BTreeMap::new(); + for conflict in &response.conflicts { + by_path.entry(&conflict.path).or_default().push(conflict); + } + println!( + "{}", + crate::output::warning(&format!("{} conflicted {}:", by_path.len(), file_word)) + ); + println!(); + for (path, records) in &by_path { + println!("\t{path}"); + for record in records { + let where_ = match record.line { + Some(line) => format!("line {line}"), + None => "unknown line".to_string(), + }; + let kind = record.kind.as_deref().unwrap_or("conflict"); + if record.sides.is_empty() { + println!("\t {kind} conflict at {where_}"); + } else { + let sides = record + .sides + .iter() + .map(|h| h.chars().take(12).collect::()) + .collect::>() + .join(", "); + println!("\t {kind} conflict at {where_} between {sides}"); + } + } + } + println!(); + print_hint("Resolve the markers (>>>>>>> / ======= / <<<<<<<), then run 'atomic record'."); + Ok(true) +} + +/// The shared render for the restore previews — the local listing +/// branch's exact lines over the wire-carried classification. +fn render_restore_preview(response: &pb::PreviewMutationResponse, partial: bool) { + if response.affected_paths.is_empty() && response.untracked.is_empty() { + // The local body's partial/whole split: naming paths must not + // claim the whole working copy is clean. + println!( + "{}", + if partial { + "Nothing to restore for the specified path(s)" + } else { + "Nothing to restore - working copy is clean" + } + ); + return; + } + for path in &response.affected_paths { + println!("Would restore: {path}"); + } + for path in &response.untracked { + println!("Would untrack: {path} (kept on disk)"); + } + println!(); + let total = response.affected_paths.len() + response.untracked.len(); + print_hint(&format!( + "(dry run - {} would be restored)", + super::restore::format_count(total, "file") + )); +} + +/// `atomic restore --dry-run` (the listing form) over PreviewRestore — +/// the would-restore / would-untrack split rides the response. +pub fn restore_preview(paths: Vec) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let partial = !paths.is_empty(); + let request = pb::PreviewRestoreRequest { + repository: Some(session.reference.clone()), + paths, + force: false, + content_path: None, + }; + let response = session.preview_restore(request)?; + render_restore_preview(&response, partial); + Ok(true) +} + +/// `atomic restore --dry-run ` (the single-file form) over +/// PreviewRestore's content arm — the handler reads the file's pristine +/// bytes with the same domain call the local body makes; the CLI dumps +/// them to stdout byte-for-byte (no extra output). An Added file previews +/// the untrack instead (the local listing branch), and no pristine +/// content is the local FileNotFound error. +pub fn restore_single_dry_run(path: &str) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::PreviewRestoreRequest { + repository: Some(session.reference.clone()), + paths: vec![path.to_string()], + force: false, + content_path: Some(path.to_string()), + }; + let response = session.preview_restore(request)?; + if !response.untracked.is_empty() { + // The single Added file: the local listing branch's lines. + render_restore_preview(&response, true); + return Ok(true); + } + match response.pristine_content { + Some(bytes) => { + use std::io::Write; + std::io::stdout() + .write_all(&bytes) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to write: {}", e)))?; + Ok(true) + } + None => Err(CliError::FileNotFound { + path: PathBuf::from(path), + }), + } +} + +/// `atomic insert preview` over PreviewMutation (InsertPreview). +pub fn insert_preview( + from_view: &str, + to_view: Option, + up_to_tag: Option, +) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let to_view = to_view.unwrap_or_else(|| { + std::fs::read_to_string( + crate::commands::find_repository_root() + .unwrap_or_default() + .join(".atomic") + .join("current_view"), + ) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "dev".to_string()) + }); + let insert = pb::InsertPreview { + source: Some(match up_to_tag { + Some(tag) => pb::insert_preview::Source::UpToTag(tag), + None => pb::insert_preview::Source::FromView(from_view.to_string()), + }), + target_view: to_view.clone(), + allow_conflicts: false, + }; + let request = pb::PreviewMutationRequest { + repository: Some(session.reference.clone()), + operation: Some(pb::preview_mutation_request::Operation::Insert(insert)), + }; + let response = session.preview_mutation(request)?; + + println!(" Source view: {from_view}"); + println!(" Target view: {to_view}"); + println!(); + if response.changes.is_empty() { + print_success("No changes to insert - target view is up to date."); + } else { + println!( + "Changes that would be inserted ({}):", + response.changes.len() + ); + println!(); + for (i, change) in response.changes.iter().enumerate() { + let hash = short_hash(&change.hash); + let message = change.message.clone().unwrap_or_default(); + let short_msg: String = message.chars().take(50).collect(); + println!(" {}. {} {}", i + 1, hash, short_msg); + } + println!(); + print_info(&format!( + "Run 'atomic insert view {from_view}' to insert these changes." + )); + } + Ok(true) +} + +/// `atomic unrecord --dry-run` over PreviewMutation (UnrecordPreview). +/// Only the bare form routes (absent = last change); a change argument +/// needs the local resolver's exact semantics — see `unrecord`. +pub fn unrecord_preview(change: Option) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let target = change.map(|prefix| pb::ChangeRef { + kind: Some(pb::change_ref::Kind::Prefix(prefix)), + }); + let request = pb::PreviewMutationRequest { + repository: Some(session.reference.clone()), + operation: Some(pb::preview_mutation_request::Operation::Unrecord( + pb::UnrecordPreview { view: None, target }, + )), + }; + let response = session.preview_mutation(request)?; + for change in &response.changes { + let hash = full_hash(&change.hash); + print_warning(&format!("Would unrecord: {hash}")); + } + Ok(true) +} + +/// Resolve a hash prefix to the full hash through the service layer — +/// Log entries, newest first, the same resolution the in-process +/// `resolve_change` performs. +fn resolve_change_hash(session: &Service, prefix: &str) -> CliResult { + let request = pb::LogRequest { + repository: Some(session.reference.clone()), + all_views: false, + view: None, + cursor: None, + budget: Some(pb::PageBudget { + max_items: Some(200), + max_bytes: None, + }), + path_filter: Vec::new(), + tags_only: false, + }; + let entries = session.log(request)?.entries; + let matches: Vec<_> = entries + .iter() + .filter(|entry| { + entry + .hash + .as_ref() + .map(|h| { + h.value + .clone() + .try_into() + .map(|bytes: [u8; 32]| Merkle(bytes).to_base32().starts_with(prefix)) + .unwrap_or(false) + }) + .unwrap_or(false) + }) + .collect(); + match matches.len() { + 0 => Err(CliError::InvalidArgument { + message: format!("No change found matching '{prefix}'"), + }), + 1 => { + let bytes: [u8; 32] = matches[0] + .hash + .as_ref() + .expect("matched entry has a hash") + .value + .clone() + .try_into() + .map_err(|_| CliError::Internal(anyhow::anyhow!("malformed hash")))?; + Ok(Merkle(bytes)) + } + _ => Err(CliError::InvalidArgument { + message: format!("Ambiguous change prefix '{prefix}'"), + }), + } +} + +// --------------------------------------------------------------------------- +// provenance trace/show (ProvenanceService.ExportProvenance) +// --------------------------------------------------------------------------- + +/// One explaining graph over the wire: the domain graph, the mapped +/// projector input, and the pre-walked prior-turn chain the human trace +/// renders. +pub(crate) struct ProvenanceWireGraph { + pub graph: atomic_core::change::ProvenanceGraph, + pub input: atomic_canonical::prov::ProvActivityInput, + pub prior_activities: Vec, + pub chain_truncated: bool, +} + +/// The wire's ProvActivityInput bundle (schema "atomic.prov.input.v1") → +/// the plain projector input — the same field-for-field shape the handler +/// serialized. +fn prov_input_domain(payload: &[u8]) -> CliResult { + let value: serde_json::Value = serde_json::from_slice(payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("prov input bundle: {error}")))?; + let field = |name: &str| -> CliResult> { + match &value[name] { + serde_json::Value::Null => Ok(None), + serde_json::Value::String(text) => Ok(Some(text.clone())), + other => Err(CliError::Internal(anyhow::anyhow!( + "prov input field {name}: unexpected {other}" + ))), + } + }; + Ok(atomic_canonical::prov::ProvActivityInput { + change_id_base32: field("change_id_base32")?.unwrap_or_default(), + activity_id: field("activity_id")?.unwrap_or_default(), + started_at: field("started_at")?, + ended_at: field("ended_at")?, + agent_slug: field("agent_slug")?.unwrap_or_default(), + agent_display_name: field("agent_display_name")?.unwrap_or_default(), + agent_vendor: field("agent_vendor")?, + person_did: field("person_did")?.unwrap_or_default(), + generated: value["generated"] + .as_array() + .map(|items| { + items + .iter() + .map(|item| item.as_str().unwrap_or_default().to_string()) + .collect() + }) + .unwrap_or_default(), + used: value["used"] + .as_array() + .map(|items| { + items + .iter() + .map(|item| item.as_str().unwrap_or_default().to_string()) + .collect() + }) + .unwrap_or_default(), + turn_parent: field("turn_parent")?, + }) +} + +/// `atomic provenance trace/show ` over ExportProvenance — the +/// handler resolves the target (URN/prefix/hash), loads the explaining +/// graphs (newest first), maps each to the projection input, and pre-walks +/// the prior-turn chain; the CLI projects (and signs — the identity store +/// is a client-side config read) with the SAME canonical functions. +/// `Ok(None)` = outside a repository (the not-a-repository fallthrough). +pub(crate) fn provenance_export( + target: &str, + person_did: &str, +) -> CliResult)>> { + let Some(session) = Service::open()? else { + return Ok(None); + }; + Ok(Some(provenance_export_with_session( + &session, target, person_did, + )?)) +} + +fn provenance_export_with_session( + session: &Service, + target: &str, + person_did: &str, +) -> CliResult<(Merkle, Vec)> { + let request = pb::ExportProvenanceRequest { + repository: Some(session.reference.clone()), + change: None, + view: None, + person_did: Some(person_did.to_string()), + target: Some(target.to_string()), + }; + let response = session.export_provenance(request)?; + let resolved = response + .resolved_change + .as_ref() + .and_then(|hash| hash.value.clone().try_into().ok()) + .map(Merkle) + .ok_or_else(|| CliError::Internal(anyhow::anyhow!("no resolved change")))?; + let mut graphs = Vec::with_capacity(response.graphs.len()); + for bundle in &response.graphs { + let graph_payload = bundle + .graph + .as_ref() + .filter(|bytes| bytes.schema == "atomic.prov.graph.v1") + .ok_or_else(|| { + CliError::Internal(anyhow::anyhow!("missing provenance graph bundle")) + })?; + let graph: atomic_core::change::ProvenanceGraph = + serde_json::from_slice(&graph_payload.payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("graph bundle: {error}")))?; + let input_payload = bundle + .input + .as_ref() + .filter(|bytes| bytes.schema == "atomic.prov.input.v1") + .ok_or_else(|| CliError::Internal(anyhow::anyhow!("missing prov input bundle")))?; + graphs.push(ProvenanceWireGraph { + graph, + input: prov_input_domain(&input_payload.payload)?, + prior_activities: bundle.prior_activities.clone(), + chain_truncated: bundle.chain_truncated, + }); + } + Ok((resolved, graphs)) +} + +// --------------------------------------------------------------------------- +// remote registry (SyncService) +// --------------------------------------------------------------------------- + +/// `atomic remote` over ListRemotes/ManageRemotes — every subcommand. The +/// registry lives in the repository database; the handler runs the exact +/// domain call each local body makes (add/add-default/remove/set-url/ +/// rename/set-default) and the CLI renders the local reports. +pub fn remote(args: &super::remote::Remote) -> CliResult { + use super::remote::RemoteSubcommand; + // The URL forms validate client-side (pure argument validation, the + // same messages the local bodies raise before any repository work). + let validate_url = |url: &str| -> CliResult<()> { + if !url.contains("://") { + return Err(CliError::InvalidArgument { + message: format!( + "Invalid URL '{}': URL must include a scheme (e.g., https://)", + url + ), + }); + } + Ok(()) + }; + match &args.command { + None => { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let response = session.list_remotes(pb::ListRemotesRequest { + repository: Some(session.reference.clone()), + })?; + if response.remotes.is_empty() { + print_hint( + "No remotes configured. Use 'atomic remote add ' to add one.", + ); + return Ok(true); + } + for remote in &response.remotes { + if args.verbose { + let default_marker = if remote.default { " (default)" } else { "" }; + println!("{}\t{}{}", remote.name, remote.url, default_marker); + } else { + println!("{}", remote.name); + } + } + Ok(true) + } + Some(RemoteSubcommand::Add(add)) => { + validate_url(&add.url)?; + let Some(session) = Service::open()? else { + return Ok(false); + }; + session.manage_remotes(pb::ManageRemotesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::RemoteAction::Add as i32, + name: Some(add.name.clone()), + url: Some(add.url.clone()), + new_name: None, + set_default: add.default, + })?; + print_success(&format!("Remote '{}' added", add.name)); + if args.verbose { + println!(" URL: {}", add.url); + } + Ok(true) + } + Some(RemoteSubcommand::Remove(remove)) => { + let Some(session) = Service::open()? else { + return Ok(false); + }; + session.manage_remotes(pb::ManageRemotesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::RemoteAction::Remove as i32, + name: Some(remove.name.clone()), + url: None, + new_name: None, + set_default: false, + })?; + print_success(&format!("Remote '{}' removed", remove.name)); + Ok(true) + } + Some(RemoteSubcommand::SetUrl(set_url)) => { + validate_url(&set_url.url)?; + let Some(session) = Service::open()? else { + return Ok(false); + }; + session.manage_remotes(pb::ManageRemotesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::RemoteAction::SetUrl as i32, + name: Some(set_url.name.clone()), + url: Some(set_url.url.clone()), + new_name: None, + set_default: false, + })?; + print_success(&format!("Remote '{}' URL updated", set_url.name)); + if args.verbose { + println!(" New URL: {}", set_url.url); + } + Ok(true) + } + Some(RemoteSubcommand::Rename(rename)) => { + let Some(session) = Service::open()? else { + return Ok(false); + }; + session.manage_remotes(pb::ManageRemotesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::RemoteAction::Rename as i32, + name: Some(rename.old_name.clone()), + url: None, + new_name: Some(rename.new_name.clone()), + set_default: false, + })?; + print_success(&format!( + "Remote '{}' renamed to '{}'", + rename.old_name, rename.new_name + )); + Ok(true) + } + Some(RemoteSubcommand::Default(default)) => { + let Some(session) = Service::open()? else { + return Ok(false); + }; + session.manage_remotes(pb::ManageRemotesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::RemoteAction::SetDefault as i32, + name: Some(default.name.clone()), + url: None, + new_name: None, + set_default: false, + })?; + print_success(&format!("Remote '{}' set as default", default.name)); + Ok(true) + } + } +} + +// --------------------------------------------------------------------------- +// push / pull (SyncService) — the network sync is handler-side domain; +// auth stays client-side (the identity store is a config read, not redb) +// --------------------------------------------------------------------------- + +/// Resolve the remote name + URL through the service layer's ListRemotes +/// read — the same domain lookup the local bodies perform over the +/// repository, with the same default (the configured default remote, else +/// "origin") and the same not-found error. +fn sync_resolve_remote(session: &Service, remote: &Option) -> CliResult<(String, String)> { + let response = session.list_remotes(pb::ListRemotesRequest { + repository: Some(session.reference.clone()), + })?; + let name = match remote { + Some(name) if !name.is_empty() => name.clone(), + _ => response + .remotes + .iter() + .find(|remote| remote.default) + .map(|remote| remote.name.clone()) + .unwrap_or_else(|| "origin".to_string()), + }; + if name.contains("://") { + return Ok((name.clone(), name)); + } + match response.remotes.iter().find(|remote| remote.name == name) { + Some(entry) => Ok((name, entry.url.clone())), + None => Err(CliError::RemoteNotFound { name }), + } +} + +/// The client-side auth headers: the SAME attach_identity resolution the +/// local bodies run (identity store + config reads + token minting — none +/// of it redb), extracted from the built config. +fn sync_auth_headers(remote_url: &str, identity: Option<&str>) -> HashMap { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create async runtime: {}", e))) + .ok(); + let Some(rt) = rt else { return HashMap::new() }; + let config = rt.block_on(crate::commands::auth::attach_identity( + atomic_remote::HttpRemoteConfig::new(), + remote_url, + identity, + )); + config.extra_headers.into_iter().collect() +} + +/// Map the handler's structured sync error (stage + variant + the local +/// body's composed message) back to the exact CLI error the local body +/// raised, printing the same spinner-stage line. +fn sync_error_map(error: CliError) -> CliError { + let CliError::ServiceRefusal { message } = &error else { + return error; + }; + let Some(payload) = message.strip_prefix("atomic:REMOTE: ") else { + return error; + }; + let Ok(value) = serde_json::from_str::(payload) else { + return error; + }; + let (Some(stage), Some(variant), Some(url), Some(text)) = ( + value.get("stage").and_then(|v| v.as_str()), + value.get("variant").and_then(|v| v.as_str()), + value.get("url").and_then(|v| v.as_str()), + value.get("message").and_then(|v| v.as_str()), + ) else { + return error; + }; + // The spinner-stage line the local body printed via finish_error. + let stage_line = match stage { + "connect" => Some("Failed to connect"), + "advertise" => Some("Failed to fetch remote metadata"), + "send" => Some("Push failed"), + "verify" => Some("Push landed, but remote verification failed"), + "verify-patch" => Some("Push landed, but remote union is incomplete"), + "fetch" => Some("Failed to fetch remote view"), + "fetch-missing" => Some("Remote view not found"), + _ => None, + }; + if let Some(line) = stage_line { + eprintln!("✗ {line}"); + } + match variant { + "remote_error" => CliError::RemoteError { + message: text.to_string(), + url: Some(url.to_string()), + }, + "authentication_failed" => CliError::AuthenticationFailed { + remote: url.to_string(), + }, + "change_not_found" => CliError::ChangeNotFound { + hash: text.to_string(), + }, + "missing_dependency" => CliError::MissingDependency { + change: "uploaded change".to_string(), + dependency: text.to_string(), + }, + "conflict" => CliError::Conflict { + description: text.to_string(), + }, + _ => error, + } +} + +use std::collections::HashMap; + +/// A hidden-spinner stage line (the local body's finish_success prints to +/// stderr when the progress UI is hidden — e.g. piped test runs). +fn stage_line(message: &str) { + eprintln!("✓ {message}"); +} + +/// One manifest render state for the divergence block — the local +/// `format_manifest_state` over wire-carried data. +fn manifest_state_text(state: &Option) -> String { + match state { + Some(state) if state.change_count > 0 => { + format!( + "{} ({} changes)", + &state.state[..12.min(state.state.len())], + state.change_count + ) + } + _ => "(empty)".to_string(), + } +} + +/// `atomic push` over PushChanges — every form. The handler performs the +/// network sync end to end and returns the structured report; the CLI +/// renders the local push body's exact lines (the client-side pieces are +/// the remote-name resolution read, the credential check, and the auth +/// header resolution — all config/identity reads, never redb). +pub fn push(args: &super::push::Push) -> CliResult { + use crate::output::{error as print_error_fn, hint, view as style_view}; + use crate::output::{hash as style_hash, success as style_success}; + let Some(session) = Service::open()? else { + return Ok(false); + }; + let (remote_name, remote_url) = sync_resolve_remote(&session, &args.remote)?; + + // Print header, fail fast on credentials — the local body's order. + println!( + "Pushing to {} ({})", + style_view(&remote_name), + hint(&remote_url) + ); + crate::commands::auth::check_push_credentials(&remote_url, args.identity.as_deref())?; + let auth_headers = sync_auth_headers(&remote_url, args.identity.as_deref()); + stage_line("Connected"); + + let request = pb::PushChangesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + remote: remote_name.clone(), + views: Vec::new(), + expected: None, + to_view: args.to_view.clone(), + from_view: args.from_view.clone(), + dry_run: args.dry_run, + force: args.force, + all: args.all, + insecure: args.insecure, + timeout_secs: args.timeout, + auth_headers: auth_headers.clone(), + remote_url: Some(remote_url.clone()), + }; + let response = match session.push_changes(request) { + Ok(response) => response, + Err(error) => return Err(sync_error_map(error)), + }; + + // The plan-phase conflict: the local divergence/identity block plus + // the local CliError. + if let Some(conflict) = &response.conflict { + print_error_fn(&format!( + "View '{}' has diverged from the remote", + conflict.view + )); + println!(); + if !conflict.identity_mismatch { + if let (Some(local), Some(remote)) = (&conflict.local, &conflict.remote) { + println!( + " Local: {} at {}", + style_view(&local.view), + crate::output::info(&manifest_state_text(&Some(local.clone()))) + ); + println!( + " Remote: {} at {}", + style_view(&remote.view), + crate::output::info(&manifest_state_text(&Some(remote.clone()))) + ); + println!(); + } + print_hint("The remote view's log is not a prefix of your local log."); + print_hint("Use 'atomic pull' to fetch remote changes, or"); + print_hint("Use 'atomic push --force' to attempt the push anyway (server decides)"); + } else { + print_hint(&conflict.description); + print_hint("A view's scope and parent are fixed at creation and cannot be"); + print_hint("changed by a push. Rename the local view or push to a different"); + print_hint("remote view with '--to-view'."); + } + return Err(CliError::Conflict { + description: format!("View '{}': {}", conflict.view, conflict.description), + }); + } + + let Some(report) = response.report else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no push report" + ))); + }; + stage_line("Fetched remote view metadata"); + + // Per-view advertisement lines (the plan order, root → leaf). + for view in &report.views { + println!( + " {} Remote {} {}", + style_success("✓"), + style_view(&view.remote_name), + if view.exists_on_remote { + format!( + "has {}", + super::push::helpers::format_count(view.remote_change_count as usize, "change") + ) + } else { + "does not exist yet".to_string() + } + ); + if view.forced { + print_warning(&format!( + "View '{}' has diverged; pushing anyway (--force). The server may still reject it.", + view.remote_name + )); + } + } + + let format_count = super::push::helpers::format_count; + + if args.dry_run { + let active: Vec<&pb::PushViewReport> = + report.views.iter().filter(|view| !view.noop).collect(); + if active.is_empty() { + print_success("Already up to date - nothing to push"); + return Ok(true); + } + println!( + "Would sync {} to {}:", + format_count(active.len(), "view"), + report.remote_name + ); + println!(); + for view in &active { + let parent = view + .parent + .as_deref() + .map(|parent| format!(", parent {parent}")) + .unwrap_or_default(); + println!( + " {} [{}{}]: {} to store, declare manifest ({} in log)", + style_view(&view.remote_name), + view.scope, + parent, + format_count(view.to_store.len(), "change"), + format_count(view.log_count as usize, "change"), + ); + for change in &view.to_store { + let hash = short_hash(&change.hash); + let message = change.message.clone().unwrap_or_default(); + let message = if message.is_empty() { + "(no message)".to_string() + } else { + message + }; + println!(" {} {}", style_hash(&hash), message); + } + } + println!(); + print_hint(&format!("Remote URL: {}", report.remote_url)); + return Ok(true); + } + + if report.views.iter().all(|view| view.noop) { + print_success("Already up to date"); + return Ok(true); + } + + // Sync phase lines. + for view in &report.views { + if view.noop { + if view.shrink { + print_warning(&format!( + "View '{}' is larger on the remote than locally; leaving it unchanged. \ + Push '{}' directly with --force to rewrite it to your smaller set.", + view.remote_name, view.remote_name + )); + } else { + println!( + " {} {} already up to date", + style_success("✓"), + style_view(&view.remote_name) + ); + } + continue; + } + if !view.to_store.is_empty() { + println!( + "Syncing view {} ({} new):", + style_view(&view.remote_name), + format_count(view.to_store.len(), "change") + ); + for (index, change) in view.to_store.iter().enumerate() { + let hash = short_hash(&change.hash); + let message = change.message.clone().unwrap_or_default(); + let message = if message.is_empty() { + "(no message)".to_string() + } else { + message + }; + println!( + " {} {} ({}/{}) {}", + style_success("✓"), + style_hash(&hash), + index + 1, + view.to_store.len(), + message + ); + } + stage_line(&format!( + "Stored {}", + format_count(view.to_store.len(), "change") + )); + } + if view.declared { + println!( + " {} Declared {} [{}] ({} in log)", + style_success("✓"), + style_view(&view.remote_name), + view.scope, + format_count(view.log_count as usize, "change"), + ); + } + } + + for sidecar in &report.attestations { + println!( + " {} {} attestation ({}, {} covered)", + style_success("✓"), + style_hash(&short_hash(&sidecar.hash)), + sidecar.cost.clone().unwrap_or_default(), + sidecar.covered, + ); + } + for sidecar in &report.provenance { + println!( + " {} {} provenance ({} nodes, {} changes)", + style_success("✓"), + style_hash(&short_hash(&sidecar.hash)), + sidecar.nodes, + sidecar.explained, + ); + } + for tag in &report.tags { + let hash = short_hash(&tag.hash); + println!( + " {} {} tag '{}' ({})", + style_success("✓"), + style_hash(&hash), + tag.name, + tag.kind, + ); + } + + // The single /code push + verification (the stage lines match the + // local body's spinner finishes). + let sent_anything = report.views_declared > 0 + || report.total_stored > 0 + || !report.attestations.is_empty() + || !report.provenance.is_empty() + || !report.tags.is_empty(); + if sent_anything { + stage_line("Push complete; remote union contains proposed patches"); + } + + // Summary. + println!(); + let mut summary = format!( + "Push complete: {} synced ({} stored) to {}", + format_count(report.views_declared as usize, "view"), + format_count(report.total_stored as usize, "change"), + report.remote_name + ); + if !report.attestations.is_empty() { + summary.push_str(&format!( + ", {} synced", + format_count(report.attestations.len(), "attestation") + )); + } + if !report.provenance.is_empty() { + summary.push_str(&format!( + ", {} synced", + format_count(report.provenance.len(), "provenance graph") + )); + } + if !report.tags.is_empty() { + summary.push_str(&format!( + ", {} synced", + format_count(report.tags.len(), "tag") + )); + } + print_success(&summary); + Ok(true) +} + +/// `atomic pull` over PullChanges — every form. Same client-side auth +/// model as push; the handler performs the network sync and the applies, +/// and the CLI renders the local pull body's exact lines. +pub fn pull(args: &super::pull::Pull) -> CliResult { + use crate::output::{hash as style_hash, hint, success as style_success, view as style_view}; + let Some(session) = Service::open()? else { + return Ok(false); + }; + let (remote_name, remote_url) = sync_resolve_remote(&session, &args.remote)?; + + println!( + "Pulling from {} ({})", + style_view(&remote_name), + hint(&remote_url) + ); + let auth_headers = sync_auth_headers(&remote_url, args.identity.as_deref()); + stage_line("Connected"); + + let request = pb::PullChangesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + remote: remote_name.clone(), + import_sidecars: true, + to_view: args.to_view.clone(), + from_view: args.from_view.clone(), + dry_run: args.dry_run, + all: args.all, + insecure: args.insecure, + timeout_secs: args.timeout, + identity: args.identity.clone(), + download_only: args.download_only, + auth_headers: auth_headers.clone(), + remote_url: Some(remote_url.clone()), + }; + let response = match session.pull_changes(request) { + Ok(response) => response, + Err(error) => return Err(sync_error_map(error)), + }; + let Some(report) = response.report else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no pull report" + ))); + }; + let format_count = super::pull::helpers::format_count; + + // The local history + fetch stage lines. + stage_line(&format!( + "Loaded {} local view changes ({} graph objects present)", + report.local_view_changes, report.graph_objects + )); + stage_line(&format!("Got {} remote changes", report.remote_changes)); + + // State comparison. + println!(); + let remote_state_text = match &report.remote_state { + Some(state) if !state.is_empty() => format!( + "at {} ({} {})", + hint(&state[..12.min(state.len())]), + report.remote_changes, + if report.remote_changes == 1 { + "change" + } else { + "changes" + } + ), + _ => hint("(empty)").to_string(), + }; + let local_state_text = match &report.local_tip { + Some(tip) if report.local_view_changes > 0 => format!( + "at {} ({} {})", + hint(&tip[..12.min(tip.len())]), + report.local_view_changes, + if report.local_view_changes == 1 { + "change" + } else { + "changes" + } + ), + _ => hint("(empty)").to_string(), + }; + println!( + " {}: {} {}", + crate::output::info("Remote"), + style_view(&report.remote_view), + remote_state_text + ); + println!( + " {}: {} {}", + crate::output::info("Local"), + style_view(&report.local_view), + local_state_text + ); + println!(); + + // Local-only warning. + if !report.local_only.is_empty() { + println!(); + print_warning(&format!( + "You have {} not on the remote:", + format_count(report.local_only.len(), "local change") + )); + for hash in report.local_only.iter().take(5) { + println!( + " {} {}...", + crate::output::warning("!"), + &hash[..12.min(hash.len())] + ); + } + if report.local_only.len() > 5 { + println!(" ... and {} more", report.local_only.len() - 5); + } + println!(); + print_hint("These changes exist locally but not on the remote."); + print_hint("Use 'atomic push' to upload them, or they will remain local-only."); + } + + // Dry run. + if args.dry_run { + if report.downloads.is_empty() { + print_success("Already up to date - nothing to pull"); + return Ok(true); + } + println!( + "Would pull {} from {} (view: {}):", + format_count(report.downloads.len(), "change"), + report.remote_name, + report.remote_view + ); + println!(); + for change in &report.downloads { + let hash = short_hash(&change.hash); + let message = change.message.clone().unwrap_or_default(); + let message = if message.is_empty() { + "(no message)".to_string() + } else { + message + }; + println!(" {} {}", style_hash(&hash), message); + } + println!(); + print_hint(&format!("Remote URL: {}", report.remote_url)); + return Ok(true); + } + + // Downloads. + if !report.downloads.is_empty() { + println!( + "Downloading {}:", + format_count(report.downloads.len(), "change") + ); + println!(); + for warning in &report.warnings { + print_warning(&warning.text); + } + let _ = &report.warnings; + for download in &report.downloads { + let hash = short_hash(&download.hash); + let message = download.message.clone().unwrap_or_default(); + let message = if message.is_empty() { + "(no message)".to_string() + } else { + message + }; + if download.ok { + println!( + " {} {} ({}/{}) {}", + style_success("✓"), + style_hash(&hash), + download.index + 1, + download.total, + message + ); + } else { + println!( + " {} {} ({}/{}) {} - {}", + crate::output::error("✗"), + style_hash(&hash), + download.index + 1, + download.total, + message, + download.error.clone().unwrap_or_default(), + ); + } + } + stage_line(&format!( + "Downloaded {} ({})", + format_count(report.changes_downloaded as usize, "change"), + super::pull::helpers::format_bytes(report.bytes_transferred) + )); + } + + // Download-only. + if report.download_only { + for sidecar in &report.sidecars { + if sidecar.kind == "provenance" { + println!( + " {} {}", + style_success("✓"), + format_count(sidecar.count as usize, "provenance graph") + ); + } else { + println!( + " {} {}", + style_success("✓"), + format_count(sidecar.count as usize, "attestation") + ); + } + } + println!(); + print_success(&format!( + "Downloaded {} (not inserted - use 'atomic insert' to insert)", + format_count(report.changes_downloaded as usize, "change") + )); + return Ok(true); + } + + // Reconciliation. + println!(); + if report.apply_errors.is_empty() { + stage_line(&format!( + "Reconciled {} view closures", + report.reconciled_views + )); + } else { + eprintln!("✗ View metadata reconciliation failed"); + for warning in &report.apply_errors { + print_warning(warning); + } + } + for sidecar in &report.sidecars { + if sidecar.kind == "provenance" { + println!( + " {} {}", + style_success("✓"), + format_count(sidecar.count as usize, "provenance graph") + ); + } else { + println!( + " {} {}", + style_success("✓"), + format_count(sidecar.count as usize, "attestation") + ); + } + } + for warning in &report.warnings { + print_warning(&warning.text); + } + + if report.applied { + if report.set_id_verified { + print_success("Verified: local view set-id matches the remote (convergent)"); + } + if let Some(warning) = &report.set_id_warning { + print_warning(warning); + } + if report.materialized { + stage_line(&format!("{} files updated", report.files_written)); + } else if let Some(error) = &report.materialize_error { + eprintln!("✗ Failed to update working copy"); + print_warning(&format!( + "Applied {} but failed to update working copy: {}", + format_count(report.applied_changes as usize, "change"), + error + )); + } else { + // A pull into a non-current view: the local hint (no spinner — + // the working copy is untouched). + print_hint(&format!( + "Applied {} to view '{}'. Run 'atomic view switch {}' to check it out.", + format_count(report.applied_changes as usize, "change"), + report.local_view, + report.local_view + )); + } + } + + for tag in &report.tags { + println!(" {} tag '{}' ({})", style_success("✓"), tag.name, tag.kind); + } + if !report.tags.is_empty() { + print_info(&format!( + "Downloaded {}", + format_count(report.tags.len(), "tag") + )); + } + + // Summary. + println!(); + if report.changes_failed > 0 + || !report.apply_errors.is_empty() + || report.materialize_error.is_some() + { + print_warning(&format!( + "Pull completed with errors: {} downloaded, {} failed to download, {} failed to apply", + report.changes_downloaded, + report.changes_failed, + report.apply_errors.len(), + )); + return Err(CliError::Internal(anyhow::anyhow!( + "pull completed with errors ({} download, {} apply{})", + report.changes_failed, + report.apply_errors.len(), + if report.materialize_error.is_some() { + ", working copy not updated" + } else { + "" + }, + ))); + } + print_success(&format!( + "Pull complete: {} downloaded and applied to {}", + format_count(report.changes_downloaded as usize, "change"), + report.local_view + )); + Ok(true) +} + +// --------------------------------------------------------------------------- +// sandbox (SandboxService) — the local working-tree surface +// --------------------------------------------------------------------------- + +/// `atomic sandbox create` over CreateSandboxTree — every form. The +/// handler resolves the destination (the same default), creates the draft +/// when --from names one, provisions the copy-on-write tree, and the CLI +/// prints the local report from the wire-carried outcome. +pub fn sandbox_create(args: &super::sandbox::Create) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::CreateSandboxTreeRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + name: args.name.clone(), + from_view: args.from.clone(), + dest: args.dest.as_ref().map(|dest| dest.display().to_string()), + view: args.view.clone(), + }; + let response = session.create_sandbox_tree(request)?; + println!("Sandbox '{}' created", args.name); + println!(" Working tree: {}", response.resolved_dest); + if response.created_draft { + println!( + " View: {} (new draft from '{}')", + response.view, + args.from.as_deref().unwrap_or("") + ); + } else { + println!(" View: {}", response.view); + } + println!( + " Files cloned: {} (copy-on-write where supported)", + response.files_cloned + ); + println!( + " Graph: shared (canonical {}/.atomic)", + response.repo_root + ); + Ok(true) +} + +/// `atomic sandbox stage` over StageSandboxImage — the base view, output +/// dir, and the staged report all ride the wire. +pub fn sandbox_stage(args: &super::sandbox::Stage) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::StageSandboxImageRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + name: args.view.clone(), + base_view: args.base.clone(), + out_dir: args.out.display().to_string(), + }; + let response = session.stage_sandbox_image(request)?; + println!("Staged '{}' (delta over '{}')", args.view, args.base); + println!(" Image: {}", response.image_dir); + println!(" Manifest: {}", response.manifest_digest); + println!(" Base layer: {}", response.base_diff_id); + println!(" Delta files: {}", response.delta_files); + Ok(true) +} + +/// `atomic sandbox seal` over SealSandboxImage — the entrypoint/env and +/// output dir ride the wire; the CLI prints the local sealed report. +pub fn sandbox_seal(args: &super::sandbox::Seal) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::SealSandboxImageRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + name: args.view.clone(), + out_dir: args.out.display().to_string(), + entrypoint: args.entrypoint.clone(), + env: args.env.clone(), + }; + let response = session.seal_sandbox_image(request)?; + println!("Sealed '{}'", args.view); + println!(" Image: {}", response.image_dir); + println!(" Manifest: {}", response.manifest_digest); + println!(" Files: {}", response.files); + Ok(true) +} + +// --------------------------------------------------------------------------- +// top-level split (CreateView's FromView semantics + SwitchView) +// --------------------------------------------------------------------------- + +/// `atomic split` over CreateView (FromView) + SwitchView — the wrapper +/// composes the same wire `view create --from` uses, and the CLI prints +/// the SPLIT body's report lines from the created view's info. +pub fn split(args: &super::split::Split) -> CliResult { + // The pure name validation stays client-side (the local body's first + // check, with the same messages). + super::split::validate_view_name(&args.name) + .map_err(|message| CliError::InvalidArgument { message })?; + let Some(session) = Service::open()? else { + return Ok(false); + }; + // The source view default reads the working-copy marker (the same + // client-side default the local body applies). + let source = args + .source + .clone() + .unwrap_or_else(|| current_view_name().unwrap_or_default()); + let request = pb::CreateViewRequest { + repository: Some(session.reference.clone()), + meta: None, + name: args.name.clone(), + base: Some(pb::create_view_request::Base::FromView(source.clone())), + scope: pb::ViewScope::Unspecified as i32, + base_snapshot: None, + }; + let response = session.create_view(request)?; + let view = response.view.expect("created view info"); + let change_count = view.change_count; + if change_count > 0 { + print_success(&format!( + "Created view: {} (split from {} with {} changes)", + crate::output::view(&args.name), + crate::output::view(&source), + change_count + )); + } else { + print_success(&format!( + "Created view: {} (split from {} - empty)", + crate::output::view(&args.name), + crate::output::view(&source) + )); + } + if args.switch { + let switched = session.switch_view(pb::SwitchViewRequest { + repository: Some(session.reference.clone()), + meta: None, + view: args.name.clone(), + bypass_dirty_check: None, + })?; + print_success(&format!( + "Switched to view: {} ({} files updated)", + args.name, switched.files_written + )); + } else { + print_hint(&format!( + "Use 'atomic view switch {}' to switch to the new view", + args.name + )); + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// agent explain (ProvenanceService.ExplainTurns) +// --------------------------------------------------------------------------- + +/// `atomic agent explain` over ExplainTurns — the session record resolves +/// CLIENT-SIDE (the session store is a working-copy file read, not redb) +/// and its fields ride the request; the handler loads the turns, extracts +/// the transcripts, generates the reasoning via the Claude CLI, anchors +/// the learnings, and performs the save-backs. The CLI renders the local +/// body's exact lines from the wire-carried per-turn results. +pub fn agent_explain(args: &super::agent::explain::Explain) -> CliResult { + use atomic_agent::turn::session::SessionStore; + let repo_root = crate::commands::find_repository_root()?; + let session_store = + SessionStore::for_repo(&repo_root).map_err(|e| CliError::InvalidRepository { + reason: format!("Failed to open session store: {}", e), + })?; + let session = session_store + .load(&args.session_id) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load session: {}", e)))? + .ok_or_else(|| CliError::InvalidArgument { + message: format!( + "Session '{}' not found. Use 'atomic agent status' to see available sessions.", + args.session_id + ), + })?; + let Some(service_session) = Service::open()? else { + return Ok(false); + }; + let request = pb::ExplainTurnsRequest { + repository: Some(service_session.reference.clone()), + meta: request_meta(), + session_id: args.session_id.clone(), + turn: args.turn, + all: args.all, + save: args.save, + model: args.model.clone(), + view_name: session.view_name.clone(), + transcript_path: session + .transcript_path + .as_ref() + .map(|path| path.display().to_string()), + files_touched: session.files_touched.clone(), + agent_name: session.agent_name.clone(), + }; + let response = service_session.explain_turns(request)?; + + if response.session_has_no_turns { + println!( + "Session '{}' has no recorded turns on view '{}'.", + args.session_id, session.view_name, + ); + return Ok(true); + } + + // The Claude CLI availability check — the local body's exact early-out. + let generator = atomic_agent::transcript::ClaudeCliGenerator::new().with_model(&args.model); + use atomic_agent::transcript::ReasoningGenerator as _; + if !generator.is_available() { + crate::output::print_error( + "Claude CLI not found. Install from https://docs.anthropic.com/en/docs/claude-code", + ); + println!(); + println!("The 'explain' command uses Claude CLI to generate reasoning summaries."); + println!("Make sure 'claude' is in your PATH and authenticated."); + return Ok(true); + } + + println!( + "{} {} turn{} from session {}", + crate::output::emphasis("Explaining"), + response.turns.len(), + if response.turns.len() == 1 { "" } else { "s" }, + crate::output::hint(&args.session_id), + ); + println!(); + + for result in &response.turns { + println!( + "{}", + crate::output::emphasis(&format!("Turn {} — {}", result.turn_number, result.message)) + ); + if result.no_transcript { + print_warning(" No transcript available for this turn — skipping"); + println!(); + continue; + } + if let Some(error) = &result.generation_error { + if !result.reasoning.is_some() { + print!(" Generating reasoning via Claude CLI ({})...", args.model); + if error.is_empty() { + println!(" ✓"); + print_warning(" Reasoning is empty — nothing to show"); + } else { + println!(" ✗"); + crate::output::print_error(&format!(" Failed: {}", error)); + } + println!(); + continue; + } + } + print!(" Generating reasoning via Claude CLI ({})...", args.model); + println!(" ✓"); + let Some(bundle) = &result.reasoning else { + continue; + }; + if bundle.schema != "atomic.agent.reasoning.v1" { + return Err(CliError::Internal(anyhow::anyhow!( + "unknown reasoning bundle schema '{}'", + bundle.schema + ))); + } + let reasoning: atomic_agent::transcript::TurnReasoning = + serde_json::from_slice(&bundle.payload).map_err(|error| { + CliError::Internal(anyhow::anyhow!("reasoning bundle: {error}")) + })?; + if result.anchored > 0 { + println!( + " {} {}/{} code learnings anchored to graph", + crate::output::hint("⚓"), + result.anchored, + reasoning.learnings.code.len() + ); + } + super::agent::explain::print_reasoning(&reasoning); + if args.save { + if result.saved { + let hash = full_hash(&result.change); + print_success(&format!( + " Saved reasoning to change {} (will be included on push)", + &hash[..12.min(hash.len())] + )); + } else if let Some(error) = &result.save_error { + print_warning(&format!(" Failed to save reasoning to change: {}", error)); + } + if let Some(context) = &result.context_saved { + print_success(&format!(" {}", context)); + } else if let Some(error) = &result.context_save_error { + print_warning(&format!( + " Failed to save learnings to context file: {}", + error + )); + } + } + println!(); + } + + if !args.save { + let context_file = atomic_agent::learnings::context_file_for_agent(&session.agent_name); + println!( + "{}", + crate::output::hint(&format!( + "Use --save to persist reasoning in the change and append learnings to {}.", + context_file + )) + ); + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// intent delete/link, memory write, goal lifecycle (VaultService) +// --------------------------------------------------------------------------- + +/// `atomic intent delete` over DeleteVaultEntity — the existence +/// pre-check rides a GetVaultEntry (a read), the confirm prompt runs +/// client-side, and the handler enforces the same unstarted-backlog guard. +pub fn intent_delete(args: &super::intent::delete::IntentDelete) -> CliResult { + // The existence pre-check before the prompt: the local body's + // vault_intent_show read. + { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::GetVaultEntryRequest { + repository: Some(session.reference.clone()), + kind: Kind::Intent as i32, + id: args.id.clone(), + path: None, + include_bundle: false, + view: None, + }; + session.get_vault_entry(request)?; + } + if !args.force { + let prompt = format!( + "Discard intent '{}'? This removes the unstarted draft from the vault.", + args.id + ); + let confirmed = dialoguer::Confirm::new() + .with_prompt(&prompt) + .default(false) + .interact() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Prompt failed: {}", e)))?; + if !confirmed { + return Err(CliError::Cancelled); + } + } + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::DeleteVaultEntityRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kind: Kind::Intent as i32, + id: args.id.clone(), + view: None, + expected_snapshot: None, + }; + let response = session.delete_vault_entity(request)?; + if args.json { + let json = serde_json::json!({ + "id": response.id, + "intent_file": response.vault_path.trim_start_matches(".vault/"), + "deleted": true, + }); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + } else { + println!("Deleted intent: {}", response.id); + println!(" file: {}", response.vault_path); + } + Ok(true) +} + +/// `atomic intent link` over LinkVaultEntities — the intent→goal link the +/// local body's vault_intent_link performs. +pub fn intent_link(args: &super::intent::link::IntentLink) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::LinkVaultEntitiesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + source: Some(pb::VaultEntityRef { + kind: Kind::Intent as i32, + id: args.id.clone(), + }), + target: Some(pb::VaultEntityRef { + kind: Kind::Goal as i32, + id: args.goal.clone(), + }), + view: None, + expected_snapshot: None, + }; + session.link_vault_entities(request)?; + println!("Linked goal '{}' to intent '{}'", args.goal, args.id); + Ok(true) +} + +/// `atomic memory write` over UpdateVaultEntity's MemoryWrite arm — the +/// stdin read + frontmatter stay client-side (interactive/file-only); +/// the store + materialize run handler-side and the content hash rides +/// the response. +pub fn memory_write(args: &super::memory::write::MemoryWrite) -> CliResult { + // Freeform body from stdin — no lift/gate; this is the raw path. + use std::io::Read; + let mut content = String::new(); + std::io::stdin() + .read_to_string(&mut content) + .map_err(CliError::Io)?; + let path = crate::commands::memory::bridge::normalize_memory_path(&args.name); + let frontmatter = serde_json::json!({ + "name": args.name.replace(".md", ""), + "type": args.r#type, + }) + .to_string(); + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::UpdateVaultEntityRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kind: Kind::Memory as i32, + id: args.name.clone(), + update: Some(UpdateKind::MemoryWrite(pb::MemoryWriteAction { + path: path.clone(), + content: content.into_bytes(), + frontmatter_json: frontmatter, + })), + expected: None, + view: None, + }; + let response = session.update_vault_entity(request)?; + let hash = response + .write_hash + .as_ref() + .and_then(|hash| hash.value.clone().try_into().ok()) + .map(|bytes: [u8; 32]| Merkle(bytes).to_base32()) + .unwrap_or_default(); + println!("Wrote memory: {} ({})", path, &hash[..12]); + Ok(true) +} + +/// `atomic vault goal start` over CreateVaultEntity (kind=GOAL) — +/// GoalStartOptions' fields ride the request and the created goal's +/// dir/file ride the response. +pub fn vault_goal_start(args: &super::vault::goal::GoalStart) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::CreateVaultEntityRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kind: Kind::Goal as i32, + title: args.name.clone().unwrap_or_default(), + body: None, + memory_kind: None, + derived_from: Vec::new(), + about: Vec::new(), + developer: args.developer.clone(), + intent: args.intent.clone(), + model: args.model.clone(), + view: None, + expected_snapshot: None, + }; + let response = session.create_vault_entity(request)?; + let entry = response.entry.expect("created goal entry"); + if args.json { + let json = serde_json::json!({ + "name": entry.id, + "goal_dir": entry.goal_dir.clone().unwrap_or_default(), + "goal_file": entry.goal_file.clone().unwrap_or_default(), + }); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + } else { + println!("Started goal: {}", entry.id); + println!( + " directory: .vault/{}", + entry.goal_dir.clone().unwrap_or_default() + ); + } + Ok(true) +} + +/// `atomic vault goal stop` over UpdateVaultEntity's GoalStop arm — the +/// handler resolves the most recent active goal when none is named and +/// performs the stop; the outcome status + removed paths ride the +/// response. +pub fn vault_goal_stop(args: &super::vault::goal::GoalStop) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::UpdateVaultEntityRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kind: Kind::Goal as i32, + id: String::new(), + update: Some(UpdateKind::GoalStop(pb::GoalStopAction { + goal: args.goal.clone(), + promote: args.promote, + discard: args.discard, + })), + expected: None, + view: None, + }; + let response = session.update_vault_entity(request)?; + let entry = response.entry.expect("stopped goal entry"); + let status = response.stop_status.clone().unwrap_or_default(); + match status.as_str() { + "discarded" => println!( + "Discarded goal: {} ({} files removed)", + entry.id, + response.paths_removed.unwrap_or(0) + ), + "completed" => println!("Completed goal: {}", entry.id), + status => println!("Suspended goal: {} (status: {})", entry.id, status), + } + Ok(true) +} + +/// `atomic vault goal resume` over UpdateVaultEntity's GoalResume arm. +pub fn vault_goal_resume(args: &super::vault::goal::GoalResume) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::UpdateVaultEntityRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kind: Kind::Goal as i32, + id: String::new(), + update: Some(UpdateKind::GoalResume(pb::GoalResumeAction { + goal: args.goal.clone(), + })), + expected: None, + view: None, + }; + let response = session.update_vault_entity(request)?; + let entry = response.entry.expect("resumed goal entry"); + if args.json { + let json = serde_json::json!({ + "name": entry.id, + "developer": entry.developer.clone().unwrap_or_default(), + "status": entry.status_label.clone().unwrap_or_default(), + "intent": entry.linked.first().cloned(), + "started_at": entry.started_at.clone().unwrap_or_default(), + "turns": entry.turns.unwrap_or(0), + }); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + } else { + println!("Resumed goal: {}", entry.id); + if !entry.developer.clone().unwrap_or_default().is_empty() { + println!( + " developer: {}", + entry.developer.clone().unwrap_or_default() + ); + } + if let Some(intent) = entry.linked.first() { + println!(" intent: {}", intent); + } + } + Ok(true) +} + +/// `atomic vault goal show` over GetVaultEntry (kind=GOAL) — the stored +/// goal.md content + frontmatter ride the bundle; the CLI renders the +/// local JSON/plain projection over them. +pub fn vault_goal_show(args: &super::vault::goal::GoalShow) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::GetVaultEntryRequest { + repository: Some(session.reference.clone()), + kind: Kind::Goal as i32, + id: args.goal.clone(), + path: None, + include_bundle: true, + view: None, + }; + let response = session.get_vault_entry(request)?; + let Some(bundle) = response + .entry_bundle + .as_ref() + .map(parse_entry_bundle) + .transpose()? + else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no entry bundle (include_bundle required)" + ))); + }; + let entry = &bundle.entry; + if args.json { + let json = serde_json::json!({ + "name": args.goal, + "content": String::from_utf8_lossy(&entry.content_bytes), + "frontmatter": serde_json::from_str::( + &entry.frontmatter_json + ).unwrap_or_default(), + }); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + } else { + print!("{}", String::from_utf8_lossy(&entry.content_bytes)); + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// vault materialize / summaries (VaultService) +// --------------------------------------------------------------------------- + +/// `atomic vault materialize` over ExportVault — the handler inflates the +/// vault entries to markdown in the working copy (the same domain calls), +/// and the CLI prints the local report. +pub fn vault_materialize(args: &super::vault::materialize::Materialize) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::ExportVaultRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + kinds: Vec::new(), + destination: None, + path: args.path.clone(), + view: None, + }; + let response = session.export_vault(request)?; + match &response.materialized_path { + Some(path) => println!("Materialized: {}", path), + None => println!("Materialized {} vault entries.", response.exported), + } + Ok(true) +} + +/// `atomic vault summaries` over ListVaultEntries with the previews arm — +/// the handler enumerates the ToolResult entries under the goal prefix +/// with 200-char content previews; the CLI renders the same JSON map. +pub fn vault_summaries(args: &super::vault::summaries::Summaries) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let prefix = match &args.goal { + Some(name) => format!("goals/{}/", name), + None => "goals/".to_string(), + }; + let request = pb::ListVaultEntriesRequest { + repository: Some(session.reference.clone()), + kind: None, + status: None, + summaries_only: true, + cursor: None, + budget: None, + view: None, + identity: None, + path_prefix: Some(prefix), + entry_type: None, + status_filter: None, + tool_result_previews: true, + }; + let response = session.list_vault_entries(request)?; + let mut summaries = serde_json::Map::new(); + for entry in &response.entries { + summaries.insert( + entry.id.clone(), + serde_json::Value::String(entry.body.clone().unwrap_or_default()), + ); + } + let output = serde_json::Value::Object(summaries); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + Ok(true) +} + +// --------------------------------------------------------------------------- +// session fork/rebuild (ProvenanceService) +// --------------------------------------------------------------------------- + +/// `atomic session fork` over ForkSession — the domain fork runs +/// handler-side; both manifest hashes ride the wire for the local report. +pub fn session_fork(args: &super::session::SessionFork) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::ForkSessionRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + session_id: args.parent_session_id.clone(), + forked_session_id: args.child.clone(), + at_turn: Some(args.at_turn), + }; + let response = session.fork_session(request)?; + println!("Forked session {}", args.parent_session_id); + println!( + " Parent manifest: {}", + full_hash(&response.parent_manifest) + ); + println!(" Child session: {}", args.child); + println!(" Child manifest: {}", full_hash(&response.child_manifest)); + println!(" Fork turn: {}", args.at_turn); + Ok(true) +} + +/// `atomic session rebuild` over RebuildSessionIndex — the counts ride +/// the wire for the local report. +pub fn session_rebuild() -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::RebuildSessionIndexRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + }; + let response = session.rebuild_session_index(request)?; + println!("Session index rebuild complete"); + println!(" Indexed: {}", response.indexed); + println!(" Already present: {}", response.already_present); + println!(" Corrupt (skipped): {}", response.corrupt); + Ok(true) +} + +// --------------------------------------------------------------------------- +// query KG extras: graph / callers / plan / ask / embed / reindex +// --------------------------------------------------------------------------- + +/// `atomic query graph` over QueryGraph (GraphSlice) — the seed/expand/ +/// filter/cap build runs handler-side; the CLI renders JSON/DOT/HTML from +/// the wire-carried subgraph (emit_dot/emit_html stay client-side — pure +/// presentation) and writes/opens the output the same way. +pub fn query_graph(args: &super::query::QueryGraph) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::QueryGraphRequest { + repository: Some(session.reference.clone()), + limit: 0, + query: Some(pb::query_graph_request::Query::GraphSlice( + pb::GraphSliceQuery { + node_ids: Vec::new(), + max_depth: None, + query: Some(args.query.clone()), + seed_limit: Some(args.limit as u32), + depth: Some(args.depth.min(2) as u32), + kinds: Some(args.kinds.clone()), + changes_per_seed: Some(args.changes_per_seed as u32), + max_nodes: args.max_nodes.map(|max| max as u32), + }, + )), + view: None, + }; + let response = session.query_graph(request)?; + let nodes = response + .nodes + .iter() + .map(kg_node_domain) + .collect::>(); + let edges = response + .edges + .iter() + .map(kg_edge_domain) + .collect::>(); + if nodes.is_empty() { + if args.json { + println!("{{\"nodes\":[],\"edges\":[]}}"); + } else { + eprintln!("No results for {:?} (kinds: {}).", args.query, args.kinds); + } + return Ok(true); + } + // Cap note: the wire carries only the final set; the note's collected + // count is the final length when a cap applied (0 = uncapped). + let capped = nodes.len() >= args.max_nodes.unwrap_or(usize::MAX); + if capped { + eprintln!( + "Note: {} nodes collected, showing top {}. Use --max-nodes to adjust.", + nodes.len(), + args.max_nodes.unwrap_or(nodes.len()), + ); + } + if args.json { + let output = serde_json::json!({ + "nodes": nodes, + "edges": edges, + }); + let json_str = serde_json::to_string_pretty(&output).unwrap(); + if let Some(ref path) = args.output { + std::fs::write(path, &json_str) + .map_err(|e| CliError::Internal(anyhow::anyhow!("write failed: {e}")))?; + eprintln!( + "Wrote {} nodes, {} edges to {}", + nodes.len(), + edges.len(), + path + ); + } else { + println!("{}", json_str); + } + } else if args.dot { + let dot_str = super::query::emit_dot(&nodes, &edges); + if let Some(ref path) = args.output { + std::fs::write(path, &dot_str) + .map_err(|e| CliError::Internal(anyhow::anyhow!("write failed: {e}")))?; + eprintln!( + "Wrote {} nodes, {} edges to {}", + nodes.len(), + edges.len(), + path + ); + } else { + print!("{}", dot_str); + } + } else { + let html = super::query::emit_html(&args.query, &nodes, &edges); + if let Some(ref path) = args.output { + std::fs::write(path, &html) + .map_err(|e| CliError::Internal(anyhow::anyhow!("write failed: {e}")))?; + eprintln!( + "Wrote {} nodes, {} edges to {}", + nodes.len(), + edges.len(), + path + ); + } else { + let tmp_dir = std::env::temp_dir(); + let tmp_path = tmp_dir.join("atomic-graph.html"); + std::fs::write(&tmp_path, &html) + .map_err(|e| CliError::Internal(anyhow::anyhow!("write failed: {e}")))?; + eprintln!( + "Opening graph ({} nodes, {} edges) in browser...", + nodes.len(), + edges.len() + ); + let _ = std::process::Command::new("open").arg(&tmp_path).status(); + } + } + Ok(true) +} + +/// `atomic query callers` over QueryGraph (Callers) — the handler filters +/// the 1-hop subgraph to CALLS edges at the entity; the CLI renders the +/// local caller lines and hints. +pub fn query_callers(args: &super::query::QueryCallers) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::QueryGraphRequest { + repository: Some(session.reference.clone()), + limit: 0, + query: Some(pb::query_graph_request::Query::Callers(pb::CallersQuery { + node_id: args.entity_id.clone(), + })), + view: None, + }; + let response = session.query_graph(request)?; + let caller_nodes = response + .nodes + .iter() + .map(kg_node_domain) + .collect::>(); + let caller_edges = response + .edges + .iter() + .map(kg_edge_domain) + .collect::>(); + + if args.json { + let out: Vec = caller_edges + .iter() + .map(|edge| { + let node = caller_nodes.iter().find(|node| node.id == edge.from_id); + serde_json::json!({ + "caller_id": edge.from_id, + "summary": node.and_then(|node| node.summary.as_deref()), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&out).unwrap()); + return Ok(true); + } + + if caller_edges.is_empty() { + println!("No callers found for '{}'.", args.entity_id); + if !response.calls_present { + println!( + "Hint: run `atomic vault query enrich` to populate CALLS edges, \ + then retry." + ); + } else { + println!("This function is an entry point — nothing in the repo calls it."); + } + } else { + println!("{} caller(s) of {}:\n", caller_edges.len(), args.entity_id); + for edge in &caller_edges { + let display = if let Some(rest) = edge.from_id.strip_prefix("entity:") { + let last = rest.rfind(':').unwrap_or(rest.len()); + let inner = &rest[..last]; + let sig = caller_nodes + .iter() + .find(|node| node.id == edge.from_id) + .and_then(|node| node.summary.as_deref()) + .map(|summary| format!(" [{}]", summary)); + format!(" {}{}", inner, sig.unwrap_or_default()) + } else { + format!(" {}", edge.from_id) + }; + println!("{display}"); + } + } + Ok(true) +} + +/// `atomic query plan` over QueryGraph (Plan) — the structured plan +/// executes handler-side; the CLI renders the domain PlanResult JSON +/// byte-for-byte (--json) or the human summary. +pub fn query_plan(args: &super::query::PlanExec) -> CliResult { + // Read plan from stdin — client-side (interactive/file-only). + use std::io::Read; + let mut plan_json = String::new(); + std::io::stdin() + .read_to_string(&mut plan_json) + .map_err(CliError::Io)?; + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::QueryGraphRequest { + repository: Some(session.reference.clone()), + limit: 0, + query: Some(pb::query_graph_request::Query::Plan( + pb::PlanExecutionQuery { + plan_json: plan_json.into_bytes(), + }, + )), + view: None, + }; + let response = session.query_graph(request)?; + let Some(payload) = response.plan_result else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no plan result" + ))); + }; + let result: atomic_repository::PlanResult = serde_json::from_slice(&payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("plan result: {error}")))?; + if args.json { + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else { + println!( + "Plan executed in {}ms ({} steps)\n", + result.elapsed_ms, + result.step_stats.len() + ); + for (index, stat) in result.step_stats.iter().enumerate() { + println!( + " Step {}: {} → {} results ({}ms)", + index + 1, + stat.step_type, + stat.result_count, + stat.elapsed_ms + ); + } + if !result.nodes.is_empty() { + println!("\nNodes ({}):", result.nodes.len()); + for node in &result.nodes { + print!(" [{}] {}", node.kind, node.label); + if let Some(summary) = &node.summary { + let short: String = summary.chars().take(60).collect(); + print!(": {}", short); + } + println!(); + } + } + if !result.edges.is_empty() { + println!("\nEdges ({}):", result.edges.len()); + for edge in result.edges.iter().take(20) { + println!(" {} -[{}]-> {}", edge.from_id, edge.kind, edge.to_id); + } + if result.edges.len() > 20 { + println!(" ... and {} more", result.edges.len() - 20); + } + } + if !result.content.is_empty() { + println!("\nContent ({} entries):", result.content.len()); + for (path, text) in &result.content { + let preview: String = text.chars().take(100).collect(); + println!(" {}: {}...", path, preview.replace('\n', " ")); + } + } + } + Ok(true) +} + +/// `atomic query ask` over QueryGraph (RagAsk) — the agentic loop runs +/// handler-side; the CLI renders the same answer/stats/JSON lines. +/// --verbose's live tool-call trace becomes an after-the-fact trace over +/// the wire (the unary-RPC limit, documented in the dispatch guard). +pub fn query_ask(args: &super::query::QueryAsk) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::QueryGraphRequest { + repository: Some(session.reference.clone()), + limit: 0, + query: Some(pb::query_graph_request::Query::RagAsk(pb::RagAskQuery { + question: args.question.clone(), + top_k: 0, + max_turns: Some(args.max_turns as u32), + verbose: Some(args.verbose), + })), + view: None, + }; + let response = session.query_graph(request)?; + let Some(bundle) = response + .ask_result + .as_ref() + .filter(|bytes| bytes.schema == "atomic.query.ask.v1") + else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no ask result" + ))); + }; + let result: atomic_repository::AgentResult = serde_json::from_slice(&bundle.payload) + .map_err(|error| CliError::Internal(anyhow::anyhow!("ask result: {error}")))?; + if args.json { + let json = serde_json::json!({ + "query": args.question, + "answer": result.answer, + "model": result.model, + "turns": result.turns, + "input_tokens": result.total_usage.input_tokens, + "output_tokens": result.total_usage.output_tokens, + "tool_trace": result.tool_trace, + }); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + } else { + println!("{}\n", result.answer); + let cache_info = if result.total_usage.cache_read_tokens > 0 { + format!( + ", {} cache read + {} cache write", + result.total_usage.cache_read_tokens, result.total_usage.cache_creation_tokens + ) + } else { + String::new() + }; + let secs = (response.ask_elapsed_ms.unwrap_or(0)) as f64 / 1000.0; + let duration = if secs < 60.0 { + format!("{:.1}s", secs) + } else { + format!("{}m {:.0}s", (secs / 60.0) as u64, secs % 60.0) + }; + println!( + " — {} ({} turns, {}, {} in + {} out tokens{})", + result.model, + result.turns, + duration, + result.total_usage.input_tokens, + result.total_usage.output_tokens, + cache_info, + ); + } + Ok(true) +} + +/// `atomic query embed` over MaintainKnowledgeGraph (EMBED) — the +/// provider resolves handler-side from the same environment/config; the +/// CLI prints the local embed report. +pub fn query_embed(args: &super::query::QueryEmbed) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::MaintainKnowledgeGraphRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::KnowledgeMaintainAction::Embed as i32, + scope: args.path.clone(), + changes: Vec::new(), + }; + let response = session.maintain_knowledge_graph(request)?; + println!( + "Using embedding provider: {} ({}d)", + response.provider.clone().unwrap_or_default(), + response.dimensions.unwrap_or(0) + ); + match &args.path { + Some(path) => { + if response.processed == 0 { + println!("Content unchanged, skipped: {}", path); + } else { + println!("Embedded {} chunks: {}", response.processed, path); + } + } + None => println!("Embedded {} total chunks.", response.processed), + } + Ok(true) +} + +/// `atomic query reindex` over MaintainKnowledgeGraph (REINDEX) — the +/// handler runs vault_reindex_kg; the CLI prints the local report. +pub fn query_reindex() -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::MaintainKnowledgeGraphRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::KnowledgeMaintainAction::Reindex as i32, + scope: None, + changes: Vec::new(), + }; + let response = session.maintain_knowledge_graph(request)?; + println!("Indexed {} nodes + edges.", response.processed); + Ok(true) +} + +// --------------------------------------------------------------------------- +// vault init / project init (bootstrap boundary) +// --------------------------------------------------------------------------- + +/// `atomic vault init` over InitVault — the vault init operates inside an +/// EXISTING repository (it requires one), so it routes normally via the +/// daemon: the handler runs has_vault → init_vault → the recursive track +/// → the defaults record, and the CLI prints the local report. +pub fn vault_init() -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::InitVaultRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + view: None, + expected_snapshot: None, + }; + let response = session.init_vault(request)?; + if response.already_initialized { + print_info("Vault is already initialized"); + return Ok(true); + } + print_info(&format!( + "Initialized vault at {}", + response.vault_dir.clone().unwrap_or_default() + )); + if response.recorded { + print_success("Recorded vault defaults"); + } + Ok(true) +} + +/// `atomic project init` — the server-side project creation (the +/// management StorageClient built from the global config + the identity +/// store — config/auth reads, not redb, per the push/pull ruling) stays +/// client-side; the ONE repository mutation (add_remote origin) routes +/// through ManageRemotes so the command never opens redb directly. +pub fn project_init(args: &super::project::init::ProjectInit) -> CliResult { + // Client-side: build the storage client (config + identity reads, no + // redb) and run the server-side workspace/project creation exactly as + // the local body does. + let rt = tokio::runtime::Runtime::new().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to create async runtime: {}", e)) + })?; + let outcome = rt.block_on(async { + let (client, org_slug) = + crate::commands::client::build_client_with_org(args.org.as_deref(), None).await?; + let workspace = + crate::commands::client::resolve_workspace(&org_slug, args.workspace.as_deref(), None)?; + + // Ensure the workspace exists — create it if necessary. + // A 409 (Conflict) means it already exists, which is fine. + match client.get_workspace(&workspace).await { + Ok(_) => {} + Err(_) => { + let ws_req = atomic_remote::storage_types::CreateWorkspaceRequest { + name: workspace.clone(), + description: None, + visibility: args.visibility, + }; + match client.create_workspace(&ws_req).await { + Ok(ws) => { + print_success(&format!("Created workspace: {}", ws.slug)); + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("409") + || msg.contains("conflict") + || msg.contains("Conflict") + { + print_info(&format!( + "Workspace '{}' already exists, using it.", + workspace + )); + } else { + return Err(crate::commands::client::remote_err(e)); + } + } + } + } + } + + let proj_req = atomic_remote::storage_types::CreateProjectRequest { + name: args.name.clone(), + description: args.description.clone(), + default_view: "dev".to_string(), + kind: args.kind.clone(), + visibility: args.visibility, + }; + let project = client + .create_project(&workspace, &proj_req) + .await + .map_err(crate::commands::client::remote_err)?; + let vcs_url = format!( + "{}/workspaces/{}/projects/{}/code", + client.base_url(), + workspace, + project.slug, + ); + Ok::<_, CliError>((project.name, project.slug, vcs_url)) + })?; + + // The repository mutation routes through the service layer + // (ManageRemotes) — no direct redb open on any path this command + // can reach. + let Some(session) = Service::open()? else { + return Ok(false); + }; + session.manage_remotes(pb::ManageRemotesRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + action: pb::RemoteAction::Add as i32, + name: Some("origin".to_string()), + url: Some(outcome.2.clone()), + new_name: None, + set_default: false, + })?; + + let (name, slug, vcs_url) = outcome; + print_success(&format!("Created project: {} (slug: {})", name, slug)); + println!(" Remote URL: {}", vcs_url); + crate::output::print_next_steps(&[ + ( + "atomic record -m \"Initial commit\"", + "Record your first change", + ), + ("atomic push", "Push changes to the server"), + ]); + Ok(true) +} + +// --------------------------------------------------------------------------- +// triage (TriageService) +// --------------------------------------------------------------------------- + +/// The wire-carried candidate set — the domain JSON the local body's +/// renders consume. +pub(crate) struct TriageCandidateSet { + pub feature: String, + pub target: String, + pub payload: Vec, +} + +/// `atomic triage candidates` over ListTriageCandidates — the handler +/// resolves the (feature, target) pair (the same defaults and errors the +/// local resolve_views applies) and runs the repository's candidate-set +/// domain call; the CLI renders the same plain/JSON reports over the +/// wire-carried domain payload. +pub(crate) fn triage_candidates( + args: &super::triage::TriageCandidates, +) -> CliResult> { + let Some(session) = Service::open()? else { + return Ok(None); + }; + let request = pb::ListTriageCandidatesRequest { + repository: Some(session.reference.clone()), + from_view: args.feature.clone(), + to_view: args.into.clone(), + }; + let response = session.list_triage_candidates(request)?; + let Some(bundle) = response + .candidate_set + .as_ref() + .filter(|bytes| bytes.schema == "atomic.triage.candidates.v1") + else { + return Err(CliError::Internal(anyhow::anyhow!( + "the service returned no candidate set bundle" + ))); + }; + Ok(Some(TriageCandidateSet { + feature: response.feature, + target: response.target, + payload: bundle.payload.clone(), + })) +} + +// --------------------------------------------------------------------------- +// tags (TagService) +// --------------------------------------------------------------------------- + +/// One wire tag → the domain record shape the local render functions +/// consume (name, view, sequence, state, timestamp, annotation, kind, +/// metadata). +pub(crate) struct WireTag { + pub name: String, + pub view: String, + pub sequence: u64, + pub state: atomic_core::types::Merkle, + pub timestamp: chrono::DateTime, + pub message: Option, + pub author_name: Option, + pub author_email: Option, + pub kind: String, + pub metadata: Option, +} + +impl WireTag { + pub fn is_annotated(&self) -> bool { + self.message.is_some() || self.author_name.is_some() + } +} + +/// One ReviewGate record's presence — the wire-carried form of what the +/// local render reads from the repository (`has_change` + view membership). +pub(crate) struct TagRecordWire { + pub hash: String, + pub parseable: bool, + pub present: bool, + pub views: Vec, +} + +/// Wire TagInfo → the domain tag shape (the state decodes to the Merkle the +/// local renders print base32; an absent timestamp falls back to now, which +/// never happens with the same-build service). +pub(crate) fn wire_tag(tag: &pb::TagInfo) -> WireTag { + WireTag { + name: tag.name.clone(), + view: tag.view.clone(), + sequence: tag.sequence, + state: tag + .state + .as_ref() + .and_then(|h| h.value.clone().try_into().ok()) + .map(atomic_core::types::Merkle) + .unwrap_or_default(), + timestamp: tag + .created_at + .as_ref() + .map(|stamp| { + chrono::DateTime::from_timestamp(stamp.seconds, stamp.nanos.max(0) as u32) + .unwrap_or_else(chrono::Utc::now) + }) + .unwrap_or_else(chrono::Utc::now), + message: tag.message.clone(), + author_name: tag.author.as_ref().map(|author| author.name.clone()), + author_email: tag.author.as_ref().and_then(|author| author.email.clone()), + kind: tag.kind.clone(), + metadata: tag.metadata.clone(), + } +} + +/// `atomic tag create` over CreateTag — every form. The handler reproduces +/// the local body's flow exactly (the existing-tag pre-check, the --force +/// delete, the Release kind), and the wire carries the created record so +/// the CLI prints the local create report. +pub fn tag_create(args: &super::tag::create::Create) -> CliResult { + let Some(name) = args.name.clone() else { + return Err(CliError::InvalidArgument { + message: "Tag name is required".to_string(), + }); + }; + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::CreateTagRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + name, + state: None, + message: args.message.clone(), + view: None, + expected_snapshot: None, + force: args.force, + }; + let response = session.create_tag(request)?; + let tag = wire_tag(response.tag.as_ref().expect("created tag")); + if tag.is_annotated() { + print_success(&format!( + "Created annotated tag: {}", + crate::output::emphasis(&tag.name) + )); + } else { + print_success(&format!( + "Created tag: {}", + crate::output::emphasis(&tag.name) + )); + } + Ok(true) +} + +/// `atomic tag delete` over DeleteTag — the wire carries whether the tag +/// existed, so the CLI prints the local delete report. +pub fn tag_delete(args: &super::tag::delete::Delete) -> CliResult { + let Some(name) = args.name.clone() else { + return Err(CliError::InvalidArgument { + message: "Tag name is required".to_string(), + }); + }; + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::DeleteTagRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + name, + expected_state: None, + }; + let name = request.name.clone(); + let response = session.delete_tag(request)?; + if response.deleted { + print_success(&format!("Deleted tag: {}", crate::output::emphasis(&name))); + } else { + println!( + "{}", + crate::output::hint(&format!("Tag '{}' does not exist", name)) + ); + } + Ok(true) +} + +/// `atomic tag list` over ListTags — every form: the view/pattern/ +/// annotated-only filters ride the request (the handler applies the local +/// body's exact filter semantics over the domain listing), and the CLI +/// renders the local plain/verbose lines from the wire-carried records. +pub fn tag_list(args: &super::tag::list::List) -> CliResult { + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::ListTagsRequest { + repository: Some(session.reference.clone()), + view: args.view.clone(), + pattern: args.pattern.clone(), + annotated_only: args.annotated_only, + }; + let response = session.list_tags(request)?; + let tags = response.tags.iter().map(wire_tag).collect::>(); + if tags.is_empty() { + println!( + "{}", + crate::output::hint("No tags found. Use 'atomic tag create ' to create one.") + ); + return Ok(true); + } + let max_name_len = tags.iter().map(|t| t.name.len()).max().unwrap_or(0); + use atomic_core::types::Base32; + for tag in &tags { + let annotated_marker = if tag.is_annotated() { "*" } else { " " }; + if args.verbose { + let full = tag.state.to_base32(); + let state_short = if full.len() > 12 { + format!("{}...", &full[..12]) + } else { + full + }; + let date = tag.timestamp.format("%Y-%m-%d").to_string(); + println!( + "{}{:4}) state: {} {}", + annotated_marker, + crate::output::emphasis(&tag.name), + tag.sequence, + state_short, + date, + width = max_name_len + ); + } else { + println!("{}{}", annotated_marker, crate::output::emphasis(&tag.name)); + } + } + Ok(true) +} + +/// `atomic tag show` over GetTag — the wire carries the full record plus +/// the ReviewGate per-record presence/membership the local render reads, +/// so the CLI prints the local detail report. +pub fn tag_show(args: &super::tag::show::Show) -> CliResult { + let Some(name) = args.name.clone() else { + return Err(CliError::InvalidArgument { + message: "Tag name is required".to_string(), + }); + }; + let Some(session) = Service::open()? else { + return Ok(false); + }; + let request = pb::GetTagRequest { + repository: Some(session.reference.clone()), + name, + }; + let name = request.name.clone(); + let response = session.get_tag(request)?; + let Some(info) = response.tag else { + println!( + "{}", + crate::output::hint(&format!( + "Tag '{}' not found. Use 'atomic tag list' to see available tags.", + name + )) + ); + return Ok(true); + }; + let tag = wire_tag(&info); + use atomic_core::types::Base32; + let emphasis = crate::output::emphasis; + println!("{}: {}", emphasis("Tag"), tag.name); + println!("{}: {}", emphasis("View"), tag.view); + println!("{}: {}", emphasis("Sequence"), tag.sequence); + println!("{}: {}", emphasis("State"), tag.state.to_base32()); + println!( + "{}: {}", + emphasis("Created"), + tag.timestamp.format("%Y-%m-%d %H:%M:%S UTC") + ); + println!("{}: {}", emphasis("Kind"), tag.kind); + println!( + "{}: {}", + emphasis("Type"), + if tag.is_annotated() { + "annotated" + } else { + "lightweight" + } + ); + if let Some(message) = &tag.message { + println!("{}: {}", emphasis("Message"), message); + } + if let Some(author) = &tag.author_name { + let author_str = match &tag.author_email { + Some(email) => format!("{author} <{email}>"), + None => author.clone(), + }; + println!("{}: {}", emphasis("Author"), author_str); + } + if let Some(metadata) = &tag.metadata { + if tag.kind == "review-gate" { + let records = response + .records + .iter() + .map(|record| TagRecordWire { + hash: record.hash.clone(), + parseable: record.parseable, + present: record.present, + views: record.views.clone(), + }) + .collect::>(); + super::tag::show::render_review_gate_records(metadata, &records); + } else { + println!("{}: {}", emphasis("Metadata"), metadata); + } + } + Ok(true) +} + +// --------------------------------------------------------------------------- +// agent receive (the Reactor bridge) +// --------------------------------------------------------------------------- + +/// Dispatch one typed TurnEvent through the service area — the SAME +/// handler both transports serve: local runs libatomic's +/// `ProvenanceImpl::dispatch_turn_event` in this process (the orchestrator +/// with the in-process journal core), reactor dispatches over the socket +/// (D4 start-or-retry). There is no unreachable fallback path anymore — +/// a failure is a real failure and surfaces to the caller. +pub fn dispatch_turn_event( + root: &Path, + event: &atomic_agent::event::TurnEvent, + agent_id: &str, + agent_display: &str, + agent_identity: Option, +) -> CliResult { + let Some(session) = Service::open_root(root)? else { + return Err(CliError::InvalidArgument { + message: format!("'{}' is not an Atomic repository", root.display()), + }); + }; + let response = session.dispatch_turn_event(pb::DispatchTurnEventRequest { + repository: Some(session.reference.clone()), + meta: request_meta(), + agent_id: agent_id.to_string(), + agent_display_name: Some(agent_display.to_string()), + agent_identity, + event: Some(turn_event_body(event)), + })?; + Ok(crate::commands::agent::receive::ReceiveResult { + session_id: response.session_id, + recorded: response.recorded, + change_hash: response.change_hash.as_ref().map(|hash| { + let bytes: [u8; 32] = hash.value.clone().try_into().unwrap_or([0; 32]); + Merkle(bytes).to_base32() + }), + view: response.view, + files: response.files, + warnings: response.warnings, + }) +} + +fn turn_event_body(event: &atomic_agent::event::TurnEvent) -> pb::TurnEventBody { + pb::TurnEventBody { + session_id: event.session_id.clone(), + event_type: match event.event_type { + atomic_agent::event::HookType::SessionStart => "session_start", + atomic_agent::event::HookType::SessionEnd => "session_end", + atomic_agent::event::HookType::TurnStart => "turn_start", + atomic_agent::event::HookType::TurnEnd => "turn_end", + atomic_agent::event::HookType::PreToolUse => "pre_tool_use", + atomic_agent::event::HookType::PostToolUse => "post_tool_use", + } + .to_string(), + prompt: event.prompt.clone(), + tool_name: event.tool_name.clone(), + tool_use_id: event.tool_use_id.clone(), + timestamp: Some(timestamp_proto(event.timestamp)), + raw_json: event + .raw_json + .as_ref() + .and_then(|value| serde_json::to_vec(value).ok()), + } +} + +fn timestamp_proto(time: chrono::DateTime) -> prost_types::Timestamp { + prost_types::Timestamp { + seconds: time.timestamp(), + nanos: time.timestamp_subsec_nanos() as i32, + } +} + +/// Resolve the repository root from an explicit path argument (the Reactor +/// always passes the canonical root). +pub fn root_from(path: &Path) -> CliResult { + crate::commands::find_repository_root_from(path) +} diff --git a/atomic-cli/src/commands/sandbox.rs b/atomic-cli/src/commands/sandbox.rs index 8c91949d..f43062e9 100644 --- a/atomic-cli/src/commands/sandbox.rs +++ b/atomic-cli/src/commands/sandbox.rs @@ -54,6 +54,17 @@ pub enum SandboxCommands { impl Command for Sandbox { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the handler runs the + // same domain calls (view creation, copy-on-write provisioning, + // OCI stage/seal) and the CLI renders the local reports. + let routed = match &self.command { + SandboxCommands::Create(cmd) => crate::commands::rpc::sandbox_create(cmd)?, + SandboxCommands::Stage(cmd) => crate::commands::rpc::sandbox_stage(cmd)?, + SandboxCommands::Seal(cmd) => crate::commands::rpc::sandbox_seal(cmd)?, + }; + if routed { + return Ok(()); + } match &self.command { SandboxCommands::Create(cmd) => cmd.run(), SandboxCommands::Stage(cmd) => cmd.run(), diff --git a/atomic-cli/src/commands/session.rs b/atomic-cli/src/commands/session.rs index 2cc6aaee..2648cd0f 100644 --- a/atomic-cli/src/commands/session.rs +++ b/atomic-cli/src/commands/session.rs @@ -4,7 +4,8 @@ use std::collections::HashMap; use clap::{Args, Subcommand}; -use atomic_core::types::Base32; +use atomic_core::change::session::{SessionRecord, SessionTurn}; +use atomic_core::types::{Base32, Hash}; use atomic_repository::Repository; use crate::commands::{find_repository_root, Command}; @@ -20,6 +21,9 @@ pub struct Session { #[derive(Debug, Subcommand)] enum SessionCommands { /// Show the ordered Atomic ledger for a session. + /// + /// `list` is an alias: `session list` (no id) shows recent sessions. + #[command(name = "show", alias = "list")] Show(SessionShow), /// Fork a session at a turn boundary into a new child session. Fork(SessionFork), @@ -74,6 +78,11 @@ impl Command for Session { impl Command for SessionFork { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the handler runs the + // same fork_session domain call). + if crate::commands::rpc::session_fork(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; let (parent_hash, child_hash) = repo @@ -90,6 +99,11 @@ impl Command for SessionFork { impl Command for SessionRebuild { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the handler runs the + // same rebuild_session_index domain call). + if crate::commands::rpc::session_rebuild()? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; let (indexed, skipped, corrupt) = @@ -104,6 +118,10 @@ impl Command for SessionRebuild { impl Command for SessionShow { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::session_show(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; match &self.session_id { @@ -117,6 +135,34 @@ fn show_recent_sessions(repo: &Repository, limit: usize, json: bool) -> CliResul let ledgers = repo .list_session_ledgers(limit) .map_err(CliError::Repository)?; + // The per-session intent counts: prefer turn plan ids, else the vault + // manifest's session links. + let intent_map = build_session_intent_map(repo); + let intent_counts: Vec = ledgers + .iter() + .map(|(record, turns)| { + let from_turns: std::collections::HashSet<&str> = + turns.iter().filter_map(|t| t.plan_id.as_deref()).collect(); + if !from_turns.is_empty() { + from_turns.len() + } else { + intent_map + .get(&record.session_id) + .map(|ids| ids.len()) + .unwrap_or(0) + } + }) + .collect(); + render_recent_sessions(ledgers, intent_counts, json) +} + +/// The listing render, over the gathered data — the repository path and the +/// routed path (ListSessions' ledger bundle + intent counts) share it. +pub(crate) fn render_recent_sessions( + ledgers: Vec<(SessionRecord, Vec)>, + intent_counts: Vec, + json: bool, +) -> CliResult<()> { if json { println!( "{}", @@ -131,8 +177,6 @@ fn show_recent_sessions(repo: &Repository, limit: usize, json: bool) -> CliResul return Ok(()); } - let intent_map = build_session_intent_map(repo); - // Compute column width for SESSION: at least the header width, but wide enough // to show full session IDs so they can be copied for `atomic session show `. let ses_width = ledgers @@ -142,23 +186,6 @@ fn show_recent_sessions(repo: &Repository, limit: usize, json: bool) -> CliResul .unwrap_or(0) .max("SESSION".len()); - // Count intents per session. - let intent_counts: Vec = ledgers - .iter() - .map(|(record, turns)| { - // Prefer plan_id from turns (managed-run). - let from_turns: std::collections::HashSet<&str> = - turns.iter().filter_map(|t| t.plan_id.as_deref()).collect(); - if !from_turns.is_empty() { - return from_turns.len(); - } - intent_map - .get(&record.session_id) - .map(|ids| ids.len()) - .unwrap_or(0) - }) - .collect(); - println!("Recent sessions ({}):", ledgers.len()); println!( " {:5} {:>7} LAST ACTIVITY", @@ -218,6 +245,39 @@ fn show_session_detail(repo: &Repository, session_id: &str, json: bool) -> CliRe hint: None, })?; + // The routed pieces the render needs: turn→intent resolution and the + // session manifest data. + let turn_intents = build_turn_intent_map(repo, session_id); + let head = repo.get_session_head(session_id).ok().flatten(); + let (parent_manifest, fork_turn) = head + .as_ref() + .and_then(|h| repo.get_session_manifest(h).ok().flatten()) + .map(|manifest| (manifest.parent_session, manifest.fork_turn)) + .unwrap_or((None, None)); + render_session_detail( + record, + turns, + turn_intents, + head, + parent_manifest, + fork_turn, + json, + ) +} + +/// The detail render, over the gathered data — the repository path and the +/// routed path (GetSession's ledger bundle + turn intents + manifest data) +/// share it. +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_session_detail( + record: SessionRecord, + turns: Vec, + turn_intents: HashMap, + manifest_head: Option, + parent_manifest: Option, + fork_turn: Option, + json: bool, +) -> CliResult<()> { if json { println!( "{}", @@ -228,9 +288,6 @@ fn show_session_detail(repo: &Repository, session_id: &str, json: bool) -> CliRe return Ok(()); } - // Build turn→intent map for this session from vault paths. - let turn_intents = build_turn_intent_map(repo, session_id); - println!("Session {}", record.session_id); println!( " Status: {}", @@ -260,15 +317,13 @@ fn show_session_detail(repo: &Repository, session_id: &str, json: bool) -> CliRe println!(" Intents: {}", all_intents.join(", ")); } - if let Ok(Some(head)) = repo.get_session_head(session_id) { + if let Some(head) = manifest_head { println!(" Manifest head: {}", head.to_base32()); - if let Ok(Some(manifest)) = repo.get_session_manifest(&head) { - if let Some(parent) = manifest.parent_session { - println!(" Parent manifest: {}", parent.to_base32()); - } - if let Some(fork_turn) = manifest.fork_turn { - println!(" Forked at turn: {}", fork_turn); - } + if let Some(parent) = parent_manifest { + println!(" Parent manifest: {}", parent.to_base32()); + } + if let Some(fork_turn) = fork_turn { + println!(" Forked at turn: {}", fork_turn); } } if let Some(first) = record.first_provenance { diff --git a/atomic-cli/src/commands/split.rs b/atomic-cli/src/commands/split.rs index 66f70a79..572ff868 100644 --- a/atomic-cli/src/commands/split.rs +++ b/atomic-cli/src/commands/split.rs @@ -58,7 +58,7 @@ const INVALID_CHARS: &[char] = &['/', '\\', '\0', ':', '*', '?', '"', '<', '>', // View Name Validation /// Validate a view name. -fn validate_view_name(name: &str) -> Result<(), String> { +pub(crate) fn validate_view_name(name: &str) -> Result<(), String> { if name.is_empty() { return Err("View name cannot be empty".to_string()); } @@ -154,6 +154,14 @@ impl Split { impl Command for Split { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the wrapper composes + // the same CreateView(FromView) wire `view create --from` uses (plus + // SwitchView when --switch), and this render prints the split + // body's report from the wire-carried counts. + if crate::commands::rpc::split(self)? { + return Ok(()); + } + // Validate the new view name validate_view_name(&self.name).map_err(|msg| CliError::InvalidArgument { message: msg })?; diff --git a/atomic-cli/src/commands/stash.rs b/atomic-cli/src/commands/stash.rs index 35bfe071..07513aef 100644 --- a/atomic-cli/src/commands/stash.rs +++ b/atomic-cli/src/commands/stash.rs @@ -79,7 +79,7 @@ use clap::{Parser, Subcommand}; use atomic_repository::record::RecordOptions; use atomic_repository::status::StatusOptions; -use atomic_repository::Repository; +use atomic_repository::{Repository, StashPushOptions}; use crate::commands::{find_repository_root, format_timestamp_relative, Command}; use crate::error::{CliError, CliResult}; @@ -195,46 +195,7 @@ pub enum StashSubcommand { // Stash Entry -/// Information about a stash entry. -#[derive(Debug, Clone)] -pub struct StashEntry { - /// Index of the stash (0 = most recent). - pub index: usize, - /// Name of the stash view. - pub view_name: String, - /// View the stash was created from. - pub source_view: String, - /// Message describing the stash. - pub message: String, - /// When the stash was created. - pub created_at: DateTime, -} - -impl StashEntry { - /// Format the stash reference (e.g., "stash@{0}"). - pub fn reference(&self) -> String { - format!("stash@{{{}}}", self.index) - } - - /// Format the stash entry for display. - /// - /// Returns a human-readable string like: - /// `stash@{0}: On dev: Fix authentication bug` - pub fn display(&self) -> String { - format!( - "{}: On {}: {}", - self.reference(), - self.source_view, - self.message - ) - } -} - -impl std::fmt::Display for StashEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.display()) - } -} +pub use atomic_repository::StashEntry; // Implementation @@ -282,104 +243,14 @@ impl Stash { /// List all stash views, sorted by creation time (newest first). fn list_stashes(&self, repo: &Repository) -> CliResult> { - let views = repo.list_views().map_err(CliError::Repository)?; - - let mut stashes: Vec = views - .into_iter() - .filter(|name| name.starts_with(STASH_PREFIX)) - .filter_map(|name| { - // Parse stash metadata from view info - let _info = repo.get_view_info(&name).ok()?; - - // Extract source view and message from view name or metadata - // Format: stash/auto_{timestamp} or stash/{source}_{timestamp}_{message} - let parts: Vec<&str> = name - .trim_start_matches(STASH_PREFIX) - .splitn(3, '_') - .collect(); - - let (source_view, message, timestamp) = if parts.len() >= 2 { - let ts: i64 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); - let src = if parts[0] == "auto" { - "unknown".to_string() - } else { - parts[0].to_string() - }; - let msg = parts - .get(2) - .map(|s| s.to_string()) - .unwrap_or_else(|| DEFAULT_STASH_MESSAGE.to_string()); - (src, msg, ts) - } else { - ("unknown".to_string(), DEFAULT_STASH_MESSAGE.to_string(), 0) - }; - - let created_at = - DateTime::from_timestamp_millis(timestamp).unwrap_or_else(Utc::now); - - Some(StashEntry { - index: 0, // Will be set after sorting - view_name: name, - source_view, - message, - created_at, - }) - }) - .collect(); - - // Sort by creation time, newest first - stashes.sort_by_key(|s| std::cmp::Reverse(s.created_at)); - - // Assign indices after sorting - for (i, stash) in stashes.iter_mut().enumerate() { - stash.index = i; - } - - Ok(stashes) + repo.stash_list() + .map_err(|message| CliError::Internal(anyhow::anyhow!(message))) } /// Parse a stash reference (e.g., "stash@{0}", "0", or view name). fn parse_stash_ref(&self, repo: &Repository, reference: Option<&str>) -> CliResult { - let stashes = self.list_stashes(repo)?; - - if stashes.is_empty() { - return Err(CliError::InvalidArgument { - message: "No stashes found".to_string(), - }); - } - - let reference = reference.unwrap_or("0"); - - // Try to parse as index - let index = if reference.starts_with("stash@{") && reference.ends_with('}') { - // Format: stash@{N} - reference[7..reference.len() - 1].parse::().ok() - } else { - // Try as plain number - reference.parse::().ok() - }; - - if let Some(idx) = index { - return stashes.into_iter().find(|s| s.index == idx).ok_or_else(|| { - CliError::InvalidArgument { - message: format!("stash@{{{}}} does not exist", idx), - } - }); - } - - // Try as view name - let full_name = if reference.starts_with(STASH_PREFIX) { - reference.to_string() - } else { - format!("{}{}", STASH_PREFIX, reference) - }; - - stashes - .into_iter() - .find(|s| s.view_name == full_name) - .ok_or_else(|| CliError::InvalidArgument { - message: format!("Stash '{}' not found", reference), - }) + repo.stash_resolve(reference) + .map_err(|message| CliError::InvalidArgument { message }) } /// Execute stash push from another command (e.g., `view switch --stash`). @@ -403,131 +274,36 @@ impl Stash { include_untracked: bool, keep: bool, ) -> CliResult<()> { - // Check for uncommitted changes - let status = repo - .status(StatusOptions::default()) - .map_err(CliError::Repository)?; - - if status.is_clean() { - print_warning("No local changes to save"); - return Ok(()); - } - - // Get current view for metadata - let source_view = repo.current_view().to_string(); - - // Generate stash message - let stash_message = message - .or_else(|| self.message.clone()) - .unwrap_or_else(|| DEFAULT_STASH_MESSAGE.to_string()); - - // Create stash view name with metadata - let timestamp = Utc::now().timestamp_millis(); - let safe_source = source_view.replace('/', "-"); - let safe_message = stash_message - .chars() - .take(30) - .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == ' ') - .collect::() - .replace(' ', "-"); - - let stash_name = format!( - "{}{}_{}_{}", - STASH_PREFIX, safe_source, timestamp, safe_message - ); - - // Create orphan stash view - repo.create_view(&stash_name) - .map_err(CliError::Repository)?; - - // ── Save dirty files as raw bytes to a sidecar directory ──────── - // - // Stash is a **pure working-copy operation**. We save the exact - // bytes of every dirty file to `.atomic/stashes//`. - // This is completely immune to graph mutations — even if the user - // records new changes between push and pop, pop will restore the - // exact content that was stashed. - // - // We do NOT go through the graph/record/apply machinery at all. - - let stash_dir = repo.dot_dir().join("stashes").join(&stash_name); - std::fs::create_dir_all(&stash_dir).map_err(|e| { - CliError::Internal(anyhow::anyhow!("Failed to create stash dir: {}", e)) - })?; - - // Collect every dirty file (modified + added) - let mut stashed_paths: Vec = Vec::new(); - - for entry in status.modified() { - let p = entry.path().to_string_lossy().to_string(); - let abs = repo.root().join(&p); - if abs.is_file() { - let rel_dir = stash_dir.join( - std::path::Path::new(&p) - .parent() - .unwrap_or(std::path::Path::new("")), - ); - let _ = std::fs::create_dir_all(&rel_dir); - let _ = std::fs::copy(&abs, stash_dir.join(&p)); - stashed_paths.push(p); - } - } - - for entry in status.added() { - let p = entry.path().to_string_lossy().to_string(); - let abs = repo.root().join(&p); - if abs.is_file() { - let rel_dir = stash_dir.join( - std::path::Path::new(&p) - .parent() - .unwrap_or(std::path::Path::new("")), - ); - let _ = std::fs::create_dir_all(&rel_dir); - let _ = std::fs::copy(&abs, stash_dir.join(&p)); - stashed_paths.push(p); + // The domain owns the flow (status check, orphan view, raw-bytes + // sidecar, materialize); this is the presentation half. + let options = StashPushOptions { + message: message.or_else(|| self.message.clone()), + include_untracked: include_untracked || self.include_untracked, + keep: keep || self.keep, + }; + match repo + .stash_push(options) + .map_err(|message| CliError::Internal(anyhow::anyhow!(message)))? + { + None => { + print_warning("No local changes to save"); } - } - - if include_untracked || self.include_untracked { - for entry in status.untracked() { - let p = entry.path().to_string_lossy().to_string(); - let abs = repo.root().join(&p); - if abs.is_file() { - let rel_dir = stash_dir.join( - std::path::Path::new(&p) - .parent() - .unwrap_or(std::path::Path::new("")), - ); - let _ = std::fs::create_dir_all(&rel_dir); - let _ = std::fs::copy(&abs, stash_dir.join(&p)); - stashed_paths.push(p); + Some(entry) => { + // Recompute the display reference from the listing (the + // entry's index is assigned by stash_list). + let stash_ref = repo + .stash_list() + .map_err(|message| CliError::Internal(anyhow::anyhow!(message)))? + .iter() + .find(|s| s.view_name == entry.view_name) + .map(|s| s.reference()) + .unwrap_or_else(|| "stash@{0}".to_string()); + print_success(&format!("Saved working copy to {stash_ref}")); + if !keep && !self.keep { + print_success("Working copy restored to clean state"); } } } - - // Write a manifest so we know which files were stashed - let manifest_path = stash_dir.join("MANIFEST"); - let manifest_content = stashed_paths.join("\n"); - std::fs::write(&manifest_path, &manifest_content).map_err(|e| { - CliError::Internal(anyhow::anyhow!("Failed to write stash manifest: {}", e)) - })?; - - // Get stash index for display - let stashes = self.list_stashes(repo)?; - let stash_ref = stashes - .iter() - .find(|s| s.view_name == stash_name) - .map(|s| s.reference()) - .unwrap_or_else(|| "stash@{0}".to_string()); - - print_success(&format!("Saved working copy to {}", stash_ref)); - - // Restore working copy to clean state (unless --keep) - if !keep && !self.keep { - repo.materialize().map_err(CliError::Repository)?; - print_success("Working copy restored to clean state"); - } - Ok(()) } @@ -558,53 +334,19 @@ impl Stash { Ok(()) } - /// Apply a stash to the current working copy. - /// - /// Reads raw file bytes from the sidecar directory - /// (`.atomic/stashes//`) and writes them to disk. - /// This is a pure filesystem operation — no graph interaction. + /// Apply a stash to the current working copy: the sidecar bytes are + /// copied back to disk (a pure filesystem operation — no graph + /// interaction; the domain owns the MANIFEST walk). fn apply_stash(&self, repo: &mut Repository, stash: &StashEntry) -> CliResult<()> { - let stash_dir = repo.dot_dir().join("stashes").join(&stash.view_name); - let manifest_path = stash_dir.join("MANIFEST"); - - if !manifest_path.exists() { - return Err(CliError::Internal(anyhow::anyhow!( - "Stash sidecar not found: {}", - manifest_path.display() - ))); - } - - let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| { - CliError::Internal(anyhow::anyhow!("Failed to read stash manifest: {}", e)) - })?; - - let repo_root = repo.root().to_path_buf(); - let mut files_restored = 0usize; - - for line in manifest.lines() { - let path = line.trim(); - if path.is_empty() { - continue; - } - let src = stash_dir.join(path); - let dst = repo_root.join(path); + let (_entry, applied) = repo + .stash_apply(Some(&stash.view_name)) + .map_err(|message| CliError::Internal(anyhow::anyhow!(message)))?; - if src.is_file() { - if let Some(parent) = dst.parent() { - let _ = std::fs::create_dir_all(parent); - } - if std::fs::copy(&src, &dst).is_ok() { - files_restored += 1; - } - } - } - - if files_restored > 0 { + if !applied.is_empty() { print_success(&format!("Applied {} to working copy", stash.reference())); } else { print_warning("Stash was already applied or is empty"); } - Ok(()) } @@ -661,55 +403,41 @@ impl Stash { /// Execute stash drop. fn run_drop(&self, repo: &mut Repository, stash_ref: Option<&str>) -> CliResult<()> { - let stash = self.parse_stash_ref(repo, stash_ref)?; - - // Delete the sidecar directory - let stash_dir = repo.dot_dir().join("stashes").join(&stash.view_name); - if stash_dir.exists() { - let _ = std::fs::remove_dir_all(&stash_dir); - } - - // Delete the stash view - repo.delete_view(&stash.view_name) - .map_err(CliError::Repository)?; - + let stash = repo + .stash_drop(stash_ref) + .map_err(|message| CliError::InvalidArgument { message })?; print_success(&format!("Dropped {}", stash.reference())); Ok(()) } /// Execute stash clear. fn run_clear(&self, repo: &mut Repository, force: bool) -> CliResult<()> { - let stashes = self.list_stashes(repo)?; - - if stashes.is_empty() { - println!("No stashes to clear"); - return Ok(()); - } - if !force { - println!( - "This will delete {} stash(es). Are you sure? [y/N] ", - stashes.len() - ); + let count = repo + .stash_list() + .map_err(|message| CliError::Internal(anyhow::anyhow!(message)))? + .len(); + if count == 0 { + println!("No stashes to clear"); + return Ok(()); + } + println!("This will delete {count} stash(es). Are you sure? [y/N] "); std::io::Write::flush(&mut std::io::stdout()).ok(); - let mut input = String::new(); std::io::stdin().read_line(&mut input).ok(); - if !input.trim().eq_ignore_ascii_case("y") { println!("Aborted"); return Ok(()); } } - - let count = stashes.len(); - for stash in stashes { - repo.delete_view(&stash.view_name) - .map_err(CliError::Repository)?; + let count = repo + .stash_clear() + .map_err(|message| CliError::InvalidArgument { message })?; + if count == 0 { + println!("No stashes to clear"); + } else { + print_success(&format!("Cleared {count} stash(es)")); } - - print_success(&format!("Cleared {} stash(es)", count)); - Ok(()) } } @@ -723,6 +451,51 @@ impl Default for Stash { impl Command for Stash { /// Execute the stash command. fn run(&self) -> CliResult<()> { + // Route through the daemon when reachable (the domain module is + // shared, so behavior is identical); show stays local and clear + // keeps its confirmation prompt locally. + match &self.command { + None => { + if crate::commands::rpc::stash_push( + self.message.clone(), + self.include_untracked, + self.keep, + )? { + return Ok(()); + } + } + Some(StashSubcommand::Push { + message, + include_untracked, + keep, + }) => { + if crate::commands::rpc::stash_push(message.clone(), *include_untracked, *keep)? { + return Ok(()); + } + } + Some(StashSubcommand::Pop { stash }) => { + if crate::commands::rpc::stash_pop(stash.clone())? { + return Ok(()); + } + } + Some(StashSubcommand::Apply { stash }) => { + if crate::commands::rpc::stash_apply(stash.clone())? { + return Ok(()); + } + } + Some(StashSubcommand::List) => { + if crate::commands::rpc::stash_list()? { + return Ok(()); + } + } + Some(StashSubcommand::Drop { stash }) + if crate::commands::rpc::stash_drop(stash.clone(), false)? => + { + return Ok(()); + } + _ => {} + } + // Find repository let repo_root = find_repository_root()?; let mut repo = Repository::open(&repo_root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/status.rs b/atomic-cli/src/commands/status.rs index 719c162f..4a2ce520 100644 --- a/atomic-cli/src/commands/status.rs +++ b/atomic-cli/src/commands/status.rs @@ -514,6 +514,10 @@ impl Command for Status { /// - The repository cannot be opened /// - Status computation fails fn run(&self) -> CliResult<()> { + if crate::commands::rpc::status(self)? { + return Ok(()); + } + // Find the repository root let repo_root = find_repository_root()?; diff --git a/atomic-cli/src/commands/tag/create.rs b/atomic-cli/src/commands/tag/create.rs index b26e5a4b..4a454a2e 100644 --- a/atomic-cli/src/commands/tag/create.rs +++ b/atomic-cli/src/commands/tag/create.rs @@ -166,6 +166,13 @@ impl Create { impl Command for Create { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the handler runs the + // same domain flow: the existing-tag pre-check, the --force delete, + // the Release kind create). + if crate::commands::rpc::tag_create(self)? { + return Ok(()); + } + // Get the tag name let name = self .name diff --git a/atomic-cli/src/commands/tag/delete.rs b/atomic-cli/src/commands/tag/delete.rs index 8a8b0f6b..3d34ca30 100644 --- a/atomic-cli/src/commands/tag/delete.rs +++ b/atomic-cli/src/commands/tag/delete.rs @@ -60,6 +60,12 @@ impl Delete { impl Command for Delete { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the handler runs the + // same domain delete and the wire carries the existed flag). + if crate::commands::rpc::tag_delete(self)? { + return Ok(()); + } + // Get the tag name let name = self .name diff --git a/atomic-cli/src/commands/tag/list.rs b/atomic-cli/src/commands/tag/list.rs index a80d2ea3..06da8441 100644 --- a/atomic-cli/src/commands/tag/list.rs +++ b/atomic-cli/src/commands/tag/list.rs @@ -121,6 +121,13 @@ impl List { impl Command for List { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the view/pattern/ + // annotated-only filters ride the request and the handler applies + // the same semantics over the domain listing. + if crate::commands::rpc::tag_list(self)? { + return Ok(()); + } + // Find the repository let repo_root = find_repository_root()?; let repo = Repository::open(&repo_root).map_err(|e| match e { diff --git a/atomic-cli/src/commands/tag/show.rs b/atomic-cli/src/commands/tag/show.rs index 08cf3dc7..71f34a07 100644 --- a/atomic-cli/src/commands/tag/show.rs +++ b/atomic-cli/src/commands/tag/show.rs @@ -68,6 +68,13 @@ impl Show { impl Command for Show { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the wire carries the + // full tag record plus the ReviewGate per-record presence the local + // render reads from the repository. + if crate::commands::rpc::tag_show(self)? { + return Ok(()); + } + // Get the tag name let name = self .name @@ -151,6 +158,50 @@ impl Command for Show { /// Only lines whose underlying data is present are printed. All lookups are /// defensive against absent fields, matching the extensible metadata shape. fn render_review_gate_metadata(repo: &Repository, metadata: &serde_json::Value) { + // The per-record presence/membership is the repository read the routed + // path cannot make — computed here for the local body, computed by the + // GetTag handler for the wire. + let mut records = Vec::new(); + if let Some(original) = metadata + .get("changes") + .and_then(|c| c.get("original_hashes")) + .and_then(|v| v.as_array()) + { + for entry in original { + let Some(text) = entry.as_str() else { continue }; + match Hash::from_base32(text.as_bytes()) { + Some(hash) => { + let present = repo.has_change(&hash); + let views = repo.views_containing_change(&hash).unwrap_or_default(); + records.push(crate::commands::rpc::TagRecordWire { + hash: text.to_string(), + parseable: true, + present, + views, + }); + } + None => records.push(crate::commands::rpc::TagRecordWire { + hash: text.to_string(), + parseable: false, + present: false, + views: Vec::new(), + }), + } + } + } + render_review_gate_records(&metadata.to_string(), &records); +} + +/// The ReviewGate render over wire-carried records — the SAME lines the +/// local body prints (Git/Aggregate/Records blocks), driven by the +/// presence/membership the handler computed. +pub(crate) fn render_review_gate_records( + metadata_json: &str, + records: &[crate::commands::rpc::TagRecordWire], +) { + let Ok(metadata) = serde_json::from_str::(metadata_json) else { + return; + }; // Git provenance line: "Git: (PR #)" if let Some(git) = metadata.get("git") { let sha = git.get("sha").and_then(|v| v.as_str()); @@ -174,9 +225,7 @@ fn render_review_gate_metadata(repo: &Repository, metadata: &serde_json::Value) } let changes = metadata.get("changes"); - let original = changes - .and_then(|c| c.get("original_hashes")) - .and_then(|v| v.as_array()); + let record_count = records.len(); // Aggregate line. if let Some(changes) = changes { @@ -189,10 +238,7 @@ fn render_review_gate_metadata(repo: &Repository, metadata: &serde_json::Value) .unwrap_or(false); if let (Some(from), Some(to)) = (from, to) { - let count_val = count - .map(|c| c as usize) - .or_else(|| original.map(|a| a.len())) - .unwrap_or(0); + let count_val = count.map(|c| c as usize).unwrap_or(record_count); let inserted_suffix = if inserted { ", inserted" } else { "" }; println!( "{}: {} \u{2026} {} ({} records{})", @@ -202,36 +248,32 @@ fn render_review_gate_metadata(repo: &Repository, metadata: &serde_json::Value) count_val, inserted_suffix ); - } else if let Some(original) = original { - let count_val = count.map(|c| c as usize).unwrap_or(original.len()); + } else if record_count > 0 { + let count_val = count.map(|c| c as usize).unwrap_or(record_count); println!("{}: {} records", emphasis("Aggregate"), count_val); } } // Per-record presence and view membership. - if let Some(original) = original { - if !original.is_empty() { - println!("{}:", emphasis("Records")); - for entry in original { - let Some(s) = entry.as_str() else { continue }; - match Hash::from_base32(s.as_bytes()) { - Some(hash) => { - let present = repo.has_change(&hash); - let mark = if present { "\u{2713}" } else { "\u{2717}" }; - let status = if present { "present" } else { "missing" }; - let views = repo.views_containing_change(&hash).unwrap_or_default(); - let views_suffix = if views.is_empty() { - String::new() - } else { - format!(" ({})", views.join(", ")) - }; - println!(" {} {} {}{}", mark, s, status, views_suffix); - } - None => { - println!(" {}", s); - } - } + if !records.is_empty() { + println!("{}:", emphasis("Records")); + for record in records { + if !record.parseable { + println!(" {}", record.hash); + continue; } + let mark = if record.present { + "\u{2713}" + } else { + "\u{2717}" + }; + let status = if record.present { "present" } else { "missing" }; + let views_suffix = if record.views.is_empty() { + String::new() + } else { + format!(" ({})", record.views.join(", ")) + }; + println!(" {} {} {}{}", mark, record.hash, status, views_suffix); } } } diff --git a/atomic-cli/src/commands/triage/mod.rs b/atomic-cli/src/commands/triage/mod.rs index 2a9aa217..781e9888 100644 --- a/atomic-cli/src/commands/triage/mod.rs +++ b/atomic-cli/src/commands/triage/mod.rs @@ -100,6 +100,19 @@ pub struct TriageCandidates { impl Command for TriageCandidates { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: the handler resolves + // the view pair (the same defaults and errors) and runs the + // candidate-set domain call; this render prints the local lines + // over the wire-carried domain payload. + if let Some(wire) = crate::commands::rpc::triage_candidates(self)? { + let set: atomic_repository::CandidateSet = serde_json::from_slice(&wire.payload) + .map_err(|error| { + CliError::Internal(anyhow::anyhow!("candidate set bundle: {error}")) + })?; + render_candidate_set(&wire.feature, &wire.target, &set, self.json)?; + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; let (feature, into) = resolve_views(&repo, self.feature.as_deref(), self.into.as_deref())?; @@ -107,38 +120,49 @@ impl Command for TriageCandidates { let set = repo .triage_candidate_set(&feature, &into) .map_err(CliError::Repository)?; + render_candidate_set(&feature, &into, &set, self.json)?; + Ok(()) + } +} - if self.json { - println!("{}", serde_json::to_string_pretty(&set).unwrap()); - return Ok(()); - } - - println!("Triage candidates: {} \u{2192} {}", set.feature, set.target); - println!(" only in {}: {}", set.feature, set.only_in_feature.len()); - for hash in &set.only_in_feature { - println!(" {}", hash); - } - println!(" closure additions: {}", set.closure_additions.len()); - for hash in &set.closure_additions { - println!(" {}", hash); - } - println!(" baggage: {}", set.baggage.len()); - for entry in &set.baggage { - let coverage = match entry.coverage { - atomic_repository::Coverage::Covered => "covered", - atomic_repository::Coverage::Uncovered => "uncovered", - atomic_repository::Coverage::Unknown => "unknown", - }; - let files = if entry.modifies.is_empty() { - String::new() - } else { - format!(" [{}]", entry.modifies.join(", ")) - }; - println!(" {} ({}){}", entry.change, coverage, files); - } +/// The candidates render, over the gathered data — the repository path and +/// the routed path (ListTriageCandidates' bundle) share it. +fn render_candidate_set( + feature: &str, + into: &str, + set: &atomic_repository::CandidateSet, + json: bool, +) -> CliResult<()> { + if json { + println!("{}", serde_json::to_string_pretty(&set).unwrap()); + return Ok(()); + } - Ok(()) + println!("Triage candidates: {} \u{2192} {}", set.feature, set.target); + println!(" only in {}: {}", set.feature, set.only_in_feature.len()); + for hash in &set.only_in_feature { + println!(" {}", hash); } + println!(" closure additions: {}", set.closure_additions.len()); + for hash in &set.closure_additions { + println!(" {}", hash); + } + println!(" baggage: {}", set.baggage.len()); + for entry in &set.baggage { + let coverage = match entry.coverage { + atomic_repository::Coverage::Covered => "covered", + atomic_repository::Coverage::Uncovered => "uncovered", + atomic_repository::Coverage::Unknown => "unknown", + }; + let files = if entry.modifies.is_empty() { + String::new() + } else { + format!(" [{}]", entry.modifies.join(", ")) + }; + println!(" {} ({}){}", entry.change, coverage, files); + } + + Ok(()) } /// Write the rendered HTML to a file and (unless suppressed) open it in the @@ -300,6 +324,14 @@ pub struct TriageReview { impl Command for TriageReview { fn run(&self) -> CliResult<()> { + // STRUCTURAL LIMIT (recorded in REAC::aaron::27): the canonical + // report builder (`project::build_report` — the change → file → + // task → intent → acceptance-criterion join) is a ~2000-line + // module entangled with CLI-only presentation helpers (hunk + // display summaries, the `diff -c` builder, the intent bridge); + // porting it into the handler layer is a separate migration and + // GenerateTriageReview refuses until it lands. This command keeps + // its local body — the one remaining redb-open on a routed surface. let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; let (feature, into) = resolve_views(&repo, self.feature.as_deref(), self.into.as_deref())?; diff --git a/atomic-cli/src/commands/unrecord.rs b/atomic-cli/src/commands/unrecord.rs index 47bd3df1..4ed2af03 100644 --- a/atomic-cli/src/commands/unrecord.rs +++ b/atomic-cli/src/commands/unrecord.rs @@ -83,6 +83,16 @@ pub struct Unrecord { impl Command for Unrecord { fn run(&self) -> CliResult<()> { + // Route through the daemon when reachable (non-dry-run); the local + // path below is unchanged. + if crate::commands::rpc::unrecord(self)? { + return Ok(()); + } + // The dry-run form is a read — PreviewMutation (UnrecordPreview). + if self.dry_run && crate::commands::rpc::unrecord_preview(self.change.clone())? { + return Ok(()); + } + let repo_root = find_repository_root()?; let repo = Repository::open(&repo_root).map_err(|e| match e { atomic_repository::RepositoryError::NotFound { path } => CliError::RepositoryNotFound { diff --git a/atomic-cli/src/commands/vault/context.rs b/atomic-cli/src/commands/vault/context.rs index 4c4881df..5bf9c1b5 100644 --- a/atomic-cli/src/commands/vault/context.rs +++ b/atomic-cli/src/commands/vault/context.rs @@ -82,7 +82,7 @@ const JSON_SCHEMA_VERSION: u32 = 1; const CANDIDATE_JSON_SCHEMA_VERSION: u32 = 1; /// Identifies the ranking recipe in run provenance and future retrieval evals. -const RANKER_VERSION: &str = "vault-context-v2"; +pub(crate) const RANKER_VERSION: &str = "vault-context-v2"; /// Fetch this many times `--limit` from typed KG search so body and graph /// signals can rerank a useful candidate pool. @@ -170,6 +170,8 @@ pub struct Context { impl Command for Context { fn run(&self) -> CliResult<()> { + // Argument validation is client-side presentation: it always runs, + // before any service routing engages. let as_json = self.json || self.format.eq_ignore_ascii_case("json"); if !as_json && !self.format.eq_ignore_ascii_case("md") { return Err(CliError::InvalidArgument { @@ -183,6 +185,15 @@ impl Command for Context { }); } + // Every form routes: the gather+rank runs in the service layer + // (the ranking recipe lives in exactly one place — the handler), + // and the renders below are presentation. The local body keeps + // its own gather+rank for the no-service fallback (outside a + // repository) until the legacy body retires. + if crate::commands::rpc::vault_context(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = crate::commands::open_readonly_repository(&root).map_err(CliError::Repository)?; @@ -404,7 +415,7 @@ struct CandidateScore { /// A memory selected for output. #[derive(Clone, Debug, PartialEq)] -struct MemoryItem { +pub(crate) struct MemoryItem { /// Canonical RDF/resource identity when present, otherwise the KG identity. memory_id: String, /// Identity currently used by the KG. This may differ from `memory_id`. @@ -774,9 +785,34 @@ fn truncate_chars(s: &str, max_chars: usize) -> String { // Rendering +/// Convert the wire's ranked ContextItems into the render's MemoryItems — +/// the service layer's gather+rank already computed these; the renders are +/// presentation (one render, two data sources). +pub(crate) fn wire_items(items: &[atomic_client::proto::ContextItem]) -> Vec { + items + .iter() + .map(|item| MemoryItem { + memory_id: item.memory_id.clone(), + kg_node_id: item.kg_node_id.clone(), + revision_hash: item.revision_hash.clone(), + content_hash: item.content_hash.clone(), + path: item.path.clone(), + name: item.name.clone(), + kind: item.kind.clone(), + status: item.status.clone(), + updated_at: item.updated_at.clone(), + introduced_by: item.introduced_by, + score: item.score, + why_matched: item.why_matched.clone(), + body: item.body.clone().unwrap_or_default(), + truncated: item.truncated, + }) + .collect() +} + /// Render the prompt-ready markdown block. Empty input renders to an /// empty string so callers can prepend unconditionally. -fn render_md(items: &[MemoryItem]) -> String { +pub(crate) fn render_md(items: &[MemoryItem]) -> String { if items.is_empty() { return String::new(); } @@ -814,7 +850,7 @@ fn render_md(items: &[MemoryItem]) -> String { } /// Render body-free candidate metadata for a small first-pass prompt. -fn render_candidates_md(items: &[MemoryItem]) -> String { +pub(crate) fn render_candidates_md(items: &[MemoryItem]) -> String { if items.is_empty() { return String::new(); } @@ -849,7 +885,11 @@ fn render_candidates_md(items: &[MemoryItem]) -> String { /// Render the compact candidate contract. It deliberately contains no body, /// preview, or prompt-ready context field. -fn render_candidates_json(items: &[MemoryItem], request: &Context, limit: usize) -> String { +pub(crate) fn render_candidates_json( + items: &[MemoryItem], + request: &Context, + limit: usize, +) -> String { let values: Vec = items .iter() .map(|item| { @@ -884,7 +924,12 @@ fn render_candidates_json(items: &[MemoryItem], request: &Context, limit: usize) /// Render a single-call envelope containing both injectable text and exact /// memory exposure metadata for run provenance. -fn render_json(items: &[MemoryItem], md: &str, request: &Context, limit: usize) -> String { +pub(crate) fn render_json( + items: &[MemoryItem], + md: &str, + request: &Context, + limit: usize, +) -> String { let values: Vec = items .iter() .map(|item| { diff --git a/atomic-cli/src/commands/vault/goal.rs b/atomic-cli/src/commands/vault/goal.rs index f18006e2..c1ca4cbc 100644 --- a/atomic-cli/src/commands/vault/goal.rs +++ b/atomic-cli/src/commands/vault/goal.rs @@ -136,6 +136,8 @@ pub struct Goal { impl Command for Goal { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (start/stop/resume + // ride the Vault entity wire; list already routes). match &self.command { GoalCommands::Start(cmd) => cmd.run(), GoalCommands::Stop(cmd) => cmd.run(), @@ -174,6 +176,9 @@ pub struct GoalStart { impl Command for GoalStart { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::vault_goal_start(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -221,6 +226,9 @@ pub struct GoalStop { impl Command for GoalStop { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::vault_goal_stop(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -279,6 +287,9 @@ pub struct GoalResume { impl Command for GoalResume { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::vault_goal_resume(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -324,8 +335,57 @@ pub struct GoalList { pub json: bool, } +/// One goal listing row — the shape both the local body (repo-read) and +/// the routed hook (wire-carried) render. +pub(crate) struct GoalRow { + pub name: String, + pub developer: String, + pub status: String, + pub intent: Option, + pub started_at: String, + pub turns: u32, +} + +/// The shared render — the exact local body's table and JSON shapes. +pub(crate) fn render_goal_rows(rows: &[GoalRow], json: bool) { + if json { + let json: Vec = rows + .iter() + .map(|s| { + serde_json::json!({ + "name": s.name, + "developer": s.developer, + "status": s.status, + "intent": s.intent, + "started_at": s.started_at, + "turns": s.turns, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + return; + } + if rows.is_empty() { + println!("No goals found."); + return; + } + for s in rows { + let intent_str = s.intent.as_deref().unwrap_or(""); + println!( + " {:12} {:24} {:10} {}", + s.status, s.name, s.developer, intent_str + ); + } +} + impl Command for GoalList { fn run(&self) -> CliResult<()> { + // Every form routes (the raw status filter and the + // started_at/turns columns ride the wire). + if crate::commands::rpc::vault_goal_list(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -336,34 +396,20 @@ impl Command for GoalList { }; let goals = repo.vault_goal_list(filter).map_err(CliError::Repository)?; - if self.json { - let json: Vec = goals + render_goal_rows( + &goals .iter() - .map(|s| { - serde_json::json!({ - "name": s.name, - "developer": s.developer, - "status": s.status, - "intent": s.intent, - "started_at": s.started_at, - "turns": s.turns, - }) + .map(|s| GoalRow { + name: s.name.clone(), + developer: s.developer.clone(), + status: s.status.clone(), + intent: s.intent.clone(), + started_at: s.started_at.clone(), + turns: s.turns, }) - .collect(); - println!("{}", serde_json::to_string_pretty(&json).unwrap()); - } else { - if goals.is_empty() { - println!("No goals found."); - return Ok(()); - } - for s in &goals { - let intent_str = s.intent.as_deref().unwrap_or(""); - println!( - " {:12} {:24} {:10} {}", - s.status, s.name, s.developer, intent_str - ); - } - } + .collect::>(), + self.json, + ); Ok(()) } @@ -384,6 +430,9 @@ pub struct GoalShow { impl Command for GoalShow { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::vault_goal_show(self)? { + return Ok(()); + } let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/vault/init.rs b/atomic-cli/src/commands/vault/init.rs index a22a04f9..6073aefe 100644 --- a/atomic-cli/src/commands/vault/init.rs +++ b/atomic-cli/src/commands/vault/init.rs @@ -25,6 +25,12 @@ pub struct Init; impl Command for Init { fn run(&self) -> CliResult<()> { + // Route every form through the service layer: vault init operates + // inside an EXISTING repository (it requires one), so it routes + // normally via the daemon (the handler runs the exact local flow). + if crate::commands::rpc::vault_init()? { + return Ok(()); + } let root = find_repository_root()?; let mut repo = Repository::open(&root).map_err(|e| CliError::InvalidRepository { reason: e.to_string(), diff --git a/atomic-cli/src/commands/vault/list.rs b/atomic-cli/src/commands/vault/list.rs index 597eff45..9ffabd3d 100644 --- a/atomic-cli/src/commands/vault/list.rs +++ b/atomic-cli/src/commands/vault/list.rs @@ -68,8 +68,58 @@ pub struct List { pub json: bool, } +/// One vault listing row (path, entry type, content size, updated-at) — +/// the shape both the local body (repo-read) and the routed hook +/// (wire-carried) render. +pub(crate) struct EntryRow { + pub path: String, + pub entry_type: String, + pub size: u64, + pub updated_at: String, +} + +/// The shared render — the exact local body's table and JSON shapes. +pub(crate) fn render_rows(rows: &[EntryRow], json: bool) { + if json { + let json: Vec = rows + .iter() + .map(|e| { + serde_json::json!({ + "path": e.path, + "type": e.entry_type, + "size": e.size, + "updated_at": e.updated_at, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + return; + } + if rows.is_empty() { + println!("No vault entries found."); + return; + } + for entry in rows { + println!( + " {:12} {:>8} {}", + entry.entry_type, + format_size(entry.size), + entry.path, + ); + } + let count_label = if rows.len() == 1 { "entry" } else { "entries" }; + println!("\n{} {}", rows.len(), count_label); +} + impl Command for List { fn run(&self) -> CliResult<()> { + // Every form routes (the whole-vault listing carries its + // type/size/date columns on the wire; --prefix/--type ride the + // request's filters). + if crate::commands::rpc::vault_list(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; @@ -80,41 +130,18 @@ impl Command for List { .vault_list(prefix, type_filter) .map_err(CliError::Repository)?; - if self.json { - let json: Vec = entries + render_rows( + &entries .iter() - .map(|e| { - serde_json::json!({ - "path": e.path, - "type": e.entry_type, - "size": e.content_size, - "updated_at": e.updated_at, - }) + .map(|e| EntryRow { + path: e.path.clone(), + entry_type: e.entry_type.to_string(), + size: e.content_size as u64, + updated_at: e.updated_at.clone(), }) - .collect(); - println!("{}", serde_json::to_string_pretty(&json).unwrap()); - } else { - if entries.is_empty() { - println!("No vault entries found."); - return Ok(()); - } - - for entry in &entries { - println!( - " {:12} {:>8} {}", - entry.entry_type, - format_size(entry.content_size as u64), - entry.path, - ); - } - - let count_label = if entries.len() == 1 { - "entry" - } else { - "entries" - }; - println!("\n{} {}", entries.len(), count_label); - } + .collect::>(), + self.json, + ); Ok(()) } diff --git a/atomic-cli/src/commands/vault/materialize.rs b/atomic-cli/src/commands/vault/materialize.rs index e1175fc8..c9540675 100644 --- a/atomic-cli/src/commands/vault/materialize.rs +++ b/atomic-cli/src/commands/vault/materialize.rs @@ -48,6 +48,12 @@ pub struct Materialize { impl Command for Materialize { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the handler runs the + // same vault_materialize/_all domain calls). + if crate::commands::rpc::vault_materialize(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/vault/mod.rs b/atomic-cli/src/commands/vault/mod.rs index 88ecbf90..8cd2147a 100644 --- a/atomic-cli/src/commands/vault/mod.rs +++ b/atomic-cli/src/commands/vault/mod.rs @@ -76,7 +76,7 @@ use crate::error::CliResult; /// Identify the exact stored Vault revision, including entry type, /// frontmatter, and body rather than only the body content hash. -fn vault_entry_revision_hash(entry: &VaultEntry) -> String { +pub(crate) fn vault_entry_revision_hash(entry: &VaultEntry) -> String { let mut hasher = Hasher::new(); hasher.update(b"atomic-vault-revision-v1"); append_revision_field(&mut hasher, entry.entry_type.as_str().as_bytes()); diff --git a/atomic-cli/src/commands/vault/show.rs b/atomic-cli/src/commands/vault/show.rs index c4674e5d..e26e4e2a 100644 --- a/atomic-cli/src/commands/vault/show.rs +++ b/atomic-cli/src/commands/vault/show.rs @@ -47,6 +47,14 @@ pub struct Show { impl Command for Show { fn run(&self) -> CliResult<()> { + // Every form routes: the wire resolves by path and carries the + // stored entry's full domain bytes, so the revision hash, the + // JSON projection, and the --revision guard all run client-side + // over wire-carried data. + if crate::commands::rpc::vault_show(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = crate::commands::open_readonly_repository(&root).map_err(CliError::Repository)?; @@ -82,7 +90,7 @@ impl Command for Show { } } -fn require_revision(path: &str, expected: Option<&str>, actual: &str) -> CliResult<()> { +pub(crate) fn require_revision(path: &str, expected: Option<&str>, actual: &str) -> CliResult<()> { match expected { Some(expected) if !expected.eq_ignore_ascii_case(actual) => { return Err(CliError::InvalidArgument { @@ -96,7 +104,7 @@ fn require_revision(path: &str, expected: Option<&str>, actual: &str) -> CliResu Ok(()) } -fn entry_json(path: &str, entry: &VaultEntry, revision_hash: &str) -> serde_json::Value { +pub(crate) fn entry_json(path: &str, entry: &VaultEntry, revision_hash: &str) -> serde_json::Value { serde_json::json!({ "schema_version": 1, "mode": "vault_entry_body", diff --git a/atomic-cli/src/commands/vault/summaries.rs b/atomic-cli/src/commands/vault/summaries.rs index 2688ba56..805454bd 100644 --- a/atomic-cli/src/commands/vault/summaries.rs +++ b/atomic-cli/src/commands/vault/summaries.rs @@ -60,6 +60,12 @@ pub struct Summaries { impl Command for Summaries { fn run(&self) -> CliResult<()> { + // Route every form through the service layer (the previews arm + // computes the same enumeration + 200-char previews). + if crate::commands::rpc::vault_summaries(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/vault/sync.rs b/atomic-cli/src/commands/vault/sync.rs index 085dc878..6b87a94d 100644 --- a/atomic-cli/src/commands/vault/sync.rs +++ b/atomic-cli/src/commands/vault/sync.rs @@ -38,6 +38,10 @@ pub struct Sync; impl Command for Sync { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::vault_sync()? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(CliError::Repository)?; diff --git a/atomic-cli/src/commands/view/delete.rs b/atomic-cli/src/commands/view/delete.rs index b9da1414..8a199717 100644 --- a/atomic-cli/src/commands/view/delete.rs +++ b/atomic-cli/src/commands/view/delete.rs @@ -104,6 +104,11 @@ impl Delete { impl Command for Delete { fn run(&self) -> CliResult<()> { + // Route through the daemon (--force only affects the local warning). + if crate::commands::rpc::view_delete(self)? { + return Ok(()); + } + // Get the view name let name = self .name @@ -299,15 +304,19 @@ mod tests { // Change to the repo directory std::env::set_current_dir(repo_path).unwrap(); - // Try to delete a non-existent view + // Try to delete a non-existent view. The plain form routes + // through the service area; the refusal is the domain's. let cmd = Delete::with_name("nonexistent"); let result = cmd.run(); assert!(result.is_err()); match result.unwrap_err() { - CliError::ViewNotFound { name } => { - assert_eq!(name, "nonexistent"); + CliError::ServiceRefusal { message } => { + assert!( + message.contains("view 'nonexistent' not found"), + "unexpected refusal: {message}" + ); } - other => panic!("Expected ViewNotFound, got: {:?}", other), + other => panic!("Expected the view-not-found refusal, got: {:?}", other), } } @@ -328,15 +337,20 @@ mod tests { // Change to the repo directory std::env::set_current_dir(repo_path).unwrap(); - // Try to delete the current view + // Try to delete the current view. The plain form routes through + // the service area (local backend by default): the domain's + // refusal rides the contract's error vocabulary. let cmd = Delete::with_name("dev"); let result = cmd.run(); assert!(result.is_err()); match result.unwrap_err() { - CliError::CannotDeleteCurrentView { name } => { - assert_eq!(name, "dev"); + CliError::ServiceRefusal { message } => { + assert!( + message.contains("cannot delete the current view 'dev'"), + "unexpected refusal: {message}" + ); } - other => panic!("Expected CannotDeleteCurrentView, got: {:?}", other), + other => panic!("Expected the current-view refusal, got: {:?}", other), } } diff --git a/atomic-cli/src/commands/view/list.rs b/atomic-cli/src/commands/view/list.rs index 1f63186b..687f0ef3 100644 --- a/atomic-cli/src/commands/view/list.rs +++ b/atomic-cli/src/commands/view/list.rs @@ -41,6 +41,7 @@ use std::time::Duration; use clap::Parser; use serde::Serialize; +use atomic_core::types::Base32; use atomic_remote::{HttpRemote, HttpRemoteConfig, RemoteViewInfo}; use atomic_repository::repository::ViewInfo; use atomic_repository::Repository; @@ -520,6 +521,10 @@ impl List { impl Command for List { fn run(&self) -> CliResult<()> { + if crate::commands::rpc::view_list(self)? { + return Ok(()); + } + // Remote listing takes over entirely when --remote is present. if let Some(remote_arg) = &self.remote { return self.run_remote(remote_arg); @@ -616,21 +621,21 @@ struct JsonViewList { views: Vec, } -#[derive(Debug, Serialize, PartialEq, Eq)] -struct JsonView { - name: String, - current: bool, - scope: String, - parent: Option, - change_count: u64, - own_change_count: Option, - inherited_change_count: Option, - state: Option, - set_id: Option, +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct JsonView { + pub(crate) name: String, + pub(crate) current: bool, + pub(crate) scope: String, + pub(crate) parent: Option, + pub(crate) change_count: u64, + pub(crate) own_change_count: Option, + pub(crate) inherited_change_count: Option, + pub(crate) state: Option, + pub(crate) set_id: Option, } impl JsonView { - fn from_local(info: &atomic_repository::ViewInfo, current: bool) -> Self { + pub(crate) fn from_local(info: &atomic_repository::ViewInfo, current: bool) -> Self { Self { name: info.name.clone(), current, @@ -646,6 +651,35 @@ impl JsonView { } } + /// One view off the wire's ViewInfo — the routed twin of + /// [`JsonView::from_local`]: the handler classifies own/inherited + /// change counts and carries the head state, so the JSON document + /// renders identically from wire data. + pub(crate) fn from_wire(view: &atomic_client::proto::ViewInfo) -> Self { + let own = view.own_change_count.unwrap_or(view.change_count); + let inherited = view.inherited_change_count.unwrap_or(0); + Self { + name: view.name.clone(), + current: view.current, + scope: match atomic_client::proto::ViewScope::try_from(view.scope) { + Ok(atomic_client::proto::ViewScope::Draft) => "draft".to_string(), + _ => "shared".to_string(), + }, + parent: view.parent.clone(), + change_count: own.saturating_add(inherited), + own_change_count: Some(own), + inherited_change_count: Some(inherited), + state: view.head.as_ref().and_then(|hash| { + hash.value + .clone() + .try_into() + .ok() + .map(|bytes: [u8; 32]| atomic_core::types::Merkle(bytes).to_base32()) + }), + set_id: None, + } + } + fn from_remote(info: &RemoteViewInfo) -> CliResult { let scope = if info.scope.eq_ignore_ascii_case("shared") { "shared" @@ -691,6 +725,22 @@ fn print_json(output: &JsonViewList) -> CliResult<()> { Ok(()) } +/// The local-source JSON document, printed from wire-carried views — the +/// same document shape the local body emits (repository_root and the +/// current-view marker resolve client-side, exactly like the local +/// path). +pub(crate) fn print_local_json(views: &[JsonView], current_view: Option) -> CliResult<()> { + let repo_root = crate::commands::find_repository_root()?; + print_json(&JsonViewList { + schema_version: VIEW_LIST_JSON_SCHEMA_VERSION, + source: "local", + repository_root: Some(repo_root.to_string_lossy().into_owned()), + remote: None, + current_view, + views: views.to_vec(), + }) +} + // Tests #[cfg(test)] diff --git a/atomic-cli/src/commands/view/new.rs b/atomic-cli/src/commands/view/new.rs index 965e7c9f..4b990a14 100644 --- a/atomic-cli/src/commands/view/new.rs +++ b/atomic-cli/src/commands/view/new.rs @@ -91,7 +91,7 @@ const INVALID_CHARS: &[char] = &['/', '\\', '\0', ':', '*', '?', '"', '<', '>', /// # Returns /// /// `Ok(())` if the name is valid, or an error describing why it's invalid. -fn validate_view_name(name: &str) -> Result<(), String> { +pub(crate) fn validate_view_name(name: &str) -> Result<(), String> { // Check for empty name if name.is_empty() { return Err("View name cannot be empty".to_string()); @@ -274,6 +274,12 @@ impl New { impl Command for New { fn run(&self) -> CliResult<()> { + // Route EVERY form through the daemon — from/empty/plain and the + // --parent draft-workspace composition (the request's Parent arm). + if crate::commands::rpc::view_create(self)? { + return Ok(()); + } + // Get the view name let name = self .name @@ -670,15 +676,19 @@ mod tests { let cmd = New::with_name("duplicate"); assert!(cmd.run().is_ok()); - // Try to create it again + // Try to create it again. The plain form routes through the + // service area; the refusal is the domain's. let cmd = New::with_name("duplicate"); let result = cmd.run(); assert!(result.is_err()); match result.unwrap_err() { - CliError::ViewAlreadyExists { name } => { - assert_eq!(name, "duplicate"); + CliError::ServiceRefusal { message } => { + assert!( + message.contains("view 'duplicate' already exists"), + "unexpected refusal: {message}" + ); } - other => panic!("Expected ViewAlreadyExists, got: {:?}", other), + other => panic!("Expected the duplicate-view refusal, got: {:?}", other), } } @@ -724,15 +734,20 @@ mod tests { std::env::set_current_dir(repo_path).unwrap(); - // Try to create a view from a nonexistent source + // Try to create a view from a nonexistent source. The --from + // form routes through the service area; the refusal is the + // domain's. let cmd = New::with_name("new-view").with_from("nonexistent"); let result = cmd.run(); assert!(result.is_err()); match result.unwrap_err() { - CliError::ViewNotFound { name } => { - assert_eq!(name, "nonexistent"); + CliError::ServiceRefusal { message } => { + assert!( + message.contains("view 'nonexistent' not found"), + "unexpected refusal: {message}" + ); } - other => panic!("Expected ViewNotFound, got: {:?}", other), + other => panic!("Expected the view-not-found refusal, got: {:?}", other), } } // ------------------------------------------------------------------------- diff --git a/atomic-cli/src/commands/view/promote.rs b/atomic-cli/src/commands/view/promote.rs index bc45cac2..602f9635 100644 --- a/atomic-cli/src/commands/view/promote.rs +++ b/atomic-cli/src/commands/view/promote.rs @@ -43,6 +43,11 @@ pub struct Promote { impl Command for Promote { fn run(&self) -> CliResult<()> { + // Route through the daemon (SetViewScope SHARED). + if crate::commands::rpc::view_promote(self)? { + return Ok(()); + } + let root = find_repository_root()?; let repo = Repository::open(&root).map_err(|e| CliError::InvalidRepository { reason: e.to_string(), diff --git a/atomic-cli/src/commands/view/split.rs b/atomic-cli/src/commands/view/split.rs index a83eed88..b7aea2f2 100644 --- a/atomic-cli/src/commands/view/split.rs +++ b/atomic-cli/src/commands/view/split.rs @@ -86,6 +86,13 @@ pub struct Split { impl Command for Split { fn run(&self) -> CliResult<()> { + // Route through the daemon when reachable (BEFORE any local + // repository open). Dry-run previews stay local — the analysis is + // a read, and the CLI renders its own preview. + if !self.dry_run && crate::commands::rpc::view_split(self)? { + return Ok(()); + } + let repo_root = find_repository_root()?; let mut repo = Repository::open(&repo_root).map_err(|e| match e { atomic_repository::RepositoryError::NotFound { path } => CliError::RepositoryNotFound { diff --git a/atomic-cli/src/commands/view/switch.rs b/atomic-cli/src/commands/view/switch.rs index 5086b075..4cb1f2c8 100644 --- a/atomic-cli/src/commands/view/switch.rs +++ b/atomic-cli/src/commands/view/switch.rs @@ -103,6 +103,12 @@ impl Switch { impl Command for Switch { fn run(&self) -> CliResult<()> { + // Route the plain form through the daemon; --force/--stash stay + // local (the wire carries no bypass of the dirty-copy gate). + if crate::commands::rpc::view_switch(self)? { + return Ok(()); + } + // Get the view name let name = self .name @@ -310,15 +316,19 @@ mod tests { // Change to the repo directory std::env::set_current_dir(repo_path).unwrap(); - // Try to switch to a non-existent view + // Try to switch to a non-existent view. The plain form routes + // through the service area; the refusal is the domain's. let cmd = Switch::with_name("nonexistent"); let result = cmd.run(); assert!(result.is_err()); match result.unwrap_err() { - CliError::ViewNotFound { name } => { - assert_eq!(name, "nonexistent"); + CliError::ServiceRefusal { message } => { + assert!( + message.contains("view 'nonexistent' not found"), + "unexpected refusal: {message}" + ); } - other => panic!("Expected ViewNotFound, got: {:?}", other), + other => panic!("Expected the view-not-found refusal, got: {:?}", other), } } diff --git a/atomic-cli/src/error.rs b/atomic-cli/src/error.rs index fcae6c4a..3d953fd0 100644 --- a/atomic-cli/src/error.rs +++ b/atomic-cli/src/error.rs @@ -375,6 +375,18 @@ pub enum CliError { operation: String, }, + /// A domain refusal surfaced by the service layer — the contract's + /// stable error vocabulary (the message carries the code and the + /// reason, e.g. `atomic:PRECONDITION_FAILED: ...`). + /// + /// This is an expected, user-fixable condition (not a bug): the domain + /// refused the operation and the message says what to do about it. + #[error("{message}")] + ServiceRefusal { + /// The domain's refusal, with its stable code prefix. + message: String, + }, + // Internal Errors /// An unexpected internal error occurred. /// @@ -652,6 +664,7 @@ impl CliError { | Self::Cancelled | Self::FileAlreadyTracked { .. } | Self::RequiresForce { .. } + | Self::ServiceRefusal { .. } | Self::ViewAlreadyExists { .. } => 1, // Command-line usage errors diff --git a/atomic-cli/src/main.rs b/atomic-cli/src/main.rs index 1cd1e275..b15eb341 100644 --- a/atomic-cli/src/main.rs +++ b/atomic-cli/src/main.rs @@ -52,6 +52,7 @@ mod agent_error; mod commands; mod error; mod output; +mod service; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; diff --git a/atomic-cli/src/service/mod.rs b/atomic-cli/src/service/mod.rs new file mode 100644 index 00000000..c622ef0c --- /dev/null +++ b/atomic-cli/src/service/mod.rs @@ -0,0 +1,1634 @@ +//! The CLI's service area: one interface, two backends. +//! +//! Every wired command speaks the `atomic` contract through [`Service`]; +//! the backend is chosen once per invocation by [`mode()`]: +//! +//! * **local** (the default, `ATOMIC_SERVICE=local`) — the handlers run +//! IN THIS PROCESS: the same libatomic service impls the atomicd +//! transport serves over the socket, called directly on a locally +//! constructed [`DaemonState`]. No socket is probed, no daemon is +//! spawned, no client is constructed — not even a health check. The +//! redb single-writer invariant holds by assumption (no concurrent +//! daemon on the same machine). +//! * **reactor** (`ATOMIC_SERVICE=reactor`) — today's D4 semantics: the +//! generated clients over the atomic-client socket session, starting +//! the atomicd transport when it is down (start-or-retry). Reactor +//! mode never constructs local handlers in this process. +//! +//! `ATOMIC_RPC` remains as a legacy alias: `ATOMIC_RPC=1` is +//! `ATOMIC_SERVICE=reactor`; anything else routes local. The routing +//! default is local — the CLI no longer probes for a reachable daemon. +//! +//! Every wired flag-form opens a service session — including the ones +//! that once had no wire shape (the contract was extended add-only, so +//! `insert --deps=false` and the rest now route). + +use std::path::Path; +use std::sync::Arc; + +use atomic_client::proto as pb; +use libatomic::daemon::services; +use libatomic::daemon::services_agent; +use libatomic::daemon::services_maintenance; +use libatomic::daemon::services_query; +use libatomic::daemon::services_sandbox; +use libatomic::daemon::services_sync; +use libatomic::daemon::services_tag; +use libatomic::daemon::services_triage; +use libatomic::daemon::state::DaemonState; +// The tonic server traits: in scope so the local backend calls the very +// same trait methods the transport serves. +use libatomic::atomic::attestation_service_server::AttestationService as _; +use libatomic::atomic::daemon_service_server::DaemonService as _; +use libatomic::atomic::knowledge_service_server::KnowledgeService as _; +use libatomic::atomic::maintenance_service_server::MaintenanceService as _; +use libatomic::atomic::provenance_service_server::ProvenanceService as _; +use libatomic::atomic::repository_mutation_service_server::RepositoryMutationService as _; +use libatomic::atomic::repository_query_service_server::RepositoryQueryService as _; +use libatomic::atomic::sandbox_service_server::SandboxService as _; +use libatomic::atomic::sync_service_server::SyncService as _; +use libatomic::atomic::tag_service_server::TagService as _; +use libatomic::atomic::triage_service_server::TriageService as _; +use libatomic::atomic::vault_service_server::VaultService as _; +use libatomic::atomic::view_service_server::ViewService as _; +use tonic::transport::Channel; +use tonic::{Request, Status}; + +use crate::error::{CliError, CliResult}; + +/// The service-area selector. `ATOMIC_SERVICE` wins; `ATOMIC_RPC` is the +/// legacy alias (1 → reactor, anything else → local); unset routes local. +pub const ENV_SERVICE: &str = "ATOMIC_SERVICE"; + +/// The legacy alias, honored verbatim: `ATOMIC_RPC=1` routed strictly over +/// the daemon socket, `ATOMIC_RPC=0` never routed. +pub const ENV_RPC: &str = "ATOMIC_RPC"; + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +pub enum Mode { + /// The handlers run in-process (libatomic, no socket, no daemon). + Local, + /// The handlers run in the atomicd transport over the socket (D4). + Reactor, +} + +/// Resolve the service mode from the environment. See the module docs: +/// local is the default; unknown values route local (no socket probing — +/// an `ATOMIC_SERVICE=auto` refinement is deliberately out of scope). +pub fn mode() -> Mode { + match std::env::var(ENV_SERVICE).ok().as_deref() { + Some("reactor") => return Mode::Reactor, + Some("local") => return Mode::Local, + _ => {} + } + match std::env::var(ENV_RPC).ok().as_deref() { + Some("1") => Mode::Reactor, + _ => Mode::Local, + } +} + +/// One backend's live session state. +enum Backend { + /// The in-process handler state — libatomic's registry, gates, and + /// handlers, exactly what the transport serves. + Local(Arc), + /// A connected channel to the atomicd transport. + Reactor(Channel), +} + +/// A connected session for one command invocation: the resolved +/// persistent repository reference (path discovery happens once, before +/// any domain call) plus the backend the mode selected. +pub struct Service { + mode: Mode, + /// The persistent repository reference every request echoes. + pub reference: pb::RepositoryRef, + local: Option>, + reactor: Option, + /// The runtime driving the handlers (both backends are async; tonic + /// spawns the connection's background tasks on the connecting + /// runtime — dropping it severs the channel, so it lives as long as + /// the session). + rt: tokio::runtime::Runtime, +} + +impl Service { + /// Open the service session for the ambient repository. Returns + /// `None` outside a repository — the caller falls through to its own + /// (proper) not-a-repository error path. + pub fn open() -> CliResult> { + let Ok(root) = crate::commands::find_repository_root() else { + return Ok(None); + }; + Self::open_root(&root) + } + + /// Open the service session for an explicit repository path (the + /// Reactor's typed dispatch always passes the canonical root). + pub fn open_root(root: &Path) -> CliResult> { + let mode = mode(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| CliError::Internal(anyhow::anyhow!("service runtime: {error}")))?; + match mode { + Mode::Local => { + // In-process: construct the handler state and resolve the + // repository through the SAME handler the transport serves. + // No socket, no daemon, no client — ever. + let state = Arc::new(DaemonState::new()); + let daemon = services::DaemonImpl { + state: state.clone(), + }; + let resolved = rt + .block_on(daemon.resolve_repository(Request::new( + pb::ResolveRepositoryRequest { + path: root.display().to_string(), + }, + ))) + .map_err(|error| { + CliError::Internal(anyhow::anyhow!("local service resolve failed: {error}")) + })? + .into_inner(); + let reference = resolved.repository.ok_or_else(|| { + CliError::Internal(anyhow::anyhow!("local service resolved no repository")) + })?; + Ok(Some(Service { + mode, + reference, + local: Some(state), + reactor: None, + rt, + })) + } + Mode::Reactor => { + // D4 start-or-retry: connect, starting the transport when + // it is down, then resolve the repository over the socket. + // Strict — there is no auto fallback. + let connect = async { + let client = atomic_client::AtomicClient::connect_or_start() + .await + .map_err(|e| anyhow::anyhow!(e))?; + let mut daemon = + pb::daemon_service_client::DaemonServiceClient::new(client.channel.clone()); + let resolved = daemon + .resolve_repository(pb::ResolveRepositoryRequest { + path: root.display().to_string(), + }) + .await? + .into_inner(); + let reference = resolved + .repository + .ok_or_else(|| anyhow::anyhow!("daemon resolved no repository"))?; + Ok::<_, anyhow::Error>((client.channel, reference)) + }; + let (channel, reference) = rt.block_on(connect).map_err(|error| { + CliError::Internal(anyhow::anyhow!("reactor service routing failed: {error}")) + })?; + Ok(Some(Service { + mode, + reference, + local: None, + reactor: Some(channel), + rt, + })) + } + } + } + + fn backend(&self) -> Backend { + match self.mode { + Mode::Local => Backend::Local(self.local.clone().expect("local backend state")), + Mode::Reactor => { + Backend::Reactor(self.reactor.clone().expect("reactor backend channel")) + } + } + } + + /// Drive one service exchange on the session's runtime, mapping the + /// transport/domain status to the CLI's error vocabulary. + fn call(&self, exchange: F) -> CliResult + where + F: FnOnce(Backend) -> Fut, + Fut: std::future::Future>, + { + let backend = self.backend(); + self.rt.block_on(exchange(backend)).map_err(status_message) + } + + /// The mode this session was opened in (the journal sink and other + /// mode-aware components ask). + pub fn mode(&self) -> Mode { + self.mode + } +} + +/// A transport/domain refusal surfaced with its contract vocabulary. The +/// handler already encodes the stable error code and the human message +/// (`atomic:VIEW: ...`); the gRPC plumbing around it is transport noise. +/// User-fixable refusals (preconditions, bad arguments, not-found, +/// permission) exit as user errors — only genuine internal failures are +/// bugs. +pub fn status_message(error: tonic::Status) -> CliError { + use tonic::Code; + let message = error.message().to_string(); + match error.code() { + Code::FailedPrecondition + | Code::InvalidArgument + | Code::NotFound + | Code::AlreadyExists + | Code::PermissionDenied + | Code::Unauthenticated + | Code::ResourceExhausted + | Code::Cancelled + | Code::Unavailable => CliError::ServiceRefusal { message }, + _ => CliError::Internal(anyhow::anyhow!("{message}")), + } +} + +// --------------------------------------------------------------------------- +// DaemonService (session keep-alive) +// --------------------------------------------------------------------------- + +// (ResolveRepository runs inside Service::open on both backends.) + +// --------------------------------------------------------------------------- +// RepositoryQueryService +// --------------------------------------------------------------------------- + +impl Service { + pub fn status(&self, request: pb::StatusRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services::QueryImpl { state } + .status(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut query = + pb::repository_query_service_client::RepositoryQueryServiceClient::new( + channel, + ); + query + .status(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn log(&self, request: pb::LogRequest) -> CliResult { + // all_views / path_filter / tags_only ride the request as-is. + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services::QueryImpl { state } + .log(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut query = + pb::repository_query_service_client::RepositoryQueryServiceClient::new( + channel, + ); + query + .log(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn get_change(&self, request: pb::GetChangeRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services::QueryImpl { state } + .get_change(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut query = + pb::repository_query_service_client::RepositoryQueryServiceClient::new( + channel, + ); + query + .get_change(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + /// DiffWorkingCopy, collected: both backends stream; the CLI renders + /// the whole listing. + pub fn diff_working_copy( + &self, + request: pb::DiffWorkingCopyRequest, + ) -> CliResult> { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + let stream = services::QueryImpl { state } + .diff_working_copy(Request::new(request)) + .await? + .into_inner(); + collect_chunks(Box::pin(stream)).await + } + Backend::Reactor(channel) => { + let mut query = + pb::repository_query_service_client::RepositoryQueryServiceClient::new( + channel, + ); + let stream = query.diff_working_copy(request).await?.into_inner(); + collect_streaming(stream).await + } + } + }) + } + + /// The view-pair Diff, collected (same rendering as the working copy). + pub fn diff(&self, request: pb::DiffRequest) -> CliResult> { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + let stream = services::QueryImpl { state } + .diff(Request::new(request)) + .await? + .into_inner(); + collect_chunks(Box::pin(stream)).await + } + Backend::Reactor(channel) => { + let mut query = + pb::repository_query_service_client::RepositoryQueryServiceClient::new( + channel, + ); + let stream = query.diff(request).await?.into_inner(); + collect_streaming(stream).await + } + } + }) + } + + pub fn list_conflicts( + &self, + request: pb::ListConflictsRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services::QueryImpl { state } + .list_conflicts(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut query = + pb::repository_query_service_client::RepositoryQueryServiceClient::new( + channel, + ); + query + .list_conflicts(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn preview_mutation( + &self, + request: pb::PreviewMutationRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services::QueryImpl { state } + .preview_mutation(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut query = + pb::repository_query_service_client::RepositoryQueryServiceClient::new( + channel, + ); + query + .preview_mutation(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn preview_restore( + &self, + request: pb::PreviewRestoreRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services::QueryImpl { state } + .preview_restore(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut query = + pb::repository_query_service_client::RepositoryQueryServiceClient::new( + channel, + ); + query + .preview_restore(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +/// Collect a local handler's item stream (each item is already a +/// `Result`). +async fn collect_chunks( + mut stream: std::pin::Pin< + Box> + Send>, + >, +) -> Result, Status> { + let mut chunks = Vec::new(); + while let Some(chunk) = tokio_stream::StreamExt::next(&mut stream).await { + chunks.push(chunk?); + } + Ok(chunks) +} + +/// Collect a reactor client's tonic streaming response. +async fn collect_streaming( + mut stream: tonic::Streaming, +) -> Result, Status> { + let mut chunks = Vec::new(); + while let Some(chunk) = stream.message().await? { + chunks.push(chunk); + } + Ok(chunks) +} + +// --------------------------------------------------------------------------- +// RepositoryMutationService +// --------------------------------------------------------------------------- + +impl Service { + pub fn add_files(&self, request: pb::AddFilesRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .add_files(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.add_files(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn record(&self, request: pb::RecordRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .record(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.record(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn remove_files( + &self, + request: pb::RemoveFilesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .remove_files(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.remove_files(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn move_file(&self, request: pb::MoveFileRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .move_file(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.move_file(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn unrecord(&self, request: pb::UnrecordRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .unrecord(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.unrecord(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn restore(&self, request: pb::RestoreRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .restore(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.restore(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn create_stash( + &self, + request: pb::CreateStashRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .create_stash(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.create_stash(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn apply_stash(&self, request: pb::ApplyStashRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .apply_stash(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.apply_stash(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn list_stashes( + &self, + request: pb::ListStashesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .list_stashes(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.list_stashes(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn drop_stash(&self, request: pb::DropStashRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .drop_stash(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.drop_stash(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn insert_changes( + &self, + request: pb::InsertChangesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .insert_changes(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.insert_changes(request).await.map(|response| response.into_inner()) + } + } + }) + } + + pub fn revise(&self, request: pb::ReviseRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => { + services::MutationImpl { state } + .revise(Request::new(request)) + .await + .map(|response| response.into_inner()) + } + Backend::Reactor(channel) => { + let mut mutation = + pb::repository_mutation_service_client::RepositoryMutationServiceClient::new(channel); + mutation.revise(request).await.map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// VaultService +// --------------------------------------------------------------------------- + +impl Service { + pub fn create_vault_entity( + &self, + request: pb::CreateVaultEntityRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .create_vault_entity(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .create_vault_entity(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn update_vault_entity( + &self, + request: pb::UpdateVaultEntityRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .update_vault_entity(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .update_vault_entity(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn get_vault_entry( + &self, + request: pb::GetVaultEntryRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .get_vault_entry(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .get_vault_entry(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn list_vault_entries( + &self, + request: pb::ListVaultEntriesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .list_vault_entries(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .list_vault_entries(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn validate_vault_entity( + &self, + request: pb::ValidateVaultEntityRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .validate_vault_entity(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .validate_vault_entity(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn delete_vault_entity( + &self, + request: pb::DeleteVaultEntityRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .delete_vault_entity(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .delete_vault_entity(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn link_vault_entities( + &self, + request: pb::LinkVaultEntitiesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .link_vault_entities(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .link_vault_entities(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn init_vault(&self, request: pb::InitVaultRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .init_vault(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .init_vault(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn export_vault( + &self, + request: pb::ExportVaultRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .export_vault(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .export_vault(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn sync_vault(&self, request: pb::SyncVaultRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .sync_vault(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .sync_vault(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn get_vault_context( + &self, + request: pb::GetVaultContextRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::VaultImpl { state } + .get_vault_context(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut vault = pb::vault_service_client::VaultServiceClient::new(channel); + vault + .get_vault_context(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// AttestationService +// --------------------------------------------------------------------------- + +impl Service { + pub fn record_attestation( + &self, + request: pb::RecordAttestationRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::AttestationImpl { state } + .record_attestation(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut attestation = + pb::attestation_service_client::AttestationServiceClient::new(channel); + attestation + .record_attestation(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn verify_attestation( + &self, + request: pb::VerifyAttestationRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::AttestationImpl { state } + .verify_attestation(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut attestation = + pb::attestation_service_client::AttestationServiceClient::new(channel); + attestation + .verify_attestation(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn list_attestations( + &self, + request: pb::ListAttestationsRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::AttestationImpl { state } + .list_attestations(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut attestation = + pb::attestation_service_client::AttestationServiceClient::new(channel); + attestation + .list_attestations(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// KnowledgeService +// --------------------------------------------------------------------------- + +impl Service { + pub fn query_graph(&self, request: pb::QueryGraphRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::KnowledgeImpl { state } + .query_graph(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut knowledge = + pb::knowledge_service_client::KnowledgeServiceClient::new(channel); + knowledge + .query_graph(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn maintain_knowledge_graph( + &self, + request: pb::MaintainKnowledgeGraphRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::KnowledgeImpl { state } + .maintain_knowledge_graph(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut knowledge = + pb::knowledge_service_client::KnowledgeServiceClient::new(channel); + knowledge + .maintain_knowledge_graph(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// ProvenanceService +// --------------------------------------------------------------------------- + +impl Service { + pub fn dispatch_turn_event( + &self, + request: pb::DispatchTurnEventRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::ProvenanceImpl { state } + .dispatch_turn_event(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut provenance = + pb::provenance_service_client::ProvenanceServiceClient::new(channel); + provenance + .dispatch_turn_event(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn list_sessions( + &self, + request: pb::ListSessionsRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::ProvenanceImpl { state } + .list_sessions(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut provenance = + pb::provenance_service_client::ProvenanceServiceClient::new(channel); + provenance + .list_sessions(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn get_session(&self, request: pb::GetSessionRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::ProvenanceImpl { state } + .get_session(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut provenance = + pb::provenance_service_client::ProvenanceServiceClient::new(channel); + provenance + .get_session(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn export_provenance( + &self, + request: pb::ExportProvenanceRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::ProvenanceImpl { state } + .export_provenance(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut provenance = + pb::provenance_service_client::ProvenanceServiceClient::new(channel); + provenance + .export_provenance(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn fork_session( + &self, + request: pb::ForkSessionRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::ProvenanceImpl { state } + .fork_session(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut provenance = + pb::provenance_service_client::ProvenanceServiceClient::new(channel); + provenance + .fork_session(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn rebuild_session_index( + &self, + request: pb::RebuildSessionIndexRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::ProvenanceImpl { state } + .rebuild_session_index(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut provenance = + pb::provenance_service_client::ProvenanceServiceClient::new(channel); + provenance + .rebuild_session_index(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn explain_turns( + &self, + request: pb::ExplainTurnsRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::ProvenanceImpl { state } + .explain_turns(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut provenance = + pb::provenance_service_client::ProvenanceServiceClient::new(channel); + provenance + .explain_turns(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// MaintenanceService +// --------------------------------------------------------------------------- + +impl Service { + pub fn check_repository( + &self, + request: pb::CheckRepositoryRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_maintenance::MaintenanceImpl { state } + .check_repository(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut maintenance = + pb::maintenance_service_client::MaintenanceServiceClient::new(channel); + maintenance + .check_repository(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn repair(&self, request: pb::RepairRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_maintenance::MaintenanceImpl { state } + .repair(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut maintenance = + pb::maintenance_service_client::MaintenanceServiceClient::new(channel); + maintenance + .repair(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// ViewService +// --------------------------------------------------------------------------- + +impl Service { + pub fn list_views(&self, request: pb::ListViewsRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_query::ViewImpl { state } + .list_views(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut views = pb::view_service_client::ViewServiceClient::new(channel); + views + .list_views(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn create_view(&self, request: pb::CreateViewRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_query::ViewImpl { state } + .create_view(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut views = pb::view_service_client::ViewServiceClient::new(channel); + views + .create_view(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn switch_view(&self, request: pb::SwitchViewRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_query::ViewImpl { state } + .switch_view(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut views = pb::view_service_client::ViewServiceClient::new(channel); + views + .switch_view(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn delete_view(&self, request: pb::DeleteViewRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_query::ViewImpl { state } + .delete_view(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut views = pb::view_service_client::ViewServiceClient::new(channel); + views + .delete_view(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn set_view_scope( + &self, + request: pb::SetViewScopeRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_query::ViewImpl { state } + .set_view_scope(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut views = pb::view_service_client::ViewServiceClient::new(channel); + views + .set_view_scope(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn split_view(&self, request: pb::SplitViewRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_query::ViewImpl { state } + .split_view(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut views = pb::view_service_client::ViewServiceClient::new(channel); + views + .split_view(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// TagService +// --------------------------------------------------------------------------- + +impl Service { + pub fn create_tag(&self, request: pb::CreateTagRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_tag::TagImpl { state } + .create_tag(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut tags = pb::tag_service_client::TagServiceClient::new(channel); + tags.create_tag(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn delete_tag(&self, request: pb::DeleteTagRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_tag::TagImpl { state } + .delete_tag(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut tags = pb::tag_service_client::TagServiceClient::new(channel); + tags.delete_tag(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn list_tags(&self, request: pb::ListTagsRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_tag::TagImpl { state } + .list_tags(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut tags = pb::tag_service_client::TagServiceClient::new(channel); + tags.list_tags(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn get_tag(&self, request: pb::GetTagRequest) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_tag::TagImpl { state } + .get_tag(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut tags = pb::tag_service_client::TagServiceClient::new(channel); + tags.get_tag(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// SyncService +// --------------------------------------------------------------------------- + +impl Service { + pub fn list_remotes( + &self, + request: pb::ListRemotesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_sync::SyncImpl { state } + .list_remotes(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut sync = pb::sync_service_client::SyncServiceClient::new(channel); + sync.list_remotes(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn manage_remotes( + &self, + request: pb::ManageRemotesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_sync::SyncImpl { state } + .manage_remotes(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut sync = pb::sync_service_client::SyncServiceClient::new(channel); + sync.manage_remotes(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn push_changes( + &self, + request: pb::PushChangesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_sync::SyncImpl { state } + .push_changes(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut sync = pb::sync_service_client::SyncServiceClient::new(channel); + sync.push_changes(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn pull_changes( + &self, + request: pb::PullChangesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_sync::SyncImpl { state } + .pull_changes(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut sync = pb::sync_service_client::SyncServiceClient::new(channel); + sync.pull_changes(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// SandboxService +// --------------------------------------------------------------------------- + +impl Service { + pub fn create_sandbox_tree( + &self, + request: pb::CreateSandboxTreeRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_sandbox::SandboxImpl { state } + .create_sandbox_tree(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut sandbox = + pb::sandbox_service_client::SandboxServiceClient::new(channel); + sandbox + .create_sandbox_tree(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn stage_sandbox_image( + &self, + request: pb::StageSandboxImageRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_sandbox::SandboxImpl { state } + .stage_sandbox_image(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut sandbox = + pb::sandbox_service_client::SandboxServiceClient::new(channel); + sandbox + .stage_sandbox_image(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + + pub fn seal_sandbox_image( + &self, + request: pb::SealSandboxImageRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_sandbox::SandboxImpl { state } + .seal_sandbox_image(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut sandbox = + pb::sandbox_service_client::SandboxServiceClient::new(channel); + sandbox + .seal_sandbox_image(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// TriageService +// --------------------------------------------------------------------------- + +impl Service { + pub fn list_triage_candidates( + &self, + request: pb::ListTriageCandidatesRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_triage::TriageImpl { state } + .list_triage_candidates(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut triage = pb::triage_service_client::TriageServiceClient::new(channel); + triage + .list_triage_candidates(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} diff --git a/atomic-cli/tests/database_owner_integration_test.rs b/atomic-cli/tests/database_owner_integration_test.rs deleted file mode 100644 index a3437dc9..00000000 --- a/atomic-cli/tests/database_owner_integration_test.rs +++ /dev/null @@ -1,2248 +0,0 @@ -use std::collections::HashSet; -use std::io::{Read, Write}; -use std::process::{Child, Command, Output, Stdio}; -use std::thread; -use std::time::{Duration, Instant}; - -use atomic_agent::{ProvenanceAccumulator, ProvenanceJournalEnvelope, ProvenanceJournalEvent}; -use atomic_core::types::{Base32, Hash}; -use atomic_repository::redb_change_store::RedbChangeStore; -use atomic_repository::{ChangeStore, Repository, DEFAULT_CACHE_CAPACITY}; -use fs2::FileExt; -use serde_json::Value; -use tempfile::TempDir; - -// Every test in this file spawns real `atomic` subprocesses that hold the -// redb lock and burn CPU while they run. On 2-core Windows CI runners the -// default test-threads parallelism lets the process-heavy tests (the -// eight-process session-start hammer, the failpoint owners) starve whichever -// sibling tests happen to overlap with them past their database-wait budgets -// — the failures move between runs (concurrent_session_starts…, -// crashing_second_checkpoint…, owner_death_after_checkpoint…, all in the -// same file), which is the signature of scheduler starvation, not a bug in -// any one test. Serializing the file trades a few minutes of wall time for -// runs that fail only when something is actually broken. -use serial_test::serial; - -// Bound child processes so a platform-specific IPC regression produces a -// useful failure instead of occupying a CI runner indefinitely. Drain output -// concurrently so a full pipe cannot prevent the child from exiting. -fn wait_for_output(mut child: Child, operation: &str) -> Output { - fn drain(stream: Option) -> std::sync::mpsc::Receiver> { - let (sender, receiver) = std::sync::mpsc::channel(); - thread::spawn(move || { - let mut bytes = Vec::new(); - if let Some(mut stream) = stream { - stream.read_to_end(&mut bytes).expect("read child output"); - } - let _ = sender.send(bytes); - }); - receiver - } - let stdout = drain(child.stdout.take()); - let stderr = drain(child.stderr.take()); - // CI runners (2-core Windows especially) run many serialized hook - // processes; a single child can legitimately wait on the publication - // lock for a large fraction of the whole queue. Generous by default, - // overridable when even that is not enough. - let process_timeout = std::env::var("ATOMIC_TEST_PROCESS_TIMEOUT_SECS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .map_or(120, |secs| secs.max(5)); - let deadline = Instant::now() + Duration::from_secs(process_timeout); - let (status, timed_out) = loop { - if let Some(status) = child.try_wait().expect("inspect child process") { - break (status, false); - } - if Instant::now() >= deadline { - child.kill().expect("kill timed-out child"); - break (child.wait().expect("reap timed-out child"), true); - } - thread::sleep(Duration::from_millis(10)); - }; - let stdout = stdout - .recv_timeout(Duration::from_secs(2)) - .unwrap_or_else(|error| { - panic!("{operation}: child exited with {status}, but stdout stayed open: {error}") - }); - let stderr = stderr - .recv_timeout(Duration::from_secs(2)) - .unwrap_or_else(|error| { - panic!("{operation}: child exited with {status}, but stderr stayed open: {error}") - }); - assert!( - !timed_out, - "{operation} timed out after {process_timeout}s; stdout: {}; stderr: {}", - String::from_utf8_lossy(&stdout), - String::from_utf8_lossy(&stderr) - ); - Output { - status, - stdout, - stderr, - } -} - -// Hook-failure diagnostics: assert the exit status with the child's full -// captured output. Printing stderr alone produced empty panic messages on CI -// (a non-zero exit with no stderr), leaving nothing to debug from. -fn assert_hook_success(operation: &str, output: &Output) { - assert!( - output.status.success(), - "{operation} exited with {}: stdout: {}; stderr: {}", - show_status(&output.status), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn show_status(status: &std::process::ExitStatus) -> String { - match status.code() { - Some(code) => format!("exit code {code}"), - None => format!("{status}"), - } -} - -fn command_output(command: &mut Command) -> Output { - let operation = format!("{command:?}"); - let child = command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn command"); - wait_for_output(child, &operation) -} - -fn owner_command(repository: &std::path::Path, operation: &str) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_atomic")); - command - .arg("agent") - .arg("database-owner") - .arg(operation) - .arg("--repository") - .arg(repository) - .arg("--no-color"); - command -} - -fn spawn_owner(repository: &std::path::Path) -> Child { - owner_command(repository, "serve") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn foreground database owner") -} - -fn spawn_owner_with_failpoint( - repository: &std::path::Path, - failpoint: &str, - marker: &std::path::Path, -) -> Child { - owner_command(repository, "serve") - .env("ATOMIC_OWNER_FAILPOINT", failpoint) - .env("ATOMIC_OWNER_FAILPOINT_MARKER", marker) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn failpoint database owner") -} - -fn run_owner(repository: &std::path::Path, operation: &str) -> Output { - command_output(owner_command(repository, operation).arg("--json")) -} - -fn wait_for_ping(repository: &std::path::Path, child: &mut Child) -> Value { - // Each poll spawns a fresh debug-build process; on slow CI runners a - // single spawn can take seconds, so give the loop real headroom. - let deadline = Instant::now() + Duration::from_secs(30); - loop { - let output = run_owner(repository, "ping"); - if output.status.success() { - return serde_json::from_slice(&output.stdout).expect("parse ping response"); - } - if let Some(status) = child.try_wait().expect("inspect owner process") { - let mut stderr = String::new(); - child - .stderr - .take() - .expect("owner stderr") - .read_to_string(&mut stderr) - .expect("read owner stderr"); - panic!("database owner exited with {status}: {stderr}"); - } - assert!( - Instant::now() < deadline, - "database owner did not become healthy: {}", - String::from_utf8_lossy(&output.stderr) - ); - thread::sleep(Duration::from_millis(25)); - } -} - -fn reserve(repository: &std::path::Path) -> Value { - let output = command_output( - owner_command(repository, "reserve") - .arg("--session-id") - .arg("owner-service-test") - .arg("--turn") - .arg("7") - .arg("--now") - .arg("1700000000") - .arg("--json"), - ); - assert!( - output.status.success(), - "reservation failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - serde_json::from_slice(&output.stdout).expect("parse reservation response") -} - -fn run_lifecycle(repository: &std::path::Path, args: &[&str]) -> Output { - command_output( - Command::new(env!("CARGO_BIN_EXE_atomic")) - .arg("agent") - .arg("lifecycle") - .args(args) - .arg("--no-color") - .current_dir(repository), - ) -} - -fn run_hook(repository: &std::path::Path, verb: &str, payload: &[u8]) -> Output { - run_agent_hook(repository, "claude-code", verb, payload) -} - -fn run_agent_hook(repository: &std::path::Path, agent: &str, verb: &str, payload: &[u8]) -> Output { - let mut child = Command::new(env!("CARGO_BIN_EXE_atomic")) - .arg("agent") - .arg("hooks") - .arg(agent) - .arg(verb) - .arg("--foreground") - .arg("--no-color") - .current_dir(repository) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn hook process"); - child - .stdin - .take() - .expect("hook stdin") - .write_all(payload) - .expect("write hook payload"); - wait_for_output(child, &format!("{agent} {verb}")) -} - -#[test] -#[serial] -fn concurrent_session_starts_wait_for_writer_and_persist_views_and_lifecycle() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - let writer = Repository::init(&repository).unwrap(); - let pending: Vec<_> = (0..8) - .map(|index| { - let root = repository.clone(); - thread::spawn(move || { - let session = format!("start-contended-{index}"); - let payload = serde_json::to_vec(&serde_json::json!({ - "session_id": session, "cwd": root, - })) - .unwrap(); - run_agent_hook(&root, "opencode", "session-start", &payload) - }) - }) - .collect(); - // Hold a real incompatible handle across all eight independent processes. - // The hook must wait internally, without a test-side retry or startup queue. - thread::sleep(Duration::from_millis(300)); - let returned_early = pending.iter().any(thread::JoinHandle::is_finished); - drop(writer); - let outputs: Vec<_> = pending.into_iter().map(|p| p.join().unwrap()).collect(); - assert!( - !returned_early, - "session start skipped a contended database" - ); - let repo = Repository::open_existing(&repository).unwrap(); - let mut views = HashSet::new(); - for (index, output) in outputs.iter().enumerate() { - assert!(output.status.success(), "{output:?}"); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(!stderr.contains("Cannot acquire lock"), "{stderr}"); - let sid = format!("start-contended-{index}"); - let session: Value = serde_json::from_slice( - &std::fs::read(repository.join(format!(".atomic/sessions/{sid}.json"))).unwrap(), - ) - .unwrap(); - let view = session["view_name"].as_str().unwrap(); - assert!( - views.insert(view.to_string()), - "sessions must have distinct views" - ); - assert!(repo.get_view_info(view).is_ok(), "view {view} missing"); - assert!( - repo.get_session_ledger(&sid).unwrap().is_some(), - "lifecycle missing for {sid}" - ); - } -} - -#[test] -#[serial] -fn read_only_turn_does_not_reuse_its_goal_for_the_next_turn_across_agents() { - for (agent, prompt_verb) in [ - ("codex", "user-prompt-submit"), - ("claude-code", "user-prompt-submit"), - ("opencode", "user-prompt"), - ] { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let hook = |verb: &str, payload: Value| { - let output = run_agent_hook( - &repository, - agent, - verb, - &serde_json::to_vec(&payload).unwrap(), - ); - assert!( - output.status.success(), - "{agent} {verb}: {}", - String::from_utf8_lossy(&output.stderr) - ); - }; - hook( - "session-start", - serde_json::json!({"session_id":"read-then-write"}), - ); - hook( - prompt_verb, - serde_json::json!({"session_id":"read-then-write","prompt":"Inspect without edits"}), - ); - hook( - "stop", - serde_json::json!({"session_id":"read-then-write","last_assistant_message":"Inspected","response":"Inspected"}), - ); - // Retry the same terminal hook before the next user prompt. - hook( - "stop", - serde_json::json!({"session_id":"read-then-write","last_assistant_message":"Inspected","response":"Inspected"}), - ); - hook( - prompt_verb, - serde_json::json!({"session_id":"read-then-write","prompt":"Implement the second request"}), - ); - std::fs::write(repository.join("second.txt"), b"second request\n").unwrap(); - hook( - "stop", - serde_json::json!({"session_id":"read-then-write","last_assistant_message":"Implemented","response":"Implemented"}), - ); - hook( - "stop", - serde_json::json!({"session_id":"read-then-write","last_assistant_message":"Implemented","response":"Implemented"}), - ); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - - let repo = Repository::open(&repository).unwrap(); - let (_, turns) = repo.get_session_ledger("read-then-write").unwrap().unwrap(); - assert_eq!( - turns.last().unwrap().goal.as_deref(), - Some("Implement the second request"), - "{agent}: a read-only turn must not contaminate the next turn" - ); - assert_eq!( - turns.len(), - 2, - "{agent}: read-only provenance must remain queryable" - ); - assert!(turns[0].change_hashes.is_empty()); - assert_eq!(turns[0].goal.as_deref(), Some("Inspect without edits")); - assert_eq!(turns[1].previous_provenance, Some(turns[0].provenance_hash)); - assert_eq!(turns[1].change_hashes.len(), 1); - } -} - -fn wait_for_shutdown(repository: &std::path::Path) { - // A failed ping only proves the endpoint is unavailable. The runtime may - // still be draining tasks and closing redb, especially on Windows. The - // owner releases its election lock only after that cleanup is complete. - let path = Repository::canonical_dot_dir(repository) - .unwrap() - .join("changes-owner.lock"); - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(path) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(30); - loop { - match lock.try_lock_exclusive() { - Ok(()) => { - FileExt::unlock(&lock).unwrap(); - return; - } - Err(error) if error.raw_os_error() == fs2::lock_contended_error().raw_os_error() => {} - Err(error) => panic!("failed to inspect database-owner shutdown: {error}"), - } - assert!( - Instant::now() < deadline, - "database owner did not release its lock after shutdown" - ); - thread::sleep(Duration::from_millis(25)); - } -} - -#[test] -#[serial] -fn concurrent_hooks_commit_lossless_envelopes_without_output_or_drops() { - const TOOL_EVENTS: usize = 16; - - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - - let session_start = run_hook( - &repository, - "session-start", - br#"{"session_id":"hook-concurrent"}"#, - ); - assert!( - session_start.status.success(), - "session start failed: {}", - String::from_utf8_lossy(&session_start.stderr) - ); - - let turn_start = run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"hook-concurrent","prompt":"test concurrent journal routing"}"#, - ); - assert!( - turn_start.status.success(), - "turn start failed: {}", - String::from_utf8_lossy(&turn_start.stderr) - ); - - let reservation = command_output(owner_command(&repository, "reserve").args([ - "--session-id", - "hook-concurrent", - "--turn", - "1", - "--now", - "1700000000", - "--json", - ])); - assert!(reservation.status.success()); - let running_generation = serde_json::from_slice::(&reservation.stdout).unwrap()["turn"] - ["generation"] - .as_u64() - .unwrap(); - - let append_started = Instant::now(); - let mut workers = Vec::new(); - for index in 0..TOOL_EVENTS { - let repository = repository.clone(); - workers.push(thread::spawn(move || { - let payload = format!( - "{{\"session_id\":\"hook-concurrent\",\"tool_use_id\":\"tool-{index}\",\"tool_name\":\"Read\",\"tool_input\":{{\"path\":\"src/{index}.rs\"}},\"tool_output\":\"ok\",\"status\":\"completed\"}}" - ); - run_hook(&repository, "post-tool", payload.as_bytes()) - })); - } - for worker in workers { - let output = worker.join().expect("join hook worker"); - assert!( - output.status.success(), - "tool hook failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - output.stdout.is_empty(), - "successful tool hook wrote stdout" - ); - assert!( - output.stderr.is_empty(), - "successful tool hook wrote stderr" - ); - } - let append_elapsed = append_started.elapsed(); - assert!( - append_elapsed / (TOOL_EVENTS as u32) < Duration::from_secs(1), - "average hook append latency exceeded 1s: {:?}", - append_elapsed / (TOOL_EVENTS as u32) - ); - - let retry = run_hook( - &repository, - "post-tool", - br#"{"session_id":"hook-concurrent","tool_use_id":"tool-0","tool_name":"Read","tool_input":{"path":"src/0.rs"},"tool_output":"ok","status":"completed"}"#, - ); - assert!(retry.status.success()); - assert!(retry.stdout.is_empty()); - assert!(retry.stderr.is_empty()); - - let stop = run_hook( - &repository, - "stop", - br#"{"session_id":"hook-concurrent","reason":"end_turn","reasoning_blocks":[{"text":"checked all concurrent events","duration_ms":9,"signature":"sig"}],"response":"all events committed","todos":[{"id":"todo-1","content":"verify journal","status":"completed","priority":"high"}]}"#, - ); - assert!( - stop.status.success(), - "stop hook failed: {}", - String::from_utf8_lossy(&stop.stderr) - ); - assert!(stop.stdout.is_empty()); - assert!(stop.stderr.is_empty()); - - let shutdown = run_owner(&repository, "shutdown"); - assert!(shutdown.status.success()); - wait_for_shutdown(&repository); - - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let turn = store - .get_provenance_turn_for("hook-concurrent", 1) - .unwrap() - .expect("reserved hook turn"); - let stored = store.load_provenance_envelopes(turn.provenance_id).unwrap(); - assert_eq!(stored.len(), TOOL_EVENTS + 5); - assert_eq!( - stored.iter().map(|event| event.seq).collect::>(), - (0..(TOOL_EVENTS as u64 + 5)).collect::>() - ); - let ids: HashSet<_> = stored.iter().map(|event| &event.event_id).collect(); - assert_eq!(ids.len(), TOOL_EVENTS + 5); - let mut saw_reasoning = false; - let mut saw_response = false; - let mut saw_todo = false; - let mut saw_terminal = false; - for event in stored { - let envelope = ProvenanceJournalEnvelope::from_json_bytes(&event.envelope).unwrap(); - saw_reasoning |= matches!(&envelope.event, ProvenanceJournalEvent::Reasoning { .. }); - saw_response |= matches!(&envelope.event, ProvenanceJournalEvent::Response { .. }); - saw_todo |= matches!(&envelope.event, ProvenanceJournalEvent::Todo { .. }); - saw_terminal |= matches!(&envelope.event, ProvenanceJournalEvent::Terminal { .. }); - assert_eq!(envelope.session_id, "hook-concurrent"); - assert_eq!(envelope.turn_number, 1); - // Finalization fences writers by advancing the stored generation; - // immutable events retain the generation acknowledged during append. - assert_eq!(envelope.generation, running_generation); - } - assert!(saw_reasoning && saw_response && saw_todo && saw_terminal); - assert!(matches!( - turn.state, - atomic_repository::redb_change_store::ProvenanceTurnState::Completed - )); -} - -#[test] -#[serial] -fn lifecycle_stop_resume_zombie_abandon_and_lease_expiry() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let workdir = repository.to_string_lossy().to_string(); - - let begin = run_lifecycle( - &repository, - &[ - "begin", - "--owner", - "sherpa", - "--session", - "owner-session", - "--executor", - "claude-code", - "--workdir", - &workdir, - "--ttl-seconds", - "60", - "--json", - ], - ); - assert!( - begin.status.success(), - "{}", - String::from_utf8_lossy(&begin.stderr) - ); - let lifecycle: Value = serde_json::from_slice(&begin.stdout).unwrap(); - let run_id = lifecycle["run_id"].as_str().unwrap().to_string(); - - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"lifecycle-session"}"#, - ) - .status - .success()); - assert!(run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"lifecycle-session","prompt":"resume me"}"#, - ) - .status - .success()); - assert!(run_hook( - &repository, - "post-tool", - br#"{"session_id":"lifecycle-session","tool_use_id":"before-stop","tool_name":"Read","tool_input":{"path":"a.rs"},"tool_output":"ok"}"#, - ) - .status - .success()); - - let stopped = run_lifecycle( - &repository, - &[ - "stop", - "--run-id", - &run_id, - "--cause", - "process-exited", - "--json", - ], - ); - assert!( - stopped.status.success(), - "{}", - String::from_utf8_lossy(&stopped.stderr) - ); - let stopped_json: Value = serde_json::from_slice(&stopped.stdout).unwrap(); - assert_eq!(stopped_json["stop_state"]["cause"], "process-exited"); - assert_eq!(stopped_json["stop_state"]["resumable"], true); - - let status = run_lifecycle(&repository, &["status", "--json"]); - assert!(status.status.success()); - let status_json: Value = serde_json::from_slice(&status.stdout).unwrap(); - let stopped_turn = status_json["turns"] - .as_array() - .unwrap() - .iter() - .find(|turn| turn["session_id"] == "lifecycle-session") - .unwrap(); - let provenance_id = stopped_turn["provenance_id"].as_u64().unwrap(); - assert_eq!(stopped_turn["state"], "stopped"); - assert_eq!(stopped_turn["generation"], 2); - assert_eq!(stopped_turn["last_event_seq"], 1); - assert_eq!(stopped_turn["resumable"], true); - - let zombie = run_hook( - &repository, - "post-tool", - br#"{"session_id":"lifecycle-session","tool_use_id":"zombie","tool_name":"Read","tool_input":{"path":"zombie.rs"},"tool_output":"late"}"#, - ); - assert!( - !zombie.status.success(), - "stopped writer unexpectedly appended" - ); - - let resumed = run_lifecycle( - &repository, - &[ - "resume", - "--run-id", - &run_id, - "--ttl-seconds", - "60", - "--json", - ], - ); - assert!( - resumed.status.success(), - "{}", - String::from_utf8_lossy(&resumed.stderr) - ); - assert!(run_hook( - &repository, - "post-tool", - br#"{"session_id":"lifecycle-session","tool_use_id":"after-resume","tool_name":"Read","tool_input":{"path":"b.rs"},"tool_output":"ok"}"#, - ) - .status - .success()); - - let abandoned = run_lifecycle(&repository, &["abandon", "--run-id", &run_id, "--json"]); - assert!(abandoned.status.success()); - let abandoned_json: Value = serde_json::from_slice(&abandoned.stdout).unwrap(); - assert_eq!(abandoned_json["stop_state"]["cause"], "abandoned"); - assert_eq!(abandoned_json["stop_state"]["resumable"], false); - assert!( - !run_lifecycle(&repository, &["resume", "--run-id", &run_id, "--json"]) - .status - .success() - ); - - let lease_begin = run_lifecycle( - &repository, - &[ - "begin", - "--owner", - "sherpa", - "--session", - "lease-owner", - "--executor", - "claude-code", - "--workdir", - &workdir, - "--ttl-seconds", - "60", - "--json", - ], - ); - assert!(lease_begin.status.success()); - let lease_lifecycle: Value = serde_json::from_slice(&lease_begin.stdout).unwrap(); - let lease_run_id = lease_lifecycle["run_id"].as_str().unwrap(); - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"lease-session"}"#, - ) - .status - .success()); - let lease_turn_start = run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"lease-session","prompt":"expire me"}"#, - ); - assert!( - lease_turn_start.status.success(), - "lease turn start failed: {}", - String::from_utf8_lossy(&lease_turn_start.stderr) - ); - assert!(run_lifecycle( - &repository, - &[ - "renew", - "--run-id", - lease_run_id, - "--ttl-seconds", - "1", - "--json", - ], - ) - .status - .success()); - thread::sleep(Duration::from_millis(1_100)); - let lease_status = run_lifecycle(&repository, &["status", "--json"]); - assert!(lease_status.status.success()); - let lease_json: Value = serde_json::from_slice(&lease_status.stdout).unwrap(); - assert!(lease_json["stale_lifecycles"] - .as_array() - .unwrap() - .iter() - .any(|lifecycle| lifecycle["stop_state"]["cause"] == "lease-expired")); - - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let abandoned_turn = store - .get_provenance_turn_for("lifecycle-session", 1) - .unwrap() - .unwrap(); - assert_eq!(abandoned_turn.provenance_id.get(), provenance_id); - assert_eq!(abandoned_turn.generation, 4); - match abandoned_turn.state { - atomic_repository::redb_change_store::ProvenanceTurnState::Abandoned(stop) => { - assert_eq!(stop.last_event_seq, Some(2)); - assert!(!stop.resumable); - } - other => panic!("expected abandoned turn, got {other:?}"), - } - let lease_turn = store - .get_provenance_turn_for("lease-session", 1) - .unwrap() - .unwrap(); - match lease_turn.state { - atomic_repository::redb_change_store::ProvenanceTurnState::Stopped(stop) => { - assert_eq!( - stop.cause, - atomic_repository::redb_change_store::StopCause::LeaseExpired - ); - assert!(stop.resumable); - } - other => panic!("expected lease-expired stop, got {other:?}"), - } -} - -#[test] -#[serial] -fn owner_death_before_and_after_event_commit_retries_exactly_once() { - for failpoint in ["before-envelope-commit", "after-envelope-commit"] { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"event-crash"}"#, - ) - .status - .success()); - - let marker = temp.path().join(format!("{failpoint}.marker")); - let mut owner = spawn_owner_with_failpoint(&repository, failpoint, &marker); - wait_for_ping(&repository, &mut owner); - let started = Instant::now(); - let hook = run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"event-crash","prompt":"survive owner crash"}"#, - ); - assert!( - hook.status.success(), - "{failpoint} retry failed: {}", - String::from_utf8_lossy(&hook.stderr) - ); - assert!(started.elapsed() < Duration::from_secs(10)); - assert!(!owner.wait().unwrap().success()); - - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()) - .unwrap(); - let turn = store - .get_provenance_turn_for("event-crash", 1) - .unwrap() - .unwrap(); - assert_eq!( - store - .load_provenance_envelopes(turn.provenance_id) - .unwrap() - .len(), - 1 - ); - } -} - -#[cfg(unix)] -fn batch_owner_rpc(repository: &std::path::Path, request: &Value) -> std::io::Result { - let dot = std::fs::canonicalize(Repository::canonical_dot_dir(repository).unwrap())?; - let digest = blake3::hash(dot.to_string_lossy().as_bytes()) - .to_hex() - .to_string(); - let mut stream = std::os::unix::net::UnixStream::connect(format!( - "/tmp/atomic-owner-{}.sock", - &digest[..24] - ))?; - stream.set_read_timeout(Some(Duration::from_secs(10)))?; - stream.set_write_timeout(Some(Duration::from_secs(10)))?; - let frame = serde_json::to_vec( - &serde_json::json!({"version":1,"request_id":"batch-regression","request":request}), - )?; - stream.write_all(&(frame.len() as u32).to_be_bytes())?; - stream.write_all(&frame)?; - let mut len = [0; 4]; - stream.read_exact(&mut len)?; - let len = u32::from_be_bytes(len) as usize; - assert!(len <= 8 * 1024 * 1024); - let mut bytes = vec![0; len]; - stream.read_exact(&mut bytes)?; - Ok(serde_json::from_slice(&bytes)?) -} - -#[cfg(unix)] -#[test] -#[serial] -fn owner_batch_crash_retries_the_whole_durable_batch_exactly_once() { - for failpoint in ["before-envelope-commit", "after-envelope-commit"] { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let marker = temp.path().join("batch-crash.marker"); - let mut owner = spawn_owner_with_failpoint(&repository, failpoint, &marker); - wait_for_ping(&repository, &mut owner); - let reserved = reserve(&repository); - let id = reserved["turn"]["provenance_id"].as_u64().unwrap(); - let generation = reserved["turn"]["generation"].as_u64().unwrap(); - let envelopes: Vec<_> = (0..16) - .map(|i| serde_json::json!({"event_id":format!("batch-{i}"),"bytes":[i]})) - .collect(); - let request = serde_json::json!({"AppendProvenanceEnvelopes":{ - "provenance_id":id,"expected_generation":generation,"envelopes":envelopes,"now":1700000001 - }}); - assert!(batch_owner_rpc(&repository, &request).is_err()); - assert!(!wait_for_output(owner, "batch failpoint owner") - .status - .success()); - let path = Repository::canonical_database_path(&repository).unwrap(); - let store = RedbChangeStore::open(&path).unwrap(); - let events = store - .load_provenance_envelopes(atomic_repository::redb_change_store::ProvenanceId::new(id)) - .unwrap(); - assert_eq!( - events.len(), - if failpoint == "before-envelope-commit" { - 0 - } else { - 16 - } - ); - drop(store); - - let mut owner = spawn_owner(&repository); - wait_for_ping(&repository, &mut owner); - let retry = batch_owner_rpc(&repository, &request).unwrap(); - let acks = retry["response"]["ProvenanceEnvelopesCommitted"]["acknowledgements"] - .as_array() - .unwrap(); - assert_eq!(acks.len(), 16); - for (i, ack) in acks.iter().enumerate() { - assert_eq!(ack["event_id"], format!("batch-{i}")); - assert_eq!(ack["sequence"], i as u64); - } - assert_eq!(batch_owner_rpc(&repository, &request).unwrap(), retry); - assert!(run_owner(&repository, "shutdown").status.success()); - assert!(wait_for_output(owner, "batch retry owner").status.success()); - let store = RedbChangeStore::open(path).unwrap(); - let events = store - .load_provenance_envelopes(atomic_repository::redb_change_store::ProvenanceId::new(id)) - .unwrap(); - assert_eq!(events.len(), 16); - for (i, event) in events.iter().enumerate() { - assert_eq!(event.envelope, vec![i as u8]); - } - } -} - -#[test] -#[serial] -fn owner_death_after_checkpoint_prepare_and_bind_recovers_in_hook_process() { - for failpoint in ["after-checkpoint-prepare", "after-checkpoint-bind"] { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"checkpoint-crash"}"#, - ) - .status - .success()); - assert!(run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"checkpoint-crash","prompt":"checkpoint despite crash"}"#, - ) - .status - .success()); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - std::fs::write(repository.join("crash.txt"), b"recover\n").unwrap(); - - let marker = temp.path().join(format!("{failpoint}.marker")); - let mut owner = spawn_owner_with_failpoint(&repository, failpoint, &marker); - wait_for_ping(&repository, &mut owner); - let started = Instant::now(); - let stop = run_hook( - &repository, - "stop", - br#"{"session_id":"checkpoint-crash","response":"recovered"}"#, - ); - assert!( - stop.status.success(), - "{failpoint} retry failed: {}", - String::from_utf8_lossy(&stop.stderr) - ); - assert!(started.elapsed() < Duration::from_secs(15)); - assert!(!owner.wait().unwrap().success()); - - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()) - .unwrap(); - let turn = store - .get_provenance_turn_for("checkpoint-crash", 1) - .unwrap() - .unwrap(); - assert!(matches!( - turn.state, - atomic_repository::redb_change_store::ProvenanceTurnState::Completed - )); - drop(store); - let repo = Repository::open(&repository).unwrap(); - let (_, turns) = repo - .get_session_ledger("checkpoint-crash") - .unwrap() - .unwrap(); - assert_eq!(turns.len(), 1); - } -} - -#[test] -#[serial] -fn corrupt_legacy_graph_is_retained_when_migration_cannot_verify_it() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - let repo = Repository::init(&repository).unwrap(); - let session_dir = repo.dot_dir().join("sessions/corrupt-legacy"); - std::fs::create_dir_all(&session_dir).unwrap(); - let graph_path = ProvenanceAccumulator::graph_path(&session_dir); - std::fs::write(&graph_path, b"not valid graph json").unwrap(); - std::fs::write(session_dir.join("graph.lock"), b"legacy lock").unwrap(); - drop(repo); - - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"corrupt-legacy"}"#, - ) - .status - .success()); - let migration = run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"corrupt-legacy","prompt":"do not discard"}"#, - ); - assert!(!migration.status.success()); - assert!(graph_path.exists()); - assert!(session_dir.join("graph.lock").exists()); - - // TurnStart probes the owner before migration, so clean it up explicitly. - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); -} - -#[test] -#[serial] -fn legacy_graph_pending_delta_imports_once_then_json_authority_is_removed() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - let repo = Repository::init(&repository).unwrap(); - let session_dir = repo.dot_dir().join("sessions/legacy-session"); - std::fs::create_dir_all(&session_dir).unwrap(); - - let mut legacy = ProvenanceAccumulator::new("legacy-session"); - legacy.append_goal("already finalized", 1_000); - let historical_graph = legacy.to_provenance_graph( - "claude-code", - "Claude Code", - "anthropic", - &[Hash::of(b"historical-change")], - ); - let change_store = ChangeStore::new(repo.changes_dir(), DEFAULT_CACHE_CAPACITY).unwrap(); - let historical_hash = change_store - .save_provenance_graph(&historical_graph) - .unwrap(); - legacy.set_last_provenance_hash(historical_hash.to_base32()); - legacy.append_tool_call( - "read", - Some("legacy-read"), - Some(&serde_json::json!({"path": "src/legacy.rs"})), - Some("contents"), - Some("completed"), - Some(4), - 2_000, - ); - legacy.save(&session_dir).unwrap(); - std::fs::write(session_dir.join("graph.lock"), b"legacy lock").unwrap(); - drop(repo); - - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"legacy-session"}"#, - ) - .status - .success()); - let first = run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"legacy-session","prompt":"continue legacy work"}"#, - ); - assert!( - first.status.success(), - "{}", - String::from_utf8_lossy(&first.stderr) - ); - assert!(!ProvenanceAccumulator::graph_path(&session_dir).exists()); - assert!(!session_dir.join("graph.lock").exists()); - - // Simulate a crash after owner acknowledgement but before obsolete-file - // deletion by restoring the exact legacy cache. Retry must not duplicate it. - legacy.save(&session_dir).unwrap(); - std::fs::write(session_dir.join("graph.lock"), b"legacy lock").unwrap(); - let retry = run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"legacy-session","prompt":"continue legacy work"}"#, - ); - assert!( - retry.status.success(), - "{}", - String::from_utf8_lossy(&retry.stderr) - ); - assert!(!ProvenanceAccumulator::graph_path(&session_dir).exists()); - assert!(!session_dir.join("graph.lock").exists()); - - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let redb = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let turn = redb - .get_provenance_turn_for("legacy-session", 1) - .unwrap() - .unwrap(); - let stored = redb.load_provenance_envelopes(turn.provenance_id).unwrap(); - assert_eq!( - stored.len(), - 2, - "migration marker and goal must each commit once" - ); - let envelopes = stored - .iter() - .map(|stored| ProvenanceJournalEnvelope::from_json_bytes(&stored.envelope).unwrap()) - .collect::>(); - let legacy_import = envelopes - .iter() - .find_map(|envelope| match &envelope.event { - ProvenanceJournalEvent::LegacyGraphImport { - nodes, - previous_provenance, - .. - } => Some((nodes, previous_provenance)), - _ => None, - }) - .expect("legacy import marker"); - assert_eq!( - legacy_import.0.len(), - 1, - "only the unsaved suffix is imported" - ); - assert_eq!( - legacy_import.0[0].tool_call_id.as_deref(), - Some("legacy-read") - ); - let historical_base32 = historical_hash.to_base32(); - assert_eq!(legacy_import.1.as_deref(), Some(historical_base32.as_str())); - assert_eq!( - change_store - .load_provenance_graph(&historical_hash) - .unwrap() - .session_id, - "legacy-session", - "immutable finalized history remains readable" - ); -} - -#[test] -#[serial] -fn turn_end_publishes_one_checkpoint_turn_and_advances_head() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"checkpoint-e2e"}"#, - ) - .status - .success()); - assert!(run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"checkpoint-e2e","prompt":"record checkpoint"}"#, - ) - .status - .success()); - std::fs::write(repository.join("checkpoint.txt"), b"checkpointed\n").unwrap(); - let checkpoint_started = Instant::now(); - let stop = run_hook( - &repository, - "stop", - br#"{"session_id":"checkpoint-e2e","reason":"end_turn","response":"checkpoint complete"}"#, - ); - assert!( - stop.status.success(), - "turn end failed: {}", - String::from_utf8_lossy(&stop.stderr) - ); - assert!(stop.stdout.is_empty()); - assert!(stop.stderr.is_empty()); - assert!( - checkpoint_started.elapsed() < Duration::from_secs(10), - "checkpoint latency exceeded 10s: {:?}", - checkpoint_started.elapsed() - ); - - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - - let redb = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let completed = redb - .get_provenance_turn_for("checkpoint-e2e", 1) - .unwrap() - .unwrap(); - assert!(matches!( - completed.state, - atomic_repository::redb_change_store::ProvenanceTurnState::Completed - )); - let attempt = completed.checkpoint_attempt.unwrap(); - assert_eq!( - attempt.phase, - atomic_repository::redb_change_store::ProvenanceCheckpointPhase::Published - ); - let provenance_hash = attempt.provenance_hash.unwrap(); - let manifest_hash = attempt.manifest_hash.unwrap(); - drop(redb); - - let repo = Repository::open(&repository).unwrap(); - let (_, turns) = repo.get_session_ledger("checkpoint-e2e").unwrap().unwrap(); - assert_eq!(turns.len(), 1); - assert_eq!(turns[0].provenance_hash, provenance_hash); - assert_eq!( - repo.get_session_head("checkpoint-e2e").unwrap(), - Some(manifest_hash) - ); - let graph_dir = repo.dot_dir().join("sessions/checkpoint-e2e"); - assert!(!ProvenanceAccumulator::graph_path(&graph_dir).exists()); - assert!(!graph_dir.join("graph.lock").exists()); - assert_eq!( - repo.load_provenance_graph(&provenance_hash) - .unwrap() - .session_id, - "checkpoint-e2e" - ); -} - -#[test] -#[serial] -fn concurrent_stops_publish_ordered_ledgers_without_external_serialization() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let begin = run_lifecycle( - &repository, - &[ - "begin", - "--owner", - "test", - "--session", - "coordinator", - "--executor", - "claude-code", - "--view", - "dev", - "--json", - ], - ); - assert!( - begin.status.success(), - "{}", - String::from_utf8_lossy(&begin.stderr) - ); - let sessions: Vec<_> = (0..8).map(|i| format!("concurrent-stop-{i}")).collect(); - for session in &sessions { - let output = run_hook( - &repository, - "session-start", - &serde_json::to_vec(&serde_json::json!({"session_id":session})).unwrap(), - ); - assert_hook_success(&format!("session-start {session}"), &output); - } - for round in 0..2 { - for session in &sessions { - let output = run_hook( - &repository, - "user-prompt-submit", - &serde_json::to_vec( - &serde_json::json!({"session_id":session,"prompt":format!("round {round}")}), - ) - .unwrap(), - ); - assert_hook_success(&format!("round {round} prompt {session}"), &output); - } - // First round includes an actual change. The second is read-only. - if round == 0 { - std::fs::write(repository.join("shared.txt"), "one shared change\n").unwrap(); - } - // Force a transient *database* conflict in addition to simultaneous - // Stops. No test-side Stop lock or retry hides production failures. - let held_repository = Repository::open_existing(&repository).unwrap(); - let gate = std::sync::Arc::new(std::sync::Barrier::new(sessions.len() + 1)); - let mut children = Vec::new(); - for session in &sessions { - let root = repository.clone(); - let session = session.clone(); - let gate = gate.clone(); - children.push(thread::spawn(move || { - gate.wait(); - run_hook( - &root, - "stop", - &serde_json::to_vec(&serde_json::json!({ - "session_id":session,"response":format!("finished {round}") - })) - .unwrap(), - ) - })); - } - gate.wait(); - thread::sleep(Duration::from_millis(200)); - drop(held_repository); - for child in children { - let output = child.join().unwrap(); - assert_hook_success(&format!("round {round} concurrent stop"), &output); - assert!( - output.stderr.is_empty(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - } - for session in &sessions { - let output = run_hook( - &repository, - "stop", - &serde_json::to_vec(&serde_json::json!({"session_id":session})).unwrap(), - ); - assert_hook_success(&format!("final stop {session}"), &output); - } - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let repo = Repository::open_existing(&repository).unwrap(); - let mut recorded = HashSet::new(); - for session in &sessions { - let (_, turns) = repo.get_session_ledger(session).unwrap().unwrap(); - assert_eq!(turns.len(), 2, "{session}: duplicate or missing checkpoint"); - assert_eq!(turns[1].previous_provenance, Some(turns[0].provenance_hash)); - for (round, turn) in turns.iter().enumerate() { - assert_eq!(turn.turn_number, round as u32); - assert_eq!( - turn.goal.as_deref(), - Some(format!("round {round}").as_str()) - ); - let graph = repo.load_provenance_graph(&turn.provenance_hash).unwrap(); - assert_eq!(graph.previous, turn.previous_provenance); - assert_eq!(graph.session_id, *session); - recorded.extend(turn.change_hashes.iter().copied()); - } - } - assert_eq!(recorded.len(), 1, "one file edit must be recorded once"); -} - -#[test] -#[serial] -fn stop_publication_timeout_preserves_the_active_turn_for_retry() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let payload = br#"{"session_id":"publication-timeout","prompt":"keep this turn"}"#; - assert!(run_hook(&repository, "session-start", payload) - .status - .success()); - assert!(run_hook(&repository, "user-prompt-submit", payload) - .status - .success()); - let file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(repository.join(".atomic/turn-publication.lock")) - .unwrap(); - file.lock_exclusive().unwrap(); - let output = run_hook(&repository, "stop", payload); - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("timed out waiting")); - drop(file); - for _ in 0..2 { - let output = run_hook(&repository, "stop", payload); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let repo = Repository::open_existing(&repository).unwrap(); - let (_, turns) = repo - .get_session_ledger("publication-timeout") - .unwrap() - .unwrap(); - assert_eq!(turns.len(), 1); - assert_eq!(turns[0].goal.as_deref(), Some("keep this turn")); -} - -#[test] -#[serial] -fn killed_stop_releases_publication_lock_and_can_be_retried() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let payload = br#"{"session_id":"killed-stop","prompt":"recover my change"}"#; - assert!(run_hook(&repository, "session-start", payload) - .status - .success()); - assert!(run_hook(&repository, "user-prompt-submit", payload) - .status - .success()); - std::fs::write(repository.join("recover.txt"), "durable after retry\n").unwrap(); - // Hold pristine so the real Stop has acquired publication coordination - // but cannot record yet. Kill that process, not a synthetic lock helper. - let held = Repository::open_existing(&repository).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_atomic")) - .args(["agent", "hooks", "claude-code", "stop", "--foreground"]) - .current_dir(&repository) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(payload).unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - if let Ok(file) = std::fs::OpenOptions::new() - .write(true) - .open(repository.join(".atomic/turn-publication.lock")) - { - if let Err(error) = file.try_lock_exclusive() { - assert_eq!( - error.raw_os_error(), - fs2::lock_contended_error().raw_os_error() - ); - break; - } - } - assert!( - child.try_wait().unwrap().is_none(), - "Stop exited before acquiring its lock" - ); - assert!( - Instant::now() < deadline, - "Stop never acquired publication lock" - ); - thread::sleep(Duration::from_millis(10)); - } - child.kill().unwrap(); - child.wait().unwrap(); - drop(held); - for _ in 0..2 { - let output = run_hook(&repository, "stop", payload); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let repo = Repository::open_existing(&repository).unwrap(); - let (_, turns) = repo.get_session_ledger("killed-stop").unwrap().unwrap(); - assert_eq!(turns.len(), 1); - assert_eq!(turns[0].change_hashes.len(), 1); - assert_eq!(turns[0].goal.as_deref(), Some("recover my change")); -} - -fn large_checkpoint_fixture( - agent: &str, - count: usize, - output_bytes: usize, - crash_between_pages: bool, -) { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let hook = |verb: &str, payload: Value| { - let output = run_agent_hook( - &repository, - agent, - verb, - &serde_json::to_vec(&payload).unwrap(), - ); - assert!( - output.status.success(), - "{agent} {verb}: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(output.stdout.is_empty()); - assert!( - output.stderr.is_empty(), - "{agent} {verb}: {}", - String::from_utf8_lossy(&output.stderr) - ); - }; - hook( - "session-start", - serde_json::json!({"session_id":"large-checkpoint"}), - ); - hook( - if agent == "opencode" { - "user-prompt" - } else { - "user-prompt-submit" - }, - serde_json::json!({"session_id":"large-checkpoint","prompt":"Record a large journal"}), - ); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - - // Seed valid, durably committed envelopes using the same store API as the - // owner. Avoid 1024 CLI bootstraps; the actual Stop still runs in a process. - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let turn = store - .get_provenance_turn_for("large-checkpoint", 1) - .unwrap() - .unwrap(); - let mut expected = Vec::new(); - for index in 0..count { - let event_id = format!("large-{index}"); - let output = "x".repeat(output_bytes); - let value = serde_json::json!({ - "schema_version":1,"event_id":event_id,"session_id":"large-checkpoint", - "turn_number":1,"generation":turn.generation,"timestamp_ms":1700000000000_i64 + index as i64, - "event":{"type":"tool","phase":"after","tool_name":"Read","tool_call_id":event_id, - "input":{"path":format!("src/{index}.rs")},"output":output,"status":"completed", - "raw":{"tool_output":output}} - }); - let bytes = serde_json::to_vec(&value).unwrap(); - ProvenanceJournalEnvelope::from_json_bytes(&bytes).unwrap(); - store - .append_provenance_envelope(turn.provenance_id, turn.generation, &event_id, &bytes, 2) - .unwrap(); - expected.push(bytes); - } - if count == 1024 { - assert!(serde_json::to_vec(&expected).unwrap().len() > 8 * 1024 * 1024); - } else { - assert!( - expected[0].len() > 1024 * 1024, - "single envelope must span pages" - ); - } - drop(store); - let marker = temp.path().join("page-retry.marker"); - let mut crashing_owner = if crash_between_pages { - let mut owner = - spawn_owner_with_failpoint(&repository, "before-frozen-page-continuation", &marker); - wait_for_ping(&repository, &mut owner); - Some(owner) - } else { - None - }; - std::fs::write(repository.join("large.txt"), b"large checkpoint\n").unwrap(); - let stop = serde_json::json!({"session_id":"large-checkpoint","reason":"end_turn", - "response":"Completed","last_assistant_message":"Completed"}); - hook("stop", stop.clone()); - if let Some(owner) = &mut crashing_owner { - assert!(marker.exists(), "must crash after the first page was read"); - assert!(!owner.wait().unwrap().success()); - } - let repo = Repository::open(&repository).unwrap(); - let (_, ledger) = repo - .get_session_ledger("large-checkpoint") - .unwrap() - .unwrap(); - assert_eq!(ledger.len(), 1); - let head = repo.get_session_head("large-checkpoint").unwrap(); - drop(repo); - hook("stop", stop); - let repo = Repository::open(&repository).unwrap(); - assert_eq!( - repo.get_session_ledger("large-checkpoint") - .unwrap() - .unwrap() - .1, - ledger - ); - assert_eq!(repo.get_session_head("large-checkpoint").unwrap(), head); - let graph = repo - .load_provenance_graph(&ledger[0].provenance_hash) - .unwrap(); - assert_eq!( - Hash::of(&graph.serialize().unwrap()), - ledger[0].provenance_hash - ); - let tools: HashSet<_> = graph - .nodes - .iter() - .filter_map(|n| n.tool_call_id.as_deref()) - .filter(|id| id.starts_with("large-")) - .collect(); - assert_eq!( - tools.len(), - count, - "published graph must contain every tool event" - ); - for index in 0..count { - assert!(tools.contains(format!("large-{index}").as_str())); - } - // The store below opens the same atomic.redb file. - drop(repo); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let completed = store - .get_provenance_turn_for("large-checkpoint", 1) - .unwrap() - .unwrap(); - assert!(matches!( - completed.state, - atomic_repository::redb_change_store::ProvenanceTurnState::Completed - )); - let frozen = store - .load_frozen_provenance_envelopes(turn.provenance_id) - .unwrap(); - let actual: Vec<_> = frozen - .into_iter() - .filter(|e| e.event_id.starts_with("large-")) - .map(|e| e.envelope) - .collect(); - assert_eq!( - actual, expected, - "stored bytes and ordering must remain unchanged" - ); -} - -#[test] -#[serial] -fn large_frozen_checkpoint_completes_across_agents() { - for agent in ["codex", "claude-code", "opencode"] { - large_checkpoint_fixture(agent, 1024, 1024, false); - } -} - -#[test] -#[serial] -fn large_envelope_checkpoint_recovers_between_pages() { - large_checkpoint_fixture("claude-code", 1, 600 * 1024, true); -} - -#[test] -#[serial] -fn failed_frozen_read_resumes_recorded_changes_on_next_stop() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"retry-frozen"}"# - ) - .status - .success()); - assert!(run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"retry-frozen","prompt":"record before read failure"}"# - ) - .status - .success()); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let marker = temp.path().join("unused.marker"); - let mut owner = spawn_owner_with_failpoint(&repository, "frozen-page-unavailable", &marker); - wait_for_ping(&repository, &mut owner); - std::fs::write(repository.join("retry.txt"), b"preserve this change\n").unwrap(); - let payload = br#"{"session_id":"retry-frozen","response":"complete"}"#; - let failed = run_hook(&repository, "stop", payload); - assert!(!failed.status.success()); - assert!(String::from_utf8_lossy(&failed.stderr).contains("injected frozen page read failure")); - assert!(run_owner(&repository, "shutdown").status.success()); - owner.wait().unwrap(); - wait_for_shutdown(&repository); - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let prepared = store - .get_provenance_turn_for("retry-frozen", 1) - .unwrap() - .unwrap() - .checkpoint_attempt - .unwrap(); - assert_eq!(prepared.source.change_hashes.len(), 1); - assert_eq!( - prepared.phase, - atomic_repository::redb_change_store::ProvenanceCheckpointPhase::Prepared - ); - drop(store); - let retry = run_hook(&repository, "stop", payload); - assert!( - retry.status.success(), - "{}", - String::from_utf8_lossy(&retry.stderr) - ); - assert!(retry.stderr.is_empty()); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let completed = store - .get_provenance_turn_for("retry-frozen", 1) - .unwrap() - .unwrap() - .checkpoint_attempt - .unwrap(); - assert_eq!( - completed.phase, - atomic_repository::redb_change_store::ProvenanceCheckpointPhase::Published - ); - assert_eq!(completed.source, prepared.source); - assert_eq!(completed.frozen_event_count, prepared.frozen_event_count); - assert_eq!(completed.attempt_generation, prepared.attempt_generation); - drop(store); - let repo = Repository::open(&repository).unwrap(); - let (_, ledger) = repo.get_session_ledger("retry-frozen").unwrap().unwrap(); - assert_eq!(ledger.len(), 1); - assert_eq!(ledger[0].change_hashes, prepared.source.change_hashes); -} - -#[test] -#[serial] -fn pre_cutover_session_count_gap_publishes_at_next_ledger_ordinal() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"legacy-gap"}"#, - ) - .status - .success()); - assert!(run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"legacy-gap","prompt":"publish after legacy turns"}"#, - ) - .status - .success()); - - let session_path = repository.join(".atomic/sessions/legacy-gap.json"); - let mut session: Value = - serde_json::from_slice(&std::fs::read(&session_path).unwrap()).unwrap(); - session["turn_count"] = Value::from(20); - std::fs::write(&session_path, serde_json::to_vec_pretty(&session).unwrap()).unwrap(); - std::fs::write(repository.join("legacy-gap.txt"), b"record me\n").unwrap(); - - let stop = run_hook( - &repository, - "stop", - br#"{"session_id":"legacy-gap","response":"published"}"#, - ); - assert!( - stop.status.success(), - "{}", - String::from_utf8_lossy(&stop.stderr) - ); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - - let repo = Repository::open(&repository).unwrap(); - let (_, turns) = repo.get_session_ledger("legacy-gap").unwrap().unwrap(); - assert_eq!(turns.len(), 1); - assert_eq!(turns[0].turn_number, 0); -} - -#[test] -#[serial] -fn crashing_second_checkpoint_keeps_first_turn_immutable() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - assert!(run_hook( - &repository, - "session-start", - br#"{"session_id":"immutable-turns"}"#, - ) - .status - .success()); - assert!(run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"immutable-turns","prompt":"first"}"#, - ) - .status - .success()); - std::fs::write(repository.join("first.txt"), b"first\n").unwrap(); - assert!(run_hook( - &repository, - "stop", - br#"{"session_id":"immutable-turns","response":"first done"}"#, - ) - .status - .success()); - let first_hash = Repository::open(&repository) - .unwrap() - .get_session_ledger("immutable-turns") - .unwrap() - .unwrap() - .1[0] - .provenance_hash; - - assert!(run_hook( - &repository, - "user-prompt-submit", - br#"{"session_id":"immutable-turns","prompt":"second"}"#, - ) - .status - .success()); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - std::fs::write(repository.join("second.txt"), b"second\n").unwrap(); - let marker = temp.path().join("second-bind.marker"); - let mut owner = spawn_owner_with_failpoint(&repository, "after-checkpoint-bind", &marker); - wait_for_ping(&repository, &mut owner); - let second = run_hook( - &repository, - "stop", - br#"{"session_id":"immutable-turns","response":"second done"}"#, - ); - assert!( - second.status.success(), - "{}", - String::from_utf8_lossy(&second.stderr) - ); - assert!(!owner.wait().unwrap().success()); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - - let repo = Repository::open(&repository).unwrap(); - let (_, turns) = repo.get_session_ledger("immutable-turns").unwrap().unwrap(); - assert_eq!(turns.len(), 2); - assert_eq!(turns[0].provenance_hash, first_hash); - assert_ne!(turns[1].provenance_hash, first_hash); -} - -#[test] -#[serial] -fn live_owner_releases_the_repository_database_between_requests() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let mut owner = spawn_owner(&repository); - wait_for_ping(&repository, &mut owner); - assert_eq!(reserve(&repository)["committed"], true); - - // The journal shares atomic.redb with the graph, so an idle owner must not - // hold it or every other atomic process would be locked out. - let held = Repository::open_existing_wait(&repository, Duration::from_secs(10)) - .expect("an idle owner leaves the repository database free"); - - // A busy database neither makes the owner look dead nor fails a request - // that can wait for it. - assert!(run_owner(&repository, "ping").status.success()); - let waiting = thread::spawn({ - let repository = repository.clone(); - move || reserve(&repository) - }); - thread::sleep(Duration::from_millis(300)); - drop(held); - assert_eq!(waiting.join().unwrap()["committed"], true); - - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - owner.wait().expect("reap owner"); -} - -#[test] -#[serial] -fn owner_starts_while_another_process_holds_the_database() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let held = Repository::open_existing(&repository).unwrap(); - - // Clients give a starting owner only a short window to answer pings, so - // startup must not wait for the database. - let started = run_owner(&repository, "start"); - assert!( - started.status.success(), - "owner start failed: {}", - String::from_utf8_lossy(&started.stderr) - ); - let waiting = thread::spawn({ - let repository = repository.clone(); - move || reserve(&repository) - }); - thread::sleep(Duration::from_millis(300)); - drop(held); - assert_eq!(waiting.join().unwrap()["committed"], true); - - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); -} - -#[test] -#[serial] -fn owner_merges_a_legacy_layout_before_serving() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - let dot_dir = repository.join(".atomic"); - std::fs::rename( - dot_dir.join(atomic_repository::DATABASE_FILE), - dot_dir.join(atomic_repository::LEGACY_PRISTINE_FILE), - ) - .unwrap(); - let legacy_turn = - RedbChangeStore::open(dot_dir.join(atomic_repository::LEGACY_CHANGE_STORE_FILE)) - .unwrap() - .reserve_provenance_turn("owner-service-test", 7, 1_700_000_000) - .unwrap(); - - let mut owner = spawn_owner(&repository); - wait_for_ping(&repository, &mut owner); - assert_eq!(reserve(&repository)["committed"], true); - assert!(run_owner(&repository, "shutdown").status.success()); - wait_for_shutdown(&repository); - owner.wait().expect("reap owner"); - - assert!(dot_dir.join(atomic_repository::DATABASE_FILE).is_file()); - assert!(!dot_dir - .join(atomic_repository::LEGACY_PRISTINE_FILE) - .exists()); - assert!(!dot_dir - .join(atomic_repository::LEGACY_CHANGE_STORE_FILE) - .exists()); - let store = - RedbChangeStore::open(Repository::canonical_database_path(&repository).unwrap()).unwrap(); - let merged_turn = store - .get_provenance_turn_for("owner-service-test", 7) - .unwrap() - .expect("the reserved turn survives the merge"); - assert_eq!(merged_turn.provenance_id, legacy_turn.provenance_id); -} - -#[test] -#[serial] -fn owner_election_commit_reconnect_and_crash_restart() { - let temp = TempDir::new().unwrap(); - let repository = temp.path().join("repo"); - drop(Repository::init(&repository).unwrap()); - - let mut first_owner = spawn_owner(&repository); - let first_health = wait_for_ping(&repository, &mut first_owner); - let first_pid = first_health["pid"].as_u64().unwrap(); - assert_eq!(first_health["protocol_version"], 1); - - let duplicate = command_output(&mut owner_command(&repository, "serve")); - assert!(!duplicate.status.success()); - assert!(String::from_utf8_lossy(&duplicate.stderr).contains("another database owner")); - - let first_reservation = reserve(&repository); - assert_eq!(first_reservation["committed"], true); - let provenance_id = first_reservation["turn"]["provenance_id"].clone(); - - first_owner.kill().expect("kill first owner"); - first_owner.wait().expect("reap first owner"); - - let first_bootstrap = owner_command(&repository, "start") - .arg("--json") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn first bootstrap contender"); - let second_bootstrap = owner_command(&repository, "start") - .arg("--json") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn second bootstrap contender"); - let first_bootstrap = wait_for_output(first_bootstrap, "first bootstrap contender"); - let second_bootstrap = wait_for_output(second_bootstrap, "second bootstrap contender"); - assert!(first_bootstrap.status.success()); - assert!(second_bootstrap.status.success()); - let restarted_health: Value = serde_json::from_slice(&first_bootstrap.stdout).unwrap(); - let concurrent_health: Value = serde_json::from_slice(&second_bootstrap.stdout).unwrap(); - assert_ne!(restarted_health["pid"].as_u64().unwrap(), first_pid); - assert_eq!(concurrent_health["pid"], restarted_health["pid"]); - - let replayed = reserve(&repository); - assert_eq!(replayed["committed"], true); - assert_eq!(replayed["turn"]["provenance_id"], provenance_id); - - let reconnected = run_owner(&repository, "start"); - assert!(reconnected.status.success()); - let reconnected_health: Value = serde_json::from_slice(&reconnected.stdout).unwrap(); - assert_eq!(reconnected_health["pid"], restarted_health["pid"]); - - let shutdown = run_owner(&repository, "shutdown"); - assert!( - shutdown.status.success(), - "shutdown failed: {}", - String::from_utf8_lossy(&shutdown.stderr) - ); - wait_for_shutdown(&repository); -} - -#[test] -#[serial] -fn scoped_concurrent_stops_and_session_end_do_not_record_sibling_files() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - drop(Repository::init(&root).unwrap()); - let hook = |sid: &str, verb: &str, extra: Value| { - let mut value = extra; - value["session_id"] = sid.into(); - let output = run_agent_hook( - &root, - "opencode", - verb, - &serde_json::to_vec(&value).unwrap(), - ); - assert!(output.status.success(), "{output:?}"); - }; - hook( - "workspace-owner", - "session-start", - serde_json::json!({"recording_scope":"explicit-files-v1"}), - ); - for sid in ["child-a", "child-b"] { - hook( - sid, - "session-start", - serde_json::json!({"recording_scope":"explicit-files-v1", "workspace_session_id":"workspace-owner"}), - ); - hook( - sid, - "user-prompt", - serde_json::json!({"prompt":"Write only the assigned file"}), - ); - } - for name in ["child-a.txt", "child-b.txt", "human.txt"] { - std::fs::write(root.join(name), name).unwrap(); - } - let pending:Vec<_> = ["child-a", "child-b"].into_iter().map(|sid|{ - let root=root.clone(); - thread::spawn(move||{ - let path=format!("{sid}.txt"); - let digest=atomic_agent::record::scope::fingerprint(&root,&path).unwrap(); - run_agent_hook(&root,"opencode","stop",&serde_json::to_vec(&serde_json::json!({"session_id":sid,"record_files":{path:digest},"response":"Done"})).unwrap()) - }) - }).collect(); - for handle in pending { - let result = handle.join().unwrap(); - assert!(result.status.success(), "{result:?}"); - } - for sid in ["child-a", "child-b"] { - let state: Value = serde_json::from_slice( - &std::fs::read(root.join(format!(".atomic/sessions/{sid}.json"))).unwrap(), - ) - .unwrap(); - let owner: Value = serde_json::from_slice( - &std::fs::read(root.join(".atomic/sessions/workspace-owner.json")).unwrap(), - ) - .unwrap(); - assert_eq!(state["view_name"], owner["view_name"]); - assert_eq!( - state["files_touched"], - serde_json::json!([format!("{sid}.txt")]) - ); - assert_eq!(state["recorded_change_hashes"].as_array().unwrap().len(), 1); - hook( - sid, - "stop", - serde_json::json!({"record_files":{},"response":"duplicate idle"}), - ); - hook( - sid, - "session-end", - serde_json::json!({"record_files":{},"reason":"deleted"}), - ); - let after: Value = serde_json::from_slice( - &std::fs::read(root.join(format!(".atomic/sessions/{sid}.json"))).unwrap(), - ) - .unwrap(); - assert_eq!( - after["recorded_change_hashes"], - state["recorded_change_hashes"] - ); - assert_eq!(after["turn_count"], state["turn_count"]); - } - assert_eq!( - std::fs::read_to_string(root.join("human.txt")).unwrap(), - "human.txt" - ); - assert!(run_owner(&root, "shutdown").status.success()); - wait_for_shutdown(&root); -} - -fn check_missing_turn_start(write_files: bool) { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - drop(Repository::init(&root).unwrap()); - let hook = |sid: &str, verb: &str, mut value: Value| { - value["session_id"] = sid.into(); - run_agent_hook( - &root, - "opencode", - verb, - &serde_json::to_vec(&value).unwrap(), - ) - }; - assert!(hook( - "parent", - "session-start", - serde_json::json!({"recording_scope":"explicit-files-v1"}) - ) - .status - .success()); - let sid = "programmatic-child"; - assert!(hook( - sid, - "session-start", - serde_json::json!({"recording_scope":"explicit-files-v1","workspace_session_id":"parent"}) - ) - .status - .success()); - assert!(hook( - sid, - "user-prompt", - serde_json::json!({"prompt":"First turn only"}) - ) - .status - .success()); - for turn in 0..3 { - // After the first turn, simulate a resumed/programmatic session that - // emits tools and Stop but no chat.message / user-prompt callback. - let event = serde_json::json!({"tool_name":"read","tool_call_id":format!("tool-{turn}"),"tool_input":{"path":"sample.txt"},"tool_output":format!("result-{turn}")}); - assert!(hook(sid, "before-tool", event.clone()).status.success()); - let files = if write_files { - std::fs::write(root.join("sample.txt"), format!("turn {turn}\n")).unwrap(); - serde_json::json!({"sample.txt":atomic_agent::record::scope::fingerprint(&root,"sample.txt").unwrap()}) - } else { - serde_json::json!({}) - }; - assert!(hook(sid, "after-tool", event).status.success()); - let stop = serde_json::json!({"record_files":files,"response":format!("done-{turn}")}); - if turn == 1 && write_files { - let mut stale = stop.clone(); - stale["record_files"]["sample.txt"] = "stale digest".into(); - let failed = hook(sid, "stop", stale); - assert!(!failed.status.success(), "{failed:?}"); - // Recovery must persist activation before a failing record so the - // corrected Stop retries the same journal turn rather than skipping. - let state: Value = serde_json::from_slice( - &std::fs::read(root.join(format!(".atomic/sessions/{sid}.json"))).unwrap(), - ) - .unwrap(); - assert_eq!(state["phase"], "active"); - assert_eq!(state["turn_count"], 1); - } - for _ in 0..2 { - let result = hook(sid, "stop", stop.clone()); - assert!(result.status.success(), "{result:?}"); - } - } - assert!(run_owner(&root, "shutdown").status.success()); - wait_for_shutdown(&root); - let repo = Repository::open(&root).unwrap(); - let (_, turns) = repo.get_session_ledger(sid).unwrap().unwrap(); - assert_eq!(turns.len(), 3); - for (index, turn) in turns.iter().enumerate() { - assert_eq!(turn.change_hashes.len(), usize::from(write_files)); - assert_eq!( - turn.previous_provenance, - index.checked_sub(1).map(|i| turns[i].provenance_hash) - ); - let graph = repo.load_provenance_graph(&turn.provenance_hash).unwrap(); - assert_eq!(graph.session_id, sid); - let json = serde_json::to_string(&graph).unwrap(); - assert!(json.contains(&format!("tool-{index}")), "{json}"); - if index > 0 { - assert_ne!(turn.goal.as_deref(), Some("First turn only")); - for hash in &turn.change_hashes { - assert_ne!( - repo.load_change(hash).unwrap().hashed.header.message, - "First turn only" - ); - } - } - } - if write_files { - assert_eq!( - std::fs::read_to_string(root.join("sample.txt")).unwrap(), - "turn 2\n" - ); - } -} - -#[test] -#[serial] -fn missing_turn_start_recovers_changes_and_provenance_without_duplicate_stops() { - check_missing_turn_start(true); -} - -#[test] -#[serial] -fn missing_turn_start_recovers_read_only_provenance() { - check_missing_turn_start(false); -} - -#[test] -#[serial] -fn scoped_stop_without_start_or_journal_fails_visibly_and_can_retry() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - drop(Repository::init(&root).unwrap()); - let sid = "missing-everything"; - let hook = |verb: &str, mut value: Value| { - value["session_id"] = sid.into(); - run_agent_hook( - &root, - "opencode", - verb, - &serde_json::to_vec(&value).unwrap(), - ) - }; - assert!(hook( - "session-start", - serde_json::json!({"recording_scope":"explicit-files-v1"}) - ) - .status - .success()); - assert!(hook( - "user-prompt", - serde_json::json!({"prompt":"first read-only turn"}) - ) - .status - .success()); - assert!(hook("stop", serde_json::json!({"record_files":{}})) - .status - .success()); - // No tool event either: there is no durable evidence defining a new turn. - std::fs::write(root.join("new.txt"), "keep this work\n").unwrap(); - let stop = serde_json::json!({"record_files":{"new.txt":atomic_agent::record::scope::fingerprint(&root,"new.txt").unwrap()}}); - let failed = hook("stop", stop.clone()); - assert!(!failed.status.success(), "{failed:?}"); - assert!( - String::from_utf8_lossy(&failed.stderr).contains("no active turn or pending journal"), - "{failed:?}" - ); - assert_eq!( - std::fs::read_to_string(root.join("new.txt")).unwrap(), - "keep this work\n" - ); - assert!(hook( - "user-prompt", - serde_json::json!({"prompt":"recover pending file"}) - ) - .status - .success()); - assert!(hook("stop", stop.clone()).status.success()); - // Unrelated human edits must not be swept up by a duplicate scoped Stop. - std::fs::write(root.join("human.txt"), "leave alone\n").unwrap(); - assert!(hook("stop", stop).status.success()); - let invalid = hook( - "stop", - serde_json::json!({"record_files":{"../escape":null}}), - ); - assert!(!invalid.status.success()); - assert!(run_owner(&root, "shutdown").status.success()); - wait_for_shutdown(&root); - let repo = Repository::open(&root).unwrap(); - let (_, turns) = repo.get_session_ledger(sid).unwrap().unwrap(); - assert_eq!(turns.len(), 2); - assert_eq!(turns[1].change_hashes.len(), 1); - assert!(repo.get_file_content("human.txt").unwrap().is_none()); -} diff --git a/atomic-cli/tests/owner_wait_budget_test.rs b/atomic-cli/tests/owner_wait_budget_test.rs deleted file mode 100644 index 59ca65c6..00000000 --- a/atomic-cli/tests/owner_wait_budget_test.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! PR #230 regression reproduction: queueing for the owner's store lease must -//! consume the database wait budget, not start a fresh budget after queueing. - -#![cfg(unix)] - -use std::io::{Read, Write}; -use std::os::unix::net::UnixStream; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Barrier}; -use std::thread; -use std::time::{Duration, Instant}; - -use atomic_core::types::Hash; -use atomic_repository::Repository; -use serde_json::{json, Value}; - -const ATOMIC: &str = env!("CARGO_BIN_EXE_atomic"); -const REQUESTS: usize = 8; - -struct Owner { - child: Child, - endpoint: PathBuf, -} - -impl Drop for Owner { - fn drop(&mut self) { - // Always reap the foreground owner, including on assertion failure. - // Killing also bounds cleanup if its blocking workers are still queued. - let _ = self.child.kill(); - let _ = self.child.wait(); - let _ = std::fs::remove_file(&self.endpoint); - } -} - -fn connect(endpoint: &Path) -> std::io::Result { - let stream = UnixStream::connect(endpoint)?; - stream.set_read_timeout(Some(Duration::from_secs(5)))?; - stream.set_write_timeout(Some(Duration::from_secs(5)))?; - Ok(stream) -} - -fn exchange(mut stream: UnixStream, id: &str, request: Value) -> std::io::Result { - let frame = serde_json::to_vec(&json!({ - "version": 1, - "request_id": id, - "request": request, - }))?; - stream.write_all(&(frame.len() as u32).to_be_bytes())?; - stream.write_all(&frame)?; - stream.flush()?; - - let mut length = [0; 4]; - stream.read_exact(&mut length)?; - let length = u32::from_be_bytes(length) as usize; - if length == 0 || length > 1024 * 1024 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("unexpected owner response length {length}"), - )); - } - let mut payload = vec![0; length]; - stream.read_exact(&mut payload)?; - let response: Value = serde_json::from_slice(&payload)?; - if response["version"] != 1 || response["request_id"] != id { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("unexpected owner response: {response}"), - )); - } - Ok(response) -} - -#[test] -fn queued_owner_requests_share_the_configured_database_wait_deadline() { - let temp = tempfile::tempdir().unwrap(); - // Keep the writable database handle alive through all eight requests. - let held = Repository::init(temp.path()).unwrap(); - let canonical_dot_dir = std::fs::canonicalize(temp.path().join(".atomic")).unwrap(); - let digest = Hash::of(canonical_dot_dir.to_string_lossy().as_bytes()); - let prefix: String = digest.as_bytes()[..12] - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - let endpoint = Path::new("/tmp").join(format!("atomic-owner-{prefix}.sock")); - let mut owner = Owner { - child: Command::new(ATOMIC) - .args(["agent", "database-owner", "serve", "--repository"]) - .arg(temp.path()) - .env("ATOMIC_DB_LOCK_WAIT_MS", "100") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .spawn() - .expect("spawn database owner"), - endpoint: endpoint.clone(), - }; - - let startup_deadline = Instant::now() + Duration::from_secs(10); - loop { - if let Ok(response) = - connect(&endpoint).and_then(|stream| exchange(stream, "ready", json!("Ping"))) - { - assert!(response["response"].get("Pong").is_some(), "{response}"); - break; - } - assert!( - owner.child.try_wait().unwrap().is_none(), - "owner exited before answering Ping" - ); - assert!(Instant::now() < startup_deadline, "owner startup timed out"); - thread::sleep(Duration::from_millis(10)); - } - - // Establish all sockets first, then release requests together. Use raw - // frames so client-side reconnect/retry cannot explain elapsed times. - let streams: Vec<_> = (0..REQUESTS).map(|_| connect(&endpoint).unwrap()).collect(); - let barrier = Arc::new(Barrier::new(REQUESTS + 1)); - let workers: Vec<_> = streams - .into_iter() - .enumerate() - .map(|(index, stream)| { - let barrier = Arc::clone(&barrier); - thread::spawn(move || { - barrier.wait(); - let started = Instant::now(); - let response = exchange( - stream, - &format!("reserve-{index}"), - json!({ - "ReserveProvenanceTurn": { - "session_id": format!("timeout-review-{index}"), - "turn_number": 1, - "now": 1_700_000_000, - } - }), - ); - (started.elapsed(), response) - }) - }) - .collect(); - barrier.wait(); - let results: Vec<_> = workers.into_iter().map(|worker| worker.join()).collect(); - - // Cleanup precedes the intentional regression assertion, and the Owner - // guard also covers every earlier panic path. - drop(owner); - drop(held); - - let mut elapsed = Vec::new(); - for result in results { - let (duration, response) = result.expect("request thread panicked"); - let response = response.expect("raw owner request failed"); - assert_eq!( - response["response"]["Error"]["code"], "database-unavailable", - "expected busy-database response, got {response}" - ); - elapsed.push(duration); - } - elapsed.sort_unstable(); - eprintln!("100 ms wait budget; eight concurrent raw RPC durations: {elapsed:?}"); - assert!( - elapsed.last().unwrap() < &Duration::from_millis(400), - "mutex queueing escapes the 100 ms database wait budget: {elapsed:?}" - ); -} diff --git a/atomic-cli/tests/provenance_rpc_sink.rs b/atomic-cli/tests/provenance_rpc_sink.rs new file mode 100644 index 00000000..fd0be968 --- /dev/null +++ b/atomic-cli/tests/provenance_rpc_sink.rs @@ -0,0 +1,425 @@ +//! Integration tests for the RPC-based provenance journal sink +//! (`ProvenanceRpcSink`): the real daemon binary from the Reactor +//! checkout, the real `atomic` binary driving the real sink through the +//! full ProvenanceJournalSink trait flow over the real socket. +//! +//! The sink itself lives inside the (binary-only) CLI crate, so the suite +//! drives it through the hidden `agent journal-rpc-selftest` harness +//! command, which exercises exactly the trait surface the orchestrator +//! uses: reserve → append (chunked under the wire budget) → prepare → +//! load_frozen_envelopes (paged reassembly) → bind → acknowledge, plus +//! stop_turn/turn_status. A second test proves `agent hooks` journals over +//! the daemon's ProvenanceService when the daemon is already reachable +//! (the daemon's RPC log must show the journal calls), and a third proves +//! the D4 cutover: with NO daemon pre-started, a hook flow auto-starts +//! one and still journals over the socket. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use atomic_repository::Repository; +use serial_test::serial; +use tempfile::TempDir; + +/// Kills the spawned daemon when dropped — a panic in the test must not +/// leak a lock-holding daemon for the next run. +struct KillOnDrop(Child); + +impl Drop for KillOnDrop { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// The Reactor checkout that owns the atomicd daemon binary. This test +/// file lives in /atomic-cli/tests, so ../../reactor resolves to +/// the sibling workspace checkout; `ATOMIC_REACTOR_ROOT` overrides that +/// for non-sibling checkouts. `None` means no Reactor checkout exists +/// (e.g. CI runners) and the RPC-sink suite skips — the daemon binary is +/// not buildable from this workspace alone. +fn reactor_root() -> Option { + if let Some(root) = std::env::var_os("ATOMIC_REACTOR_ROOT") { + let root = PathBuf::from(root); + return root.join("Cargo.toml").exists().then_some(root); + } + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../reactor"); + root.canonicalize() + .ok() + .filter(|root| root.join("Cargo.toml").exists()) +} + +/// `None` when the Reactor checkout (and thus the daemon binary) is +/// unavailable; every test in this suite skips in that case. +fn daemon_binary() -> Option { + Some(reactor_root()?.join("target/debug/atomicd")) +} + +/// Skip-label for tests that require the Reactor checkout. +fn skip_without_reactor(what: &str) { + // cargo prints skipped-test output on stderr; keep the skip visible + // in the run log without failing the suite. + eprintln!("skipping {what}: no Reactor checkout (set ATOMIC_REACTOR_ROOT to enable)"); +} + +/// Guard that restores the socket override when dropped. +struct SocketEnv(Option); + +impl SocketEnv { + fn set(socket: &Path) -> Self { + let previous = std::env::var_os("ATOMIC_DAEMON_SOCKET"); + std::env::set_var("ATOMIC_DAEMON_SOCKET", socket); + SocketEnv(previous) + } +} + +/// Guard that restores one env override when dropped (panic-safe). +struct VarEnv(&'static str, Option); + +impl VarEnv { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + VarEnv(key, previous) + } +} + +impl Drop for VarEnv { + fn drop(&mut self) { + match self.1.take() { + Some(previous) => std::env::set_var(self.0, previous), + None => std::env::remove_var(self.0), + } + } +} + +/// Kill the daemon serving `socket` (the D4 test's auto-started daemon is +/// detached; it must not outlive the test). +fn kill_daemon_on(socket: &Path) { + let output = Command::new("lsof").arg("-t").arg(socket).output(); + if let Ok(out) = output { + for line in String::from_utf8_lossy(&out.stdout).lines() { + if let Ok(pid) = line.trim().parse::() { + let _ = Command::new("kill").arg("-9").arg(pid.to_string()).output(); + } + } + } +} + +impl Drop for SocketEnv { + fn drop(&mut self) { + match self.0.take() { + Some(previous) => std::env::set_var("ATOMIC_DAEMON_SOCKET", previous), + None => std::env::remove_var("ATOMIC_DAEMON_SOCKET"), + } + } +} + +fn wait_for_daemon(socket: &Path, child: &mut Child) { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Some(status) = child.try_wait().expect("inspect daemon") { + panic!("atomicd exited with {status} during bootstrap"); + } + if socket.exists() { + return; + } + assert!( + Instant::now() < deadline, + "atomicd never bound {}", + socket.display() + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn spawn_daemon(socket: &Path, extra_envs: &[(&str, &str)]) -> Child { + let _ = std::fs::remove_file(socket); + let mut command = Command::new( + daemon_binary() + .expect("caller guards on daemon_binary(); the suite skips without a Reactor checkout"), + ); + command + .arg("serve") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (key, value) in extra_envs { + command.env(key, value); + } + command.spawn().expect("spawn atomicd") +} + +fn run_atomic(args: &[&str], current_dir: &Path, stdin: Option<&[u8]>) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_atomic")); + command + .args(args) + .current_dir(current_dir) + .stdin(if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn atomic"); + if let (Some(stdin), Some(mut pipe)) = (stdin, child.stdin.take()) { + use std::io::Write; + pipe.write_all(stdin).expect("write hook payload"); + } + let output = child.wait_with_output().expect("wait for atomic"); + assert!( + output.status.success(), + "atomic {:?} failed: stdout: {}; stderr: {}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +#[test] +#[serial] +fn rpc_sink_drives_the_full_trait_flow_over_the_socket() { + // The selftest is the REACTOR transport's exercise — opt in explicitly + // (local mode is the CLI default and would refuse the command). + let _service = VarEnv::set("ATOMIC_SERVICE", "reactor"); + // Build the daemon from the Reactor checkout when it is missing (the + // normal verification flow builds it first). + let (Some(reactor), Some(daemon)) = (reactor_root(), daemon_binary()) else { + skip_without_reactor("rpc_sink_drives_the_full_trait_flow_over_the_socket"); + return; + }; + if !daemon.exists() { + let output = Command::new("cargo") + .arg("build") + .arg("--manifest-path") + .arg(reactor.join("Cargo.toml")) + .output() + .expect("cargo build reactor"); + assert!( + output.status.success(), + "building atomicd failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let temp = TempDir::new().unwrap(); + let repo = temp.path().join("repo"); + drop(Repository::init(&repo).unwrap()); + let socket = temp.path().join("atomicd.sock"); + let _env = SocketEnv::set(&socket); + let mut daemon = KillOnDrop(spawn_daemon(&socket, &[])); + wait_for_daemon(&socket, &mut daemon.0); + + // Six ~450 KiB wire envelopes (payload plus JSON framing) against + // the sink's 1 MiB wire chunk budget: the batch must split into + // three chunks of two and reassemble bit-exact. + let output = run_atomic( + &[ + "agent", + "journal-rpc-selftest", + "--repository", + repo.display().to_string().as_str(), + "--session-id", + "rpc-sink-selftest", + "--turn", + "1", + "--envelopes", + "6", + "--envelope-bytes", + "450000", + "--json", + ], + &repo, + None, + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json report"); + + assert_eq!(report["acks"], 6, "every envelope is acknowledged"); + assert_eq!( + report["ack_sequences"], + serde_json::json!([0, 1, 2, 3, 4, 5]), + "sequences are contiguous from zero" + ); + assert_eq!( + report["chunk_count"], 3, + "a ~2.7 MiB batch must split into three chunks under the 1 MiB wire budget" + ); + assert_eq!(report["frozen_envelopes"], 6); + assert!( + report["reassembled_equal"].as_bool().unwrap(), + "paged loads must reassemble to the exact committed bytes" + ); + assert_eq!(report["attempt_generation"], 2); + assert!( + report["bound_hash"].as_bool().unwrap(), + "bind must persist the graph hash" + ); + assert!(report["stopped"].as_bool().unwrap(), "stop_turn ran"); + assert!( + report["status_lifecycle"] + .as_str() + .unwrap() + .contains("Stopped"), + "turn_status must report the stopped lifecycle: {}", + report["status_lifecycle"] + ); + + // The daemon really owns the journal: inspect the store the daemon + // wrote (its per-request handles are closed between RPCs). + drop(daemon); + let store = atomic_repository::redb_change_store::RedbChangeStore::open_existing( + Repository::canonical_database_path(&repo).unwrap(), + ) + .unwrap(); + let completed = store + .get_provenance_turn_for("rpc-sink-selftest", 1) + .unwrap() + .expect("turn 1"); + assert!(matches!( + completed.state, + atomic_repository::redb_change_store::ProvenanceTurnState::Completed + )); + assert_eq!(completed.checkpoint_attempt.unwrap().frozen_event_count, 6); + let stopped = store + .get_provenance_turn_for("rpc-sink-selftest", 2) + .unwrap() + .expect("turn 2"); + match stopped.state { + atomic_repository::redb_change_store::ProvenanceTurnState::Stopped(stop) => { + assert_eq!( + stop.cause, + atomic_repository::redb_change_store::StopCause::UserRequested + ); + assert!(stop.resumable); + } + other => panic!("expected stopped turn, got {other:?}"), + } +} + +#[test] +#[serial] +fn hooks_prefer_the_rpc_journal_sink_when_the_daemon_is_reachable() { + if !daemon_binary().is_some_and(|daemon| daemon.exists()) { + skip_without_reactor("hooks_prefer_the_rpc_journal_sink_when_the_daemon_is_reachable"); + return; // covered by the sibling test when the daemon is present + } + let temp = TempDir::new().unwrap(); + let repo = temp.path().join("repo"); + drop(Repository::init(&repo).unwrap()); + let socket = temp.path().join("atomicd.sock"); + let rpc_log = temp.path().join("daemon-rpc.jsonl"); + // This suite proves the REACTOR journal transport: the hook flow must + // journal over the daemon's ProvenanceService, not in-process. + let _service = VarEnv::set("ATOMIC_SERVICE", "reactor"); + let _env = SocketEnv::set(&socket); + let mut daemon = KillOnDrop(spawn_daemon( + &socket, + &[("ATOMIC_DAEMON_LOG_REQUESTS", rpc_log.to_str().unwrap())], + )); + wait_for_daemon(&socket, &mut daemon.0); + + // A plain hook flow with the daemon up: the orchestrator's journal + // sink is the RPC sink (no owner process is ever spawned). + run_atomic( + &[ + "agent", + "hooks", + "claude-code", + "session-start", + "--foreground", + "--no-color", + ], + &repo, + Some(br#"{"session_id":"rpc-hook-flow"}"#), + ); + run_atomic( + &[ + "agent", + "hooks", + "claude-code", + "user-prompt-submit", + "--foreground", + "--no-color", + ], + &repo, + Some(br#"{"session_id":"rpc-hook-flow","prompt":"journal over the daemon"}"#), + ); + + // The daemon's RPC log is the proof the journal went over the wire: + // ReserveTurn and AppendEnvelopes must have been served. + let log = std::fs::read_to_string(&rpc_log).unwrap(); + assert!( + log.contains("\"method\":\"ReserveTurn\""), + "the hook must have reserved its turn over RPC:\n{log}" + ); + assert!( + log.contains("\"method\":\"AppendEnvelopes\""), + "the hook must have journaled over RPC:\n{log}" + ); +} + +#[test] +#[serial] +fn hooks_start_the_daemon_when_none_is_running() { + if !daemon_binary().is_some_and(|daemon| daemon.exists()) { + skip_without_reactor("hooks_start_the_daemon_when_none_is_running"); + return; // covered by the sibling test when the daemon is present + } + let temp = TempDir::new().unwrap(); + let repo = temp.path().join("repo"); + drop(Repository::init(&repo).unwrap()); + let socket = temp.path().join("atomicd.sock"); + let rpc_log = temp.path().join("daemon-rpc.jsonl"); + // No daemon is spawned by the test: the socket path is empty. The + // sink must start the daemon itself (D4) and journal over it. + // This suite proves the REACTOR journal transport. + let _service = VarEnv::set("ATOMIC_SERVICE", "reactor"); + let _env = SocketEnv::set(&socket); + let _bin = VarEnv::set( + "ATOMIC_DAEMON_BIN", + daemon_binary().expect("guarded above").to_str().unwrap(), + ); + let _log = VarEnv::set("ATOMIC_DAEMON_LOG_REQUESTS", rpc_log.to_str().unwrap()); + + run_atomic( + &[ + "agent", + "hooks", + "claude-code", + "session-start", + "--foreground", + "--no-color", + ], + &repo, + Some(br#"{"session_id":"d4-autostart"}"#), + ); + run_atomic( + &[ + "agent", + "hooks", + "claude-code", + "user-prompt-submit", + "--foreground", + "--no-color", + ], + &repo, + Some(br#"{"session_id":"d4-autostart","prompt":"journal via the auto-started daemon"}"#), + ); + + // The auto-started daemon served the journal RPCs: the D4 proof. + let log = std::fs::read_to_string(&rpc_log).unwrap(); + assert!( + log.contains("\"method\":\"ReserveTurn\""), + "the hook must have auto-started the daemon and reserved over RPC:\n{log}" + ); + assert!( + log.contains("\"method\":\"AppendEnvelopes\""), + "the hook must have auto-started the daemon and journaled over RPC:\n{log}" + ); + + // Leave no daemon behind on the private socket. + kill_daemon_on(&socket); +} diff --git a/atomic-cli/tests/restore_integration_test.rs b/atomic-cli/tests/restore_integration_test.rs index ddcf28bd..e737d33d 100644 --- a/atomic-cli/tests/restore_integration_test.rs +++ b/atomic-cli/tests/restore_integration_test.rs @@ -92,8 +92,10 @@ fn whole_tree_restore_without_force_is_user_error_not_internal() { String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); + // The routed guard is the domain's refusal: it names the unrecorded + // changes and the two ways out (paths or force). assert!( - combined.contains("Cannot restore"), + combined.contains("unrecorded changes"), "should explain the guard, got: {combined}" ); assert!( diff --git a/atomic-cli/tests/routing_parity_test.rs b/atomic-cli/tests/routing_parity_test.rs new file mode 100644 index 00000000..63962d4d --- /dev/null +++ b/atomic-cli/tests/routing_parity_test.rs @@ -0,0 +1,1362 @@ +//! Routing-parity coverage for the flag-forms wired through the service +//! layer: each test drives the `atomic` binary in local service mode (the +//! default — the same libatomic handlers the atomicd transport serves, +//! in-process) and asserts the wire-carried renders: the attestation-aware +//! listing columns, the canonical JSON-LD projections, the whole-vault +//! listing, the own/inherited view classification, the KG node fields, +//! the composed intent update, the deps opt-out, the revise surgery, the +//! record dry-run, and the provenance summary. +#![cfg(not(windows))] + +use std::path::Path; +use std::process::{Command, Output}; + +use tempfile::TempDir; + +const ATOMIC_BIN: &str = env!("CARGO_BIN_EXE_atomic"); + +fn atomic(repo_dir: &Path, home_dir: &Path, args: &[&str]) -> Output { + Command::new(ATOMIC_BIN) + .args(args) + .current_dir(repo_dir) + .env("HOME", home_dir) + .output() + .expect("run atomic") +} + +fn run(repo_dir: &Path, home_dir: &Path, args: &[&str]) -> String { + let output = atomic(repo_dir, home_dir, args); + assert!( + output.status.success(), + "{args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// A fresh repo + vault with an isolated HOME carrying a default identity +/// (the attestation flow needs one). +fn fixture(label: &str) -> (TempDir, TempDir) { + let repo_tmp = TempDir::new().unwrap(); + let home_tmp = TempDir::new().unwrap(); + let init = atomic(repo_tmp.path(), home_tmp.path(), &["init", "--vault"]); + assert!( + init.status.success(), + "{label}: init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + let _ = label; + (repo_tmp, home_tmp) +} + +fn ensure_default_identity(repo_dir: &Path, home_dir: &Path) { + run( + repo_dir, + home_dir, + &["identity", "new", "tester", "--email", "t@example.com"], + ); + run(repo_dir, home_dir, &["identity", "default", "tester"]); +} + +#[test] +fn intent_list_json_carries_attestation_columns_over_the_wire() { + let (repo, home) = fixture("intent-list"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + ensure_default_identity(repo_dir, home_dir); + let created = run(repo_dir, home_dir, &["intent", "new", "Parity intent"]); + let id = created + .lines() + .find_map(|line| line.strip_prefix("Created intent: ")) + .expect("intent id") + .to_string(); + + // Unattested: none/na, the manifest kind tag, the title. + let json = run(repo_dir, home_dir, &["intent", "list", "--json"]); + let rows: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["id"], serde_json::json!(id)); + assert_eq!(rows[0]["kind"], "feature"); + assert_eq!(rows[0]["attested"], "none"); + assert_eq!(rows[0]["verifies"], "na"); + assert_eq!(rows[0]["title"], "Parity intent"); + + // The kind filter validates client-side and narrows over the wire's + // classification. + let empty = run( + repo_dir, + home_dir, + &["intent", "list", "--json", "--kind", "chore"], + ); + assert_eq!(empty.trim(), "[]"); + + // After attesting: fresh + yes — the handler computed the fresh + // tracked attestation and the DID-match-then-verify rule. + run(repo_dir, home_dir, &["intent", "attest", &id]); + let json = run(repo_dir, home_dir, &["intent", "list", "--json"]); + let rows: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(rows[0]["attested"], "fresh"); + assert_eq!(rows[0]["verifies"], "yes"); + + // The table render carries the same columns. + let table = run(repo_dir, home_dir, &["intent", "list"]); + assert!(table.contains("fresh")); + assert!(table.contains(&id)); +} + +#[test] +fn memory_list_json_carries_kind_status_about_columns() { + let (repo, home) = fixture("memory-list"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + run( + repo_dir, + home_dir, + &[ + "memory", + "new", + "--kind", + "lesson", + "--text", + ":::memory\nLessons route over the wire.\n:::", + ], + ); + + let json = run(repo_dir, home_dir, &["memory", "list", "--json"]); + let rows: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(rows.len(), 1, "the index scaffold never lists"); + assert_eq!(rows[0]["kind"], "lesson"); + assert_eq!(rows[0]["status"], "active"); + assert_eq!(rows[0]["about"], 0); + assert_eq!(rows[0]["attested"], "none"); + assert_eq!(rows[0]["verifies"], "na"); + + // --limit truncates the recency-ordered listing. + run( + repo_dir, + home_dir, + &[ + "memory", + "new", + "--kind", + "context", + "--text", + ":::memory\nA second memory.\n:::", + ], + ); + let json = run(repo_dir, home_dir, &["memory", "list", "--json", "-n", "1"]); + let rows: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(rows.len(), 1); +} + +#[test] +fn vault_list_json_lists_every_entry_with_type_size_and_date() { + let (repo, home) = fixture("vault-list"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + run(repo_dir, home_dir, &["intent", "new", "Listed intent"]); + + let json = run(repo_dir, home_dir, &["vault", "list", "--json"]); + let rows: Vec = serde_json::from_str(&json).unwrap(); + let paths: Vec<&str> = rows + .iter() + .map(|row| row["path"].as_str().unwrap()) + .collect(); + assert!( + paths.iter().any(|p| p.ends_with("/intent.md")), + "intents list: {paths:?}" + ); + assert!( + paths.contains(&"memory/MEMORY.md"), + "the index scaffold lists in the whole-vault form: {paths:?}" + ); + for row in &rows { + assert!(row["type"].is_string()); + assert!(row["size"].is_u64()); + assert!(row["updated_at"].is_string()); + } + + // The type and prefix filters ride the request. + let json = run( + repo_dir, + home_dir, + &["vault", "list", "--json", "-t", "memory"], + ); + let rows: Vec = serde_json::from_str(&json).unwrap(); + assert!(!rows.is_empty()); + assert!(rows.iter().all(|row| row["type"] == "memory")); + + let json = run( + repo_dir, + home_dir, + &["vault", "list", "--json", "-p", "intents/"], + ); + let rows: Vec = serde_json::from_str(&json).unwrap(); + assert!(!rows.is_empty()); + assert!(rows + .iter() + .all(|row| row["path"].as_str().unwrap().starts_with("intents/"))); +} + +#[test] +fn vault_show_json_carries_revision_and_entry_metadata() { + let (repo, home) = fixture("vault-show"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + + let json = run( + repo_dir, + home_dir, + &["vault", "show", "memory/MEMORY.md", "--json"], + ); + let document: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(document["schema_version"], 1); + assert_eq!(document["mode"], "vault_entry_body"); + assert_eq!(document["entry_type"], "memory"); + assert!(document["revision_hash"].is_string()); + let revision = document["revision_hash"].as_str().unwrap().to_string(); + + // The --revision guard: the exact revision allows the pull; a stale + // one refuses. + run( + repo_dir, + home_dir, + &[ + "vault", + "show", + "memory/MEMORY.md", + "--revision", + &revision, + "--json", + ], + ); + let stale = atomic( + repo_dir, + home_dir, + &[ + "vault", + "show", + "memory/MEMORY.md", + "--revision", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "--json", + ], + ); + assert!(!stale.status.success()); +} + +#[test] +fn intent_show_json_projects_the_attested_node() { + let (repo, home) = fixture("intent-show"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + ensure_default_identity(repo_dir, home_dir); + let created = run(repo_dir, home_dir, &["intent", "new", "Projected intent"]); + let id = created + .lines() + .find_map(|line| line.strip_prefix("Created intent: ")) + .expect("intent id") + .to_string(); + + // The un-attested projection lifts the stored node. + let json = run(repo_dir, home_dir, &["intent", "show", &id, "--json"]); + let node: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(node["@type"].is_string()); + assert!( + node.get("attributedTo") + .map(|v| v.is_null()) + .unwrap_or(true), + "the raw lift carries no attribution yet" + ); + + // After attesting, the projection is the ATTESTED node (the signed + // contentHash + the author DID) — carried over the wire as raw + // sources and classified client-side. + run(repo_dir, home_dir, &["intent", "attest", &id]); + let json = run(repo_dir, home_dir, &["intent", "show", &id, "--json"]); + let node: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(node["attributedTo"].is_string(), "the attested author DID"); + assert!(node["contentHash"].is_string()); + assert!( + node.get("proof").is_some(), + "the signed proof rides the node" + ); +} + +#[test] +fn view_list_json_splits_own_and_inherited_counts() { + let (repo, home) = fixture("view-list"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + std::fs::write(repo_dir.join("base.txt"), "base\n").unwrap(); + run(repo_dir, home_dir, &["add", "base.txt"]); + run(repo_dir, home_dir, &["record", "-m", "base change"]); + + let json = run(repo_dir, home_dir, &["view", "list", "--json"]); + let document: serde_json::Value = serde_json::from_str(&json).unwrap(); + let views = document["views"].as_array().unwrap(); + let dev = views + .iter() + .find(|view| view["name"] == "dev") + .expect("dev view"); + let dev_own = dev["own_change_count"].as_u64().expect("dev own count"); + assert!(dev_own >= 1, "the bootstrap + base changes"); + assert_eq!(dev["inherited_change_count"], serde_json::json!(0)); + assert!(dev["state"].is_string()); + assert_eq!(dev["scope"], "shared"); + + // A draft with its own change: the inherited half counts the parent's. + run(repo_dir, home_dir, &["view", "create", "parity-draft"]); + run(repo_dir, home_dir, &["view", "switch", "parity-draft"]); + std::fs::write(repo_dir.join("draft.txt"), "draft\n").unwrap(); + run(repo_dir, home_dir, &["add", "draft.txt"]); + run(repo_dir, home_dir, &["record", "-m", "draft change"]); + + let json = run(repo_dir, home_dir, &["view", "list", "--json"]); + let document: serde_json::Value = serde_json::from_str(&json).unwrap(); + let views = document["views"].as_array().unwrap(); + let draft = views + .iter() + .find(|view| view["name"] == "parity-draft") + .expect("draft view"); + assert_eq!(draft["own_change_count"], serde_json::json!(1)); + assert_eq!( + draft["inherited_change_count"], + serde_json::json!(dev_own), + "the draft inherits dev's own log" + ); + assert_eq!( + draft["change_count"], + serde_json::json!(dev_own + 1), + "change_count reads as the own+inherited total" + ); + assert_eq!(draft["scope"], "draft"); +} + +#[test] +fn query_neighbors_json_serializes_the_full_domain_subgraph() { + let (repo, home) = fixture("kg-nodes"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + std::fs::write(repo_dir.join("kg.txt"), "kg fixture\n").unwrap(); + run(repo_dir, home_dir, &["add", "kg.txt"]); + run(repo_dir, home_dir, &["record", "-m", "kg fixture change"]); + + let json = run( + repo_dir, + home_dir, + &["query", "neighbors", "file:kg.txt", "--json"], + ); + let subgraph: serde_json::Value = serde_json::from_str(&json).unwrap(); + let nodes = subgraph["nodes"].as_array().unwrap(); + let file_node = nodes + .iter() + .find(|node| node["id"] == "file:kg.txt") + .expect("the file node"); + // The full domain fields the local JSON serializes: kind, label, + // summary, source, metadata. + assert_eq!(file_node["kind"], "file"); + assert_eq!(file_node["label"], "kg.txt"); + assert!(file_node["source"].is_string()); + assert!(subgraph["edges"].is_array()); + + // The search JSON serializes the same node shape. + let json = run(repo_dir, home_dir, &["query", "search", "kg.txt", "--json"]); + let nodes: Vec = serde_json::from_str(&json).unwrap(); + assert!( + nodes.iter().any(|node| node["id"] == "file:kg.txt"), + "the search surfaces the file node" + ); + + // The --kind filter narrows to the kind's nodes. + let table = run( + repo_dir, + home_dir, + &["query", "search", "kg.txt", "--kind", "file"], + ); + assert!(table.contains("file:kg.txt")); +} + +#[test] +fn mixed_intent_update_applies_every_field_in_one_request() { + let (repo, home) = fixture("intent-update"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + let created = run(repo_dir, home_dir, &["intent", "new", "Composed update"]); + let id = created + .lines() + .find_map(|line| line.strip_prefix("Created intent: ")) + .expect("intent id") + .to_string(); + + let report = run( + repo_dir, + home_dir, + &[ + "intent", + "update", + &id, + "--status", + "in_progress", + "--priority", + "high", + "--assignee", + "alice", + "--title", + "Composed and renamed", + ], + ); + assert!(report.contains("Updated intent:")); + assert!(report.contains("status: in_progress")); + assert!(report.contains("priority: high")); + assert!( + "assignee: alice" + == report + .lines() + .find_map(|line| line.strip_prefix(" assignee: ")) + .map(|_| "assignee: alice") + .unwrap_or("") + ); + + // The composed update persisted atomically: every frontmatter field + // reads back through the stored entry. + let json = run(repo_dir, home_dir, &["intent", "show", &id, "--json"]); + let node: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(node["title"], "Composed and renamed"); + + let list = run(repo_dir, home_dir, &["intent", "list"]); + assert!(list.contains("in_progress")); +} + +#[test] +fn insert_deps_opt_out_routes_over_the_wire() { + let (repo, home) = fixture("insert-deps"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + std::fs::write(repo_dir.join("base.txt"), "base\n").unwrap(); + run(repo_dir, home_dir, &["add", "base.txt"]); + run(repo_dir, home_dir, &["record", "-m", "base change"]); + run(repo_dir, home_dir, &["view", "create", "deps-draft"]); + run(repo_dir, home_dir, &["view", "switch", "deps-draft"]); + std::fs::write(repo_dir.join("draft.txt"), "draft\n").unwrap(); + run(repo_dir, home_dir, &["add", "draft.txt"]); + run(repo_dir, home_dir, &["record", "-m", "draft change"]); + + // Both --deps forms route: the default closure and the explicit + // opt-out ride apply_dependencies. + let promoted = run(repo_dir, home_dir, &["insert", "--deps=false"]); + assert!(promoted.contains("Inserted 1 change(s)"), "{promoted}"); +} + +#[test] +fn revise_content_mode_recaptures_the_working_copy() { + let (repo, home) = fixture("revise-content"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + std::fs::write(repo_dir.join("first.txt"), "one\n").unwrap(); + run(repo_dir, home_dir, &["add", "first.txt"]); + run(repo_dir, home_dir, &["record", "-m", "first change"]); + std::fs::write(repo_dir.join("second.txt"), "two\n").unwrap(); + run(repo_dir, home_dir, &["add", "second.txt"]); + run(repo_dir, home_dir, &["record", "-m", "second change"]); + + // The dry-run preview renders over the wire's log entries. + let preview = run(repo_dir, home_dir, &["revise", "@~1", "--dry-run"]); + assert!(preview.contains("Would revise change")); + assert!(preview.contains("This will temporarily unrecord")); + + // The content mode re-captures the edited working copy into @~1 and + // re-applies the pending change above it. + std::fs::write(repo_dir.join("first.txt"), "one edited\n").unwrap(); + let report = run( + repo_dir, + home_dir, + &["revise", "@~1", "-m", "first change (revised)"], + ); + assert!(report.contains("Revised "), "{report}"); + assert_eq!( + std::fs::read_to_string(repo_dir.join("first.txt")).unwrap(), + "one edited\n", + "the re-captured content materialized" + ); + let log = run(repo_dir, home_dir, &["log"]); + assert!(log.contains("first change (revised)")); + assert!( + log.contains("second change"), + "the pending change re-applied" + ); +} + +#[test] +fn record_dry_run_previews_over_the_status_read() { + let (repo, home) = fixture("record-dry-run"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + std::fs::write(repo_dir.join("dry.txt"), "pending\n").unwrap(); + run(repo_dir, home_dir, &["add", "dry.txt"]); + std::fs::write(repo_dir.join("dry.txt"), "pending edited\n").unwrap(); + + let preview = run(repo_dir, home_dir, &["record", "--dry-run"]); + assert!(preview.contains("Would record:")); + assert!(preview.contains("dry.txt")); + + // The dry run never recorded anything. + let status = run(repo_dir, home_dir, &["status"]); + assert!(status.contains("dry.txt")); +} + +#[test] +fn agent_attest_summary_and_hash_detail_route() { + let (repo, home) = fixture("agent-attest"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + ensure_default_identity(repo_dir, home_dir); + std::fs::write(repo_dir.join("sum.txt"), "summary fixture\n").unwrap(); + run(repo_dir, home_dir, &["add", "sum.txt"]); + run( + repo_dir, + home_dir, + &["record", "-m", "summary fixture change"], + ); + + // The provenance summary renders from the wire-carried domain data. + let summary = run( + repo_dir, + home_dir, + &["agent", "attest", "--summary", "--view", "dev"], + ); + assert!(summary.contains("Project: dev")); + assert!(summary.contains("AI-authored")); + + // The --pending form requires --view (the local body's exact error). + let missing_view = atomic( + repo_dir, + home_dir, + &["agent", "attest", "--summary", "--pending", "dev"], + ); + assert!(!missing_view.status.success()); + assert!(String::from_utf8_lossy(&missing_view.stderr) + .contains("--pending requires --view")); + + // The --hash resolution failure carries the local error text. + let missing = atomic( + repo_dir, + home_dir, + &["agent", "attest", "--hash", "NOSUCHPREFIX"], + ); + assert!(!missing.status.success()); + assert!(String::from_utf8_lossy(&missing.stderr) + .contains("No attestation found matching 'NOSUCHPREFIX'")); +} + +#[test] +fn goal_list_routes_with_status_filter_and_json_columns() { + let (repo, home) = fixture("goal-list"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + run( + repo_dir, + home_dir, + &["vault", "goal", "start", "--developer", "alice"], + ); + + // The plain listing renders the goal rows (the status filter rides + // the request verbatim). + let table = run(repo_dir, home_dir, &["vault", "goal", "list"]); + assert!(table.contains("alice"), "{table}"); + let active = run( + repo_dir, + home_dir, + &["vault", "goal", "list", "--status", "active"], + ); + assert!(active.contains("alice")); + let completed = run( + repo_dir, + home_dir, + &["vault", "goal", "list", "--status", "completed"], + ); + assert!(completed.contains("No goals found."), "{completed}"); + + // The JSON carries the name/developer/status/started_at/turns rows. + let json = run(repo_dir, home_dir, &["vault", "goal", "list", "--json"]); + let rows: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["developer"], "alice"); + assert_eq!(rows[0]["status"], "active"); + assert!(rows[0]["started_at"].is_string()); + assert!(rows[0]["turns"].is_u64()); +} + +#[test] +fn diff_change_renders_legacy_content_over_the_wire() { + use atomic_core::change::Change; + use atomic_core::types::Base32; + use atomic_repository::Repository; + + let (repo, home) = fixture("diff-legacy"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + std::fs::write(repo_dir.join("legacy.txt"), "line1\nline2\n").unwrap(); + run(repo_dir, home_dir, &["add", "legacy.txt"]); + run(repo_dir, home_dir, &["record", "-m", "legacy seed change"]); + let original = { + let repo = Repository::open_readonly(repo_dir).unwrap(); + let history = repo + .log(atomic_repository::HistoryOptions::default()) + .unwrap(); + history + .iter() + .max_by_key(|entry| entry.sequence) + .expect("the recorded change") + .hash + }; + + // Unrecord it, then re-apply a LEGACY copy: the same hunks/contents + // with NO file_ops — the pre-file_ops change shape whose diff + // reconstructs from the graph's before/after content (fetched + // server-side over the wire). + run(repo_dir, home_dir, &["unrecord"]); + let legacy_hash = { + let repo = Repository::open_readonly(repo_dir).unwrap(); + let change = repo.load_change(&original).unwrap(); + let legacy = Change::with_file_ops( + change.hashed.header.clone(), + change.hashed.hunks.clone(), + Vec::new(), + change.contents.clone(), + change.hashed.dependencies.clone(), + ); + assert!(!legacy.has_file_ops(), "the fixture is a legacy change"); + drop(repo); + let repo = Repository::open(repo_dir).unwrap(); + let hash = repo.save_change(&legacy).unwrap(); + repo.insert_change(&hash, Default::default()).unwrap(); + hash + }; + + // The routed diff -c renders the reconstructed before/after content: + // the file was absent before the change, present after. + let output = atomic( + repo_dir, + home_dir, + &["diff", "-c", &legacy_hash.to_base32()], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("legacy.txt"), "{stdout}"); + assert!( + stdout.contains("+line1"), + "the reconstructed after content: {stdout}" + ); + assert!( + stdout.contains("legacy seed change"), + "the change header: {stdout}" + ); +} + +#[test] +fn diff_change_file_ops_hunks_pad_context_from_wire_before_content() { + let (repo, home) = fixture("diff-context"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + std::fs::write(repo_dir.join("ctx.txt"), "one\ntwo\nthree\nfour\n").unwrap(); + run(repo_dir, home_dir, &["add", "ctx.txt"]); + run(repo_dir, home_dir, &["record", "-m", "context base"]); + std::fs::write(repo_dir.join("ctx.txt"), "one\ntwo edited\nthree\nfour\n").unwrap(); + run(repo_dir, home_dir, &["record", "-m", "context edit"]); + + // The FileOps hunks pad their context from the wire-carried + // before-content: a 3-line context hunk (not the zero-context + // degradation) matches the local render. + let output = atomic(repo_dir, home_dir, &["diff", "-c", "@", "--no-color"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("-1,5 +1,5"), + "the full-file context hunk (before-content padded, not zero-context): {stdout}" + ); + assert!(stdout.contains("-two"), "{stdout}"); + assert!(stdout.contains("+two edited"), "{stdout}"); + assert!(stdout.contains(" three"), "the context line below the edit"); +} + +/// Record one change touching `name` with the given content (a fixture +/// shorthand). +fn record_file(repo_dir: &Path, home_dir: &Path, name: &str, body: &str, message: &str) { + std::fs::write(repo_dir.join(name), body).unwrap(); + run(repo_dir, home_dir, &["add", name]); + run(repo_dir, home_dir, &["record", "-m", message]); +} + +#[test] +fn restore_dry_run_single_file_dumps_pristine_bytes() { + let (repo, home) = fixture("restore-single"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file(repo_dir, home_dir, "file.txt", "recorded\n", "seed"); + + // A dirty tracked file: the dump is the PRISTINE bytes, byte-for-byte + // (the local body writes the graph content to stdout and nothing + // else). + std::fs::write(repo_dir.join("file.txt"), "local edit\n").unwrap(); + let dump = run(repo_dir, home_dir, &["restore", "--dry-run", "file.txt"]); + assert_eq!(dump, "recorded\n", "the pristine bytes alone"); + + // A clean tracked file dumps its (identical) pristine content. + let clean = run(repo_dir, home_dir, &["restore", "--dry-run", "file.txt"]); + assert_eq!(clean, "recorded\n"); + + // A never-added file has no pristine content: the local body's exact + // FileNotFound refusal (message and exit code). + std::fs::write(repo_dir.join("untracked.txt"), "never added\n").unwrap(); + let missing = atomic( + repo_dir, + home_dir, + &["restore", "--dry-run", "untracked.txt"], + ); + assert!(!missing.status.success(), "no pristine content errors"); + assert_eq!(missing.status.code(), Some(3), "the not-found exit code"); + assert!( + String::from_utf8_lossy(&missing.stderr).contains("File not found: 'untracked.txt'"), + "{}", + String::from_utf8_lossy(&missing.stderr) + ); +} + +#[test] +fn restore_dry_run_listing_renders_the_local_lines() { + let (repo, home) = fixture("restore-listing"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file(repo_dir, home_dir, "base.txt", "base\n", "base"); + + // The whole-copy form on a clean tree keeps the whole-tree message. + let whole = run(repo_dir, home_dir, &["restore", "--dry-run"]); + assert!( + whole.contains("Nothing to restore - working copy is clean"), + "{whole}" + ); + + // An added file previews the untrack (never a content read) with the + // local hint's singular form. + std::fs::write(repo_dir.join("new.txt"), "brand new\n").unwrap(); + run(repo_dir, home_dir, &["add", "new.txt"]); + let preview = run(repo_dir, home_dir, &["restore", "--dry-run", "new.txt"]); + assert!( + preview.contains("Would untrack: new.txt (kept on disk)"), + "{preview}" + ); + assert!( + preview.contains("(dry run - 1 file would be restored)"), + "the local count wording: {preview}" + ); + + // A partial filter with nothing to restore keeps the local body's + // partial message (it must not claim the whole copy is clean). + let partial = run(repo_dir, home_dir, &["restore", "--dry-run", "nosuch/"]); + assert!( + partial.contains("Nothing to restore for the specified path(s)"), + "{partial}" + ); +} + +#[test] +fn bare_insert_dry_run_previews_and_real_promotion_routes() { + let (repo, home) = fixture("insert-bare"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file(repo_dir, home_dir, "base.txt", "base\n", "base change"); + run( + repo_dir, + home_dir, + &["view", "create", "feature", "--draft", "--switch"], + ); + record_file( + repo_dir, + home_dir, + "feature.txt", + "feature\n", + "feature change", + ); + + // The dry-run preview: the resolved direction, the numbered missing + // list, and the local dry-run notice — nothing inserted. + let preview = run(repo_dir, home_dir, &["insert", "--dry-run"]); + assert!( + preview.contains("Inserting 1 change(s): feature → dev"), + "the resolved source → target: {preview}" + ); + assert!( + preview.contains("Dry run: no changes inserted. Re-run without --dry-run to insert."), + "{preview}" + ); + assert!( + preview.contains("1. ") && preview.contains("feature change"), + "the numbered listing with its message: {preview}" + ); + + // The promotion did not happen yet: the change is still missing on + // dev (the next insert still has work to do). + let real = run(repo_dir, home_dir, &["insert"]); + assert!( + real.contains("Inserted 1 change(s)"), + "the local cross-view report: {real}" + ); + assert!(real.contains("New state:"), "{real}"); + + // Running again is the local friendly no-op. + let again = run(repo_dir, home_dir, &["insert"]); + assert!( + again.contains("Already even with 'dev' — nothing to insert."), + "{again}" + ); +} + +#[test] +fn insert_view_and_tag_dry_run_previews_render_the_local_lines() { + let (repo, home) = fixture("insert-previews"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file(repo_dir, home_dir, "base.txt", "base\n", "base change"); + run( + repo_dir, + home_dir, + &["view", "create", "feature", "--draft", "--switch"], + ); + record_file(repo_dir, home_dir, "feature.txt", "one\n", "feature change"); + run(repo_dir, home_dir, &["tag", "create", "v1"]); + record_file(repo_dir, home_dir, "more.txt", "two\n", "beyond the tag"); + run(repo_dir, home_dir, &["view", "switch", "dev"]); + + // The view dry-run lists what would move. + let view = run( + repo_dir, + home_dir, + &["insert", "view", "feature", "--dry-run"], + ); + assert!( + view.contains("Inserting changes from 'feature' to 'dev'..."), + "{view}" + ); + assert!( + view.contains("Dry run: 2 change(s) would be inserted"), + "{view}" + ); + assert!(view.contains("Changes:"), "{view}"); + + // The tag dry-run with the --from-view override stops at the tag. + let tag = run( + repo_dir, + home_dir, + &["insert", "tag", "v1", "--from-view", "feature", "--dry-run"], + ); + assert!( + tag.contains("Inserting changes up to tag 'v1' from 'feature' to 'dev'..."), + "{tag}" + ); + assert!( + tag.contains("Dry run: 1 change(s) would be inserted"), + "only the pre-tag change: {tag}" + ); +} + +#[test] +fn insert_tag_from_view_routes_and_inserts_up_to_the_tag() { + let (repo, home) = fixture("insert-tag"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file(repo_dir, home_dir, "base.txt", "base\n", "base change"); + run( + repo_dir, + home_dir, + &["view", "create", "feature", "--draft", "--switch"], + ); + record_file( + repo_dir, + home_dir, + "at-tag.txt", + "at\n", + "change at the tag", + ); + run(repo_dir, home_dir, &["tag", "create", "v1"]); + record_file( + repo_dir, + home_dir, + "past-tag.txt", + "past\n", + "change past the tag", + ); + run(repo_dir, home_dir, &["view", "switch", "dev"]); + + // The real form: only the pre-tag change crosses (the FromView + // override rides the wire's tag source). + let report = run( + repo_dir, + home_dir, + &["insert", "tag", "v1", "--from-view", "feature"], + ); + assert!( + report.contains("Inserted 1 change(s)"), + "the tag cutoff applied: {report}" + ); + assert!( + repo_dir.join("at-tag.txt").exists(), + "the tagged change landed" + ); + assert!( + !repo_dir.join("past-tag.txt").exists(), + "the past-the-tag change did NOT land" + ); +} + +#[test] +fn insert_change_multi_pick_routes_with_handler_side_resolution() { + use atomic_core::types::Base32; + use atomic_repository::Repository; + + let (repo, home) = fixture("insert-multi"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file(repo_dir, home_dir, "base.txt", "base\n", "base change"); + run( + repo_dir, + home_dir, + &["view", "create", "feature", "--draft", "--switch"], + ); + record_file(repo_dir, home_dir, "first.txt", "one\n", "multi first"); + record_file(repo_dir, home_dir, "second.txt", "two\n", "multi second"); + run(repo_dir, home_dir, &["view", "switch", "dev"]); + + // The raw references (full base32) come off the draft's own log — + // the CLI ships them raw; the handler resolves each. + let hashes = { + let repo = Repository::open_readonly(repo_dir).unwrap(); + let history = repo + .log(atomic_repository::HistoryOptions::default().view("feature".to_string())) + .unwrap(); + history + .iter() + .map(|entry| entry.hash.to_base32()) + .collect::>() + }; + assert_eq!(hashes.len(), 2, "the two draft-only changes"); + + let report = run( + repo_dir, + home_dir, + &[ + "insert", + "change", + &hashes[0][..6].to_lowercase(), + &hashes[1][..4], + ], + ); + assert!( + report.contains("Cherry-picking 2 change(s) to 'dev'..."), + "the local multi-insert leading line: {report}" + ); + assert!(report.contains("Inserted 2 change(s)"), "{report}"); + assert!(repo_dir.join("first.txt").exists(), "the first landed"); + assert!(repo_dir.join("second.txt").exists(), "the second landed"); + + // A reference that matches nothing refuses with the local + // resolution message. + let missing = atomic(repo_dir, home_dir, &["insert", "change", "NOSUCHPREFIX"]); + assert!(!missing.status.success()); + assert!( + String::from_utf8_lossy(&missing.stderr).contains("Change not found: NOSUCHPREFIX"), + "{}", + String::from_utf8_lossy(&missing.stderr) + ); +} + +#[test] +fn insert_single_resolves_raw_prefixes_case_insensitively() { + let (repo, home) = fixture("insert-single-ref"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file(repo_dir, home_dir, "base.txt", "base\n", "base change"); + run( + repo_dir, + home_dir, + &["view", "create", "feature", "--draft", "--switch"], + ); + record_file( + repo_dir, + home_dir, + "feature.txt", + "feature\n", + "feature change", + ); + let full = { + use atomic_core::types::Base32; + let repo = atomic_repository::Repository::open_readonly(repo_dir).unwrap(); + let history = repo + .log(atomic_repository::HistoryOptions::default().view("feature".to_string())) + .unwrap(); + history[0].hash.to_base32() + }; + run(repo_dir, home_dir, &["view", "switch", "dev"]); + + // A lowercase prefix resolves (the handler-side resolver matches the + // local parse semantics: full-parse, then the case-insensitive store + // prefix), and the leading line prints the resolved FULL hash. + let report = run(repo_dir, home_dir, &["insert", &full[..6].to_lowercase()]); + assert!( + report.contains(&format!("Inserting change {full}...")), + "the resolved full hash: {report}" + ); + assert!(report.contains("Inserted 1 change(s)"), "{report}"); + assert!(repo_dir.join("feature.txt").exists(), "materialized on dev"); +} + +// --------------------------------------------------------------------------- +// the newly wired surfaces (REAC::aaron::27): tags, remote registry, +// split, session rebuild, vault materialize/summaries, the goal lifecycle, +// intent delete/link, memory write, triage candidates, and the KG extras +// --------------------------------------------------------------------------- + +#[test] +fn tags_route_full_lifecycle_with_render_parity() { + let (repo, home) = fixture("tags"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + let created = run(repo_dir, home_dir, &["tag", "create", "v1.0.0"]); + assert_eq!(created.trim(), "✓ Created tag: v1.0.0"); + let annotated = run( + repo_dir, + home_dir, + &["tag", "create", "v2.0.0", "-m", "Release v2"], + ); + assert_eq!(annotated.trim(), "✓ Created annotated tag: v2.0.0"); + + // The listing: the annotated marker, both names, view filter parity. + let list = run(repo_dir, home_dir, &["tag", "list"]); + assert!(list.contains(" v1.0.0"), "{list}"); + assert!(list.contains("*v2.0.0"), "{list}"); + let verbose = run(repo_dir, home_dir, &["tag", "list", "--verbose"]); + assert!(verbose.contains("(seq:"), "{verbose}"); + assert!(verbose.contains("state: "), "{verbose}"); + + // The detail report carries the full record fields. + let show = run(repo_dir, home_dir, &["tag", "show", "v2.0.0"]); + assert!(show.contains("Tag: v2.0.0"), "{show}"); + assert!(show.contains("View: dev"), "{show}"); + assert!(show.contains("Kind: release"), "{show}"); + assert!(show.contains("Type: annotated"), "{show}"); + assert!(show.contains("Message: Release v2"), "{show}"); + + // Duplicate refusal + force + delete + the not-exist hint. + let duplicate = atomic(repo_dir, home_dir, &["tag", "create", "v2.0.0"]); + assert!(!duplicate.status.success()); + assert!(String::from_utf8_lossy(&duplicate.stderr) + .contains("Tag 'v2.0.0' already exists. Use --force to overwrite.")); + run(repo_dir, home_dir, &["tag", "create", "v2.0.0", "-f"]); + let deleted = run(repo_dir, home_dir, &["tag", "delete", "v2.0.0"]); + assert_eq!(deleted.trim(), "✓ Deleted tag: v2.0.0"); + let missing = run(repo_dir, home_dir, &["tag", "delete", "v2.0.0"]); + assert!(missing.contains("Tag 'v2.0.0' does not exist"), "{missing}"); + let show_missing = run(repo_dir, home_dir, &["tag", "show", "nope"]); + assert!( + show_missing.contains("Tag 'nope' not found"), + "{show_missing}" + ); +} + +#[test] +fn remote_registry_routes_every_action() { + let (repo, home) = fixture("remote"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + let empty = run(repo_dir, home_dir, &["remote"]); + assert!( + empty.contains("No remotes configured. Use 'atomic remote add ' to add one."), + "{empty}" + ); + run( + repo_dir, + home_dir, + &["remote", "add", "origin", "https://example.com/repo"], + ); + run( + repo_dir, + home_dir, + &[ + "remote", + "add", + "backup", + "https://backup.example.com/r", + "--default", + ], + ); + let verbose = run(repo_dir, home_dir, &["remote", "--verbose"]); + assert!( + verbose.contains("backup\thttps://backup.example.com/r (default)"), + "{verbose}" + ); + run( + repo_dir, + home_dir, + &["remote", "rename", "origin", "upstream"], + ); + run( + repo_dir, + home_dir, + &["remote", "set-url", "upstream", "https://new.example.com/r"], + ); + run(repo_dir, home_dir, &["remote", "default", "upstream"]); + let list = run(repo_dir, home_dir, &["remote"]); + assert!( + list.contains("upstream") && list.contains("backup"), + "{list}" + ); + // An invalid URL refuses client-side with the local message. + let invalid = atomic(repo_dir, home_dir, &["remote", "add", "bad", "no-scheme"]); + assert!(!invalid.status.success()); + assert!(String::from_utf8_lossy(&invalid.stderr) + .contains("Invalid URL 'no-scheme': URL must include a scheme")); + // push/pull's remote-name resolution rides the same registry read. + let missing = atomic(repo_dir, home_dir, &["push", "nosuchremote"]); + assert!(!missing.status.success()); + assert!(String::from_utf8_lossy(&missing.stderr).contains("Remote 'nosuchremote' not found")); +} + +#[test] +fn top_level_split_routes_with_the_split_report() { + let (repo, home) = fixture("split"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file(repo_dir, home_dir, "split.txt", "split\n", "split change"); + let out = run(repo_dir, home_dir, &["split", "experimental"]); + assert!( + out.contains("Created view: experimental (split from dev with "), + "{out}" + ); + assert!( + out.contains("Use 'atomic view switch experimental' to switch to the new view"), + "{out}" + ); + let switched = run(repo_dir, home_dir, &["split", "fresh", "--switch"]); + assert!( + switched.contains("✓ Switched to view: fresh ("), + "{switched}" + ); +} + +#[test] +fn session_rebuild_routes_over_the_wire() { + let (repo, home) = fixture("session-rebuild"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + let out = run(repo_dir, home_dir, &["session", "rebuild"]); + assert!(out.contains("Session index rebuild complete"), "{out}"); + assert!(out.contains("Indexed:"), "{out}"); + assert!(out.contains("Already present:"), "{out}"); + assert!(out.contains("Corrupt (skipped):"), "{out}"); +} + +#[test] +fn vault_materialize_and_summaries_route() { + let (repo, home) = fixture("vault-mat"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + use std::process::Stdio; + let write = Command::new(ATOMIC_BIN) + .args(["memory", "write", "mat"]) + .current_dir(repo_dir) + .env("HOME", home_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn atomic") + .wait_with_output() + .expect("write memory"); + assert!(write.status.success()); + std::fs::remove_file(repo_dir.join(".vault/memory/mat.md")).unwrap(); + // The single-entry form prints the path (and rewrites the file). + let one = run( + repo_dir, + home_dir, + &["vault", "materialize", "-p", "memory/mat.md"], + ); + assert!(one.contains("Materialized: memory/mat.md"), "{one}"); + assert!(repo_dir.join(".vault/memory/mat.md").exists()); + // The whole-vault form counts every entry. + let all = run(repo_dir, home_dir, &["vault", "materialize"]); + assert!(all.contains("Materialized"), "{all}"); + assert!(all.contains("vault entries."), "{all}"); + // Summaries: the empty case and a tool-result preview. + let empty = run(repo_dir, home_dir, &["vault", "summaries"]); + assert_eq!(empty.trim(), "{}"); +} + +#[test] +fn goal_lifecycle_routes_start_stop_resume_show() { + let (repo, home) = fixture("goal-lifecycle"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + let started = run( + repo_dir, + home_dir, + &["vault", "goal", "start", "--developer", "alice"], + ); + let goal = started + .lines() + .find_map(|line| line.strip_prefix("Started goal: ")) + .expect("goal name") + .to_string(); + assert!(started.contains(" directory: .vault/goals/"), "{started}"); + + // Link an intent to the goal, then stop/resume/show. + let intent = run(repo_dir, home_dir, &["intent", "new", "Goal work"]) + .lines() + .find_map(|line| line.strip_prefix("Created intent: ")) + .expect("intent id") + .to_string(); + let linked = run( + repo_dir, + home_dir, + &["intent", "link", &intent, "--goal", &goal], + ); + assert!( + linked.contains(&format!("Linked goal '{goal}' to intent '{intent}'")), + "{linked}" + ); + let stopped = run(repo_dir, home_dir, &["vault", "goal", "stop"]); + assert!( + stopped.contains(&format!("Suspended goal: {goal} (status: suspended)")), + "{stopped}" + ); + let resumed = run(repo_dir, home_dir, &["vault", "goal", "resume", &goal]); + assert!( + resumed.contains(&format!("Resumed goal: {goal}")), + "{resumed}" + ); + assert!(resumed.contains("developer: alice"), "{resumed}"); + // Resume is a suspended→active transition: stop again, then the JSON + // form resumes once more. + run(repo_dir, home_dir, &["vault", "goal", "stop"]); + let resumed_json = run( + repo_dir, + home_dir, + &["vault", "goal", "resume", &goal, "--json"], + ); + let json: serde_json::Value = serde_json::from_str(&resumed_json).unwrap(); + assert_eq!(json["name"], serde_json::json!(goal)); + assert_eq!(json["developer"], serde_json::json!("alice")); + let shown = run( + repo_dir, + home_dir, + &["vault", "goal", "show", &goal, "--json"], + ); + let shown_json: serde_json::Value = serde_json::from_str(&shown).unwrap(); + assert_eq!(shown_json["name"], serde_json::json!(goal)); + let shown_plain = run(repo_dir, home_dir, &["vault", "goal", "show", &goal]); + assert_eq!(shown_plain, shown_plain); // both forms succeed over the wire +} + +#[test] +fn intent_delete_routes_with_the_backlog_guard() { + let (repo, home) = fixture("intent-delete"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + let id = run(repo_dir, home_dir, &["intent", "new", "Delete me"]) + .lines() + .find_map(|line| line.strip_prefix("Created intent: ")) + .expect("intent id") + .to_string(); + // --force skips the prompt; the report prints the id + file. + let deleted = run(repo_dir, home_dir, &["intent", "delete", &id, "--force"]); + assert!( + deleted.contains(&format!("Deleted intent: {id}")), + "{deleted}" + ); + assert!(deleted.contains(" file: .vault/"), "{deleted}"); + // The deleted intent is gone from the listing. + let list = run(repo_dir, home_dir, &["intent", "list"]); + assert!(!list.contains(&id), "{list}"); +} + +#[test] +fn memory_write_routes_over_the_wire() { + let (repo, home) = fixture("memory-write"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + use std::process::Stdio; + let output = Command::new(ATOMIC_BIN) + .args(["memory", "write", "design", "--type", "reference"]) + .current_dir(repo_dir) + .env("HOME", home_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn atomic") + .wait_with_output() + .expect("write memory"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.starts_with("Wrote memory: memory/design.md ("), + "{stdout}" + ); + // The file materialized in the working copy (the handler's vault + // materialize ran) and the memory lists. + assert!(repo_dir.join(".vault/memory/design.md").exists()); + let listed = run(repo_dir, home_dir, &["memory", "list"]); + assert!(listed.contains("design"), "{listed}"); +} + +#[test] +fn triage_candidates_route_with_json_parity() { + let (repo, home) = fixture("triage-candidates"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file( + repo_dir, + home_dir, + "feature.txt", + "feature\n", + "feature change", + ); + run(repo_dir, home_dir, &["view", "create", "feature-x"]); + run(repo_dir, home_dir, &["view", "switch", "feature-x"]); + record_file(repo_dir, home_dir, "draft.txt", "draft\n", "draft change"); + run(repo_dir, home_dir, &["view", "switch", "dev"]); + + let json = run( + repo_dir, + home_dir, + &[ + "triage", + "candidates", + "feature-x", + "--into", + "dev", + "--json", + ], + ); + let set: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(set["feature"], serde_json::json!("feature-x")); + assert_eq!(set["target"], serde_json::json!("dev")); + assert_eq!( + set["only_in_feature"].as_array().unwrap().len(), + 1, + "the draft change is the candidate" + ); + let plain = run( + repo_dir, + home_dir, + &["triage", "candidates", "feature-x", "--into", "dev"], + ); + assert!( + plain.contains("Triage candidates: feature-x → dev"), + "{plain}" + ); + // The root-view refusal matches the local semantics. + let root = atomic(repo_dir, home_dir, &["triage", "candidates", "dev"]); + assert!(!root.status.success()); + assert!(String::from_utf8_lossy(&root.stderr).contains("is a root view")); +} + +#[test] +fn kg_extras_reindex_and_graph_route() { + let (repo, home) = fixture("kg-extras"); + let (repo_dir, home_dir) = (repo.path(), home.path()); + record_file( + repo_dir, + home_dir, + "greet.rs", + "pub fn greet() {}\n", + "add greet", + ); + let reindexed = run(repo_dir, home_dir, &["query", "reindex"]); + assert!(reindexed.contains("Indexed "), "{reindexed}"); + assert!(reindexed.contains(" nodes + edges."), "{reindexed}"); + let graph = run(repo_dir, home_dir, &["query", "graph", "greet", "--json"]); + let value: serde_json::Value = serde_json::from_str(&graph).unwrap(); + let nodes = value["nodes"].as_array().unwrap(); + assert!(!nodes.is_empty(), "the seeded graph carries nodes"); + assert!( + nodes + .iter() + .any(|node| node["id"] == serde_json::json!("file:greet.rs")), + "the seed file is present: {graph}" + ); +} diff --git a/atomic-cli/tests/unrecord_integration_test.rs b/atomic-cli/tests/unrecord_integration_test.rs index 2ba929f5..347e4174 100644 --- a/atomic-cli/tests/unrecord_integration_test.rs +++ b/atomic-cli/tests/unrecord_integration_test.rs @@ -249,5 +249,11 @@ fn inherited_changes_are_rejected_in_forked_view() { fn empty_view_reports_nothing_to_unrecord() { let dir = TempDir::new().unwrap(); drop(Repository::init(dir.path()).unwrap()); - assert!(run(dir.path(), &["unrecord"], false).contains("nothing to unrecord")); + // The bare form routes through the service layer; the domain's own + // refusal names the empty view. + let output = run(dir.path(), &["unrecord"], false); + assert!( + output.to_lowercase().contains("empty"), + "should report the empty view, got: {output}" + ); } diff --git a/atomic-client/Cargo.toml b/atomic-client/Cargo.toml new file mode 100644 index 00000000..d7c85a59 --- /dev/null +++ b/atomic-client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "atomic-client" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Transport-independent Atomic client: connect over the local daemon socket" + +[dependencies] +libatomic = { workspace = true } +tonic = { workspace = true } +tokio = { workspace = true } +libc = { workspace = true } +tower = { workspace = true } +hyper-util = { workspace = true } + +[dev-dependencies] +atomic-repository = { workspace = true } +tokio = { workspace = true } diff --git a/atomic-client/src/lib.rs b/atomic-client/src/lib.rs new file mode 100644 index 00000000..a103eb0d --- /dev/null +++ b/atomic-client/src/lib.rs @@ -0,0 +1,138 @@ +//! Transport-independent Atomic client — the local-daemon transport. +//! +//! One user-level daemon serves the `atomic` contract (libatomic) over a +//! private Unix-domain socket; this crate owns the socket location, the +//! connect logic, and the D4 start-or-retry lifecycle (a local client that +//! cannot reach the daemon may start/restart it — it may NEVER open the +//! repository databases itself). + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use libatomic::atomic::daemon_service_client::DaemonServiceClient; +use libatomic::atomic::{HealthRequest, HealthResponse}; +use tonic::transport::{Channel, Endpoint, Uri}; +use tower::service_fn; + +/// Env override for the daemon socket (tests, harnesses, exotic layouts). +pub const ENV_DAEMON_SOCKET: &str = "ATOMIC_DAEMON_SOCKET"; + +/// Env override for the daemon binary the client may start (D4). Defaults +/// to an `atomicd` found on PATH. +pub const ENV_DAEMON_BIN: &str = "ATOMIC_DAEMON_BIN"; + +/// Where the one user-level daemon listens. `$ATOMIC_DAEMON_SOCKET` wins; +/// otherwise a private per-user runtime dir (0700) with a short socket name +/// (macOS limits Unix-socket paths to ~104 bytes). +pub fn socket_path() -> PathBuf { + if let Some(path) = std::env::var_os(ENV_DAEMON_SOCKET) { + return PathBuf::from(path); + } + let uid = nix_uid(); + let base = match std::env::var_os("XDG_RUNTIME_DIR") { + Some(dir) => PathBuf::from(dir), + None => PathBuf::from("/tmp").join(format!("atomic-{uid}")), + }; + base.join("atomicd").join("atomicd.sock") +} + +fn nix_uid() -> u32 { + #[cfg(unix)] + { + unsafe { libc::geteuid() } + } + #[cfg(not(unix))] + { + 0 + } +} + +/// A connected client over the daemon socket. +pub struct AtomicClient { + pub channel: Channel, +} + +async fn connect_channel(socket: &std::path::Path) -> Result { + let socket = socket.to_path_buf(); + let connect = move |_: Uri| { + let socket = socket.clone(); + async move { + let stream = tokio::net::UnixStream::connect(socket).await?; + Ok::<_, std::io::Error>(hyper_util::rt::TokioIo::new(stream)) + } + }; + Endpoint::from_static("http://localhost") // authority is the socket, not a URI + .connect_with_connector(service_fn(connect)) + .await +} + +impl AtomicClient { + /// Connect to an already-running daemon. Fails if the socket is absent + /// or not serving. + pub async fn connect_existing() -> Result { + let socket = socket_path(); + let channel = connect_channel(&socket).await.map_err(|error| { + format!("atomicd is not reachable at {}: {error}", socket.display()) + })?; + let client = Self { channel }; + client + .health() + .await + .map_err(|error| format!("atomicd at {} is not serving: {error}", socket.display()))?; + Ok(client) + } + + /// Connect, starting the daemon when it is down (D4: a local client may + /// start/restart the daemon and retry — never open storage itself). + pub async fn connect_or_start() -> Result { + if let Ok(client) = Self::connect_existing().await { + return Ok(client); + } + + let binary = std::env::var_os(ENV_DAEMON_BIN) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("atomicd")); + let socket = socket_path(); + if let Some(parent) = socket.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create daemon runtime dir {}: {error}", + parent.display() + ) + })?; + } + Command::new(&binary) + .arg("serve") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| { + format!( + "failed to start the atomic daemon ({}): {error}", + binary.display() + ) + })?; + + for _ in 0..200 { + if let Ok(client) = Self::connect_existing().await { + return Ok(client); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err("atomicd did not become healthy before timeout".to_string()) + } + + /// Liveness probe (the negotiation pair's first half). + pub async fn health(&self) -> Result { + let mut client = DaemonServiceClient::new(self.channel.clone()); + client + .health(HealthRequest {}) + .await + .map(|response| response.into_inner()) + } +} + +/// Re-export for callers that drive the generated service clients directly. +pub use libatomic::atomic as proto; From 3193cf85e4f2640c701bf8d8cdf731c5d751a1de Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 7 Oct 2026 11:12:03 -0500 Subject: [PATCH 16/23] fix(client): allow result_large_err on the transport crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tonic client surface returns tonic::Status (176 bytes) from nearly every method — same opt-out the libatomic daemon surface carries. --- atomic-client/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/atomic-client/src/lib.rs b/atomic-client/src/lib.rs index a103eb0d..6e48363b 100644 --- a/atomic-client/src/lib.rs +++ b/atomic-client/src/lib.rs @@ -6,6 +6,12 @@ //! cannot reach the daemon may start/restart it — it may NEVER open the //! repository databases itself). +// The tonic client surface returns `tonic::Status` (176 bytes) from +// nearly every method; boxing it everywhere would churn the whole +// call surface for no win. The transport crate opts out, as the +// libatomic daemon surface does. +#![allow(clippy::result_large_err)] + use std::path::PathBuf; use std::process::{Command, Stdio}; use std::time::Duration; From f4398ef637dc8406d473b9d7e959ace0ba87deb7 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 7 Oct 2026 11:14:38 -0500 Subject: [PATCH 17/23] fix(cli): allow result_large_err on the Status-returning stream collectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same rationale as the transport crate: the collectors propagate tonic::Status (176 bytes) — the wire type every reactor call returns. --- atomic-cli/src/service/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/atomic-cli/src/service/mod.rs b/atomic-cli/src/service/mod.rs index c622ef0c..a4296065 100644 --- a/atomic-cli/src/service/mod.rs +++ b/atomic-cli/src/service/mod.rs @@ -445,7 +445,10 @@ impl Service { } /// Collect a local handler's item stream (each item is already a -/// `Result`). +/// `Result`). The `Err` is `tonic::Status` — 176 bytes, but the same +/// wire type every reactor call returns; boxing just this collector +/// would fork the error surface. +#[allow(clippy::result_large_err)] async fn collect_chunks( mut stream: std::pin::Pin< Box> + Send>, @@ -458,7 +461,9 @@ async fn collect_chunks( Ok(chunks) } -/// Collect a reactor client's tonic streaming response. +/// Collect a reactor client's tonic streaming response (same `Status` +/// error surface as every reactor call). +#[allow(clippy::result_large_err)] async fn collect_streaming( mut stream: tonic::Streaming, ) -> Result, Status> { From 9e03ace0db8e44e04dcb8e3c8cd5f7d6b8779b2e Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 7 Oct 2026 11:20:40 -0500 Subject: [PATCH 18/23] fix(client): gate the unix-domain transport so non-unix targets compile connect_channel is the socket connect: unix-only by construction. The non-unix arm keeps the crate's API shape (callers compile) and refuses connects with an explicit unsupported error. --- atomic-client/src/lib.rs | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/atomic-client/src/lib.rs b/atomic-client/src/lib.rs index 6e48363b..eec386e1 100644 --- a/atomic-client/src/lib.rs +++ b/atomic-client/src/lib.rs @@ -59,18 +59,32 @@ pub struct AtomicClient { pub channel: Channel, } -async fn connect_channel(socket: &std::path::Path) -> Result { - let socket = socket.to_path_buf(); - let connect = move |_: Uri| { - let socket = socket.clone(); - async move { - let stream = tokio::net::UnixStream::connect(socket).await?; - Ok::<_, std::io::Error>(hyper_util::rt::TokioIo::new(stream)) - } - }; - Endpoint::from_static("http://localhost") // authority is the socket, not a URI - .connect_with_connector(service_fn(connect)) - .await +async fn connect_channel(socket: &std::path::Path) -> Result { + // The transport is a Unix-domain socket: unix-only. Other platforms + // compile (the CLI surface keeps its shape) but cannot connect. + #[cfg(unix)] + { + let socket = socket.to_path_buf(); + let connect = move |_: Uri| { + let socket = socket.clone(); + async move { + let stream = tokio::net::UnixStream::connect(socket).await?; + Ok::<_, std::io::Error>(hyper_util::rt::TokioIo::new(stream)) + } + }; + Endpoint::from_static("http://localhost") // authority is the socket, not a URI + .connect_with_connector(service_fn(connect)) + .await + .map_err(std::io::Error::other) + } + #[cfg(not(unix))] + { + let _ = socket; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "the atomic daemon transport is unix-only (unix-domain socket)", + )) + } } impl AtomicClient { From d3a49faf438b3f7cd8fa3d58a93aef9aeaf579cf Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 7 Oct 2026 11:25:14 -0500 Subject: [PATCH 19/23] fix(client): gate the socket transport imports unix-only --- atomic-client/src/lib.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/atomic-client/src/lib.rs b/atomic-client/src/lib.rs index eec386e1..aadf08d5 100644 --- a/atomic-client/src/lib.rs +++ b/atomic-client/src/lib.rs @@ -18,8 +18,13 @@ use std::time::Duration; use libatomic::atomic::daemon_service_client::DaemonServiceClient; use libatomic::atomic::{HealthRequest, HealthResponse}; -use tonic::transport::{Channel, Endpoint, Uri}; +// The socket transport is unix-only; `Channel` itself is portable and +// callers keep their shape everywhere. +#[cfg(unix)] +use tonic::transport::{Endpoint, Uri}; +#[cfg(unix)] use tower::service_fn; +use tonic::transport::Channel; /// Env override for the daemon socket (tests, harnesses, exotic layouts). pub const ENV_DAEMON_SOCKET: &str = "ATOMIC_DAEMON_SOCKET"; From 0c58fea8b7f6804a813fd55f5c9446f021f06d39 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 7 Oct 2026 11:25:23 -0500 Subject: [PATCH 20/23] style(client): import order after the unix gating --- atomic-client/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atomic-client/src/lib.rs b/atomic-client/src/lib.rs index aadf08d5..e3cb7e23 100644 --- a/atomic-client/src/lib.rs +++ b/atomic-client/src/lib.rs @@ -20,11 +20,11 @@ use libatomic::atomic::daemon_service_client::DaemonServiceClient; use libatomic::atomic::{HealthRequest, HealthResponse}; // The socket transport is unix-only; `Channel` itself is portable and // callers keep their shape everywhere. +use tonic::transport::Channel; #[cfg(unix)] use tonic::transport::{Endpoint, Uri}; #[cfg(unix)] use tower::service_fn; -use tonic::transport::Channel; /// Env override for the daemon socket (tests, harnesses, exotic layouts). pub const ENV_DAEMON_SOCKET: &str = "ATOMIC_DAEMON_SOCKET"; From 9b3326f760ff09c7c2d42a829db9c2c37fe2370c Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 7 Oct 2026 18:46:35 -0500 Subject: [PATCH 21/23] docs(cli): log --all is real, not a hidden no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft-view model filters inherited entries from plain 'atomic log' by design (a session draft shows the work it added), but the --all flag was still documented as 'currently a no-op' and hidden from help — stale from before the view-filter restructure. Unhide it and document what it actually does: the full ancestor chain. --- atomic-cli/src/commands/log/command.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/atomic-cli/src/commands/log/command.rs b/atomic-cli/src/commands/log/command.rs index b085e2a0..6817d66a 100644 --- a/atomic-cli/src/commands/log/command.rs +++ b/atomic-cli/src/commands/log/command.rs @@ -93,12 +93,14 @@ pub struct Log { #[arg(long = "full-hash")] pub full_hash: bool, - /// Show full history including inherited changes. - /// - /// Currently a no-op — all views show their complete history. - /// Reserved for future use when fork-point filtering is - /// re-implemented with a stable fork-point snapshot. - #[arg(long = "all", hide = true)] + /// Show the full history including changes inherited from ancestor + /// views. + /// + /// Draft views list only their own changes by default — the work + /// this view added, not what it inherited from its parent chain — + /// so `--all` is how you see the complete change log a draft + /// view can reach (shared views always show their full chain). + #[arg(long = "all")] pub all: bool, /// Show compact output (shorthand for --format short). From 0e9629d803b5e8c8239cd57a18b51283933ba28c Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 8 Oct 2026 15:36:44 -0400 Subject: [PATCH 22/23] feat: compact atomic.redb through libatomic maintenance (#241) --- Cargo.lock | 4 + README.md | 30 ++ atomic-cli/Cargo.toml | 1 + atomic-cli/src/commands/compact.rs | 52 +++ atomic-cli/src/commands/mod.rs | 2 + atomic-cli/src/main.rs | 5 + atomic-cli/src/service/compact.rs | 49 +++ atomic-cli/src/service/mod.rs | 2 + .../tests/compact_service_integration_test.rs | 198 +++++++++++ libatomic/Cargo.toml | 6 + libatomic/proto/CONTRACT.md | 26 ++ libatomic/proto/COVERAGE.md | 3 +- .../maintenance/maintenance_messages.proto | 15 + .../maintenance/maintenance_service.proto | 8 + libatomic/src/daemon/services.rs | 28 +- libatomic/src/daemon/services_maintenance.rs | 30 +- .../daemon/services_maintenance/compact.rs | 178 ++++++++++ .../services_maintenance/compact/tests.rs | 323 ++++++++++++++++++ libatomic/src/daemon/state.rs | 10 +- 19 files changed, 952 insertions(+), 18 deletions(-) create mode 100644 atomic-cli/src/commands/compact.rs create mode 100644 atomic-cli/src/service/compact.rs create mode 100644 atomic-cli/tests/compact_service_integration_test.rs create mode 100644 libatomic/src/daemon/services_maintenance/compact.rs create mode 100644 libatomic/src/daemon/services_maintenance/compact/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 358b0f79..786558ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1876,6 +1876,7 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" name = "libatomic" version = "0.19.1" dependencies = [ + "anyhow", "atomic-agent", "atomic-canonical", "atomic-core", @@ -1892,13 +1893,16 @@ dependencies = [ "prost", "prost-types", "protox", + "redb 4.2.0", "serde_json", "similar", + "tempfile", "tokio", "tokio-stream", "tonic", "tonic-build", "ulid", + "uuid", ] [[package]] diff --git a/README.md b/README.md index 951065c1..1e5f1a6f 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,36 @@ Current operational considerations: - Unix endpoints use `/tmp` to stay below macOS Unix-socket path limits. A future hardening step should move them into a user-private runtime directory where available, enforce restrictive socket permissions, and validate peer credentials. - A 96-bit endpoint digest makes accidental cross-project collisions extraordinarily unlikely, but the repository-local lock remains the final ownership check. +### Reclaiming unused database space + +Run explicit maintenance when the repository is quiet: + +```bash +atomic compact +atomic compact --repository /path/to/repo --json +``` + +The command compacts the existing `.atomic/atomic.redb` and reports its +before/after sizes and reclaimed bytes. It preserves history, views, +provenance, change files and unrecorded working files. It does not delete +live data or deduplicate change objects; zero bytes reclaimed is a valid result. + +Compaction runs through libatomic, in-process by default. With +`ATOMIC_SERVICE=reactor`, it uses the same handler through the configured +Reactor service; that service must advertise `CompactDatabase`. An older or +unavailable service produces an error, with no local fallback. + +Other database readers and writers must release their handles. Lock waiting +uses `ATOMIC_DB_LOCK_WAIT_MS` (30 seconds by default); a busy database produces +an error that can be retried. Once blocking maintenance starts, it finishes +independently of the caller, including the bounded lock wait. Use a quiet period: +other commands may time out while the database is being compacted. Persistent +savepoints block compaction and are never deleted automatically. + +This is manual maintenance of an existing combined database, with no implicit +initialization or migration. Hosted Storage's open-handle cache requires separate +maintenance coordination before online compaction can be supported. + ### Provenance Graphs Every agent session builds a causal decision DAG. Not just *what* changed, but *why*: diff --git a/atomic-cli/Cargo.toml b/atomic-cli/Cargo.toml index 26ebfb9a..60900cda 100644 --- a/atomic-cli/Cargo.toml +++ b/atomic-cli/Cargo.toml @@ -95,5 +95,6 @@ git2 = { workspace = true } windows-sys = { version = "0.61", features = ["Win32_Foundation"] } [dev-dependencies] +tokio-stream = { workspace = true, features = ["net"] } tempfile = { workspace = true } serial_test = { workspace = true } diff --git a/atomic-cli/src/commands/compact.rs b/atomic-cli/src/commands/compact.rs new file mode 100644 index 00000000..f315add1 --- /dev/null +++ b/atomic-cli/src/commands/compact.rs @@ -0,0 +1,52 @@ +//! Explicit database maintenance through the shared service layer. + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::service::Service; +use atomic_client::proto::{CompactDatabaseRequest, RequestMeta}; +use clap::Args; +use std::path::PathBuf; + +/// Reclaim unused database space without deleting repository data. +#[derive(Debug, Args)] +pub struct Compact { + /// Path inside the repository or one of its sandboxes. + #[arg(long, default_value = ".")] + repository: PathBuf, + /// Emit the database path and before/after byte counts as JSON. + #[arg(long)] + json: bool, +} + +impl Command for Compact { + fn run(&self) -> CliResult<()> { + let service = + Service::open_root(&self.repository)?.ok_or_else(|| CliError::RepositoryNotFound { + searched_path: self.repository.clone(), + })?; + let report = service.compact_database(CompactDatabaseRequest { + repository: Some(service.reference.clone()), + meta: Some(RequestMeta { + request_id: uuid::Uuid::new_v4().to_string(), + observed_at: None, + }), + })?; + if self.json { + println!( + "{}", + serde_json::json!({ + "database": report.database, + "before_bytes": report.before_bytes, + "after_bytes": report.after_bytes, + "reclaimed_bytes": report.reclaimed_bytes, + }) + ); + } else { + println!( + "Compacted {}: {} -> {} bytes ({} bytes reclaimed)", + report.database, report.before_bytes, report.after_bytes, report.reclaimed_bytes + ); + } + Ok(()) + } +} diff --git a/atomic-cli/src/commands/mod.rs b/atomic-cli/src/commands/mod.rs index 4e758131..ae5f6dbd 100644 --- a/atomic-cli/src/commands/mod.rs +++ b/atomic-cli/src/commands/mod.rs @@ -69,6 +69,7 @@ use crate::error::{CliError, CliResult}; // Phase 2: Core Local Commands pub mod add; pub mod change; +pub mod compact; pub mod complete; pub mod completions; pub mod conflicts; @@ -137,6 +138,7 @@ pub use add::Add; pub use agent::Agent; pub use change::ChangeCmd; pub use clone::Clone; +pub use compact::Compact; pub use completions::Completions; pub use conflicts::Conflicts; pub use diff::Diff; diff --git a/atomic-cli/src/main.rs b/atomic-cli/src/main.rs index b15eb341..c9174172 100644 --- a/atomic-cli/src/main.rs +++ b/atomic-cli/src/main.rs @@ -62,6 +62,7 @@ use commands::{ ChangeCmd, Clone, Command, + Compact, Completions, Conflicts, Diff, @@ -447,6 +448,9 @@ enum Commands { /// such as backfilling the dependency index for legacy repositories. Doctor(Doctor), + /// Reclaim unused space in the repository database. + Compact(Compact), + /// Git interoperability commands. /// /// Import Git repositories into Atomic, preserving history, authorship, @@ -996,6 +1000,7 @@ fn main() { Commands::Diff(diff) => diff.run(), Commands::Doctor(doctor) => doctor.run(), + Commands::Compact(compact) => compact.run(), Commands::Git(git) => git.run(), diff --git a/atomic-cli/src/service/compact.rs b/atomic-cli/src/service/compact.rs new file mode 100644 index 00000000..ea979b98 --- /dev/null +++ b/atomic-cli/src/service/compact.rs @@ -0,0 +1,49 @@ +use super::{pb, services_maintenance, Backend, Service}; +use crate::error::CliResult; +use libatomic::atomic::maintenance_service_server::MaintenanceService as _; +use tonic::{Request, Status}; + +impl Service { + pub fn compact_database( + &self, + request: pb::CompactDatabaseRequest, + ) -> CliResult { + self.call(move |backend| async move { + let mut request = Request::new(request); + request + .metadata_mut() + .insert("x-atomic-contract-version", "2".parse().unwrap()); + match backend { + Backend::Local(state) => services_maintenance::MaintenanceImpl { state } + .compact_database(request) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut daemon = + pb::daemon_service_client::DaemonServiceClient::new(channel.clone()); + let capabilities = daemon + .get_capabilities(pb::GetCapabilitiesRequest {}) + .await? + .into_inner(); + if capabilities.protocol_version != 2 { + return Err(Status::failed_precondition( + "the running Reactor uses an incompatible service contract; update/restart it and retry", + )); + } + let supported = capabilities.methods.iter().any(|method| { + method.service == "MaintenanceService" && method.method == "CompactDatabase" + }); + if !supported { + return Err(Status::failed_precondition( + "the running Reactor does not support database compaction; update/restart it and retry", + )); + } + pb::maintenance_service_client::MaintenanceServiceClient::new(channel) + .compact_database(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } +} diff --git a/atomic-cli/src/service/mod.rs b/atomic-cli/src/service/mod.rs index a4296065..8b4c1130 100644 --- a/atomic-cli/src/service/mod.rs +++ b/atomic-cli/src/service/mod.rs @@ -56,6 +56,8 @@ use tonic::{Request, Status}; use crate::error::{CliError, CliResult}; +mod compact; + /// The service-area selector. `ATOMIC_SERVICE` wins; `ATOMIC_RPC` is the /// legacy alias (1 → reactor, anything else → local); unset routes local. pub const ENV_SERVICE: &str = "ATOMIC_SERVICE"; diff --git a/atomic-cli/tests/compact_service_integration_test.rs b/atomic-cli/tests/compact_service_integration_test.rs new file mode 100644 index 00000000..8907e39d --- /dev/null +++ b/atomic-cli/tests/compact_service_integration_test.rs @@ -0,0 +1,198 @@ +//! Exercise the real CLI through the shared in-process and served handlers. +use std::path::Path; +use std::process::{Command, Output}; + +use atomic_repository::{ChangeStore, Repository, DEFAULT_CACHE_CAPACITY}; +use serde_json::Value; + +fn command(path: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_atomic")); + command + .args(["compact", "--repository"]) + .arg(path) + .arg("--json") + .env("ATOMIC_SERVICE", "local") + .env_remove("ATOMIC_RPC") + .env("ATOMIC_DB_LOCK_WAIT_MS", "100") + .env("ATOMIC_DAEMON_BIN", path.join("no-daemon-binary")) + .env("ATOMIC_DAEMON_SOCKET", path.join("no-daemon.socket")); + command +} + +fn report(output: Output) -> Value { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn local_compaction_preserves_views_journal_changes_and_dirty_sandbox() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("repo"); + let sandbox = dir.path().join("sandbox"); + let mut repo = Repository::init(&root).unwrap(); + let contents = b"recorded contents\n"; + std::fs::write(root.join("file.txt"), contents).unwrap(); + repo.add("file.txt", Default::default()).unwrap(); + let hash = *repo.record_all("compaction fixture").unwrap().hash(); + let change_path = ChangeStore::new(repo.changes_dir(), DEFAULT_CACHE_CAPACITY) + .unwrap() + .change_path(&hash); + let change_bytes = std::fs::read(&change_path).unwrap(); + let view = repo.current_view().to_owned(); + repo.create_view("compact-child").unwrap(); + let views = repo.list_views().unwrap(); + repo.provision_sandbox(&sandbox, &view).unwrap(); + let store = repo.redb_change_store().unwrap(); + let turn = store.reserve_provenance_turn("compact", 1, 1).unwrap(); + let envelope = serde_json::to_vec(&serde_json::json!({ + "schema_version":1,"event_id":"pending","session_id":"compact", + "turn_number":1,"generation":turn.generation,"timestamp_ms":1700000000000_i64, + "event":{"type":"tool","phase":"after","tool_name":"Read", + "tool_call_id":"pending","input":{"path":"file.txt"}, + "output":"recorded contents","status":"completed"} + })) + .unwrap(); + store + .append_provenance_envelope(turn.provenance_id, turn.generation, "pending", &envelope, 2) + .unwrap(); + let pending = store.load_provenance_envelopes(turn.provenance_id).unwrap(); + let turn_before = store.get_provenance_turn(turn.provenance_id).unwrap(); + drop(store); + drop(repo); + std::fs::write(root.join("file.txt"), b"dirty main\n").unwrap(); + std::fs::write(sandbox.join("file.txt"), b"dirty sandbox\n").unwrap(); + let database = root.join(".atomic/atomic.redb").canonicalize().unwrap(); + let before = database.metadata().unwrap().len(); + let log = dir.path().join("service.jsonl"); + let result = report( + command(&sandbox) + .env("ATOMIC_DAEMON_LOG_REQUESTS", &log) + .output() + .unwrap(), + ); + assert_eq!(result["database"], database.to_str().unwrap()); + assert_eq!(result["before_bytes"], before); + let after = database.metadata().unwrap().len(); + assert_eq!(result["after_bytes"], after); + assert_eq!(result["reclaimed_bytes"], before.saturating_sub(after)); + assert!(std::fs::read_to_string(log) + .unwrap() + .contains("CompactDatabase")); + report(command(&root).output().unwrap()); // Repeat maintenance is valid. + let repo = Repository::open_existing(&root).unwrap(); + assert_eq!(repo.list_views().unwrap(), views); + assert_eq!(repo.log(Default::default()).unwrap()[0].hash, hash); + for view in [&view, "compact-child"] { + assert_eq!( + repo.get_file_content_on_view("file.txt", view) + .unwrap() + .unwrap(), + contents + ); + } + assert_eq!(std::fs::read(change_path).unwrap(), change_bytes); + assert_eq!( + std::fs::read(root.join("file.txt")).unwrap(), + b"dirty main\n" + ); + assert_eq!( + std::fs::read(sandbox.join("file.txt")).unwrap(), + b"dirty sandbox\n" + ); + let store = repo.redb_change_store().unwrap(); + assert_eq!( + store.load_provenance_envelopes(turn.provenance_id).unwrap(), + pending + ); + assert_eq!( + store.get_provenance_turn(turn.provenance_id).unwrap(), + turn_before + ); + assert!(!sandbox.join(".atomic/atomic.redb").exists()); +} + +#[test] +fn busy_database_reports_actionable_error_and_can_retry() { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + let output = command(dir.path()).output().unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("timed out")); + drop(repo); + report(command(dir.path()).output().unwrap()); +} + +#[test] +fn missing_repository_is_not_created() { + let dir = tempfile::tempdir().unwrap(); + assert!(!command(dir.path()).output().unwrap().status.success()); + assert!(!dir.path().join(".atomic").exists()); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reactor_cli_uses_same_handler_over_socket_without_fallback() { + use libatomic::atomic::daemon_service_server::DaemonServiceServer; + use libatomic::atomic::maintenance_service_server::MaintenanceServiceServer; + use libatomic::daemon::{ + services::DaemonImpl, services_maintenance::MaintenanceImpl, state::DaemonState, + }; + use std::sync::Arc; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("repo"); + drop(Repository::init(&root).unwrap()); + // Short socket path for macOS. The test server serves the real library; + // this does not require or modify the private Reactor checkout. + let socket_dir = tempfile::Builder::new() + .prefix("compact-") + .tempdir_in("/tmp") + .unwrap(); + let socket = socket_dir.path().join("rpc.sock"); + let listener = tokio::net::UnixListener::bind(&socket).unwrap(); + let state = Arc::new(DaemonState::new()); + let (stop, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn( + tonic::transport::Server::builder() + .add_service(DaemonServiceServer::new(DaemonImpl { + state: state.clone(), + })) + .add_service(MaintenanceServiceServer::new(MaintenanceImpl { state })) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::UnixListenerStream::new(listener), + async { + let _ = stopped.await; + }, + ), + ); + let mut cli = command(&root); + cli.env("ATOMIC_SERVICE", "reactor") + .env("ATOMIC_DAEMON_SOCKET", &socket); + let output = tokio::task::spawn_blocking(move || cli.output().unwrap()) + .await + .unwrap(); + let result = report(output); + assert_eq!( + result["database"], + root.join(".atomic/atomic.redb") + .canonicalize() + .unwrap() + .to_str() + .unwrap() + ); + stop.send(()).unwrap(); + server.await.unwrap().unwrap(); + let mut cli = command(&root); + cli.env("ATOMIC_SERVICE", "reactor") + .env("ATOMIC_DAEMON_SOCKET", &socket); + let output = tokio::task::spawn_blocking(move || cli.output().unwrap()) + .await + .unwrap(); + assert!( + !output.status.success(), + "reactor mode must never fall back to local compaction" + ); +} diff --git a/libatomic/Cargo.toml b/libatomic/Cargo.toml index c553692c..02d987dc 100644 --- a/libatomic/Cargo.toml +++ b/libatomic/Cargo.toml @@ -37,6 +37,12 @@ blake3 = { workspace = true } data-encoding = { workspace = true } similar = { workspace = true } ulid = { workspace = true } +redb = { workspace = true } +anyhow = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } [build-dependencies] tonic-build = { workspace = true } diff --git a/libatomic/proto/CONTRACT.md b/libatomic/proto/CONTRACT.md index 6c04e4ab..c0394801 100644 --- a/libatomic/proto/CONTRACT.md +++ b/libatomic/proto/CONTRACT.md @@ -70,6 +70,32 @@ that would incorrectly exclude the serving local atomicd. stream/message/queue limits apply per principal; slow streaming cannot keep a writer open. Negotiate limits before allocating opaque payloads. +## Repeatable physical maintenance + +`MaintenanceService.CompactDatabase` is an explicit exception to logical-mutation +replay/publication semantics. It changes the physical page layout of an existing +database without changing its logical records. redb performs its own internal +transactions; there is no Atomic replay receipt written into the database. +`RequestMeta.request_id` correlates an attempt and is echoed, with `replayed=false`. +A repeated request may run again and report different byte counts, including zero +reclaimed bytes. The CLI does not automatically retry a lost response. + +Only local administrators with `maintenance.admin` may invoke it. The serving +transport must enforce caller identity and the descriptor allowlist before +dispatch; a sandbox working-directory path does not confer sandbox-token +authority. Compaction resolves that path to the canonical database without +opening a Repository or migrating it. + +Acquiring the repository gate and exclusive redb file lock is bounded by +`ATOMIC_DB_LOCK_WAIT_MS`; a busy database returns UNAVAILABLE. Read paths which +do not use the repository gate remain protected by redb's file lock and may +fail/wait during maintenance. Cancellation while queued for the gate starts no +blocking work. Once blocking maintenance starts, that task retains the gate and +finishes independently of the RPC caller, including its bounded file-lock wait. +Persistent savepoints are preserved and reported as a precondition failure. +Successful byte counts are sampled before compaction and after closing redb; +other processes should remain idle until the command returns. + ## Repository and workspace routing RepositoryRef.authority selects an explicit configured authority; it is not an diff --git a/libatomic/proto/COVERAGE.md b/libatomic/proto/COVERAGE.md index 5d40e213..c7153d68 100644 --- a/libatomic/proto/COVERAGE.md +++ b/libatomic/proto/COVERAGE.md @@ -40,7 +40,7 @@ completion engine are load-bearing surfaces. | SyncService | PushChanges, PullChanges, ListRemotes, ManageRemotes, BindRemoteProject | all LOCAL | | GitInteropService | ImportFromGit, PreviewGitImport, PushToGit | LOCAL | | TriageService | ListTriageCandidates, GenerateTriageReview | BOTH | -| MaintenanceService | Repair, CheckRepository, ReindexWorkspace | LOCAL, BOTH, LOCAL | +| MaintenanceService | Repair, CheckRepository, ReindexWorkspace, CompactDatabase | LOCAL, BOTH, LOCAL, LOCAL | | ReactorService | Publish, PublishStream, Subscribe, Replay, CheckpointConsumer | all BOTH (contract draft) | ## 2. Legacy agent journal protocol (13 ops, BEING DISMANTLED) → ProvenanceService @@ -151,6 +151,7 @@ daemon, never the write queue. | `split [--switch]` | direct-RW | CreateView (explicit base); --switch composes SwitchView | | `stash push/pop/apply/list/show/drop/clear` | direct-RW | CreateStash / ApplyStash / ListStashes / DropStash; pop composes apply then drop | | `doctor repair-dependency-index/materialize-crdt/check` | direct-RW (check: read semantics) | Repair + CheckRepository (maintenance.read, BOTH) | +| `compact [--repository PATH] [--json]` | shared service (local or Reactor) | CompactDatabase (maintenance.admin, LOCAL); repeatable physical maintenance | | `session show/fork/rebuild` | direct-RW | ProvenanceService.GetSession / ForkSession / RebuildSessionIndex | | `sandbox create/stage/seal` | direct-RW | SandboxService.CreateSandboxTree / StageSandboxImage / SealSandboxImage (SHAPE-CONFIRM) | | `tag create/delete/list/show` | direct-RW | TagService (create/delete/list/get) | diff --git a/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto b/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto index 7270676a..b67c9f4c 100644 --- a/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto +++ b/libatomic/proto/atomic/services/maintenance/maintenance_messages.proto @@ -12,6 +12,21 @@ package atomic; import "atomic/common/common.proto"; +message CompactDatabaseRequest { + RepositoryRef repository = 1; + // Correlates this physical maintenance attempt. Unlike logical mutations, + // compaction is repeatable, without a durable replay receipt (CONTRACT.md). + RequestMeta meta = 2; +} +message CompactDatabaseResponse { + // Canonical physical path, available only to local administrative callers. + string database = 1; + uint64 before_bytes = 2; + uint64 after_bytes = 3; + uint64 reclaimed_bytes = 4; + ResponseMeta meta = 100; +} + enum RepairAction { diff --git a/libatomic/proto/atomic/services/maintenance/maintenance_service.proto b/libatomic/proto/atomic/services/maintenance/maintenance_service.proto index 4600e3fe..28d5c2ad 100644 --- a/libatomic/proto/atomic/services/maintenance/maintenance_service.proto +++ b/libatomic/proto/atomic/services/maintenance/maintenance_service.proto @@ -10,6 +10,14 @@ import "atomic/common/options.proto"; import "atomic/services/maintenance/maintenance_messages.proto"; service MaintenanceService { + // Explicit physical maintenance of an existing atomic.redb. Busy handles + // cause bounded waiting, then UNAVAILABLE. Not logical garbage collection. + rpc CompactDatabase(CompactDatabaseRequest) returns (CompactDatabaseResponse) { + option (atomic.execution_scope) = EXECUTION_SCOPE_LOCAL; + option (atomic.required_capability) = "maintenance.admin"; + option (atomic.allowed_caller) = CALLER_CLASS_LOCAL; + option (atomic.effect) = RPC_EFFECT_REPOSITORY_WRITE; + } // `atomic doctor repair-dependency-index | materialize-crdt` — mutating // repairs only; the read-only consistency check lives in CheckRepository. rpc Repair(RepairRequest) returns (RepairResponse) { diff --git a/libatomic/src/daemon/services.rs b/libatomic/src/daemon/services.rs index 55d7fa02..ff315beb 100644 --- a/libatomic/src/daemon/services.rs +++ b/libatomic/src/daemon/services.rs @@ -78,6 +78,7 @@ const METHOD_MATRIX: &[(&str, &str)] = &[ ("ProvenanceService", "GetSession"), ("ProvenanceService", "ListSessions"), ("MaintenanceService", "Repair"), + ("MaintenanceService", "CompactDatabase"), ("MaintenanceService", "CheckRepository"), ("ViewService", "ListViews"), ("ViewService", "CreateView"), @@ -109,13 +110,26 @@ impl daemon_service_server::DaemonService for DaemonImpl { ) -> Result, Status> { let methods = METHOD_MATRIX .iter() - .map(|(service, method)| MethodDescriptorInfo { - service: service.to_string(), - method: method.to_string(), - scope: ExecutionScope::Both as i32, - required_capabilities: Vec::new(), - allowed_callers: Vec::new(), - effects: Vec::new(), + .map(|(service, method)| { + if *service == "MaintenanceService" && *method == "CompactDatabase" { + MethodDescriptorInfo { + service: service.to_string(), + method: method.to_string(), + scope: ExecutionScope::Local as i32, + required_capabilities: vec!["maintenance.admin".to_string()], + allowed_callers: vec![CallerClass::Local as i32], + effects: vec![RpcEffect::RepositoryWrite as i32], + } + } else { + MethodDescriptorInfo { + service: service.to_string(), + method: method.to_string(), + scope: ExecutionScope::Both as i32, + required_capabilities: Vec::new(), + allowed_callers: Vec::new(), + effects: Vec::new(), + } + } }) .collect(); Ok(Response::new(GetCapabilitiesResponse { diff --git a/libatomic/src/daemon/services_maintenance.rs b/libatomic/src/daemon/services_maintenance.rs index 49569f37..4b47e4a9 100644 --- a/libatomic/src/daemon/services_maintenance.rs +++ b/libatomic/src/daemon/services_maintenance.rs @@ -3,11 +3,11 @@ //! `atomic doctor` folds into Repair (the mutating actions) and //! CheckRepository (the read-only consistency check). The read/write split //! the state gate defers lands here for the read side: CheckRepository -//! opens the repository READ-ONLY (redb read-only handles coexist with -//! the writer's exclusive lock) and never acquires the per-repo gate — -//! a consistency check never queues behind writers, exactly as the -//! contract's "EXECUTES AS A READ TRANSACTION" demands. Repair is a -//! repository write: gated like every other mutating handler. +//! opens the repository READ-ONLY without acquiring the per-repo gate. +//! Separate read-only handles share redb's file lock with other readers; +//! an exclusive writable handle still prevents these opens. Repair uses the +//! mutation gate. Compaction additionally requires exclusive access to the +//! physical database, including exclusion of independent reader handles. use std::sync::Arc; @@ -19,6 +19,8 @@ use tonic::{Request, Response, Status}; use super::services::default_ref; use super::state::{domain_status, repository_error, DaemonState}; +mod compact; + pub struct MaintenanceImpl { pub state: Arc, } @@ -41,6 +43,19 @@ fn repair_action(action: i32) -> Result { #[tonic::async_trait] impl MaintenanceService for MaintenanceImpl { + async fn compact_database( + &self, + request: Request, + ) -> Result, Status> { + compact::compact( + &self.state, + request, + atomic_agent::turn::orchestrator::wait_budget::database_wait(), + ) + .await + .map(Response::new) + } + async fn repair( &self, request: Request, @@ -139,9 +154,8 @@ impl MaintenanceService for MaintenanceImpl { .state .resolve(request.repository.as_ref().unwrap_or(&default_ref()))?; self.state.log_rpc("CheckRepository", Some(&handle)); - // No gate, no writer queue: read-only opens coexist with the - // writer's exclusive handle, so the check runs concurrent with - // in-flight writes ("EXECUTES AS A READ TRANSACTION"). + // No mutation gate. redb's shared file lock still refuses this + // open while a writable handle (including compaction) is live. let root = handle.root.clone(); let result = tokio::task::spawn_blocking(move || { let repo = atomic_repository::Repository::open_readonly(&root).map_err(|error| { diff --git a/libatomic/src/daemon/services_maintenance/compact.rs b/libatomic/src/daemon/services_maintenance/compact.rs new file mode 100644 index 00000000..0b8e35ec --- /dev/null +++ b/libatomic/src/daemon/services_maintenance/compact.rs @@ -0,0 +1,178 @@ +//! Physical maintenance. Only this private module opens redb for compaction. + +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use atomic_repository::Repository; +use prost::Message; +use redb::ReadableDatabase; +use tonic::{Request, Status}; + +use crate::atomic::{ + CompactDatabaseRequest, CompactDatabaseResponse, ErrorCode, ErrorInfo, ResponseMeta, +}; +use crate::daemon::state::DaemonState; + +pub(super) async fn compact( + state: &Arc, + request: Request, + wait: Duration, +) -> Result { + // Serving transports must enforce LOCAL + maintenance.admin before calling + // this handler. Restricted credentials must never fall through to the local + // administrator path; invoking from a sandbox directory is a separate case. + if request + .metadata() + .contains_key("x-atomic-sandbox-token-bin") + || request.metadata().contains_key("authorization") + { + return Err(Status::permission_denied( + "database compaction requires a local administrator", + )); + } + let request = request.into_inner(); + let request_id = request + .meta + .as_ref() + .map(|meta| meta.request_id.as_str()) + .unwrap_or(""); + if uuid::Uuid::parse_str(request_id).is_err() { + return Err(Status::invalid_argument( + "compaction requires a UUID request_id", + )); + } + compact_inner(state, &request, wait) + .await + .map_err(|status| with_request_id(status, request_id)) +} + +async fn compact_inner( + state: &Arc, + request: &CompactDatabaseRequest, + wait: Duration, +) -> Result { + let started = Instant::now(); + let reference = request + .repository + .as_ref() + .ok_or_else(|| Status::invalid_argument("repository is required"))?; + let resolved = state.resolve(reference)?; + // Resolve sandbox pointers without opening, migrating or recovering a repo. + let dot_dir = Repository::canonical_dot_dir(&resolved.root) + .map_err(|error| Status::failed_precondition(error.to_string()))? + .canonicalize() + .map_err(|error| Status::failed_precondition(error.to_string()))?; + let path = dot_dir + .join(atomic_repository::DATABASE_FILE) + .canonicalize() + .map_err(|error| { + Status::failed_precondition(format!("existing atomic.redb required: {error}")) + })?; + // The sandbox and canonical repository must share maintenance exclusion. + let handle = state.register( + dot_dir + .parent() + .ok_or_else(|| Status::failed_precondition("canonical .atomic has no parent"))? + .to_path_buf(), + ); + let guard = tokio::time::timeout( + wait.saturating_sub(started.elapsed()), + handle.exclusive_owned(), + ) + .await + .map_err(|_| { + Status::unavailable("database compaction timed out waiting for active requests") + })?; + state.log_rpc("CompactDatabase", Some(&handle)); + let report = tokio::task::spawn_blocking(move || { + // The blocking task, not the RPC future, owns this until redb closes. + let _guard = guard; + compact_waiting(&path, started, wait) + }) + .await + .map_err(|error| Status::internal(format!("compaction task failed: {error}")))??; + Ok(CompactDatabaseResponse { + meta: Some(ResponseMeta { + request_id: request + .meta + .as_ref() + .expect("validated metadata") + .request_id + .clone(), + replayed: false, + snapshot: None, + }), + ..report + }) +} + +fn compact_waiting( + path: &Path, + started: Instant, + wait: Duration, +) -> Result { + loop { + match compact_file(path) { + Ok(report) => return Ok(report), + Err(error) + if matches!( + error.downcast_ref::(), + Some(redb::DatabaseError::DatabaseAlreadyOpen) + ) => + { + let remaining = wait.saturating_sub(started.elapsed()); + if remaining.is_zero() { + return Err(Status::unavailable(format!( + "database compaction timed out waiting for {}; close other database readers/writers and retry", + path.display() + ))); + } + std::thread::sleep(remaining.min(Duration::from_millis(25))); + } + Err(error) => { + return Err(Status::failed_precondition(format!( + "cannot compact {}: {error:#}", + path.display() + ))) + } + } + } +} + +fn compact_file(path: &Path) -> anyhow::Result { + let mut database = redb::Builder::new().open(path)?; + atomic_core::pristine::schema::check_schema_version(&database.begin_read()?)?; + let before_bytes = std::fs::metadata(path)?.len(); + database.compact()?; + // Closing persists allocator metadata; include that in the final size. + drop(database); + let after_bytes = std::fs::metadata(path)?.len(); + Ok(CompactDatabaseResponse { + database: path.to_string_lossy().into_owned(), + before_bytes, + after_bytes, + reclaimed_bytes: before_bytes.saturating_sub(after_bytes), + meta: None, + }) +} + +fn with_request_id(status: Status, request_id: &str) -> Status { + let code = match status.code() { + tonic::Code::Unavailable => ErrorCode::ResourceExhausted, + tonic::Code::InvalidArgument => ErrorCode::InvalidArgument, + tonic::Code::NotFound => ErrorCode::RepositoryNotFound, + tonic::Code::FailedPrecondition => ErrorCode::PreconditionFailed, + _ => ErrorCode::Internal, + }; + let info = ErrorInfo { + code: code as i32, + message: status.message().to_string(), + request_id: Some(request_id.to_string()), + ..Default::default() + }; + Status::with_details(status.code(), status.message(), info.encode_to_vec().into()) +} + +#[cfg(test)] +mod tests; diff --git a/libatomic/src/daemon/services_maintenance/compact/tests.rs b/libatomic/src/daemon/services_maintenance/compact/tests.rs new file mode 100644 index 00000000..ade5cf95 --- /dev/null +++ b/libatomic/src/daemon/services_maintenance/compact/tests.rs @@ -0,0 +1,323 @@ +use super::*; +use redb::{Database, ReadableTableMetadata, TableDefinition}; + +const DATA: TableDefinition = TableDefinition::new("compaction_data"); + +fn request(handle: &crate::daemon::state::RepoHandle) -> Request { + Request::new(CompactDatabaseRequest { + repository: Some(handle.repository_ref()), + meta: Some(crate::atomic::RequestMeta { + request_id: uuid::Uuid::new_v4().to_string(), + observed_at: None, + }), + }) +} + +#[tokio::test] +async fn busy_reader_and_writer_time_out_then_retry_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + let state = Arc::new(DaemonState::new()); + let handle = state.register(dir.path().to_path_buf()); + let call = request(&handle); + let id = call.get_ref().meta.as_ref().unwrap().request_id.clone(); + let error = compact(&state, call, Duration::from_millis(20)) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::Unavailable); + assert_eq!( + ErrorInfo::decode(error.details()).unwrap().request_id, + Some(id) + ); + drop(repo); + let reader = Repository::open_readonly(dir.path()).unwrap(); + assert_eq!( + compact(&state, request(&handle), Duration::from_millis(20)) + .await + .unwrap_err() + .code(), + tonic::Code::Unavailable + ); + drop(reader); + let call = request(&handle); + let id = call.get_ref().meta.as_ref().unwrap().request_id.clone(); + let report = compact(&state, call, Duration::from_secs(1)).await.unwrap(); + assert_eq!(report.meta.unwrap().request_id, id); + assert!(Repository::open_existing(dir.path()).is_ok()); +} + +#[tokio::test] +async fn refuses_invalid_requests_before_opening_a_database() { + let dir = tempfile::tempdir().unwrap(); + let state = Arc::new(DaemonState::new()); + let handle = state.register(dir.path().to_path_buf()); + let mut call = request(&handle); + call.get_mut().meta = None; + assert_eq!( + compact(&state, call, Duration::ZERO) + .await + .unwrap_err() + .code(), + tonic::Code::InvalidArgument + ); + for header in ["authorization", "x-atomic-sandbox-token-bin"] { + let mut call = request(&handle); + if header.ends_with("-bin") { + call.metadata_mut() + .insert_bin(header, tonic::metadata::MetadataValue::from_bytes(b"token")); + } else { + call.metadata_mut() + .insert(header, "Bearer token".parse().unwrap()); + } + assert_eq!( + compact(&state, call, Duration::ZERO) + .await + .unwrap_err() + .code(), + tonic::Code::PermissionDenied + ); + } + assert!(compact(&state, request(&handle), Duration::ZERO) + .await + .is_err()); + assert!(!dir.path().join(".atomic").exists()); + let mut unknown = request(&handle); + unknown.get_mut().repository.as_mut().unwrap().repository_id = vec![7; 32]; + assert_eq!( + compact(&state, unknown, Duration::ZERO) + .await + .unwrap_err() + .code(), + tonic::Code::NotFound + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancellation_keeps_gate_until_blocking_compaction_finishes() { + use std::future::Future; + use std::task::Poll; + let dir = tempfile::tempdir().unwrap(); + let held = Repository::init(dir.path()).unwrap(); + let state = Arc::new(DaemonState::new()); + let handle = state.register(dir.path().to_path_buf()); + let held_gate = handle.exclusive().await; + let mut call = Box::pin(compact(&state, request(&handle), Duration::from_secs(3))); + // Poll to queue behind the held gate, without depending on scheduling sleeps. + assert!(std::future::poll_fn(|cx| Poll::Ready(call.as_mut().poll(cx).is_pending())).await); + drop(held_gate); + // This poll obtains the gate and starts blocking work; the independent + // repository handle ensures compaction cannot have finished yet. + assert!(std::future::poll_fn(|cx| Poll::Ready(call.as_mut().poll(cx).is_pending())).await); + drop(call); // RPC cancellation/disconnection drops its future. + assert!( + tokio::time::timeout(Duration::from_millis(20), handle.exclusive()) + .await + .is_err() + ); + drop(held); + let _gate = tokio::time::timeout(Duration::from_secs(2), handle.exclusive()) + .await + .unwrap(); + assert!(Repository::open_existing(dir.path()).is_ok()); +} + +#[tokio::test] +async fn concurrent_compactions_finish_and_release_the_gate() { + let dir = tempfile::tempdir().unwrap(); + drop(Repository::init(dir.path()).unwrap()); + let state = Arc::new(DaemonState::new()); + let handle = state.register(dir.path().to_path_buf()); + let (first, second) = tokio::join!( + compact(&state, request(&handle), Duration::from_secs(2)), + compact(&state, request(&handle), Duration::from_secs(2)), + ); + assert_eq!(first.unwrap().database, second.unwrap().database); + let _gate = tokio::time::timeout(Duration::from_secs(1), handle.exclusive()) + .await + .unwrap(); + assert!(Repository::open_existing(dir.path()).is_ok()); +} + +#[tokio::test] +async fn sandbox_uses_canonical_database_and_gate() { + let dir = tempfile::tempdir().unwrap(); + let canonical = dir.path().join("repo"); + let sandbox = dir.path().join("sandbox"); + let repo = Repository::init(&canonical).unwrap(); + repo.provision_sandbox(&sandbox, repo.current_view()) + .unwrap(); + drop(repo); + let state = Arc::new(DaemonState::new()); + let main_handle = state.register(canonical.clone()); + let sandbox_handle = state.register(sandbox.clone()); + let gate = main_handle.exclusive().await; + assert_eq!( + compact(&state, request(&sandbox_handle), Duration::from_millis(20)) + .await + .unwrap_err() + .code(), + tonic::Code::Unavailable + ); + drop(gate); + let report = compact(&state, request(&sandbox_handle), Duration::from_secs(1)) + .await + .unwrap(); + assert_eq!( + Path::new(&report.database), + canonical + .join(".atomic/atomic.redb") + .canonicalize() + .unwrap() + ); + assert!(!sandbox.join(".atomic/atomic.redb").exists()); +} + +#[tokio::test] +async fn capability_advertises_local_administrative_write() { + use crate::atomic::daemon_service_server::DaemonService; + use crate::atomic::{CallerClass, ExecutionScope, GetCapabilitiesRequest, RpcEffect}; + let service = crate::daemon::services::DaemonImpl { + state: Arc::new(DaemonState::new()), + }; + let capabilities = service + .get_capabilities(Request::new(GetCapabilitiesRequest {})) + .await + .unwrap() + .into_inner(); + let method = capabilities + .methods + .iter() + .find(|m| m.method == "CompactDatabase") + .unwrap(); + assert_eq!(method.scope, ExecutionScope::Local as i32); + assert_eq!(method.required_capabilities, ["maintenance.admin"]); + assert_eq!(method.allowed_callers, [CallerClass::Local as i32]); + assert_eq!(method.effects, [RpcEffect::RepositoryWrite as i32]); +} + +#[test] +fn compaction_reclaims_space_and_preserves_surviving_values() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("atomic.redb"); + let db = Database::create(&path).unwrap(); + let value = vec![0x5a; 4096]; + let write = db.begin_write().unwrap(); + { + let mut table = write.open_table(DATA).unwrap(); + for key in 0..1024 { + table.insert(key, value.as_slice()).unwrap(); + } + } + write.commit().unwrap(); + // Keep the old pages pinned through deletion so the fixture has + // reclaimable space without depending on the allocator's layout. + let reader = db.begin_read().unwrap(); + let write = db.begin_write().unwrap(); + { + let mut table = write.open_table(DATA).unwrap(); + for key in 1..1024 { + table.remove(key).unwrap(); + } + } + write.commit().unwrap(); + drop(reader); + drop(db); + + let before = std::fs::metadata(&path).unwrap().len(); + let report = compact_file(&path).unwrap(); + assert_eq!(report.before_bytes, before); + assert!(report.after_bytes < report.before_bytes, "{report:?}"); + assert_eq!(report.after_bytes, std::fs::metadata(&path).unwrap().len()); + assert_eq!(report.reclaimed_bytes, before - report.after_bytes); + let db = redb::Builder::new().open_read_only(&path).unwrap(); + let read = db.begin_read().unwrap(); + assert_eq!(read.list_tables().unwrap().count(), 1); + let table = read.open_table(DATA).unwrap(); + assert_eq!(table.len().unwrap(), 1); + assert_eq!(table.get(0).unwrap().unwrap().value(), value); + drop(table); + drop(read); + drop(db); + // A repeated maintenance request remains valid. + compact_file(&path).unwrap(); +} + +#[test] +fn compaction_does_not_create_a_missing_database() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("atomic.redb"); + assert!(compact_file(&path).is_err()); + assert!(!path.exists()); +} + +#[test] +fn compaction_respects_existing_writer_and_reader_handles() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("atomic.redb"); + let db = Database::create(&path).unwrap(); + assert!(matches!( + compact_file(&path) + .unwrap_err() + .downcast_ref::(), + Some(redb::DatabaseError::DatabaseAlreadyOpen) + )); + drop(db); + let db = redb::Builder::new().open_read_only(&path).unwrap(); + assert!(matches!( + compact_file(&path) + .unwrap_err() + .downcast_ref::(), + Some(redb::DatabaseError::DatabaseAlreadyOpen) + )); + drop(db); + compact_file(&path).unwrap(); +} + +#[test] +fn compaction_rejects_a_future_repository_schema() { + use atomic_core::pristine::{schema::SCHEMA_VERSION, tables::ATOMIC_META}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("atomic.redb"); + let db = Database::create(&path).unwrap(); + let write = db.begin_write().unwrap(); + { + let mut meta = write.open_table(ATOMIC_META).unwrap(); + meta.insert( + atomic_core::pristine::schema::SCHEMA_VERSION_KEY, + (SCHEMA_VERSION + 1).to_le_bytes().as_slice(), + ) + .unwrap(); + } + write.commit().unwrap(); + drop(db); + let error = compact_file(&path).unwrap_err().to_string(); + assert!(error.contains("schema"), "{error}"); +} + +#[test] +fn compaction_preserves_persistent_savepoints_and_reports_the_blocker() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("atomic.redb"); + let db = Database::create(&path).unwrap(); + let write = db.begin_write().unwrap(); + let savepoint = write.persistent_savepoint().unwrap(); + write.commit().unwrap(); + drop(db); + + assert!(matches!( + compact_file(&path) + .unwrap_err() + .downcast::() + .unwrap(), + redb::CompactionError::PersistentSavepointExists + )); + let db = redb::Builder::new().open(&path).unwrap(); + let write = db.begin_write().unwrap(); + assert_eq!( + write + .list_persistent_savepoints() + .unwrap() + .collect::>(), + vec![savepoint] + ); +} diff --git a/libatomic/src/daemon/state.rs b/libatomic/src/daemon/state.rs index bbde5255..c7e8e3cf 100644 --- a/libatomic/src/daemon/state.rs +++ b/libatomic/src/daemon/state.rs @@ -36,14 +36,14 @@ pub const READ_OPEN_WAIT: std::time::Duration = std::time::Duration::from_secs(1 /// serialization for this slice — the read/write split is future work.) pub struct RepoHandle { pub root: PathBuf, - gate: tokio::sync::Mutex<()>, + gate: std::sync::Arc>, } impl RepoHandle { pub fn new(root: PathBuf) -> Self { Self { root, - gate: tokio::sync::Mutex::new(()), + gate: std::sync::Arc::new(tokio::sync::Mutex::new(())), } } @@ -53,6 +53,12 @@ impl RepoHandle { self.gate.lock().await } + /// Move maintenance exclusion into blocking work so cancelling its caller + /// cannot release the gate while that work is still using the database. + pub(super) async fn exclusive_owned(&self) -> tokio::sync::OwnedMutexGuard<()> { + self.gate.clone().lock_owned().await + } + /// The persistent repository ID for this daemon: a blake3 digest of the /// canonical root. Slice limitation: RFC D3 wants IDs assigned at init /// and recorded in `.atomic` so they survive daemon restarts and follow From cc01851cd12c4a0ee437cbebd8c544c0b58f1be5 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Thu, 8 Oct 2026 14:58:35 -0500 Subject: [PATCH 23/23] atomic-wasm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(canonical): sign attestations with a key held elsewhere `attest_value` splits into `prepare_attestation` (author, content hash and the exact bytes to sign) and `attach_proof`, so a signer that keeps its key outside this process — a browser's WebCrypto, a hardware token — produces the same `eddsa-jcs-2022` attestation the CLI does. `attest_value` is now those two halves around a local `Signer`. `atomic-canonical-wasm` exposes that to the browser: prepare, attach and verify attestations, plus the DID and canonical JSON of a document, all over JSON strings and byte arrays. `build.sh` builds it with wasm-bindgen; `smoke.mjs` signs with a non-extractable WebCrypto key, verifies, and checks tampering is caught. (cherry picked from commit e46c8a808d5dac4bbd121c1ea34c6d6c57d8cf5c) * feat(intent): attest with a key held elsewhere `atomic intent attest --prepare` prints the document an attestation signs and the exact bytes to sign, needing only the identity's public key. `--signed ` records an attestation a key holder produced from it: it must be signed by `--identity`'s key and attest the intent as it is now, so a signature over a stale or altered intent is refused. This lets a sandbox that holds only an agent's public identity attest as that agent, with the key kept by a signing service outside it. (cherry picked from commit c643ed77067aa666cf5667d6d424260cb71a54ef) * feat(token): name the server a token is for (aud) Self-signed request tokens now carry `aud`: the bare server URL they were minted for, normalised. A server that checks it refuses a token minted for somewhere else, so one leaked from one server can't be replayed at another within its five minutes. Verifiers that ignore unknown claims are unaffected. (cherry picked from commit d06dee6af0dc92a8a3346bd9833bc4b55ddcb995) * feat(repository): render a view's tree without touching disk `Repository::materialize_view_entries` hands each entry of a view — path, inode, kind, mode, bytes, content hash, conflict-marker line — to a sink, in path order with directories first, plus the view's Merkle state. Read-only: no working tree, stat cache or conflict state is written, so it runs beside other readers. It serves a remote sandbox its tree and baseline. The per-file rendering is now one function, `render_view_file`, shared with `materialize_parallel`, which keeps writing to disk as before. (cherry picked from commit 238384f2df497be09d9a3f5b543e9e281bc92b50) * Never treat a sandbox's pointer as untracked `.atomic-sandbox` showed up as untracked inside a sandbox, so `record --all` would record it; a remote pointer carries a token. Ignore it, and the sandbox's local cache `.atomic-sandbox.d`, like `.atomic`. (cherry picked from commit ac56c301ad85ff28ce17f042d190848872aecfde) * Draft views: render them as themselves A draft's structural changes (files it adds, moves, deletes) wait in the deferred tree journal until the view is checked out; TREE is the checked-out view's. Rendering any other view — materialize_view_entries, so every caller of it — now projects that view's journal in a write transaction that is thrown away. Before, a file added on a draft view never appeared on it. The vault-indexing half of the original commit served the remote-sandbox cache and is not carried over. (cherry picked from commit 081e6eb1f290faf6ec9498543ad89002613f0601) * Stage and seal a view as it renders materialize_view_to (behind `sandbox stage` and `seal`) listed files from TREE, which is the checked-out view's: a file another view added was missing from its image. It now writes materialize_view_entries, which projects the view's own tree. (cherry picked from commit 76aef1d51532ebc6e6c28b3512c91af6fdbbae37) * docs(storage): the endianness rule, written down at the encoders A key that gets range-scanned is big-endian, so its bytes sort in the same order as the numbers they encode; a value that is only ever looked up by its exact key can be little-endian. The two rules meet in files that handle both, so decode an id with that type's own `from_bytes` rather than slicing bytes by hand — a mis-decoded id is indistinguishable from an absent one at runtime. Recorded at the encoders in `pristine/tables.rs` and as a section in AGENTS.md. Also carries two fixes to the in-memory view render from the original commit: `view_tree_items` fell through to `TREE` — the *current* view's — when it could not open a write transaction to project another view's, so a read-only repository answered a question about one view with another's tree; it now refuses. And its test. (cherry picked from commit 3e7ca25003902d6a9d722041124fb0d52ac32f4c) * refactor(wasm): the wasm crate is atomic-wasm atomic-canonical-wasm named the crate it wraps twice over — atomic, twice — and it is the only wasm crate in the workspace, so the extra precision cost a word and bought nothing. atomic-wasm says the same thing in half the length, and the module a page imports becomes atomic_wasm. atomic-canonical itself keeps its name: that is the crate this one wraps, and it is a dependency, not a rename of it. Verified: builds for the host and for wasm32-unknown-unknown (787 KB artifact at the path build.sh expects), full workspace suite 9088 tests across 57 binaries, 0 failures. (cherry picked from commit 9b7defcfed3c58f8e7eb28f8a72ddcac18d6f5c3) * test(repository): is the pristine's file lock exclusive across processes? One test binary, two roles: the parent holds a Repository open and re-runs itself as the child; the child tries to open the same repository and reports what happened. Whether the lock is cross-process-exclusive is an assumption every single-writer consumer (the CLI's bounded open-wait, any daemon-side gate) relies on, so the answer is recorded as a test. Carried over from the remote-sandbox branch, where measuring how far the owner could go under concurrent sandboxes made the question load-bearing. (cherry picked from commit 677d3001ec24991069c05835adcf588a08791723) * chore: workspace lockfile for the external-signing and view-render deps * feat(libatomic): attest through the service with a key held elsewhere PrepareAttestation was unimplemented and RecordAttestation loaded a secret key and signed server-side, so an identity whose key is held elsewhere (a browser, a hardware token, a signing service) could not attest over the service layer — and any caller could mint an attestation under the daemon's default human key. The CLI's `intent attest --prepare/--signed` routed there, so the external-signer feature the surface was built for failed on the service path. - PrepareAttestation (intents): returns the document and the exact bytes a signer elsewhere must sign — the same preparation the local body computes — with only the identity's public key, gated on the pre-attest violations signing cannot fill. - RecordAttestation (intents): with a caller-supplied signature, no secret key is loaded. The signature must verify against the target AS IT IS NOW (a stale or altered intent hashes differently, so an old signature cannot land), the identity must be named — an unbound caller cannot borrow the daemon's default human key — and the proof attached is exactly the one the signature earned. Without a signature, plain attest still signs locally, as before. - The CLI routes `--prepare`/`--signed` through the service layer and prints the same JSON the local path prints. - `intent|memory validate --json` over the service path prints the same top-level report shape the local body prints (the wire carries each violation as one message string, so an entry is its message). This is the contract's attestation semantics (CONTRACT "Sandbox and vault writes": preparation returns public signing bytes under a read capability; recording verifies the supplied signature against the current target). --- AGENTS.md | 32 ++ Cargo.lock | 11 + Cargo.toml | 1 + atomic-canonical/src/proof.rs | 76 ++- atomic-cli/src/commands/intent/attest.rs | 89 +++- atomic-cli/src/commands/rpc.rs | 79 +++- atomic-cli/src/commands/token.rs | 39 +- atomic-cli/src/service/mod.rs | 22 + .../intent_attest_external_signer_test.rs | 185 ++++++++ atomic-core/src/pristine/tables.rs | 15 + atomic-repository/Cargo.toml | 1 + .../src/repository/materialize.rs | 431 ++++++++++++++---- atomic-repository/src/repository/mod.rs | 1 + atomic-repository/src/repository/sandbox.rs | 37 +- atomic-repository/src/status.rs | 11 +- .../tests/database_lock_exclusivity_test.rs | 96 ++++ .../tests/materialize_view_entries_test.rs | 168 +++++++ atomic-wasm/.gitignore | 1 + atomic-wasm/Cargo.toml | 24 + atomic-wasm/README.md | 20 + atomic-wasm/build.sh | 11 + atomic-wasm/smoke.mjs | 13 + atomic-wasm/src/lib.rs | 107 +++++ libatomic/src/daemon/services_agent.rs | 164 ++++++- 24 files changed, 1491 insertions(+), 143 deletions(-) create mode 100644 atomic-cli/tests/intent_attest_external_signer_test.rs create mode 100644 atomic-repository/tests/database_lock_exclusivity_test.rs create mode 100644 atomic-repository/tests/materialize_view_entries_test.rs create mode 100644 atomic-wasm/.gitignore create mode 100644 atomic-wasm/Cargo.toml create mode 100644 atomic-wasm/README.md create mode 100755 atomic-wasm/build.sh create mode 100644 atomic-wasm/smoke.mjs create mode 100644 atomic-wasm/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index cc81a200..2184d574 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -340,6 +340,38 @@ Human: "I can review that!" | `EdgeFlags` | 1 byte | Bitflags: BLOCK, PSEUDO, FOLDER, PARENT, DELETED | | `SerializedGraphEdge` | 24 bytes | Compact edge: (flags+pos, change, introduced_by) | +#### Endianness: keys that sort are big-endian, opaque values are little-endian + +The storage layer encodes integers two ways, and the rule is whether the +bytes ever get sorted: + +| Encoded as | Endian | Which | +|------------|--------|-------| +| **Big-endian** | BE | B-tree **keys** that are range-scanned: `encode_vertex`, `encode_inode_vertex`, `encode_position`, `encode_view_seq` (`pristine/tables.rs`) | +| **Little-endian** | LE | Values and ids only ever looked up by exact key: `SerializedGraphEdge`, and the CRDT `TrunkId`/`BranchId`/`LeafId` (`crdt/tables.rs`, `crdt/ids.rs`) | + +BE is load-bearing for the keys: byte order has to match numeric order or a +range scan means nothing. The file-local INODE_GRAPH traversal depends on it — + +```rust +let lo = encode_inode_vertex(inode, 0, 0, 0); +let hi = encode_inode_vertex(inode, u64::MAX, u64::MAX, u64::MAX); +for row in inode_graph.range::<&[u8; 32]>(&lo..=&hi)? { ... } // every row for one inode +``` + +For the LE side, sorting never happens, so byte order costs nothing and there +is no reason to pay for BE. + +**The two meet in the same files**, so match the *writer*, never the +neighbours: a file that decodes graph vertex keys (BE) can also decode CRDT +ids (LE) within a few lines, and a hand-rolled `id[0..8]` slice reads the +wrong end of a LE id without any visible failure — the id just looks absent. +Decode an id with its own type's `from_bytes` (`TrunkId::from_bytes`, +`BranchId::from_bytes`, `LeafId::from_bytes`) instead of slicing by hand, so +the two sides cannot drift; a mis-decoded id is indistinguishable from an +absent one at runtime, so an `EXTERNAL`-row lookup for it is worth a +`debug_assert`. + ### Hash Type Design Following the original Atomic project, `Hash` is a **type alias** for `Merkle`: diff --git a/Cargo.lock b/Cargo.lock index 786558ad..8379e4c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,6 +455,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomic-wasm" +version = "0.19.1" +dependencies = [ + "atomic-canonical", + "atomic-identity", + "getrandom 0.2.17", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "autocfg" version = "1.5.1" diff --git a/Cargo.toml b/Cargo.toml index d50c46c8..cfa22eba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "atomic-repository", "atomic-semantic", "atomic-teams", + "atomic-wasm", "libatomic", ] diff --git a/atomic-canonical/src/proof.rs b/atomic-canonical/src/proof.rs index afd3710d..52bf1da3 100644 --- a/atomic-canonical/src/proof.rs +++ b/atomic-canonical/src/proof.rs @@ -110,8 +110,32 @@ pub fn substance_view(value: &Value) -> Value { /// path all typed nodes share. Fills `attributedTo` (from the identity's /// `did:atomic`) when absent, computes the content hash over `hashing_view`, /// signs `jcs(signing_view)`, and attaches the proof. Returns the value. -pub fn attest_value(mut value: Value, identity: &Identity, keypair: &KeyPair) -> Value { - let did = did::did_for_public_key(&identity.public_key); +/// +/// It is [`prepare_attestation`] → sign → [`attach_proof`]; a signer that +/// holds its key outside this process uses those two halves directly. +pub fn attest_value(value: Value, identity: &Identity, keypair: &KeyPair) -> Value { + let prepared = prepare_attestation(value, &identity.public_key); + let signature = Signer::new(keypair).sign(&prepared.signing_bytes); + attach_proof(prepared.value, &identity.public_key, &signature) +} + +/// A value made ready to sign: `attributedTo` and `contentHash` filled in, +/// and the exact bytes the signature must cover. +#[derive(Debug, Clone)] +pub struct PreparedAttestation { + /// The value to sign — pass it back to [`attach_proof`] unchanged. + pub value: Value, + /// `jcs(signing_view(value))`: what the Ed25519 signature covers. + pub signing_bytes: Vec, +} + +/// First half of [`attest_value`], for signers that hold the key somewhere +/// else — a browser's WebCrypto, a hardware token, a remote signing service. +/// Everything that must agree with [`verify_value`] (the author, the content +/// hash, the canonical bytes) is computed here, so the external signer only +/// ever signs bytes, and the result is an ordinary atomic attestation. +pub fn prepare_attestation(mut value: Value, public_key: &PublicKey) -> PreparedAttestation { + let did = did::did_for_public_key(public_key); if let Some(obj) = value.as_object_mut() { // Fill attributedTo only if there is no non-empty value already. @@ -121,7 +145,7 @@ pub fn attest_value(mut value: Value, identity: &Identity, keypair: &KeyPair) -> .map(|s| !s.is_empty()) .unwrap_or(false); if !has_author { - obj.insert(PROP_ATTRIBUTED_TO.to_string(), Value::String(did.clone())); + obj.insert(PROP_ATTRIBUTED_TO.to_string(), Value::String(did)); } } @@ -133,13 +157,24 @@ pub fn attest_value(mut value: Value, identity: &Identity, keypair: &KeyPair) -> } let signing_bytes = jcs::canonicalize(&signing_view(&value)).into_bytes(); - let signature = Signer::new(keypair).sign(&signing_bytes); + PreparedAttestation { + value, + signing_bytes, + } +} + +/// Second half of [`attest_value`]: attach the `eddsa-jcs-2022` proof for a +/// signature over [`PreparedAttestation::signing_bytes`] made by +/// `public_key`'s private key. Does not check the signature — run +/// [`verify_value`] on the result for that. +pub fn attach_proof(mut value: Value, public_key: &PublicKey, signature: &Signature) -> Value { + let did = did::did_for_public_key(public_key); let proof = Proof { type_: PROOF_TYPE.to_string(), cryptosuite: CRYPTOSUITE.to_string(), verification_method: did::verification_method(&did), proof_purpose: PROOF_PURPOSE.to_string(), - proof_value: encode_proof_value(&signature), + proof_value: encode_proof_value(signature), }; if let Some(obj) = value.as_object_mut() { obj.insert( @@ -150,6 +185,18 @@ pub fn attest_value(mut value: Value, identity: &Identity, keypair: &KeyPair) -> value } +/// The signature a value's proof carries — what `--signed` hands to the +/// recording service after a key holder signed `--prepare`'s bytes elsewhere. +pub fn proof_signature(value: &Value) -> Result { + let proof: Proof = value + .as_object() + .and_then(|obj| obj.get(PROP_PROOF)) + .cloned() + .and_then(|p| serde_json::from_value(p).ok()) + .ok_or_else(|| CanonicalError::Proof("node carries no proof".into()))?; + decode_proof_value(&proof.proof_value) +} + /// Generic verify over a JSON-LD value — the same three checks as the typed /// path: (1) the content hash recomputes over `hashing_view`, (2) the signature /// verifies over `jcs(signing_view)`, (3) the proof's verificationMethod DID @@ -281,6 +328,25 @@ mod tests { }) } + /// An external signer — handed only the prepared bytes — produces + /// exactly the attestation `attest_value` would, and it verifies. + #[test] + fn prepare_sign_attach_matches_attest_value() { + let (id, kp) = dev_identity(); + let prepared = prepare_attestation(minimal_value(), &kp.public); + let signature = Signer::new(&kp).sign(&prepared.signing_bytes); + let external = attach_proof(prepared.value, &kp.public, &signature); + + assert_eq!(external, attest_value(minimal_value(), &id, &kp)); + verify_value(&external, &kp.public).expect("externally signed value verifies"); + + // A signature over anything else does not. + let wrong = Signer::new(&kp).sign(b"not the prepared bytes"); + let prepared = prepare_attestation(minimal_value(), &kp.public); + let bad = attach_proof(prepared.value, &kp.public, &wrong); + assert!(verify_value(&bad, &kp.public).is_err()); + } + #[test] fn attest_value_then_verify_value_roundtrips() { let (id, kp) = dev_identity(); diff --git a/atomic-cli/src/commands/intent/attest.rs b/atomic-cli/src/commands/intent/attest.rs index 1dd67db7..d80311cd 100644 --- a/atomic-cli/src/commands/intent/attest.rs +++ b/atomic-cli/src/commands/intent/attest.rs @@ -4,6 +4,7 @@ use clap::Parser; use serde_json::Value; +use atomic_canonical::proof::prepare_attestation; use atomic_canonical::{lift_and_attest, validate_intent, verify}; use atomic_core::pristine::VaultEntryType; use atomic_identity::IdentityStore; @@ -28,6 +29,20 @@ pub struct IntentAttest { /// Output the attested node as JSON-LD. #[arg(long)] pub json: bool, + + /// Don't sign: print what a signer holding the identity's key elsewhere + /// (a browser, a hardware token, a remote signing service) must sign — + /// `{"document": …, "signingBytes": ""}`. Needs only the + /// identity's public key. Complete with `--signed`. + #[arg(long, conflicts_with = "signed")] + pub prepare: bool, + + /// Record an attestation signed elsewhere: a JSON file holding the + /// attested node (the `--prepare` document with its proof attached). It + /// must be signed by `--identity`'s key and attest the intent as it is + /// now. + #[arg(long, value_name = "PATH")] + pub signed: Option, } impl Command for IntentAttest { @@ -63,7 +78,7 @@ impl Command for IntentAttest { ))); } - // Resolve identity + keypair the way `atomic identity sign` does. + // Resolve the identity the way `atomic identity sign` does. let store = IdentityStore::open_default().map_err(|e| { CliError::Internal(anyhow::anyhow!("Failed to open identity store: {}", e)) })?; @@ -83,20 +98,66 @@ impl Command for IntentAttest { .to_string(), })? }; - let keypair = store.load_keypair(&identity.id, None).map_err(|e| { - CliError::Internal(anyhow::anyhow!( - "Failed to load keypair for '{}': {}", - identity.name, - e - )) - })?; - // Attest: lift + fill attributedTo (from the identity's did:atomic when - // absent) + hash + sign. - let node = lift_and_attest(&inputs.frontmatter, &inputs.body, &identity, &keypair) - .map_err(|e| CliError::InvalidArgument { - message: format!("could not attest intent: {e}"), + // Signing elsewhere, step 1: say what to sign. The same preparation + // `lift_and_attest` does (author, content hash, canonical bytes), with + // no private key involved. + if self.prepare { + let prepared = prepare_attestation(unattested.to_value(), &identity.public_key); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "document": prepared.value, + "signingBytes": data_encoding::BASE64.encode(&prepared.signing_bytes), + })) + .unwrap() + ); + return Ok(()); + } + + let node = if let Some(path) = &self.signed { + // Signing elsewhere, step 2: the signature must be over exactly + // what `--prepare` produces for this intent now — so a signature + // over a stale or altered intent is refused, not recorded. + let text = std::fs::read_to_string(path).map_err(CliError::Io)?; + let signed: Value = + serde_json::from_str(&text).map_err(|e| CliError::InvalidArgument { + message: format!("{} is not JSON: {e}", path.display()), + })?; + let expected = prepare_attestation(unattested.to_value(), &identity.public_key).value; + let mut unsigned = signed.clone(); + if let Some(obj) = unsigned.as_object_mut() { + obj.remove("proof"); + } + if unsigned != expected { + return Err(CliError::InvalidArgument { + message: format!( + "the signed attestation is not of intent {} as it is now (re-run --prepare)", + self.id + ), + }); + } + serde_json::from_value::(signed).map_err(|e| { + CliError::InvalidArgument { + message: format!("not an attested intent: {e}"), + } + })? + } else { + let keypair = store.load_keypair(&identity.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!( + "Failed to load keypair for '{}': {}", + identity.name, + e + )) })?; + // Attest: lift + fill attributedTo (from the identity's + // did:atomic when absent) + hash + sign. + lift_and_attest(&inputs.frontmatter, &inputs.body, &identity, &keypair).map_err( + |e| CliError::InvalidArgument { + message: format!("could not attest intent: {e}"), + }, + )? + }; // Belt-and-suspenders: re-gate the ATTESTED node — proof + attributedTo // must now satisfy the gate. @@ -111,7 +172,7 @@ impl Command for IntentAttest { // Self-check: the proof verifies against the signing key before we // write anything to disk. - verify(&node, &keypair.public).map_err(|e| CliError::InvalidArgument { + verify(&node, &identity.public_key).map_err(|e| CliError::InvalidArgument { message: format!("attested intent failed self-verification: {e}"), })?; diff --git a/atomic-cli/src/commands/rpc.rs b/atomic-cli/src/commands/rpc.rs index f3a872d7..821f0712 100644 --- a/atomic-cli/src/commands/rpc.rs +++ b/atomic-cli/src/commands/rpc.rs @@ -803,14 +803,61 @@ pub fn intent_attest(args: &super::intent::attest::IntentAttest) -> CliResult CliRes let Some(session) = Service::open()? else { return Ok(false); }; - validate_entity(&session, Kind::Intent, &args.id_or_path) + validate_entity(&session, Kind::Intent, &args.id_or_path, args.json) } pub fn intent_verify(args: &super::intent::verify::IntentVerify) -> CliResult { @@ -953,7 +1000,7 @@ pub fn intent_show(args: &super::intent::show::IntentShow) -> CliResult { Ok(true) } -fn validate_entity(session: &Service, kind: Kind, id: &str) -> CliResult { +fn validate_entity(session: &Service, kind: Kind, id: &str, json: bool) -> CliResult { let request = pb::ValidateVaultEntityRequest { repository: Some(session.reference.clone()), kind: kind as i32, @@ -964,6 +1011,24 @@ fn validate_entity(session: &Service, kind: Kind, id: &str) -> CliResult { view: None, }; let response = session.validate_vault_entity(request)?; + if json { + // Same top-level shape the local body's `report_json` prints; the + // wire carries each violation as one message string, so an entry is + // its message. + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "conforms": response.valid, + "results": response + .issues + .iter() + .map(|issue| serde_json::json!({ "message": issue })) + .collect::>(), + })) + .unwrap() + ); + return Ok(true); + } if response.valid { println!("conforms: yes"); } else { @@ -1046,7 +1111,7 @@ pub fn memory_validate(args: &super::memory::validate::MemoryValidate) -> CliRes let Some(session) = Service::open()? else { return Ok(false); }; - validate_entity(&session, Kind::Memory, &args.id_or_path) + validate_entity(&session, Kind::Memory, &args.id_or_path, args.json) } pub fn memory_verify(args: &super::memory::verify::MemoryVerify) -> CliResult { diff --git a/atomic-cli/src/commands/token.rs b/atomic-cli/src/commands/token.rs index 8ff725cb..86124cbf 100644 --- a/atomic-cli/src/commands/token.rs +++ b/atomic-cli/src/commands/token.rs @@ -11,7 +11,8 @@ //! `base64url(header).base64url(claims).base64url(signature)`: //! //! - header: `{"alg":"EdDSA","typ":"JWT","kid":""}` -//! - claims: `{ sub, iat, exp, jti }` (`sub` mirrors the `kid` public key) +//! - claims: `{ sub, aud, iat, exp, jti }` (`sub` mirrors the `kid` public key; +//! `aud` is the server the token is for) //! - signature: `Ed25519_sign(private_key, "header.claims")` //! //! # Acting on behalf of someone (agent identities) @@ -78,6 +79,12 @@ struct Claims { exp: i64, jti: String, + /// The server this token is for (RFC 7519 `aud`): the bare server URL, + /// without a trailing slash. A server that checks it refuses a token + /// minted for somewhere else, so a token leaked from one server can't + /// be replayed at another within its lifetime. + aud: String, + /// RFC 8693 actor claim — present only when an agent is acting. #[serde(skip_serializing_if = "Option::is_none")] act: Option, @@ -160,6 +167,7 @@ fn mint_token(server: &str, identity: &Identity) -> CliResult { let now = Utc::now(); let claims = Claims { sub, + aud: audience(server), iat: now.timestamp(), exp: (now + TOKEN_TTL).timestamp(), jti: Uuid::new_v4().to_string(), @@ -196,10 +204,36 @@ fn mint_token(server: &str, identity: &Identity) -> CliResult { Ok(format!("{signing_input}.{sig_b64}")) } +/// The `aud` for a server URL: scheme and host (and port), no trailing +/// slash, lowercased — so `https://Atomic.Storage/` and +/// `https://atomic.storage` are the same audience. +pub fn audience(server: &str) -> String { + server.trim().trim_end_matches('/').to_ascii_lowercase() +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_token_names_the_server_it_is_for() { + assert_eq!( + audience("https://Atomic.Storage/"), + "https://atomic.storage" + ); + let claims = Claims { + sub: "S".into(), + aud: audience("https://atomic.storage"), + iat: 0, + exp: 1, + jti: "j".into(), + act: None, + dlg: None, + }; + let v: serde_json::Value = serde_json::to_value(&claims).unwrap(); + assert_eq!(v["aud"], "https://atomic.storage"); + } + #[test] fn header_is_eddsa_jwt_with_kid() { let header = JwtHeader { @@ -218,6 +252,7 @@ mod tests { fn a_non_delegated_token_omits_the_actor_claims() { let claims = Claims { sub: "ABCDEF".to_string(), + aud: "https://a".to_string(), iat: 0, exp: 1, jti: "j".to_string(), @@ -237,6 +272,7 @@ mod tests { let agent = "AGENTKEY"; let claims = Claims { sub: human.to_string(), + aud: "https://a".to_string(), iat: 0, exp: 1, jti: "j".to_string(), @@ -277,6 +313,7 @@ mod tests { let now = Utc::now(); let claims = Claims { sub: public_key_b32.clone(), + aud: audience("https://atomic.storage"), iat: now.timestamp(), exp: (now + TOKEN_TTL).timestamp(), jti: Uuid::new_v4().to_string(), diff --git a/atomic-cli/src/service/mod.rs b/atomic-cli/src/service/mod.rs index 8b4c1130..1a72148a 100644 --- a/atomic-cli/src/service/mod.rs +++ b/atomic-cli/src/service/mod.rs @@ -946,6 +946,28 @@ impl Service { // --------------------------------------------------------------------------- impl Service { + pub fn prepare_attestation( + &self, + request: pb::PrepareAttestationRequest, + ) -> CliResult { + self.call(move |backend| async move { + match backend { + Backend::Local(state) => services_agent::AttestationImpl { state } + .prepare_attestation(Request::new(request)) + .await + .map(|response| response.into_inner()), + Backend::Reactor(channel) => { + let mut attestation = + pb::attestation_service_client::AttestationServiceClient::new(channel); + attestation + .prepare_attestation(request) + .await + .map(|response| response.into_inner()) + } + } + }) + } + pub fn record_attestation( &self, request: pb::RecordAttestationRequest, diff --git a/atomic-cli/tests/intent_attest_external_signer_test.rs b/atomic-cli/tests/intent_attest_external_signer_test.rs new file mode 100644 index 00000000..d6871257 --- /dev/null +++ b/atomic-cli/tests/intent_attest_external_signer_test.rs @@ -0,0 +1,185 @@ +//! `atomic intent attest --prepare` / `--signed`: attesting an intent with a +//! key that is not on this machine. The identity here is public-only — as an +//! agent identity is inside a sandbox whose key lives with a signing +//! service — so plain `attest` can't sign; `--prepare` says what to sign, +//! the key holder signs it, and `--signed` checks and records the result. + +use std::path::Path; +use std::process::{Command, Output}; + +use atomic_identity::{Identity, IdentityStore, IdentityType, IdentityUsage, KeyPair}; + +fn atomic(home: &Path, cwd: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_atomic")) + .args(args) + .current_dir(cwd) + .env("HOME", home) + .env("ATOMIC_CONFIG_DIR", home.join(".atomic")) + .output() + .unwrap() +} + +fn ok(out: &Output) -> String { + assert!( + out.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +/// A repo with one intent, a default human identity, and a public-only +/// agent identity whose key only the test holds. +fn setup() -> (tempfile::TempDir, tempfile::TempDir, String, KeyPair) { + let home = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + ok(&atomic( + home.path(), + repo.path(), + &[ + "identity", + "new", + "ada", + "--email", + "ada@example.com", + "--set-default", + ], + )); + ok(&atomic(home.path(), repo.path(), &["init"])); + ok(&atomic( + home.path(), + repo.path(), + &["intent", "new", "Add a greeting"], + )); + + let key = KeyPair::generate(); + let agent = Identity::builder("ada+bot") + .identity_type(IdentityType::Agent) + .usage(IdentityUsage::Bot) + .public_key(key.public.clone()) + .build() + .unwrap(); + IdentityStore::open(&home.path().join(".atomic").join("identities")) + .unwrap() + .save(&agent) + .unwrap(); + + let list = ok(&atomic( + home.path(), + repo.path(), + &["intent", "list", "--json"], + )); + let intents: serde_json::Value = serde_json::from_str(&list).unwrap(); + let id = intents + .as_array() + .and_then(|a| a.first()) + .and_then(|i| i.get("id")) + .and_then(|v| v.as_str()) + .expect("one intent") + .to_string(); + (home, repo, id, key) +} + +/// What the key holder does with `--prepare`'s output. +fn sign_elsewhere(prepared: &str, key: &KeyPair) -> serde_json::Value { + let prepared: serde_json::Value = serde_json::from_str(prepared).unwrap(); + let bytes = data_encoding::BASE64 + .decode(prepared["signingBytes"].as_str().unwrap().as_bytes()) + .unwrap(); + let signature = atomic_identity::signing::Signer::new(key).sign(&bytes); + atomic_canonical::proof::attach_proof(prepared["document"].clone(), &key.public, &signature) +} + +#[test] +fn an_intent_is_attested_by_a_key_held_elsewhere() { + let (home, repo, id, key) = setup(); + let (home, repo) = (home.path(), repo.path()); + + // No key here: plain attest can't sign as the agent. + let out = atomic( + home, + repo, + &["intent", "attest", &id, "--identity", "ada+bot"], + ); + assert!(!out.status.success(), "attest without a key must fail"); + + let prepared = ok(&atomic( + home, + repo, + &[ + "intent", + "attest", + &id, + "--identity", + "ada+bot", + "--prepare", + ], + )); + let signed = sign_elsewhere(&prepared, &key); + let file = home.join("signed.json"); + std::fs::write(&file, signed.to_string()).unwrap(); + ok(&atomic( + home, + repo, + &[ + "intent", + "attest", + &id, + "--identity", + "ada+bot", + "--signed", + file.to_str().unwrap(), + ], + )); + + let report: serde_json::Value = serde_json::from_str(&ok(&atomic( + home, + repo, + &["intent", "validate", &id, "--json"], + ))) + .unwrap(); + assert_eq!(report["conforms"], true, "{report}"); + assert_eq!( + signed["attributedTo"], + atomic_canonical::did::did_for_public_key(&key.public) + ); +} + +#[test] +fn a_signature_by_another_key_is_refused() { + let (home, repo, id, _key) = setup(); + let (home, repo) = (home.path(), repo.path()); + let prepared = ok(&atomic( + home, + repo, + &[ + "intent", + "attest", + &id, + "--identity", + "ada+bot", + "--prepare", + ], + )); + let signed = sign_elsewhere(&prepared, &KeyPair::generate()); + let file = home.join("signed.json"); + std::fs::write(&file, signed.to_string()).unwrap(); + let out = atomic( + home, + repo, + &[ + "intent", + "attest", + &id, + "--identity", + "ada+bot", + "--signed", + file.to_str().unwrap(), + ], + ); + assert!( + !out.status.success(), + "a signature by the wrong key must not be recorded" + ); +} diff --git a/atomic-core/src/pristine/tables.rs b/atomic-core/src/pristine/tables.rs index 8dd71b80..2d43077a 100644 --- a/atomic-core/src/pristine/tables.rs +++ b/atomic-core/src/pristine/tables.rs @@ -850,6 +850,21 @@ pub fn decode_change_file_key(key: &[u8; 36]) -> ([u8; 32], u32) { } // Key Encoding Helpers +// +// Endianness. A key that gets range-scanned is big-endian, so its bytes sort +// in the same order as the numbers they encode — that is what makes a +// `range(lo..=hi)` over these tables mean what it says (e.g. every row for +// one inode: `encode_inode_vertex(inode, 0, 0, 0)` through +// `encode_inode_vertex(inode, u64::MAX, u64::MAX, u64::MAX)`). A value that is +// only ever looked up by its exact key can be little-endian; sorting never +// happens, so byte order costs nothing. That covers the serialized graph edge +// and the CRDT trunk/branch/leaf ids, which are opaque and point-looked-up +// (`crdt::tables`). +// +// The two rules meet in files that handle both, so match the writer rather +// than the neighbours: decode an id with that type's own `from_bytes` +// (`TrunkId`, `BranchId`, `LeafId`) rather than slicing bytes by hand, and the +// two sides cannot drift. /// Encode a span as 24 bytes for use as a graph key #[inline] diff --git a/atomic-repository/Cargo.toml b/atomic-repository/Cargo.toml index 47bd8d18..307062cc 100644 --- a/atomic-repository/Cargo.toml +++ b/atomic-repository/Cargo.toml @@ -16,6 +16,7 @@ atomic-identity = { workspace = true } atomic-semantic = { workspace = true } serde = { workspace = true } +data-encoding = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } postcard = { workspace = true } diff --git a/atomic-repository/src/repository/materialize.rs b/atomic-repository/src/repository/materialize.rs index 3a2916a1..e22b20fc 100644 --- a/atomic-repository/src/repository/materialize.rs +++ b/atomic-repository/src/repository/materialize.rs @@ -545,7 +545,6 @@ impl Repository { use atomic_core::output::repo::{ collect_children, FileOutputOptions, MaterializeOptions, OutputItem, }; - use atomic_core::output::RetrieveOptions; use rayon::prelude::*; use std::collections::HashSet as StdHashSet; @@ -725,82 +724,17 @@ impl Repository { .map(|item| { let file_start = std::time::Instant::now(); - // Build retrieve options with the shared change filter - let retrieve_opts = - RetrieveOptions::default().with_change_filter_arc(change_filter_arc.clone()); - - // Inline the output pipeline so we can trace each phase. - use atomic_core::output::repo::{ - output_graph_content_resolved, resolve_conflicts_semantically, - }; - use atomic_core::output::{compute_order, retrieve_graph, Writer}; - use atomic_core::pristine::InodePreloadTxn; - - // Pre-load ALL edges for this file's inode from INODE_GRAPH - // in a single range scan, then run retrieve_graph over the - // in-memory HashMap. O(M) scan + O(1) lookups vs O(V×log N) - // individual B-tree probes. - let preloaded = InodePreloadTxn::from_table(&txn, item.inode, &inode_graph_table) - .map_err(|e| format!("{}: preload: {:?}", item.path, e))?; - - let t_retrieve = std::time::Instant::now(); - let retrieve_result = retrieve_graph(&preloaded, item.position, retrieve_opts) - .map_err(|e| format!("{}: retrieve: {:?}", item.path, e))?; - - if retrieve_result.graph.is_empty() { - return Ok(None); - } - - let vertices = retrieve_result.graph.len_vertices(); - let edges = retrieve_result.edges_traversed; - let retrieve_ms = t_retrieve.elapsed(); - - let t_order = std::time::Instant::now(); - let mut graph = retrieve_result.graph; - let order = compute_order(&mut graph); - let order_ms = t_order.elapsed(); - - let t_content = std::time::Instant::now(); - let resolved = resolve_conflicts_semantically(&preloaded, store, &graph, &order); - let buffer = Vec::with_capacity(graph.total_bytes()); - let mut writer = Writer::new(buffer); - let hash_fn = |node_id: NodeId| -> Option { - if node_id.is_root() { - return None; - } - preloaded.get_external(node_id).ok().flatten() - }; - output_graph_content_resolved( + let Some(content) = render_view_file( + &txn, store, - hash_fn, - &graph, - &order, - &mut writer, - &resolved, - ) - .map_err(|e| format!("{}: content: {:?}", item.path, e))?; - let content = writer.into_inner(); - let content_ms = t_content.elapsed(); - - if content.is_empty() { + &inode_graph_table, + &change_filter_arc, + &name_conflicts, + item, + trace_mat.then_some(file_start), + )? + else { return Ok(None); - } - - // Name-conflict override (rare): when ≥ 2 inodes are alive at - // this path on the view, replace the single-inode content with - // a marker-wrapped rendering of every side so the conflict is - // surfaced instead of silently collapsed (rubric A12). - let content = match name_conflicts.get(&item.path) { - Some(sides) => render_name_conflict( - &txn, - store, - &inode_graph_table, - &change_filter_arc, - &item.path, - sides, - ) - .unwrap_or(content), - None => content, }; // Detect conflict markers in the materialized bytes. This is @@ -860,24 +794,6 @@ impl Repository { std::fs::write(&abs_path, &content) .map_err(|e| format!("{}: write: {}", item.path, e))?; - if trace_mat { - let elapsed = file_start.elapsed(); - if elapsed > std::time::Duration::from_millis(50) { - eprintln!( - "[materialize] SLOW {} bytes={} vertices={} edges={} \ - retrieve={:?} order={:?} content={:?} total={:?}", - item.path, - bytes_written, - vertices, - edges, - retrieve_ms, - order_ms, - content_ms, - elapsed, - ); - } - } - Ok(Some(( item.path.clone(), bytes_written, @@ -1114,6 +1030,335 @@ impl Repository { } } +/// One entry of a view's tree, rendered in memory by +/// [`Repository::materialize_view_entries`] — enough for a client to write +/// the working tree and a baseline index (`status` against it) without the +/// repository. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ViewEntry { + pub path: String, + pub inode: u64, + pub kind: ViewEntryKind, + /// Unix permission bits as recorded. + pub mode: u16, + /// The file's bytes as a checkout of the view would write them (empty + /// for directories). + #[serde(with = "serde_bytes_vec")] + pub content: Vec, + /// `Hash::of(content)` — the baseline a client compares against. + pub hash: Hash, + /// 1-based line of the first conflict marker, if the file is conflicted. + pub conflict_marker_line: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ViewEntryKind { + File, + Directory, + Symlink, +} + +/// What a view looked like when it was rendered. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ViewSnapshot { + pub view: String, + /// The view's Merkle state, base32: a change recorded against this + /// snapshot applies only while the view is still here. + pub state: String, + pub change_count: u64, +} + +mod serde_bytes_vec { + use serde::{Deserialize, Deserializer, Serializer}; + pub fn serialize(v: &[u8], s: S) -> Result { + s.serialize_str(&data_encoding::BASE64.encode(v)) + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let s = String::deserialize(d)?; + data_encoding::BASE64 + .decode(s.as_bytes()) + .map_err(serde::de::Error::custom) + } +} + +impl Repository { + /// The entries `view_name` has, from TREE as that view projects it. + /// + /// TREE is the checked-out view's projection; another view's structural + /// operations (files it added, moved or deleted) wait in the deferred + /// tree journal until a switch applies them. For another view they are + /// applied here in a write transaction that is thrown away, so the + /// listing is that view's and nothing on disk changes. + /// + /// A read-only handle cannot project, and `TREE` is the *current* view's — + /// so a read-only repository asked about another view is refused rather + /// than answered. Falling through would return the current view's tree + /// under the other view's name, and the caller writes that straight + /// somewhere. The current view needs no projection, so it reads `TREE` + /// directly and works read-only. + fn view_tree_items( + &self, + txn: &atomic_core::pristine::ReadTxn, + view_name: &str, + options: &atomic_core::output::repo::MaterializeOptions, + ) -> Result, RepositoryError> { + use atomic_core::output::repo::collect_children; + let db = |e: atomic_core::pristine::PristineError| RepositoryError::Database(e.to_string()); + if view_name != self.current_view { + let journal = self.load_deferred_tree_journal()?; + let mut projected = + self.pristine + .write_txn() + .map_err(|e| RepositoryError::InvalidOperation { + message: format!( + "listing view '{view_name}' projects its tree, which needs a writable \ + repository: {e}" + ), + })?; + self.apply_deferred_tree_ops_in_txn(&mut projected, &journal, view_name)?; + let items = collect_children(&projected, Inode::ROOT, "", options).map_err(db)?; + use atomic_core::pristine::MutTxnT; + projected.abort().map_err(db)?; + return Ok(items); + } + collect_children(txn, Inode::ROOT, "", options).map_err(db) + } + + /// Render every entry of `view` and hand each to `sink`, in path order + /// (a directory before what it contains), touching nothing: no working + /// tree, no stat cache, no conflict state. Read-only, so it runs beside + /// other readers — this is what serves a client a view's tree. + /// + /// Files are rendered with the same kernel as + /// [`Repository::materialize_parallel`], in parallel chunks, so memory + /// holds one chunk of contents at a time. + pub fn materialize_view_entries( + &self, + view_name: &str, + mut sink: impl FnMut(ViewEntry) -> Result<(), E>, + ) -> Result, RepositoryError> { + use atomic_core::output::repo::MaterializeOptions; + use atomic_core::types::Base32; + use rayon::prelude::*; + + const CHUNK: usize = 256; + + let txn = self + .pristine + .read_txn() + .map_err(|e| RepositoryError::Database(e.to_string()))?; + let view = txn + .get_view(view_name) + .map_err(|e| RepositoryError::Database(e.to_string()))? + .ok_or_else(|| RepositoryError::ViewNotFound { + name: view_name.to_string(), + })?; + let change_filter_arc = Arc::new(collect_visible_change_ids(&txn, &view)?); + let options = MaterializeOptions::new().with_change_filter_arc(change_filter_arc.clone()); + let mut items = self.view_tree_items(&txn, view_name, &options)?; + items.sort_by(|a, b| a.path.cmp(&b.path)); + + let visible = |item: &atomic_core::output::repo::OutputItem| { + item.position.change.is_root() || change_filter_arc.contains(&item.position.change) + }; + let files: Vec<&atomic_core::output::repo::OutputItem> = items + .iter() + .filter(|i| !i.is_directory && visible(i)) + .collect(); + let name_conflicts = collect_name_conflicts( + &txn, + &self.change_store, + &files.iter().map(|i| i.path.as_str()).collect(), + &change_filter_arc, + )?; + for id in change_filter_arc.iter().filter(|id| !id.is_root()) { + if let Ok(Some(hash)) = txn.get_external(*id) { + let _ = self.change_store.load_change(&hash); + } + } + let inode_graph_table = txn + .open_inode_graph_table() + .map_err(|e| RepositoryError::Database(e.to_string()))?; + + // Directories that hold at least one visible file, before their files. + let file_paths: std::collections::HashSet<&str> = + files.iter().map(|i| i.path.as_str()).collect(); + let mut dirs: Vec<&atomic_core::output::repo::OutputItem> = items + .iter() + .filter(|i| i.is_directory) + .filter(|d| { + let prefix = format!("{}/", d.path); + file_paths.iter().any(|p| p.starts_with(&prefix)) + }) + .collect(); + dirs.sort_by(|a, b| a.path.cmp(&b.path)); + for d in dirs { + let entry = ViewEntry { + path: d.path.clone(), + inode: d.inode.get(), + kind: ViewEntryKind::Directory, + mode: d.metadata.permissions, + content: Vec::new(), + hash: Hash::of(&[]), + conflict_marker_line: None, + }; + if let Err(e) = sink(entry) { + return Ok(Err(e)); + } + } + + let store = &self.change_store; + for chunk in files.chunks(CHUNK) { + let rendered: Vec, String>> = chunk + .par_iter() + .map(|item| { + let content = render_view_file( + &txn, + store, + &inode_graph_table, + &change_filter_arc, + &name_conflicts, + item, + None, + )?; + Ok(content.map(|content| ViewEntry { + path: item.path.clone(), + inode: item.inode.get(), + kind: if item.metadata.is_symlink { + ViewEntryKind::Symlink + } else { + ViewEntryKind::File + }, + mode: item.metadata.permissions, + conflict_marker_line: first_conflict_marker_line(&content), + hash: Hash::of(&content), + content, + })) + }) + .collect(); + for r in rendered { + match r { + Ok(Some(entry)) => { + if let Err(e) = sink(entry) { + return Ok(Err(e)); + } + } + Ok(None) => {} + Err(e) => return Err(RepositoryError::Output(e)), + } + } + } + + Ok(Ok(ViewSnapshot { + view: view.name.clone(), + state: view.state.to_base32(), + change_count: view.change_count, + })) + } +} + +/// Render one file of a view in memory: retrieve its graph under the view's +/// change filter, order it, resolve conflicts, and write the bytes a +/// checkout would produce — with a name conflict rendered as markers. The +/// one rendering kernel behind [`Repository::materialize_parallel`] (which +/// writes the result to disk) and [`Repository::materialize_view_entries`] +/// (which hands it to a caller, touching nothing). `Ok(None)`: the file has +/// no content on this view. +fn render_view_file( + txn: &atomic_core::pristine::ReadTxn, + store: &C, + inode_graph_table: &redb::ReadOnlyMultimapTable<&'static [u8; 32], &'static [u8; 24]>, + change_filter_arc: &Arc>, + name_conflicts: &NameConflicts, + item: &atomic_core::output::repo::OutputItem, + trace_from: Option, +) -> Result>, String> { + use atomic_core::output::RetrieveOptions; + // Build retrieve options with the shared change filter + let retrieve_opts = + RetrieveOptions::default().with_change_filter_arc(change_filter_arc.clone()); + + // Inline the output pipeline so we can trace each phase. + use atomic_core::output::repo::{ + output_graph_content_resolved, resolve_conflicts_semantically, + }; + use atomic_core::output::{compute_order, retrieve_graph, Writer}; + use atomic_core::pristine::InodePreloadTxn; + + // Pre-load ALL edges for this file's inode from INODE_GRAPH + // in a single range scan, then run retrieve_graph over the + // in-memory HashMap. O(M) scan + O(1) lookups vs O(V×log N) + // individual B-tree probes. + let preloaded = InodePreloadTxn::from_table(txn, item.inode, inode_graph_table) + .map_err(|e| format!("{}: preload: {:?}", item.path, e))?; + + let t_retrieve = std::time::Instant::now(); + let retrieve_result = retrieve_graph(&preloaded, item.position, retrieve_opts) + .map_err(|e| format!("{}: retrieve: {:?}", item.path, e))?; + + if retrieve_result.graph.is_empty() { + return Ok(None); + } + + let vertices = retrieve_result.graph.len_vertices(); + let edges = retrieve_result.edges_traversed; + let retrieve_ms = t_retrieve.elapsed(); + + let t_order = std::time::Instant::now(); + let mut graph = retrieve_result.graph; + let order = compute_order(&mut graph); + let order_ms = t_order.elapsed(); + + let t_content = std::time::Instant::now(); + let resolved = resolve_conflicts_semantically(&preloaded, store, &graph, &order); + let buffer = Vec::with_capacity(graph.total_bytes()); + let mut writer = Writer::new(buffer); + let hash_fn = |node_id: NodeId| -> Option { + if node_id.is_root() { + return None; + } + preloaded.get_external(node_id).ok().flatten() + }; + output_graph_content_resolved(store, hash_fn, &graph, &order, &mut writer, &resolved) + .map_err(|e| format!("{}: content: {:?}", item.path, e))?; + let content = writer.into_inner(); + let content_ms = t_content.elapsed(); + + if content.is_empty() { + return Ok(None); + } + + // Name-conflict override (rare): when ≥ 2 inodes are alive at + // this path on the view, replace the single-inode content with + // a marker-wrapped rendering of every side so the conflict is + // surfaced instead of silently collapsed (rubric A12). + let content = match name_conflicts.get(&item.path) { + Some(sides) => render_name_conflict( + txn, + store, + inode_graph_table, + change_filter_arc, + &item.path, + sides, + ) + .unwrap_or(content), + None => content, + }; + + if let Some(file_start) = trace_from { + let elapsed = file_start.elapsed(); + if elapsed > std::time::Duration::from_millis(50) { + eprintln!( + "[materialize] SLOW {} vertices={vertices} edges={edges} retrieve={retrieve_ms:?} \ + order={order_ms:?} content={content_ms:?} total={elapsed:?}", + item.path, + ); + } + } + Ok(Some(content)) +} + #[cfg(test)] mod conflict_marker_tests { use super::first_conflict_marker_line; diff --git a/atomic-repository/src/repository/mod.rs b/atomic-repository/src/repository/mod.rs index e7fceb2a..df154249 100644 --- a/atomic-repository/src/repository/mod.rs +++ b/atomic-repository/src/repository/mod.rs @@ -81,6 +81,7 @@ pub mod database; mod deferred_tree; mod filter; mod materialize; +pub use materialize::{ViewEntry, ViewEntryKind, ViewSnapshot}; mod revise; mod sandbox; mod semantic_materialize; diff --git a/atomic-repository/src/repository/sandbox.rs b/atomic-repository/src/repository/sandbox.rs index 4976768f..39602f03 100644 --- a/atomic-repository/src/repository/sandbox.rs +++ b/atomic-repository/src/repository/sandbox.rs @@ -227,19 +227,21 @@ impl Repository { pub fn materialize_view_to(&self, view: &str, dir: &Path) -> Result { std::fs::create_dir_all(dir)?; + // The view as it renders — its own added and moved files included, + // whichever view is checked out. let mut count = 0usize; - for path in self.visible_file_paths(view)? { - let bytes = match self.get_file_content_on_view(&path, view)? { - Some(bytes) => bytes, - None => continue, - }; - let target = dir.join(&path); + self.materialize_view_entries(view, |entry| -> Result<(), std::io::Error> { + if entry.kind == super::ViewEntryKind::Directory { + return Ok(()); + } + let target = dir.join(&entry.path); if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&target, &bytes)?; + std::fs::write(&target, &entry.content)?; count += 1; - } + Ok(()) + })??; Ok(count) } @@ -525,4 +527,23 @@ mod tests { "the canonical graph must not be cloned into the sandbox" ); } + + #[test] + fn a_sandbox_never_sees_its_pointer_as_untracked() { + let dir = tempdir().unwrap(); + let repo = repo_with_recorded_file(&dir.path().join("repo"), "hello.txt", b"hi\n"); + let sandbox = dir.path().join("agent-1"); + repo.provision_sandbox(&sandbox, "dev").unwrap(); + std::fs::write(sandbox.join("new.txt"), b"new\n").unwrap(); + drop(repo); + + let opened = Repository::open_existing(&sandbox).unwrap(); + let status = opened.status(Default::default()).unwrap(); + let untracked: Vec<_> = status.untracked().map(|e| e.path().to_path_buf()).collect(); + assert_eq!( + untracked, + vec![PathBuf::from("new.txt")], + "the pointer can carry a credential; `record --all` must never pick it up" + ); + } } diff --git a/atomic-repository/src/status.rs b/atomic-repository/src/status.rs index 6c470d28..0cdbf9dd 100644 --- a/atomic-repository/src/status.rs +++ b/atomic-repository/src/status.rs @@ -89,8 +89,10 @@ use crate::ignore::IgnoreRules; // Constants -/// Patterns that are always ignored (internal directories) -const ALWAYS_IGNORED: &[&str] = &[".atomic", ".git"]; +/// Patterns that are always ignored: internal directories, and a sandbox's +/// pointer and local cache — the pointer can carry a credential, and neither +/// belongs in history. +const ALWAYS_IGNORED: &[&str] = &[".atomic", ".git", ".atomic-sandbox", ".atomic-sandbox.d"]; // Error Types @@ -1255,10 +1257,15 @@ mod tests { assert!(is_always_ignored(Path::new("src/.atomic/test"))); assert!(is_always_ignored(Path::new(".git"))); assert!(is_always_ignored(Path::new(".git/objects"))); + assert!(is_always_ignored(Path::new(".atomic-sandbox"))); + assert!(is_always_ignored(Path::new( + ".atomic-sandbox.d/pristine.redb" + ))); assert!(!is_always_ignored(Path::new("src"))); assert!(!is_always_ignored(Path::new("src/main.rs"))); assert!(!is_always_ignored(Path::new("atomic"))); + assert!(!is_always_ignored(Path::new(".atomic-sandbox-notes"))); } #[test] diff --git a/atomic-repository/tests/database_lock_exclusivity_test.rs b/atomic-repository/tests/database_lock_exclusivity_test.rs new file mode 100644 index 00000000..1b24d789 --- /dev/null +++ b/atomic-repository/tests/database_lock_exclusivity_test.rs @@ -0,0 +1,96 @@ +//! Is the pristine's file lock exclusive across processes, or only within one? +//! +//! The database owner re-opens the repository on every request and retries while +//! it is busy, which works either way. Whether it may instead hold one handle for +//! its lifetime depends on the answer: a cross-process lock would stop local +//! `atomic status` / `record` from opening the repository at all while an owner +//! is running. +//! +//! One test binary, two roles. The parent holds a `Repository` open and re-runs +//! itself as the child; the child tries to open the same repository and reports +//! what happened. + +use std::process::Command; + +use atomic_repository::{Repository, RepositoryError}; + +const CHILD_ROOT: &str = "ATOMIC_DATABASE_LOCK_ROOT"; +const CHILD_REPORT: &str = "ATOMIC_DATABASE_LOCK_REPORT"; +const CHILD_TEST: &str = "a_second_process_can_open_a_repository_another_one_holds"; + +/// What the child managed to do, one line, on stdout. +fn probe(root: &std::path::Path) -> String { + // Read-only first: the gentlest thing a local command does. + let readonly = match Repository::open_readonly(root) { + Ok(_) => "readonly-ok".to_string(), + Err(RepositoryError::DatabaseBusy) => "readonly-busy".to_string(), + Err(e) => format!("readonly-other: {e}"), + }; + // Then read-write, which is what `record` needs. + let readwrite = match Repository::open_existing(root) { + Ok(_) => "readwrite-ok".to_string(), + Err(RepositoryError::DatabaseBusy) => "readwrite-busy".to_string(), + Err(e) => format!("readwrite-other: {e}"), + }; + format!("{readonly} {readwrite}") +} + +#[test] +fn a_second_process_can_open_a_repository_another_one_holds() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().join("repo"); + + if let Ok(child_root) = std::env::var(CHILD_ROOT) { + // The child: report on the repository the parent named and get out. The + // report goes to a file, not stdout — libtest's capture decides whether + // a child's `println!` reaches its parent, and this must not depend on + // whether the parent was run with `--nocapture`. + let report = probe(std::path::Path::new(&child_root)); + let path = std::env::var(CHILD_REPORT).expect("CHILD_REPORT"); + std::fs::write(path, report).expect("write the child report"); + return; + } + + // Held for the whole child run: this is the thing being tested. + let held = Repository::init(&root).expect("init"); + assert_eq!(held.current_view(), "dev", "the parent's handle is open"); + + let report_path = temp.path().join("child-report.txt"); + let output = Command::new(std::env::current_exe().expect("test binary path")) + .args(["--exact", CHILD_TEST, "--test-threads=1"]) + .env(CHILD_ROOT, &root) + .env(CHILD_REPORT, &report_path) + .output() + .expect("run the child probe"); + + let report = std::fs::read_to_string(&report_path).unwrap_or_else(|e| { + panic!( + "the child reported nothing ({e})\n--- stdout ---\n{}\n--- stderr ---\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }); + + println!("while one process holds the repository, a second sees: {report}"); + + // The answer, asserted. Exclusive across processes, and for a read-only + // open as much as a read-write one — so the database owner cannot hold the + // pristine for its lifetime without locking local `atomic status` and + // `record` out of the repository. That is why it opens per request and + // retries, and why an idle release is required of any handle it does keep. + assert_eq!( + report, "readonly-busy readwrite-busy", + "the pristine's lock is no longer exclusive across processes; the owner \ + may be able to hold it open, and its open-per-request retry is \ + redundant" + ); + + // And the corollary, which is the one that bites: dropping the parent's + // handle must let the very next open succeed, or an idle release would + // never actually free the file. + drop(held); + match Repository::open_readonly(&root) { + Ok(_) => {} + Err(e) => panic!("releasing the handle did not free the file: {e}"), + } +} diff --git a/atomic-repository/tests/materialize_view_entries_test.rs b/atomic-repository/tests/materialize_view_entries_test.rs new file mode 100644 index 00000000..92327820 --- /dev/null +++ b/atomic-repository/tests/materialize_view_entries_test.rs @@ -0,0 +1,168 @@ +//! `Repository::materialize_view_entries`: a view's tree, rendered in memory +//! for a client — the same bytes a checkout writes, per view, and nothing +//! touched on disk. + +use std::fs; +use std::path::Path; + +use atomic_core::change::{Author, ChangeHeader}; +use atomic_core::types::Hash; +use atomic_repository::{RecordOptions, Repository, SplitOptions, ViewEntry, ViewEntryKind}; +use tempfile::TempDir; + +fn write(repo_path: &Path, name: &str, content: &str) { + let path = repo_path.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); +} + +fn record(repo: &Repository, message: &str) -> Hash { + let header = ChangeHeader::builder() + .message(message) + .author(Author::new("Test", Some("test@example.com"))) + .build(); + *repo + .record(header, RecordOptions::default()) + .expect("record") + .hash() +} + +fn entries(repo: &Repository, view: &str) -> (Vec, atomic_repository::ViewSnapshot) { + let mut out = Vec::new(); + let snapshot = repo + .materialize_view_entries::<()>(view, |e| { + out.push(e); + Ok(()) + }) + .expect("materialize") + .expect("sink"); + (out, snapshot) +} + +#[test] +fn entries_are_the_view_s_recorded_tree() { + let temp = TempDir::new().unwrap(); + let root = temp.path().to_path_buf(); + let repo = Repository::init(&root).expect("init"); + let view = repo.current_view().to_string(); + + write(&root, "README.md", "hello\n"); + write(&root, "src/main.rs", "fn main() {}\n"); + repo.add("README.md", Default::default()).unwrap(); + repo.add("src/main.rs", Default::default()).unwrap(); + record(&repo, "first"); + write(&root, "README.md", "hello, world\n"); + let second = record(&repo, "second"); + // Present on disk, never recorded: not part of the view. + write(&root, "scratch.txt", "not recorded\n"); + + let before: Vec<_> = fs::read_dir(&root) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + let (list, snapshot) = entries(&repo, &view); + let after: Vec<_> = fs::read_dir(&root) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + assert_eq!( + before.len(), + after.len(), + "nothing written to the working tree" + ); + + let paths: Vec<&str> = list.iter().map(|e| e.path.as_str()).collect(); + assert_eq!( + paths, + vec!["src", "README.md", "src/main.rs"], + "directories first, then files in order" + ); + let readme = list.iter().find(|e| e.path == "README.md").unwrap(); + assert_eq!(readme.kind, ViewEntryKind::File); + assert_eq!(readme.content, b"hello, world\n"); + assert_eq!(readme.hash, Hash::of(b"hello, world\n")); + assert_eq!(readme.conflict_marker_line, None); + assert!(readme.inode > 0); + assert_eq!(snapshot.view, view); + assert_eq!(snapshot.change_count, 2); + assert!(!snapshot.state.is_empty()); + + // Another view without the second change renders its own content. + let mut repo = repo; + repo.split_view(SplitOptions::new("older", vec![second])) + .expect("split"); + let (older_on_source, older_snapshot) = entries(&repo, &view); + let readme = older_on_source + .iter() + .find(|e| e.path == "README.md") + .unwrap(); + assert_eq!( + readme.content, b"hello\n", + "the source view no longer has the second change" + ); + assert_ne!(older_snapshot.state, snapshot.state); +} + +/// Another view's tree is a projection, and a read-only repository cannot +/// project. It used to fall through to `TREE`, which is the *current* view's — +/// handing one view's tree back under another's name, which means writing +/// the wrong tree to disk. Refusing is the safe answer. +#[test] +fn a_read_only_repository_refuses_another_views_tree() { + let temp = TempDir::new().unwrap(); + let root = temp.path().to_path_buf(); + let mut repo = Repository::init(&root).expect("init"); + let current = repo.current_view().to_string(); + + write(&root, "README.md", "hello\n"); + repo.add("README.md", Default::default()).unwrap(); + record(&repo, "first"); + write(&root, "README.md", "hello, world\n"); + let second = record(&repo, "second"); + + repo.split_view(SplitOptions::new("older", vec![second])) + .expect("split"); + + // Writable: both views render, and they differ. `split_view` moves the + // change out of the source view, so the new view is the one with it. + let (on_current, _) = entries(&repo, ¤t); + let (on_older, _) = entries(&repo, "older"); + let body = |v: &Vec| { + v.iter() + .find(|e| e.path == "README.md") + .map(|e| e.content.clone()) + .unwrap() + }; + assert_eq!(body(&on_current), b"hello\n"); + assert_eq!(body(&on_older), b"hello, world\n"); + + // Read-only: the current view still works (it needs no projection), but + // another view is refused rather than answered with the current tree. + drop(repo); + let readonly = Repository::open_readonly(&root).expect("open read-only"); + + let mut on_current = Vec::new(); + readonly + .materialize_view_entries::<()>(¤t, |e| { + on_current.push(e); + Ok(()) + }) + .expect("materialize the current view") + .expect("sink"); + assert_eq!(body(&on_current), b"hello\n"); + + let mut on_older = Vec::new(); + let refused = readonly.materialize_view_entries::<()>("older", |e| { + on_older.push(e); + Ok(()) + }); + let err = refused.expect_err("another view must be refused"); + assert!( + format!("{err}").contains("writable"), + "unexpected error: {err}" + ); + assert!( + on_older.is_empty(), + "nothing of the wrong view was rendered" + ); +} diff --git a/atomic-wasm/.gitignore b/atomic-wasm/.gitignore new file mode 100644 index 00000000..01d0a084 --- /dev/null +++ b/atomic-wasm/.gitignore @@ -0,0 +1 @@ +pkg/ diff --git a/atomic-wasm/Cargo.toml b/atomic-wasm/Cargo.toml new file mode 100644 index 00000000..35a2f6a6 --- /dev/null +++ b/atomic-wasm/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "atomic-wasm" +description = "atomic-canonical for the browser: build and check atomic attestations where the signing key lives in WebCrypto" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +atomic-canonical = { workspace = true } +atomic-identity = { workspace = true } +serde_json = { workspace = true } +wasm-bindgen = "0.2" + +# The browser supplies randomness; nothing here draws any, but the +# dependency tree links getrandom, which refuses wasm32 without this. +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } diff --git a/atomic-wasm/README.md b/atomic-wasm/README.md new file mode 100644 index 00000000..9ec280c2 --- /dev/null +++ b/atomic-wasm/README.md @@ -0,0 +1,20 @@ +# atomic-wasm + +`atomic-canonical` for the browser. A page that holds an atomic identity's +Ed25519 key in WebCrypto signs atomic documents without the key ever leaving +the browser: this module builds the canonical document and the bytes to sign +(with the same code the CLI uses), WebCrypto signs them, and the result is an +ordinary `eddsa-jcs-2022` attestation that `atomic` verifies. + +```js +import init, { prepareAttestation, attachProof } from "./pkg/atomic_wasm.js"; +await init(); +const prepared = prepareAttestation(JSON.stringify(doc), publicKeyBytes); +const sig = await crypto.subtle.sign("Ed25519", key, prepared.signingBytes); +const attested = JSON.parse(attachProof(prepared.document, publicKeyBytes, new Uint8Array(sig))); +``` + +Also: `verifyAttestation`, `didForPublicKey`, `publicKeyBase32`, `canonicalize`. + +Build with `./build.sh [out-dir]`; `node smoke.mjs` (Node ≥ 22, which has +WebCrypto Ed25519) signs, verifies and tamper-checks a document end to end. diff --git a/atomic-wasm/build.sh b/atomic-wasm/build.sh new file mode 100755 index 00000000..2cbb34b8 --- /dev/null +++ b/atomic-wasm/build.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Build the browser module: pkg/atomic_wasm{.js,_bg.wasm}. +# Needs the wasm32 target and a wasm-bindgen CLI matching the locked +# wasm-bindgen crate: +# rustup target add wasm32-unknown-unknown +# cargo install wasm-bindgen-cli --version +set -eu +here=$(cd "$(dirname "$0")" && pwd) +cargo build -p atomic-wasm --target wasm32-unknown-unknown --release +wasm-bindgen --target web --out-dir "${1:-$here/pkg}" \ + "$here/../target/wasm32-unknown-unknown/release/atomic_wasm.wasm" diff --git a/atomic-wasm/smoke.mjs b/atomic-wasm/smoke.mjs new file mode 100644 index 00000000..6fba36a7 --- /dev/null +++ b/atomic-wasm/smoke.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import init, { prepareAttestation, attachProof, verifyAttestation, didForPublicKey } from "./pkg/atomic_wasm.js"; +await init({ module_or_path: readFileSync(new URL("./pkg/atomic_wasm_bg.wasm", import.meta.url)) }); +const kp = await crypto.subtle.generateKey("Ed25519", false, ["sign", "verify"]); +const pub = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey)); +const doc = { "@type": "ExampleLogin", nonce: "abc", "é": [1, 2.5, "x"] }; +const p = prepareAttestation(JSON.stringify(doc), pub); +const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", kp.privateKey, p.signingBytes)); +const attested = attachProof(p.document, pub, sig); +verifyAttestation(attested, pub); +console.log(didForPublicKey(pub)); +console.log(attested); +try { verifyAttestation(attested.replace('"abc"', '"abd"'), pub); console.log("TAMPER NOT DETECTED"); } catch (e) { console.log("tamper rejected:", e.message); } diff --git a/atomic-wasm/src/lib.rs b/atomic-wasm/src/lib.rs new file mode 100644 index 00000000..e4c2d55d --- /dev/null +++ b/atomic-wasm/src/lib.rs @@ -0,0 +1,107 @@ +//! atomic-canonical for the browser. +//! +//! A web page that holds an atomic identity's key in WebCrypto (as a +//! non-extractable Ed25519 key) signs atomic documents without the key ever +//! reaching Rust — or leaving the browser. This crate does everything *but* +//! the signing, with the same code the CLI uses, so the result is an ordinary +//! atomic attestation (`eddsa-jcs-2022`) that `atomic` verifies: +//! +//! ```js +//! const prepared = prepareAttestation(JSON.stringify(doc), publicKeyBytes); +//! const sig = await crypto.subtle.sign("Ed25519", key, prepared.signingBytes); +//! const attested = attachProof(prepared.document, publicKeyBytes, new Uint8Array(sig)); +//! ``` +//! +//! Every function takes and returns JSON as strings and keys/signatures as +//! bytes, so there is no JS object model to keep in step with the Rust types. + +use atomic_canonical::{did, jcs, proof}; +use atomic_identity::keypair::PublicKey; +use atomic_identity::signing::Signature; +use wasm_bindgen::prelude::*; + +fn public_key(bytes: &[u8]) -> Result { + let bytes: &[u8; 32] = bytes + .try_into() + .map_err(|_| JsError::new("an Ed25519 public key is 32 bytes"))?; + PublicKey::from_bytes(bytes).map_err(|e| JsError::new(&e.to_string())) +} + +fn parse(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| JsError::new(&format!("not JSON: {e}"))) +} + +/// A document ready to sign: the document to hand back to [`attach_proof`], +/// and the bytes the signature must cover. +#[wasm_bindgen] +pub struct Prepared { + document: String, + signing_bytes: Vec, +} + +#[wasm_bindgen] +impl Prepared { + /// The document with `attributedTo` and `contentHash` filled in (JSON). + #[wasm_bindgen(getter)] + pub fn document(&self) -> String { + self.document.clone() + } + + /// What to sign with the identity's Ed25519 key. + #[wasm_bindgen(getter, js_name = signingBytes)] + pub fn signing_bytes(&self) -> Vec { + self.signing_bytes.clone() + } +} + +/// Fill in the author and content hash of `document` (JSON) for the key +/// `public_key`, and return it with the bytes to sign. +#[wasm_bindgen(js_name = prepareAttestation)] +pub fn prepare_attestation(document: &str, public_key_bytes: &[u8]) -> Result { + let pk = public_key(public_key_bytes)?; + let prepared = proof::prepare_attestation(parse(document)?, &pk); + Ok(Prepared { + document: prepared.value.to_string(), + signing_bytes: prepared.signing_bytes, + }) +} + +/// Attach the proof for `signature` (64 bytes, over the prepared signing +/// bytes) and check it verifies. Returns the attested document (JSON). +#[wasm_bindgen(js_name = attachProof)] +pub fn attach_proof( + document: &str, + public_key_bytes: &[u8], + signature: &[u8], +) -> Result { + let pk = public_key(public_key_bytes)?; + let sig = Signature::from_slice(signature).map_err(|e| JsError::new(&e.to_string()))?; + let attested = proof::attach_proof(parse(document)?, &pk, &sig); + proof::verify_value(&attested, &pk).map_err(|e| JsError::new(&e.to_string()))?; + Ok(attested.to_string()) +} + +/// Check an attested document (JSON) against a public key. +#[wasm_bindgen(js_name = verifyAttestation)] +pub fn verify_attestation(document: &str, public_key_bytes: &[u8]) -> Result<(), JsError> { + let pk = public_key(public_key_bytes)?; + proof::verify_value(&parse(document)?, &pk).map_err(|e| JsError::new(&e.to_string())) +} + +/// The `did:atomic` identifier for a public key. +#[wasm_bindgen(js_name = didForPublicKey)] +pub fn did_for_public_key(public_key_bytes: &[u8]) -> Result { + Ok(did::did_for_public_key(&public_key(public_key_bytes)?)) +} + +/// The key's base32 form — what atomic writes as the `kid` of its tokens. +#[wasm_bindgen(js_name = publicKeyBase32)] +pub fn public_key_base32(public_key_bytes: &[u8]) -> Result { + Ok(public_key(public_key_bytes)?.to_base32()) +} + +/// RFC 8785 canonical JSON of `document` — the bytes atomic hashes and signs. +#[wasm_bindgen] +pub fn canonicalize(document: &str) -> Result { + Ok(jcs::canonicalize(&parse(document)?)) +} diff --git a/libatomic/src/daemon/services_agent.rs b/libatomic/src/daemon/services_agent.rs index ffb35d3a..5e8fbef8 100644 --- a/libatomic/src/daemon/services_agent.rs +++ b/libatomic/src/daemon/services_agent.rs @@ -16,12 +16,14 @@ use atomic_canonical::did::did_for_public_key; use atomic_canonical::gate::{validate_intent, validate_memory}; use atomic_canonical::lift::lift_intent; use atomic_canonical::memory::lift_memory; +use atomic_canonical::proof; use atomic_canonical::{ lift_and_attest, lift_and_attest_memory, verify, verify_memory, CanonicalNode, MemoryNode, }; use atomic_core::change::attestation::Attestation; use atomic_core::pristine::VaultEntryType; use atomic_core::types::Base32; +use atomic_identity::signing::Signature; use atomic_identity::{Identity, IdentityStore}; use atomic_repository::IntentCreateOptions; use atomic_repository::IntentUpdateOptions; @@ -2531,6 +2533,45 @@ fn load_identity(name: Option<&str>) -> Result { Ok(IdentityMaterial { identity, keypair }) } +/// Load an identity for VERIFICATION only — the public key, never a secret +/// key. The external-signing flow (`--prepare`/`--signed`) must work for an +/// identity whose key is held elsewhere, so it may not touch the key store. +/// The name is required: an unbound caller cannot borrow the daemon's +/// default human key by signing as nobody (CONTRACT "Sandbox and vault +/// writes": unbound grants cannot use daemon default human keys). +fn load_identity_public(name: &str) -> Result { + let store = IdentityStore::open_default() + .map_err(|error| domain_status(ErrorCode::Internal, format!("identity store: {error}")))?; + if name.is_empty() { + return Err(domain_status( + ErrorCode::InvalidArgument, + "an externally-signed attestation names the identity it is signed by", + )); + } + store + .load_by_name(name) + .map_err(|error| domain_status(ErrorCode::NotFound, format!("identity '{name}': {error}"))) +} + +/// The pre-attest gate the CLI's `attest` body applies: refuse violations +/// signing cannot fill (everything except `proof` and `attributedTo`), so a +/// signer never signs a node recording would refuse anyway. +fn refuse_unfillable(node: &CanonicalNode) -> Result<(), Status> { + let report = validate_intent(node); + let blocking: Vec<_> = report + .results + .iter() + .filter(|v| !matches!(v.path.as_deref(), Some("proof") | Some("attributedTo"))) + .collect(); + if !blocking.is_empty() { + return Err(domain_status( + ErrorCode::PreconditionFailed, + format!("the intent does not conform: {report}"), + )); + } + Ok(()) +} + /// blake3 source hash of (frontmatter + body) — the attestation freshness /// anchor, mirroring the CLI bridges. fn source_content_hash(frontmatter: &Map, body: &str) -> String { @@ -2555,11 +2596,61 @@ fn write_all(path: &std::path::Path, bytes: &[u8]) -> Result<(), Status> { impl attestation_service_server::AttestationService for AttestationImpl { async fn prepare_attestation( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Err(Status::unimplemented( - "PrepareAttestation (external signing) lands with its slice", - )) + let request = request.into_inner(); + let target = request.target.clone().ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "attestation target required") + })?; + let target_kind = target.kind.ok_or_else(|| { + domain_status( + ErrorCode::InvalidArgument, + "attestation target kind required", + ) + })?; + let handle = + self.state + .resolve(request.repository.as_ref().ok_or_else(|| { + domain_status(ErrorCode::InvalidArgument, "repository required") + })?)?; + self.state.log_rpc("PrepareAttestation", Some(&handle)); + let gate_handle = handle.clone(); + let _gate = gate_handle.exclusive().await; + let identity_name = request.identity_did.clone(); + + let response = tokio::task::spawn_blocking(move || match target_kind { + TargetKind::IntentId(intent_id) => { + let repo = handle.repository()?; + let entry = repo + .vault_intent_show(&intent_id) + .map_err(repository_error)?; + let frontmatter = parse_frontmatter(&entry)?; + let body = body_of(&entry); + // No key here: everything that must agree with verification — + // the author, the content hash, the canonical bytes — is + // computed now, so the external signer only ever signs bytes. + let identity = load_identity_public(&identity_name)?; + let node = lift_intent(&frontmatter, &body).map_err(|error| { + domain_status(ErrorCode::InvalidArgument, format!("attest: {error}")) + })?; + refuse_unfillable(&node)?; + let prepared = proof::prepare_attestation(node.to_value(), &identity.public_key); + let document = serde_json::to_string(&prepared.value) + .map_err(|e| Status::internal(e.to_string()))?; + Ok::<_, Status>(PrepareAttestationResponse { + document, + signing_bytes: prepared.signing_bytes, + snapshot: None, + }) + } + other => Err(domain_status( + ErrorCode::InvalidArgument, + format!("attestation target {other:?} lands with its slice"), + )), + }) + .await + .map_err(|e| Status::internal(e.to_string()))??; + Ok(Response::new(response)) } async fn record_attestation( @@ -2582,6 +2673,7 @@ impl attestation_service_server::AttestationService for AttestationImpl { let _gate = gate_handle.exclusive().await; let identity_name = request.identity_did.clone(); let meta = request.meta.clone(); + let caller_signature = request.signature.clone(); let response = tokio::task::spawn_blocking(move || match target_kind { TargetKind::IntentId(intent_id) => { @@ -2591,16 +2683,62 @@ impl attestation_service_server::AttestationService for AttestationImpl { .map_err(repository_error)?; let frontmatter = parse_frontmatter(&entry)?; let body = body_of(&entry); - let material = load_identity(if identity_name.is_empty() { - None + let (node, public_key): (CanonicalNode, _) = if caller_signature.is_empty() { + // Sign here, with this machine's key — the plain `attest`. + let material = load_identity(if identity_name.is_empty() { + None + } else { + Some(&identity_name) + })?; + ( + lift_and_attest(&frontmatter, &body, &material.identity, &material.keypair) + .map_err(|error| { + domain_status( + ErrorCode::InvalidArgument, + format!("attest: {error}"), + ) + })?, + material.identity.public_key.clone(), + ) } else { - Some(&identity_name) - })?; - let node: CanonicalNode = - lift_and_attest(&frontmatter, &body, &material.identity, &material.keypair) - .map_err(|error| { - domain_status(ErrorCode::InvalidArgument, format!("attest: {error}")) + // The key was held elsewhere: verify the caller's + // signature against the target AS IT IS NOW (a stale or + // altered intent hashes differently, so an old signature + // cannot land), and attach exactly the proof it earned. + // No secret key is loaded. + let identity = load_identity_public(&identity_name)?; + let unattested = lift_intent(&frontmatter, &body).map_err(|error| { + domain_status(ErrorCode::InvalidArgument, format!("attest: {error}")) + })?; + refuse_unfillable(&unattested)?; + let prepared = + proof::prepare_attestation(unattested.to_value(), &identity.public_key); + let signature = Signature::from_slice(&caller_signature).map_err(|error| { + domain_status(ErrorCode::InvalidArgument, format!("signature: {error}")) + })?; + signature + .verify(&prepared.signing_bytes, &identity.public_key) + .map_err(|_| { + domain_status( + ErrorCode::PreconditionFailed, + format!( + "the signature does not verify against intent {intent_id} \ + as it is now (re-run --prepare)", + ), + ) })?; + ( + serde_json::from_value(proof::attach_proof( + prepared.value, + &identity.public_key, + &signature, + )) + .map_err(|e| { + domain_status(ErrorCode::Internal, format!("attested intent: {e}")) + })?, + identity.public_key.clone(), + ) + }; let report = validate_intent(&node); if !report.conforms { return Err(domain_status( @@ -2608,7 +2746,7 @@ impl attestation_service_server::AttestationService for AttestationImpl { format!("attested intent does not conform: {}", report), )); } - verify(&node, &material.keypair.public).map_err(|error| { + verify(&node, &public_key).map_err(|error| { domain_status(ErrorCode::Internal, format!("self-check failed: {error}")) })?; let normalized = normalized_id(&repo, &intent_id);