From 2d87d60bb5a108376d2ea9a3109bb423d822ada5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Fri, 13 Mar 2026 00:18:14 +0000 Subject: [PATCH] chore: add src code --- Cargo.lock | 6592 +++++++++++++++++ Cargo.toml | 31 + crates/ontoref-daemon/Cargo.toml | 52 + crates/ontoref-daemon/src/actors.rs | 492 ++ crates/ontoref-daemon/src/api.rs | 940 +++ crates/ontoref-daemon/src/cache.rs | 302 + crates/ontoref-daemon/src/error.rs | 25 + crates/ontoref-daemon/src/lib.rs | 19 + crates/ontoref-daemon/src/main.rs | 924 +++ crates/ontoref-daemon/src/mcp/mod.rs | 1772 +++++ crates/ontoref-daemon/src/nats.rs | 433 ++ crates/ontoref-daemon/src/notifications.rs | 470 ++ crates/ontoref-daemon/src/registry.rs | 288 + crates/ontoref-daemon/src/search.rs | 479 ++ crates/ontoref-daemon/src/seed.rs | 131 + crates/ontoref-daemon/src/session.rs | 81 + crates/ontoref-daemon/src/ui/auth.rs | 122 + crates/ontoref-daemon/src/ui/backlog_ncl.rs | 189 + crates/ontoref-daemon/src/ui/drift_watcher.rs | 219 + crates/ontoref-daemon/src/ui/handlers.rs | 2467 ++++++ crates/ontoref-daemon/src/ui/login.rs | 98 + crates/ontoref-daemon/src/ui/mod.rs | 96 + crates/ontoref-daemon/src/ui/qa_ncl.rs | 288 + crates/ontoref-daemon/src/ui/watcher.rs | 83 + crates/ontoref-daemon/src/watcher.rs | 271 + crates/ontoref-daemon/templates/base.html | 510 ++ .../ontoref-daemon/templates/macros/ui.html | 52 + .../templates/pages/actions.html | 90 + .../templates/pages/backlog.html | 332 + .../templates/pages/compose.html | 407 + .../templates/pages/dashboard.html | 91 + .../ontoref-daemon/templates/pages/graph.html | 458 ++ .../ontoref-daemon/templates/pages/login.html | 34 + .../templates/pages/manage.html | 95 + .../ontoref-daemon/templates/pages/modes.html | 132 + .../templates/pages/notifications.html | 185 + .../templates/pages/project_picker.html | 294 + crates/ontoref-daemon/templates/pages/qa.html | 389 + .../templates/pages/search.html | 423 ++ .../templates/pages/sessions.html | 63 + crates/ontoref-ontology/Cargo.toml | 16 + crates/ontoref-ontology/src/error.rs | 17 + crates/ontoref-ontology/src/lib.rs | 11 + crates/ontoref-ontology/src/ontology.rs | 503 ++ crates/ontoref-ontology/src/types.rs | 226 + crates/ontoref-reflection/Cargo.toml | 32 + crates/ontoref-reflection/src/dag.rs | 200 + crates/ontoref-reflection/src/error.rs | 25 + crates/ontoref-reflection/src/executor.rs | 441 ++ crates/ontoref-reflection/src/lib.rs | 10 + crates/ontoref-reflection/src/mode.rs | 167 + 51 files changed, 22067 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/ontoref-daemon/Cargo.toml create mode 100644 crates/ontoref-daemon/src/actors.rs create mode 100644 crates/ontoref-daemon/src/api.rs create mode 100644 crates/ontoref-daemon/src/cache.rs create mode 100644 crates/ontoref-daemon/src/error.rs create mode 100644 crates/ontoref-daemon/src/lib.rs create mode 100644 crates/ontoref-daemon/src/main.rs create mode 100644 crates/ontoref-daemon/src/mcp/mod.rs create mode 100644 crates/ontoref-daemon/src/nats.rs create mode 100644 crates/ontoref-daemon/src/notifications.rs create mode 100644 crates/ontoref-daemon/src/registry.rs create mode 100644 crates/ontoref-daemon/src/search.rs create mode 100644 crates/ontoref-daemon/src/seed.rs create mode 100644 crates/ontoref-daemon/src/session.rs create mode 100644 crates/ontoref-daemon/src/ui/auth.rs create mode 100644 crates/ontoref-daemon/src/ui/backlog_ncl.rs create mode 100644 crates/ontoref-daemon/src/ui/drift_watcher.rs create mode 100644 crates/ontoref-daemon/src/ui/handlers.rs create mode 100644 crates/ontoref-daemon/src/ui/login.rs create mode 100644 crates/ontoref-daemon/src/ui/mod.rs create mode 100644 crates/ontoref-daemon/src/ui/qa_ncl.rs create mode 100644 crates/ontoref-daemon/src/ui/watcher.rs create mode 100644 crates/ontoref-daemon/src/watcher.rs create mode 100644 crates/ontoref-daemon/templates/base.html create mode 100644 crates/ontoref-daemon/templates/macros/ui.html create mode 100644 crates/ontoref-daemon/templates/pages/actions.html create mode 100644 crates/ontoref-daemon/templates/pages/backlog.html create mode 100644 crates/ontoref-daemon/templates/pages/compose.html create mode 100644 crates/ontoref-daemon/templates/pages/dashboard.html create mode 100644 crates/ontoref-daemon/templates/pages/graph.html create mode 100644 crates/ontoref-daemon/templates/pages/login.html create mode 100644 crates/ontoref-daemon/templates/pages/manage.html create mode 100644 crates/ontoref-daemon/templates/pages/modes.html create mode 100644 crates/ontoref-daemon/templates/pages/notifications.html create mode 100644 crates/ontoref-daemon/templates/pages/project_picker.html create mode 100644 crates/ontoref-daemon/templates/pages/qa.html create mode 100644 crates/ontoref-daemon/templates/pages/search.html create mode 100644 crates/ontoref-daemon/templates/pages/sessions.html create mode 100644 crates/ontoref-ontology/Cargo.toml create mode 100644 crates/ontoref-ontology/src/error.rs create mode 100644 crates/ontoref-ontology/src/lib.rs create mode 100644 crates/ontoref-ontology/src/ontology.rs create mode 100644 crates/ontoref-ontology/src/types.rs create mode 100644 crates/ontoref-reflection/Cargo.toml create mode 100644 crates/ontoref-reflection/src/dag.rs create mode 100644 crates/ontoref-reflection/src/error.rs create mode 100644 crates/ontoref-reflection/src/executor.rs create mode 100644 crates/ontoref-reflection/src/lib.rs create mode 100644 crates/ontoref-reflection/src/mode.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f83d56a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6592 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93b8a41dbe230ad5087cc721f8d41611de654542180586b315d9f4cf6b72bef" +dependencies = [ + "psl-types", +] + +[[package]] +name = "affinitypool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a58b64a64aecad4ba7f2ccf0f79115f5d2d184b1e55307f78c20be07adc6633" +dependencies = [ + "crossbeam", + "libc", + "num_cpus", + "parking_lot", + "thiserror 2.0.18", + "tokio", + "winapi", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.7", + "stable_deref_trait", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-nats" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df5af9ebfb0a14481d3eaf6101e6391261e4f30d25b26a7635ade8a39482ded0" +dependencies = [ + "base64", + "bytes", + "futures-util", + "memchr", + "nkeys", + "nuid", + "once_cell", + "pin-project", + "portable-atomic", + "rand 0.8.5", + "regex", + "ring", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[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.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +dependencies = [ + "arc-swap", + "bytes", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.3.4", + "subtle", + "zeroize", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.2.17", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bnum" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f781dba93de3a5ef6dc5b17c9958b208f6f3f021623b360fb605ea51ce443f10" + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.3", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.3", + "phf_codegen", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dmp" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2dfc7a18dffd3ef60a442b72a827126f1557d914620f8fc4d1049916da43c1" +dependencies = [ + "trice", + "urlencoding", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools 0.11.0", + "num-traits", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array 0.14.7", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "ext-sort" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5d3b056bcc471d38082b8c453acb6670f7327fd44219b3c411e40834883569" +dependencies = [ + "log", + "rayon", + "rmp-serde", + "serde", + "tempfile", +] + +[[package]] +name = "fastnum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4089ab2dfd45d8ddc92febb5ca80644389d5ebb954f40231274a3f18341762e2" +dependencies = [ + "bnum", + "num-integer", + "num-traits", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.11.0", + "rustc_version", + "serde", +] + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "fst" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "geo" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc1a1678e54befc9b4bcab6cd43b8e7f834ae8ea121118b0fd8c42747675b4a" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "robust", + "rstar 0.12.2", + "serde", + "spade", +] + +[[package]] +name = "geo" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3901269ec6d4f6068d3f09e5f02f995bd076398dcd1dfec407cd230b02d11b" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "rand 0.8.5", + "robust", + "rstar 0.12.2", + "serde", + "sif-itree", + "spade", +] + +[[package]] +name = "geo-types" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +dependencies = [ + "approx", + "num-traits", + "rayon", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.11.0", + "ignore", + "walkdir", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice", + "generic-array 0.14.7", + "hash32 0.1.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +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 = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[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-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "i_float" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010025c2c532c8d82e42d0b8bb5184afa449fa6f06c709ea9adcb16c49ae405b" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9190f86706ca38ac8add223b2aed8b1330002b5cdbbce28fb58b10914d38fc27" + +[[package]] +name = "i_overlay" +version = "4.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413183068e6e0289e18d7d0a1f661b81546e6918d5453a44570b9ab30cbed1b3" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", + "rayon", +] + +[[package]] +name = "i_shape" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea154b742f7d43dae2897fcd5ead86bc7b5eefcedd305a7ebf9f69d44d61082" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e6d558e6d4c7b82bc51d9c771e7a927862a161a7d87bf2b0541450e0e20915" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexicmp" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8f89da8fd95c4eb6274e914694bea90c7826523b26f2a2fd863d44b9d42c43" +dependencies = [ + "deunicode", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndarray-stats" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6e54a8b65764f71827a90ca1d56965ec0c67f069f996477bd493402a901d1f" +dependencies = [ + "indexmap", + "itertools 0.13.0", + "ndarray", + "noisy_float", + "num-integer", + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.5", + "signatory", +] + +[[package]] +name = "noisy_float" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16843be85dd410c6a12251c4eca0dd1d3ee8c5725f746c4d5e0fdcec0a864b2" +dependencies = [ + "num-traits", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "object_store" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2858065e55c148d294a9f3aae3b0fa9458edadb41a108397094566f4e3c0dfb" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http", + "humantime", + "itertools 0.14.0", + "parking_lot", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ontoref-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "axum-server", + "bytes", + "clap", + "dashmap", + "hostname", + "libc", + "notify", + "platform-nats", + "reqwest", + "rmcp", + "schemars", + "serde", + "serde_json", + "stratum-db", + "tempfile", + "tera", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "toml", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "ontoref-ontology" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "ontoref-reflection" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "platform-nats", + "regex", + "serde", + "serde_json", + "stratum-graph", + "stratum-state", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "tracing", + "uuid", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "papaya" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", + "unicase", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", + "unicase", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "platform-nats" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-nats", + "bytes", + "futures", + "nkeys", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[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.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.4+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick_cache" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "530e84778a55de0f52645a51d4e3b9554978acd6a1e7cd50b6a6784692b3029e" +dependencies = [ + "ahash 0.8.12", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", + "serde", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reblessive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc4a4ea2a66a41a1152c4b3d86e8954dc087bdf33af35446e6e176db4e73c8c" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "revision" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c3c8ec8b2be254beb5f8acdd80cdd57b7b5d40988c2ec3d0b7cdb6f7c2829b" +dependencies = [ + "bytes", + "chrono", + "geo 0.31.0", + "regex", + "revision-derive", + "roaring", + "rust_decimal", + "uuid", +] + +[[package]] +name = "revision-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76be63634a8b1809e663bc0b975d78f6883c0fadbcce9c52e19b9e421f423357" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rmcp" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.0", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "roaring" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba9ce64a8f45d7fc86358410bb1a82e8c987504c0d4900e9141d69a9f26c885" +dependencies = [ + "bytemuck", + "byteorder", + "serde", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs 0.8.3", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array 0.14.7", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sif-itree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spade" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" +dependencies = [ + "hashbrown 0.15.5", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "storekey" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9a94571bde7369ecaac47cec2e6844642d99166bd452fbd8def74b5b917b2f" +dependencies = [ + "bytes", + "storekey-derive", + "uuid", +] + +[[package]] +name = "storekey-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6079d53242246522ec982de613c5c952cc7b1380ef2f8622fcdab9bfe73c0098" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "stratum-db" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "surrealdb", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "stratum-graph" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "stratum-state" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde", + "serde_json", + "stratum-graph", + "surrealdb", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "surrealdb" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8062e4e1760499593dce33e780976f70a7a1554ac317fdc0b295d7aeb6c4712" +dependencies = [ + "anyhow", + "async-channel", + "boxcar", + "chrono", + "futures", + "getrandom 0.3.4", + "indexmap", + "js-sys", + "path-clean", + "reqwest", + "ring", + "rustls", + "rustls-pki-types", + "semver", + "serde", + "serde_json", + "surrealdb-core", + "surrealdb-types", + "surrealdb-types-derive", + "tokio", + "tokio-tungstenite", + "tokio-tungstenite-wasm", + "tokio-util", + "tracing", + "url", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasmtimer", + "web-sys", +] + +[[package]] +name = "surrealdb-core" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4ce9f65f242afc7e8535f603b1b62ef0a08ec982aac362ac24a01cd1bab7b5" +dependencies = [ + "addr", + "affinitypool", + "ahash 0.8.12", + "ammonia", + "anyhow", + "argon2", + "async-channel", + "async-stream", + "async-trait", + "base64", + "bcrypt", + "blake3", + "bytes", + "chrono", + "ciborium", + "dashmap", + "deunicode", + "dmp", + "ext-sort", + "fastnum", + "fst", + "futures", + "fuzzy-matcher", + "geo 0.32.0", + "geo-types", + "getrandom 0.3.4", + "headers", + "hex", + "http", + "humantime", + "ipnet", + "jsonwebtoken", + "lexicmp", + "md-5", + "mime", + "ndarray", + "ndarray-stats", + "num-traits", + "num_cpus", + "object_store", + "parking_lot", + "path-clean", + "pbkdf2", + "phf 0.13.1", + "pin-project-lite", + "quick_cache", + "radix_trie", + "rand 0.8.5", + "rayon", + "reblessive", + "regex", + "revision", + "ring", + "roaring", + "rust-stemmers", + "rust_decimal", + "scrypt", + "semver", + "serde", + "serde_json", + "sha1", + "sha2", + "storekey", + "strsim", + "subtle", + "surrealdb-protocol", + "surrealdb-types", + "surrealmx", + "sysinfo", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "ulid", + "unicase", + "url", + "uuid", + "vart", + "wasm-bindgen-futures", + "wasmtimer", + "web-time", +] + +[[package]] +name = "surrealdb-protocol" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb37698e0493bcfac3229ecb6ec6894a3ad705a3a2087b1562eeb881b3db19d4" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "flatbuffers", + "futures", + "geo 0.32.0", + "prost", + "prost-types", + "rust_decimal", + "semver", + "serde", + "serde_json", + "tonic", + "tonic-prost", + "uuid", +] + +[[package]] +name = "surrealdb-types" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221012e96d93ccbe79f9b1de2b4df579e93ad7fcde7628063a6971de2d9fa1f3" +dependencies = [ + "anyhow", + "bytes", + "chrono", + "flatbuffers", + "geo 0.32.0", + "hex", + "http", + "papaya", + "rand 0.8.5", + "regex", + "rstest", + "rust_decimal", + "serde", + "serde_json", + "surrealdb-protocol", + "surrealdb-types-derive", + "ulid", + "uuid", +] + +[[package]] +name = "surrealdb-types-derive" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521afc1b691597be4ce36fe8b60c4432894760572902b5c06e7c973aead6d6af" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "surrealmx" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6508449a7d1379a92a51ba49391b48ccab0b60dd11a4277c0dda965d8c99dbff" +dependencies = [ + "arc-swap", + "bincode", + "bytes", + "crossbeam-deque", + "crossbeam-queue", + "crossbeam-skiplist", + "lz4", + "papaya", + "parking_lot", + "serde", + "smallvec", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-tungstenite-wasm" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee909c02b8863f9bda87253127eb4da0e7e1342330b2583fbc4d1795c2f8" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "httparse", + "js-sys", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.8.5", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +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", +] + +[[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.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trice" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3aaab10ae9fac0b10f392752bf56f0fd20845f39037fec931e8537b105b515a" +dependencies = [ + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "serde", + "web-time", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vart" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1982d899e57d646498709735f16e9224cf1e8680676ad687f930cf8b5b555ae" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5a98697 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +members = ["crates/*"] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT OR Apache-2.0" + +[workspace.dependencies] +tokio = { version = "1.50", features = ["full"] } +async-trait = "0.1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +regex = "1" +bytes = "1" +tempfile = "3" +tokio-test = "0.4" +axum = { version = "0.8", features = ["json"] } +tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } +notify = { version = "8.2", default-features = false, features = ["macos_fsevent"] } +dashmap = "6.1" +clap = { version = "4", features = ["derive"] } +hostname = "0.4" +libc = "0.2" +reqwest = { version = "0.13", features = ["json"] } diff --git a/crates/ontoref-daemon/Cargo.toml b/crates/ontoref-daemon/Cargo.toml new file mode 100644 index 0000000..025276c --- /dev/null +++ b/crates/ontoref-daemon/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "ontoref-daemon" +version = "0.1.0" +edition.workspace = true +description = "Ontoref runtime daemon: NCL export cache, file watcher, actor registry, HTTP API" +license.workspace = true + +[[bin]] +name = "ontoref-daemon" +path = "src/main.rs" + +[dependencies] +stratum-db = { path = "../../../stratumiops/crates/stratum-db", default-features = false, optional = true } +platform-nats = { path = "../../../stratumiops/crates/platform-nats", optional = true } + +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +notify = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +tower-http = { workspace = true } +tera = { version = "1", default-features = false, features = ["builtins"], optional = true } +argon2 = { version = "0.5", features = ["std"], optional = true } +toml = { version = "0.8", optional = true } +uuid = { workspace = true, optional = true } +axum-server = { version = "0.7", features = ["tls-rustls"], optional = true } +rmcp = { version = "1", features = ["server", "transport-io", "transport-streamable-http-server"], optional = true } +schemars = { version = "1", optional = true } +thiserror = { workspace = true } +dashmap = { workspace = true } +clap = { workspace = true } +anyhow = { workspace = true } +bytes = { workspace = true } +hostname = { workspace = true } +reqwest = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + +[features] +default = ["db", "nats", "ui", "mcp"] +db = ["stratum-db/remote"] +nats = ["dep:platform-nats"] +ui = ["dep:tera", "dep:argon2", "dep:toml", "dep:uuid"] +tls = ["ui", "dep:axum-server"] +mcp = ["ui", "dep:rmcp", "dep:schemars"] + +[dev-dependencies] +tokio-test = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/ontoref-daemon/src/actors.rs b/crates/ontoref-daemon/src/actors.rs new file mode 100644 index 0000000..d531e55 --- /dev/null +++ b/crates/ontoref-daemon/src/actors.rs @@ -0,0 +1,492 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use tracing::{debug, info, warn}; + +/// Deterministic actor token: `{actor_type}:{hostname}:{pid}`. +/// No UUIDs — the tuple is unique per machine and disambiguated by +/// `registered_at` against PID recycling. +pub fn make_token(actor_type: &str, hostname: &str, pid: u32) -> String { + format!("{actor_type}:{hostname}:{pid}") +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ActorSession { + pub actor_type: String, + pub hostname: String, + pub pid: u32, + pub project: String, + /// Role identifier — validated against `.ontoref/roles.ncl` if present; + /// defaults to "developer". + pub role: String, + pub registered_at: u64, + pub last_seen: u64, + pub pending_notifications: AtomicU64, + /// UI and behavioral preferences (theme, nav_mode, etc.). + pub preferences: serde_json::Value, +} + +/// Serialisable snapshot of an `ActorSession` for disk persistence. +/// Written to `{persist_dir}/{token_safe}.json` on profile update. +#[derive(Debug, Serialize, Deserialize)] +struct PersistedSession { + token: String, + actor_type: String, + hostname: String, + pid: u32, + project: String, + role: String, + registered_at: u64, + last_seen: u64, + preferences: serde_json::Value, +} + +/// Lock-free concurrent actor registry. +/// +/// Key = token (`actor_type:hostname:pid`), value = session metadata. +/// Actors register via HTTP POST and deregister via HTTP DELETE or +/// are reaped by the periodic sweep. +/// +/// When `persist_dir` is set (e.g. `{project_root}/.ontoref/sessions`), actor +/// profiles are written as JSON files on update and loaded on construction. +pub struct ActorRegistry { + sessions: DashMap, + stale_timeout_secs: u64, + /// Cached at construction to avoid syscall on every sweep iteration. + local_hostname: String, + /// Directory for session profile persistence (`{root}/.ontoref/sessions`). + persist_dir: Option, +} + +impl ActorRegistry { + pub fn new(stale_timeout_secs: u64) -> Self { + let local_hostname = hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_default(); + Self { + sessions: DashMap::new(), + stale_timeout_secs, + local_hostname, + persist_dir: None, + } + } + + pub fn with_persist_dir(mut self, dir: PathBuf) -> Self { + self.persist_dir = Some(dir); + self + } + + /// Load persisted session profiles from `persist_dir`. Tokens that do not + /// correspond to a live actor are kept in the registry with their last-seen + /// timestamp so the daemon can restore preferences on the next registration + /// of the same token within the stale window. + pub fn load_persisted(&self) { + let Some(dir) = &self.persist_dir else { return }; + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(session) = serde_json::from_slice::(&bytes) else { + warn!(path = %path.display(), "failed to deserialise persisted session"); + continue; + }; + // Do not re-insert if already registered (live session takes priority). + if self.sessions.contains_key(&session.token) { + continue; + } + let token = session.token.clone(); + self.sessions.insert( + token.clone(), + ActorSession { + actor_type: session.actor_type, + hostname: session.hostname, + pid: session.pid, + project: session.project, + role: session.role, + registered_at: session.registered_at, + last_seen: session.last_seen, + pending_notifications: AtomicU64::new(0), + preferences: session.preferences, + }, + ); + debug!(token = %token, "loaded persisted actor session"); + } + } + + pub fn register(&self, req: RegisterRequest) -> String { + let token = make_token(&req.actor_type, &req.hostname, req.pid); + let now = epoch_secs(); + + let session = ActorSession { + actor_type: req.actor_type, + hostname: req.hostname, + pid: req.pid, + project: req.project, + role: req.role.unwrap_or_else(|| "developer".to_string()), + registered_at: now, + last_seen: now, + pending_notifications: AtomicU64::new(0), + preferences: req + .preferences + .unwrap_or(serde_json::Value::Object(Default::default())), + }; + + self.sessions.insert(token.clone(), session); + info!(token = %token, "actor registered"); + token + } + + pub fn deregister(&self, token: &str) -> bool { + let removed = self.sessions.remove(token).is_some(); + if removed { + info!(token = %token, "actor deregistered"); + } + removed + } + + /// Update role and/or preferences for a registered session. Returns `true` + /// if found. Persists the updated profile to disk if `persist_dir` is + /// configured. + pub fn update_profile( + &self, + token: &str, + role: Option, + preferences: Option, + ) -> bool { + let Some(mut session) = self.sessions.get_mut(token) else { + return false; + }; + if let Some(r) = role { + session.role = r; + } + if let Some(p) = preferences { + session.preferences = p; + } + session.last_seen = epoch_secs(); + + if let Some(dir) = &self.persist_dir { + let persisted = PersistedSession { + token: token.to_string(), + actor_type: session.actor_type.clone(), + hostname: session.hostname.clone(), + pid: session.pid, + project: session.project.clone(), + role: session.role.clone(), + registered_at: session.registered_at, + last_seen: session.last_seen, + preferences: session.preferences.clone(), + }; + drop(session); // release DashMap write guard before I/O + self.write_persisted_session(dir, token, &persisted); + } + true + } + + fn write_persisted_session(&self, dir: &PathBuf, token: &str, session: &PersistedSession) { + if let Err(e) = std::fs::create_dir_all(dir) { + warn!(dir = %dir.display(), error = %e, "failed to create sessions persist dir"); + return; + } + let file = dir.join(format!("{}.json", token_to_filename(token))); + match serde_json::to_vec_pretty(session) { + Ok(bytes) => { + if let Err(e) = std::fs::write(&file, bytes) { + warn!(path = %file.display(), error = %e, "failed to persist actor session"); + } + } + Err(e) => warn!(error = %e, "failed to serialise actor session"), + } + } + + /// Update `last_seen` timestamp. Returns `true` if the token was found. + pub fn touch(&self, token: &str) -> bool { + if let Some(mut session) = self.sessions.get_mut(token) { + session.last_seen = epoch_secs(); + true + } else { + false + } + } + + pub fn increment_pending(&self, token: &str) { + if let Some(session) = self.sessions.get(token) { + session + .pending_notifications + .fetch_add(1, Ordering::Relaxed); + } + } + + pub fn clear_pending(&self, token: &str) { + if let Some(session) = self.sessions.get(token) { + session.pending_notifications.store(0, Ordering::Relaxed); + } + } + + pub fn get(&self, token: &str) -> Option { + self.sessions + .get(token) + .map(|s| ActorSessionView::from(&*s)) + } + + pub fn list(&self) -> Vec<(String, ActorSessionView)> { + self.sessions + .iter() + .map(|entry| (entry.key().clone(), ActorSessionView::from(entry.value()))) + .collect() + } + + pub fn list_for_project(&self, project: &str) -> Vec<(String, ActorSessionView)> { + self.sessions + .iter() + .filter(|entry| entry.value().project == project) + .map(|entry| (entry.key().clone(), ActorSessionView::from(entry.value()))) + .collect() + } + + /// Tokens of all actors registered to a given project. + pub fn tokens_for_project(&self, project: &str) -> Vec { + self.sessions + .iter() + .filter(|entry| entry.value().project == project) + .map(|entry| entry.key().clone()) + .collect() + } + + /// Sweep stale sessions. Local actors: `kill -0` liveness check. + /// Remote actors (CI): `last_seen` timeout. + /// Returns tokens of reaped sessions. + pub fn sweep_stale(&self) -> Vec { + let now = epoch_secs(); + let mut reaped = Vec::new(); + + // Collect tokens to remove (can't remove during iteration with DashMap). + let stale_tokens: Vec = self + .sessions + .iter() + .filter(|entry| { + let session = entry.value(); + if session.hostname == self.local_hostname { + !process_alive(session.pid) + } else { + now.saturating_sub(session.last_seen) > self.stale_timeout_secs + } + }) + .map(|entry| entry.key().clone()) + .collect(); + + for token in stale_tokens { + if self.sessions.remove(&token).is_some() { + debug!(token = %token, "reaped stale actor session"); + if let Some(dir) = &self.persist_dir { + let _ = std::fs::remove_file( + dir.join(format!("{}.json", token_to_filename(&token))), + ); + } + reaped.push(token); + } + } + + if !reaped.is_empty() { + info!(count = reaped.len(), "sweep completed"); + } + + reaped + } + + pub fn count(&self) -> usize { + self.sessions.len() + } + + /// Collect all unique project names from registered actors. + pub fn active_projects(&self) -> HashSet { + self.sessions + .iter() + .map(|entry| entry.value().project.clone()) + .collect() + } +} + +/// Serializable view of an `ActorSession` (AtomicU64 → u64). +#[derive(Debug, Clone, Serialize)] +pub struct ActorSessionView { + pub actor_type: String, + pub hostname: String, + pub pid: u32, + pub project: String, + pub role: String, + pub registered_at: u64, + pub last_seen: u64, + pub pending_notifications: u64, + pub preferences: serde_json::Value, +} + +impl From<&ActorSession> for ActorSessionView { + fn from(s: &ActorSession) -> Self { + Self { + actor_type: s.actor_type.clone(), + hostname: s.hostname.clone(), + pid: s.pid, + project: s.project.clone(), + role: s.role.clone(), + registered_at: s.registered_at, + last_seen: s.last_seen, + pending_notifications: s.pending_notifications.load(Ordering::Relaxed), + preferences: s.preferences.clone(), + } + } +} + +#[derive(Debug, Default, Deserialize)] +pub struct RegisterRequest { + pub actor_type: String, + pub hostname: String, + pub pid: u32, + pub project: String, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub preferences: Option, +} + +/// Sanitise a token string into a safe filename component. +/// Replaces `:` and path-separator characters with `_`. +fn token_to_filename(token: &str) -> String { + token + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' { + c + } else { + '_' + } + }) + .collect() +} + +fn epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Check if a local process is alive via `kill -0`. +#[cfg(unix)] +fn process_alive(pid: u32) -> bool { + // SAFETY: kill(pid, 0) is a POSIX-standard liveness check. + // Signal 0 is explicitly defined as "no signal sent, but error checking + // is still performed" — it cannot affect the target process. + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } +} + +#[cfg(not(unix))] +fn process_alive(_pid: u32) -> bool { + // Non-Unix: assume alive, rely on last_seen timeout. + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_format() { + assert_eq!( + make_token("developer", "macbook", 1234), + "developer:macbook:1234" + ); + } + + #[test] + fn register_and_deregister() { + let registry = ActorRegistry::new(120); + let token = registry.register(RegisterRequest { + actor_type: "developer".into(), + hostname: "testhost".into(), + pid: 9999, + project: "ontoref".into(), + ..Default::default() + }); + + assert_eq!(registry.count(), 1); + assert!(registry.get(&token).is_some()); + + assert!(registry.deregister(&token)); + assert_eq!(registry.count(), 0); + } + + #[test] + fn list_for_project_filters() { + let registry = ActorRegistry::new(120); + registry.register(RegisterRequest { + actor_type: "developer".into(), + hostname: "h1".into(), + pid: 1, + project: "alpha".into(), + ..Default::default() + }); + registry.register(RegisterRequest { + actor_type: "agent".into(), + hostname: "h2".into(), + pid: 2, + project: "beta".into(), + ..Default::default() + }); + + let alpha_actors = registry.list_for_project("alpha"); + assert_eq!(alpha_actors.len(), 1); + assert_eq!(alpha_actors[0].1.actor_type, "developer"); + } + + #[test] + fn pending_notifications_lifecycle() { + let registry = ActorRegistry::new(120); + let token = registry.register(RegisterRequest { + actor_type: "agent".into(), + hostname: "h".into(), + pid: 42, + project: "test".into(), + ..Default::default() + }); + + registry.increment_pending(&token); + registry.increment_pending(&token); + let view = registry.get(&token).expect("session exists"); + assert_eq!(view.pending_notifications, 2); + + registry.clear_pending(&token); + let view = registry.get(&token).expect("session exists"); + assert_eq!(view.pending_notifications, 0); + } + + #[cfg(unix)] + #[test] + fn sweep_reaps_dead_processes() { + let registry = ActorRegistry::new(120); + let local_host = registry.local_hostname.clone(); + + // PID 999999 almost certainly does not exist + registry.register(RegisterRequest { + actor_type: "agent".into(), + hostname: local_host, + pid: 999_999, + project: "test".into(), + ..Default::default() + }); + + let reaped = registry.sweep_stale(); + assert_eq!(reaped.len(), 1); + assert_eq!(registry.count(), 0); + } +} diff --git a/crates/ontoref-daemon/src/api.rs b/crates/ontoref-daemon/src/api.rs new file mode 100644 index 0000000..2441cc2 --- /dev/null +++ b/crates/ontoref-daemon/src/api.rs @@ -0,0 +1,940 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{delete, get, post}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use tracing::{error, warn}; + +use crate::actors::{ActorRegistry, ActorSessionView, RegisterRequest}; +use crate::cache::NclCache; +use crate::notifications::{AckRequest, NotificationStore, NotificationView}; + +/// Shared application state injected into handlers. +#[derive(Clone)] +pub struct AppState { + pub cache: Arc, + pub project_root: PathBuf, + pub ontoref_root: Option, + pub started_at: Instant, + pub last_activity: Arc, + pub actors: Arc, + pub notifications: Arc, + /// Resolved NICKEL_IMPORT_PATH for UI-initiated NCL exports. + pub nickel_import_path: Option, + #[cfg(feature = "db")] + pub db: Option>, + #[cfg(feature = "nats")] + pub nats: Option>, + #[cfg(feature = "ui")] + pub tera: Option>>, + #[cfg(feature = "ui")] + pub public_dir: Option, + #[cfg(feature = "ui")] + pub registry: Option>, + #[cfg(feature = "ui")] + pub sessions: Arc, + /// Current project set by `set_project` MCP tool — shared across all + /// connections. + #[cfg(feature = "mcp")] + pub mcp_current_project: Arc>>, +} + +impl AppState { + fn touch_activity(&self) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.last_activity.store(now, Ordering::Relaxed); + } + + /// Derive the project name for the default project. + pub fn default_project_name(&self) -> String { + self.project_root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() + } +} + +pub fn router(state: AppState) -> axum::Router { + let app = axum::Router::new() + // Existing endpoints + .route("/health", get(health)) + .route("/nickel/export", post(nickel_export)) + .route("/cache/stats", get(cache_stats)) + .route("/cache/invalidate", post(cache_invalidate)) + // Actor endpoints + .route("/actors/register", post(actor_register)) + .route("/actors/{token}", delete(actor_deregister)) + .route("/actors/{token}/touch", post(actor_touch)) + .route("/actors/{token}/profile", post(actor_update_profile)) + .route("/actors", get(actors_list)) + // Notification endpoints + .route("/notifications/pending", get(notifications_pending)) + .route("/notifications/ack", post(notifications_ack)) + // Search endpoint + .route("/search", get(search)) + // Describe endpoints + .route("/describe/project", get(describe_project)) + .route("/describe/capabilities", get(describe_capabilities)) + .route("/describe/connections", get(describe_connections)) + .route("/describe/actor-init", get(describe_actor_init)) + // Backlog JSON endpoint + .route("/backlog-json", get(backlog_json)) + // Q&A read endpoint + .route("/qa-json", get(qa_json)); + + // Gate the mutation endpoint behind the ui feature (requires crate::ui). + #[cfg(feature = "ui")] + let app = app + .route("/qa/add", post(crate::ui::handlers::qa_add)) + .route("/qa/delete", post(crate::ui::handlers::qa_delete)) + .route("/qa/update", post(crate::ui::handlers::qa_update)); + + let app = app.with_state(state.clone()); + + #[cfg(feature = "ui")] + let app = { + use axum::response::Redirect; + use tower_http::services::ServeDir; + + let app = app + .route("/ui", get(|| async { Redirect::permanent("/ui/") })) + .nest("/ui/", crate::ui::router(state.clone())); + + if let Some(ref public_dir) = state.public_dir { + app.nest_service("/public", ServeDir::new(public_dir)) + } else { + app + } + }; + + // MCP streamable-HTTP endpoint — stateless per-request factory. + #[cfg(feature = "mcp")] + let app = { + use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, + }; + let mcp_state = state.clone(); + let mcp_svc = StreamableHttpService::new( + move || Ok(crate::mcp::OntoreServer::new(mcp_state.clone())), + std::sync::Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + app.nest_service("/mcp", mcp_svc) + }; + + app +} + +// ── Health ────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + uptime_secs: u64, + cache_entries: usize, + cache_hits: u64, + cache_misses: u64, + project_root: String, + active_actors: usize, + #[serde(skip_serializing_if = "Option::is_none")] + db_enabled: Option, +} + +async fn health(State(state): State) -> Json { + state.touch_activity(); + let db_enabled = { + #[cfg(feature = "db")] + { + Some(state.db.is_some()) + } + #[cfg(not(feature = "db"))] + { + None + } + }; + Json(HealthResponse { + status: "ok", + uptime_secs: state.started_at.elapsed().as_secs(), + cache_entries: state.cache.len(), + cache_hits: state.cache.hit_count(), + cache_misses: state.cache.miss_count(), + project_root: state.project_root.display().to_string(), + active_actors: state.actors.count(), + db_enabled, + }) +} + +// ── Nickel Export ─────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct ExportRequest { + path: String, + import_path: Option, +} + +#[derive(Serialize)] +struct ExportResponse { + data: serde_json::Value, + cached: bool, + elapsed_ms: u64, +} + +async fn nickel_export( + State(state): State, + Json(req): Json, +) -> std::result::Result, (StatusCode, String)> { + state.touch_activity(); + let start = Instant::now(); + + // Accept absolute paths — daemon is loopback-only; OS permissions are the + // boundary. Relative paths are still resolved against project_root for + // backward compatibility. + let file_path = resolve_any_path(&state.project_root, &req.path)?; + + let inherited_ip = std::env::var("NICKEL_IMPORT_PATH").unwrap_or_default(); + let merged_ip: Option = match req.import_path.as_deref() { + Some(caller_ip) => { + if inherited_ip.is_empty() { + Some(caller_ip.to_string()) + } else { + Some(format!("{caller_ip}:{inherited_ip}")) + } + } + None => { + if inherited_ip.is_empty() { + None + } else { + Some(inherited_ip) + } + } + }; + + let (data, was_hit) = state + .cache + .export(&file_path, merged_ip.as_deref()) + .await + .map_err(|e| { + error!(path = %file_path.display(), error = %e, "nickel export failed"); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + Ok(Json(ExportResponse { + data, + cached: was_hit, + elapsed_ms: start.elapsed().as_millis() as u64, + })) +} + +// ── Cache Management ──────────────────────────────────────────────────── + +#[derive(Serialize)] +struct CacheStatsResponse { + entries: usize, + hits: u64, + misses: u64, + hit_rate: f64, +} + +async fn cache_stats(State(state): State) -> Json { + state.touch_activity(); + let hits = state.cache.hit_count(); + let misses = state.cache.miss_count(); + let total = hits + misses; + Json(CacheStatsResponse { + entries: state.cache.len(), + hits, + misses, + hit_rate: if total > 0 { + hits as f64 / total as f64 + } else { + 0.0 + }, + }) +} + +#[derive(Deserialize)] +struct InvalidateRequest { + prefix: Option, + file: Option, + all: Option, +} + +#[derive(Serialize)] +struct InvalidateResponse { + invalidated: bool, + entries_remaining: usize, +} + +async fn cache_invalidate( + State(state): State, + Json(req): Json, +) -> std::result::Result, (StatusCode, String)> { + state.touch_activity(); + if req.all.unwrap_or(false) { + state.cache.invalidate_all(); + } else if let Some(prefix) = &req.prefix { + let path = resolve_path(&state.project_root, prefix)?; + state.cache.invalidate_prefix(&path); + } else if let Some(file) = &req.file { + let path = resolve_path(&state.project_root, file)?; + state.cache.invalidate_file(&path); + } else { + return Err(( + StatusCode::BAD_REQUEST, + "at least one of 'all', 'prefix', or 'file' must be specified".to_string(), + )); + } + Ok(Json(InvalidateResponse { + invalidated: true, + entries_remaining: state.cache.len(), + })) +} + +// ── Actor Endpoints ───────────────────────────────────────────────────── + +#[derive(Serialize)] +struct RegisterResponse { + token: String, + actors_connected: usize, +} + +async fn actor_register( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + state.touch_activity(); + #[cfg(feature = "nats")] + let actor_type = req.actor_type.clone(); + #[cfg(feature = "nats")] + let project = req.project.clone(); + let token = state.actors.register(req); + let count = state.actors.count(); + + #[cfg(feature = "nats")] + { + if let Some(ref nats) = state.nats { + if let Err(e) = nats + .publish_actor_registered(&token, &actor_type, &project) + .await + { + tracing::warn!(error = %e, "failed to publish actor.registered event"); + } + } + } + + ( + StatusCode::CREATED, + Json(RegisterResponse { + token, + actors_connected: count, + }), + ) +} + +async fn actor_deregister(State(state): State, Path(token): Path) -> StatusCode { + state.touch_activity(); + if state.actors.deregister(&token) { + #[cfg(feature = "nats")] + { + if let Some(ref nats) = state.nats { + if let Err(e) = nats.publish_actor_deregistered(&token, "explicit").await { + tracing::warn!(error = %e, "failed to publish actor.deregistered event"); + } + } + } + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +async fn actor_touch(State(state): State, Path(token): Path) -> StatusCode { + state.touch_activity(); + if state.actors.touch(&token) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +#[derive(Deserialize)] +struct ProfileRequest { + #[serde(default)] + role: Option, + #[serde(default)] + preferences: Option, +} + +async fn actor_update_profile( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> StatusCode { + state.touch_activity(); + if state + .actors + .update_profile(&token, req.role, req.preferences) + { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +#[derive(Serialize)] +struct ActorsListResponse { + actors: Vec, + total: usize, +} + +#[derive(Serialize)] +struct ActorEntry { + token: String, + #[serde(flatten)] + session: ActorSessionView, +} + +#[derive(Deserialize)] +struct ActorsQuery { + project: Option, +} + +async fn actors_list( + State(state): State, + Query(query): Query, +) -> Json { + state.touch_activity(); + let entries = match query.project { + Some(ref project) => state.actors.list_for_project(project), + None => state.actors.list(), + }; + let total = entries.len(); + let actors = entries + .into_iter() + .map(|(token, session)| ActorEntry { token, session }) + .collect(); + Json(ActorsListResponse { actors, total }) +} + +// ── Notification Endpoints ────────────────────────────────────────────── + +#[derive(Deserialize)] +struct PendingQuery { + token: String, + project: Option, + #[serde(default)] + check_only: bool, +} + +#[derive(Serialize)] +struct PendingResponse { + pending: usize, + #[serde(skip_serializing_if = "Option::is_none")] + notifications: Option>, +} + +async fn notifications_pending( + State(state): State, + Query(query): Query, +) -> Json { + state.touch_activity(); + state.actors.touch(&query.token); + + let project = query + .project + .unwrap_or_else(|| state.default_project_name()); + + if query.check_only { + let count = state.notifications.pending_count(&project, &query.token); + Json(PendingResponse { + pending: count, + notifications: None, + }) + } else { + let notifications = state.notifications.pending(&project, &query.token); + let count = notifications.len(); + Json(PendingResponse { + pending: count, + notifications: Some(notifications), + }) + } +} + +#[derive(Serialize)] +struct AckResponse { + acknowledged: usize, +} + +async fn notifications_ack( + State(state): State, + Json(req): Json, +) -> std::result::Result, (StatusCode, String)> { + state.touch_activity(); + state.actors.touch(&req.token); + + let project = req.project.unwrap_or_else(|| state.default_project_name()); + + let count = if req.all { + let acked = state.notifications.ack_all(&project, &req.token); + state.actors.clear_pending(&req.token); + acked + } else if let Some(id) = req.notification_id { + if state.notifications.ack_one(&project, &req.token, id) { + 1 + } else { + 0 + } + } else { + return Err(( + StatusCode::BAD_REQUEST, + "either 'all: true' or 'notification_id' must be specified".to_string(), + )); + }; + + Ok(Json(AckResponse { + acknowledged: count, + })) +} + +// ── Search ─────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct SearchQuery { + q: Option, + #[cfg(feature = "ui")] + slug: Option, +} + +#[derive(Serialize)] +struct SearchResponse { + query: String, + results: Vec, +} + +async fn search( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let q = params.q.as_deref().unwrap_or("").trim().to_string(); + if q.is_empty() { + return Json(SearchResponse { + query: q, + results: vec![], + }); + } + + // In multi-project mode, resolve context from slug; fall back to primary + // project. + #[cfg(feature = "ui")] + let results = { + if let (Some(slug), Some(ref registry)) = (params.slug.as_deref(), &state.registry) { + if let Some(ctx) = registry.get(slug) { + crate::search::search_project(&ctx.root, &ctx.cache, ctx.import_path.as_deref(), &q) + .await + } else { + vec![] + } + } else { + crate::search::search_project( + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + &q, + ) + .await + } + }; + + #[cfg(not(feature = "ui"))] + let results = crate::search::search_project( + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + &q, + ) + .await; + + Json(SearchResponse { query: q, results }) +} + +// ── Describe Endpoints ─────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct DescribeQuery { + slug: Option, +} + +#[derive(Deserialize)] +struct ActorInitQuery { + actor: Option, + slug: Option, +} + +/// Resolve project context from an optional slug. +/// Falls back to the primary project when slug is absent or not found in +/// registry. +#[cfg(feature = "ui")] +fn resolve_project_ctx( + state: &AppState, + slug: Option<&str>, +) -> (PathBuf, Arc, Option) { + if let Some(s) = slug { + if let Some(ref registry) = state.registry { + if let Some(ctx) = registry.get(s) { + return (ctx.root.clone(), ctx.cache.clone(), ctx.import_path.clone()); + } + } + } + ( + state.project_root.clone(), + state.cache.clone(), + state.nickel_import_path.clone(), + ) +} + +#[cfg(not(feature = "ui"))] +fn resolve_project_ctx( + state: &AppState, + _slug: Option<&str>, +) -> (PathBuf, Arc, Option) { + ( + state.project_root.clone(), + state.cache.clone(), + state.nickel_import_path.clone(), + ) +} + +async fn describe_project( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + state.touch_activity(); + let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); + let path = root.join(".ontology").join("core.ncl"); + if !path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "core.ncl not found" })), + ); + } + match cache.export(&path, import_path.as_deref()).await { + Ok((data, _)) => (StatusCode::OK, Json(data)), + Err(e) => { + error!(path = %path.display(), error = %e, "describe_project export failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + } +} + +async fn describe_connections( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + state.touch_activity(); + let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); + let path = root.join(".ontology").join("connections.ncl"); + if !path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "connections.ncl not found" })), + ); + } + match cache.export(&path, import_path.as_deref()).await { + Ok((data, _)) => (StatusCode::OK, Json(data)), + Err(e) => { + error!(path = %path.display(), error = %e, "describe_connections export failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + } +} + +async fn describe_capabilities( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + state.touch_activity(); + let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); + + let modes_dir = root.join("reflection").join("modes"); + let adrs_dir = root.join("adrs"); + let forms_dir = root.join("reflection").join("forms"); + + // Modes: export each NCL for id + trigger + let mut modes: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&modes_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + match cache.export(&path, import_path.as_deref()).await { + Ok((json, _)) => { + let id = json + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or(&stem) + .to_string(); + let trigger = json + .get("trigger") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + modes.push(serde_json::json!({ "id": id, "trigger": trigger })); + } + Err(e) => { + warn!(path = %path.display(), error = %e, "capabilities: mode export failed"); + modes.push(serde_json::json!({ "id": stem, "trigger": "" })); + } + } + } + } + modes.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string()); + + // ADRs: count only + let adr_count = std::fs::read_dir(&adrs_dir) + .map(|rd| { + rd.flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl")) + .count() + }) + .unwrap_or(0); + + // Forms: list file stems + let mut forms: Vec = std::fs::read_dir(&forms_dir) + .map(|rd| { + rd.flatten() + .filter_map(|e| { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) != Some("ncl") { + return None; + } + p.file_stem().and_then(|s| s.to_str()).map(str::to_string) + }) + .collect() + }) + .unwrap_or_default(); + forms.sort(); + + let mode_count = modes.len(); + let form_count = forms.len(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "modes": modes, + "adrs": adr_count, + "forms": forms, + "mode_count": mode_count, + "adr_count": adr_count, + "form_count": form_count, + })), + ) +} + +async fn describe_actor_init( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + state.touch_activity(); + let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); + let actor = q.actor.as_deref().unwrap_or("agent"); + let config_path = root.join(".ontoref").join("config.ncl"); + + if !config_path.exists() { + return ( + StatusCode::OK, + Json(serde_json::json!({ "mode": "", "auto_run": false })), + ); + } + + match cache.export(&config_path, import_path.as_deref()).await { + Ok((json, _)) => { + let entry = json + .get("actor_init") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|e| e.get("actor").and_then(|v| v.as_str()) == Some(actor)) + }) + .cloned(); + + let result = match entry { + Some(e) => { + let mode = e + .get("mode") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let auto_run = e.get("auto_run").and_then(|v| v.as_bool()).unwrap_or(false); + serde_json::json!({ "mode": mode, "auto_run": auto_run }) + } + None => serde_json::json!({ "mode": "", "auto_run": false }), + }; + (StatusCode::OK, Json(result)) + } + Err(e) => { + warn!(path = %config_path.display(), error = %e, "describe_actor_init export failed"); + ( + StatusCode::OK, + Json(serde_json::json!({ "mode": "", "auto_run": false })), + ) + } + } +} + +async fn backlog_json( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + state.touch_activity(); + let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); + let backlog_path = root.join("reflection").join("backlog.ncl"); + + if !backlog_path.exists() { + return (StatusCode::OK, Json(serde_json::json!([]))); + } + + match cache.export(&backlog_path, import_path.as_deref()).await { + Ok((json, _)) => { + let items = json + .get("items") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + (StatusCode::OK, Json(serde_json::Value::Array(items))) + } + Err(e) => { + error!(path = %backlog_path.display(), error = %e, "backlog_json export failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + } +} + +// ── Q&A endpoints ─────────────────────────────────────────────────────── + +async fn qa_json( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + state.touch_activity(); + let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref()); + let qa_path = root.join("reflection").join("qa.ncl"); + + if !qa_path.exists() { + return (StatusCode::OK, Json(serde_json::json!([]))); + } + + match cache.export(&qa_path, import_path.as_deref()).await { + Ok((json, _)) => { + let entries = json + .get("entries") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + (StatusCode::OK, Json(serde_json::Value::Array(entries))) + } + Err(e) => { + error!(path = %qa_path.display(), error = %e, "qa_json export failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +/// Resolve a path that may be absolute or relative to project_root. +/// Absolute paths are accepted as-is (daemon is loopback-only; OS enforces +/// access). Relative paths are resolved against project_root and must stay +/// within it. +fn resolve_any_path( + project_root: &std::path::Path, + path: &str, +) -> std::result::Result { + let p = PathBuf::from(path); + if p.is_absolute() { + return p.canonicalize().map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("path does not exist or is inaccessible: {e}"), + ) + }); + } + let joined = project_root.join(p); + let canonical = joined.canonicalize().map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("path does not exist or is inaccessible: {e}"), + ) + })?; + if !canonical.starts_with(project_root) { + return Err(( + StatusCode::BAD_REQUEST, + format!("path escapes project root: {path}"), + )); + } + Ok(canonical) +} + +/// Resolve a path relative to the project root and verify it stays within. +/// Rejects absolute paths — used for cache management operations scoped to a +/// project. +fn resolve_path( + project_root: &std::path::Path, + path: &str, +) -> std::result::Result { + let p = PathBuf::from(path); + if p.is_absolute() { + return Err(( + StatusCode::BAD_REQUEST, + "absolute paths are not accepted".to_string(), + )); + } + let joined = project_root.join(p); + let canonical = joined.canonicalize().map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("path does not exist or is inaccessible: {e}"), + ) + })?; + if !canonical.starts_with(project_root) { + return Err(( + StatusCode::BAD_REQUEST, + format!("path escapes project root: {path}"), + )); + } + Ok(canonical) +} + +impl IntoResponse for crate::error::DaemonError { + fn into_response(self) -> axum::response::Response { + let body = serde_json::json!({"error": self.to_string()}); + (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response() + } +} diff --git a/crates/ontoref-daemon/src/cache.rs b/crates/ontoref-daemon/src/cache.rs new file mode 100644 index 0000000..9a578f4 --- /dev/null +++ b/crates/ontoref-daemon/src/cache.rs @@ -0,0 +1,302 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::{Instant, SystemTime}; + +use dashmap::DashMap; +use serde_json::Value; +use tokio::sync::Mutex; +use tracing::debug; + +use crate::error::{DaemonError, Result}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct CacheKey { + path: PathBuf, + mtime: SystemTime, + import_path: Option, +} + +impl CacheKey { + fn new(path: &Path, mtime: SystemTime, import_path: Option<&str>) -> Self { + Self { + path: path.to_path_buf(), + mtime, + import_path: import_path.map(String::from), + } + } +} + +struct CachedExport { + json: Value, + exported_at: Instant, +} + +/// Caches `nickel export` subprocess results keyed by file path + mtime. +/// +/// First call to a file invokes `nickel export` (~100ms). Subsequent calls +/// with unchanged mtime return the cached JSON (<1ms). +pub struct NclCache { + cache: DashMap, + inflight: DashMap>>, + stats: CacheStats, +} + +struct CacheStats { + hits: std::sync::atomic::AtomicU64, + misses: std::sync::atomic::AtomicU64, +} + +impl CacheStats { + fn new() -> Self { + Self { + hits: std::sync::atomic::AtomicU64::new(0), + misses: std::sync::atomic::AtomicU64::new(0), + } + } + + fn hit(&self) { + self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + + fn miss(&self) { + self.misses + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + + fn hits(&self) -> u64 { + self.hits.load(std::sync::atomic::Ordering::Relaxed) + } + + fn misses(&self) -> u64 { + self.misses.load(std::sync::atomic::Ordering::Relaxed) + } +} + +impl Default for NclCache { + fn default() -> Self { + Self::new() + } +} + +impl NclCache { + pub fn new() -> Self { + Self { + cache: DashMap::new(), + inflight: DashMap::new(), + stats: CacheStats::new(), + } + } + + /// Export a Nickel file to JSON. Returns cached result if mtime unchanged. + /// + /// On cache miss, spawns the `nickel export` subprocess on a blocking + /// thread to avoid stalling the Tokio runtime. + /// Returns `(json, was_cache_hit)`. + pub async fn export(&self, path: &Path, import_path: Option<&str>) -> Result<(Value, bool)> { + let abs_path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + + let mtime = std::fs::metadata(&abs_path) + .map_err(|e| DaemonError::NclExport { + path: abs_path.display().to_string(), + reason: format!("stat failed: {e}"), + })? + .modified() + .map_err(|e| DaemonError::NclExport { + path: abs_path.display().to_string(), + reason: format!("mtime unavailable: {e}"), + })?; + + let key = CacheKey::new(&abs_path, mtime, import_path); + + if let Some(cached) = self.cache.get(&key) { + self.stats.hit(); + debug!( + path = %abs_path.display(), + age_ms = cached.exported_at.elapsed().as_millis(), + "cache hit" + ); + return Ok((cached.json.clone(), true)); + } + + debug!(path = %abs_path.display(), "cache miss — acquiring inflight lock"); + + // Acquire per-path lock to coalesce concurrent misses for the same file. + let lock = self + .inflight + .entry(abs_path.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _guard = lock.lock().await; + + // Re-check cache after acquiring the lock — another task may have filled it. + // Return as a hit: the subprocess ran, but not on our behalf. + if let Some(cached) = self.cache.get(&key) { + self.stats.hit(); + return Ok((cached.json.clone(), true)); + } + + // Confirmed miss — no cached result exists, we will invoke the subprocess. + self.stats.miss(); + + let export_path = abs_path.clone(); + let export_ip = import_path.map(String::from); + let result = tokio::task::spawn_blocking(move || { + run_nickel_export(&export_path, export_ip.as_deref()) + }) + .await + .map_err(|e| DaemonError::NclExport { + path: abs_path.display().to_string(), + reason: format!("spawn_blocking join failed: {e}"), + }); + + // On error: release inflight slot so future attempts can retry. + // On success: insert into cache BEFORE releasing inflight, so concurrent + // waiters find the result on their re-check instead of re-running the export. + match result { + Err(e) => { + drop(_guard); + self.inflight.remove(&abs_path); + Err(e) + } + Ok(Err(e)) => { + drop(_guard); + self.inflight.remove(&abs_path); + Err(e) + } + Ok(Ok(json)) => { + self.cache.insert( + key, + CachedExport { + json: json.clone(), + exported_at: Instant::now(), + }, + ); + // Release inflight AFTER cache is populated — concurrent waiters + // will hit the cache on their re-check. + drop(_guard); + self.inflight.remove(&abs_path); + Ok((json, false)) + } + } + } + + /// Invalidate all cache entries whose path starts with the given prefix. + pub fn invalidate_prefix(&self, prefix: &Path) { + let before = self.cache.len(); + self.cache.retain(|k, _| !k.path.starts_with(prefix)); + let evicted = before - self.cache.len(); + if evicted > 0 { + debug!(prefix = %prefix.display(), evicted, "cache invalidation"); + } + } + + /// Invalidate a specific file path (all mtimes). + /// + /// Paths from the watcher and API are always resolved to absolute before + /// calling this. The `debug_assert` catches programming errors in tests. + pub fn invalidate_file(&self, path: &Path) { + debug_assert!(path.is_absolute(), "invalidate_file expects absolute path"); + self.cache.retain(|k, _| k.path != path); + } + + /// Drop all cached entries. + pub fn invalidate_all(&self) { + let count = self.cache.len(); + self.cache.clear(); + debug!(count, "full cache invalidation"); + } + + pub fn len(&self) -> usize { + self.cache.len() + } + + pub fn is_empty(&self) -> bool { + self.cache.is_empty() + } + + pub fn hit_count(&self) -> u64 { + self.stats.hits() + } + + pub fn miss_count(&self) -> u64 { + self.stats.misses() + } +} + +/// Invoke `nickel export --format json` as a subprocess. +fn run_nickel_export(path: &Path, import_path: Option<&str>) -> Result { + let mut cmd = Command::new("nickel"); + cmd.args(["export", "--format", "json"]).arg(path); + + if let Some(ip) = import_path { + cmd.env("NICKEL_IMPORT_PATH", ip); + } else { + cmd.env_remove("NICKEL_IMPORT_PATH"); + } + + let output = cmd.output().map_err(|e| DaemonError::NclExport { + path: path.display().to_string(), + reason: format!("spawn failed: {e}"), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(DaemonError::NclExport { + path: path.display().to_string(), + reason: stderr.trim().to_string(), + }); + } + + let json: Value = + serde_json::from_slice(&output.stdout).map_err(|e| DaemonError::NclExport { + path: path.display().to_string(), + reason: format!("JSON parse failed: {e}"), + })?; + + Ok(json) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_key_equality() { + let t = SystemTime::now(); + let k1 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path1")); + let k2 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path1")); + let k3 = CacheKey::new(Path::new("/a/b.ncl"), t, Some("path2")); + assert_eq!(k1, k2); + assert_ne!(k1, k3); + } + + #[test] + fn invalidation_by_prefix() { + let cache = NclCache::new(); + let t = SystemTime::now(); + + // Insert fake entries directly + let key1 = CacheKey::new(Path::new("/project/.ontology/core.ncl"), t, None); + let key2 = CacheKey::new(Path::new("/project/.ontology/state.ncl"), t, None); + let key3 = CacheKey::new(Path::new("/project/adrs/adr-001.ncl"), t, None); + + for key in [key1, key2, key3] { + cache.cache.insert( + key, + CachedExport { + json: Value::Null, + exported_at: Instant::now(), + }, + ); + } + + assert_eq!(cache.len(), 3); + cache.invalidate_prefix(Path::new("/project/.ontology")); + assert_eq!(cache.len(), 1); + } +} diff --git a/crates/ontoref-daemon/src/error.rs b/crates/ontoref-daemon/src/error.rs new file mode 100644 index 0000000..e7c0360 --- /dev/null +++ b/crates/ontoref-daemon/src/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DaemonError { + #[error("NCL export failed for {path}: {reason}")] + NclExport { path: String, reason: String }, + + #[error("File watcher error: {0}")] + Watcher(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Configuration error: {0}")] + Config(String), + + #[cfg(feature = "db")] + #[error("Database error: {0}")] + Db(#[from] stratum_db::DbError), +} + +pub type Result = std::result::Result; diff --git a/crates/ontoref-daemon/src/lib.rs b/crates/ontoref-daemon/src/lib.rs new file mode 100644 index 0000000..7d34bf5 --- /dev/null +++ b/crates/ontoref-daemon/src/lib.rs @@ -0,0 +1,19 @@ +pub mod actors; +pub mod api; +pub mod cache; +pub mod error; +#[cfg(feature = "mcp")] +pub mod mcp; +#[cfg(feature = "nats")] +pub mod nats; +pub mod notifications; +#[cfg(feature = "ui")] +pub mod registry; +pub mod search; +#[cfg(feature = "db")] +pub mod seed; +#[cfg(feature = "ui")] +pub mod session; +#[cfg(feature = "ui")] +pub mod ui; +pub mod watcher; diff --git a/crates/ontoref-daemon/src/main.rs b/crates/ontoref-daemon/src/main.rs new file mode 100644 index 0000000..2ba5df1 --- /dev/null +++ b/crates/ontoref-daemon/src/main.rs @@ -0,0 +1,924 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use clap::Parser; +use ontoref_daemon::actors::ActorRegistry; +use ontoref_daemon::api::{self, AppState}; +use ontoref_daemon::cache::NclCache; +use ontoref_daemon::notifications::NotificationStore; +use ontoref_daemon::watcher::{FileWatcher, WatcherDeps}; +use tokio::net::TcpListener; +use tokio::sync::watch; +use tower_http::trace::TraceLayer; +use tracing::{error, info, warn}; + +/// Load daemon config from .ontoref/config.ncl and override CLI defaults. +/// Returns the resolved NICKEL_IMPORT_PATH from config (colon-separated). +fn load_config_overrides(cli: &mut Cli) -> Option { + let config_path = cli.project_root.join(".ontoref").join("config.ncl"); + if !config_path.exists() { + return None; + } + + let output = match Command::new("nickel") + .arg("export") + .arg(&config_path) + .output() + { + Ok(o) => o, + Err(e) => { + warn!(error = %e, path = %config_path.display(), "failed to read config"); + return None; + } + }; + + if !output.status.success() { + warn!("nickel export failed for config"); + return None; + } + + let config_json: serde_json::Value = match serde_json::from_slice(&output.stdout) { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "failed to parse config JSON"); + return None; + } + }; + + // Extract daemon config + if let Some(daemon) = config_json.get("daemon").and_then(|d| d.as_object()) { + if let Some(port) = daemon.get("port").and_then(|p| p.as_u64()) { + cli.port = port as u16; + } + if let Some(timeout) = daemon.get("idle_timeout").and_then(|t| t.as_u64()) { + cli.idle_timeout = timeout; + } + if let Some(interval) = daemon.get("invalidation_interval").and_then(|i| i.as_u64()) { + cli.invalidation_interval = interval; + } + if let Some(sweep) = daemon.get("actor_sweep_interval").and_then(|s| s.as_u64()) { + cli.actor_sweep_interval = sweep; + } + if let Some(stale) = daemon.get("actor_stale_timeout").and_then(|s| s.as_u64()) { + cli.actor_stale_timeout = stale; + } + if let Some(max) = daemon.get("max_notifications").and_then(|m| m.as_u64()) { + cli.max_notifications = max as usize; + } + if let Some(ack_dirs) = daemon + .get("notification_ack_required") + .and_then(|a| a.as_array()) + { + cli.notification_ack_required = ack_dirs + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + } + + // Extract db config (if db feature enabled) + #[cfg(feature = "db")] + { + if let Some(db) = config_json.get("db").and_then(|d| d.as_object()) { + if let Some(url) = db.get("url").and_then(|u| u.as_str()) { + if !url.is_empty() { + cli.db_url = Some(url.to_string()); + } + } + if let Some(ns) = db.get("namespace").and_then(|n| n.as_str()) { + if !ns.is_empty() { + cli.db_namespace = Some(ns.to_string()); + } + } + if let Some(user) = db.get("username").and_then(|u| u.as_str()) { + if !user.is_empty() { + cli.db_username = user.to_string(); + } + } + if let Some(pass) = db.get("password").and_then(|p| p.as_str()) { + if !pass.is_empty() { + cli.db_password = pass.to_string(); + } + } + } + } + + // Env var overrides for DB credentials — not persisted to disk. + #[cfg(feature = "db")] + { + if let Ok(user) = std::env::var("ONTOREF_DB_USERNAME") { + if !user.is_empty() { + cli.db_username = user; + } + } + if let Ok(pass) = std::env::var("ONTOREF_DB_PASSWORD") { + if !pass.is_empty() { + cli.db_password = pass; + } + } + } + + // UI config section — only populates fields not already set via CLI. + #[cfg(feature = "ui")] + apply_ui_config(cli, &config_json); + + info!("config loaded from {}", config_path.display()); + + let import_path = config_json + .get("nickel_import_paths") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(":") + }) + .filter(|s| !s.is_empty()); + + import_path +} + +#[derive(Parser)] +#[command(name = "ontoref-daemon", about = "Ontoref cache daemon")] +struct Cli { + /// Project root directory (where .ontoref/config.ncl lives) + #[arg(long, default_value = ".")] + project_root: PathBuf, + + /// Stratumiops root directory (for shared schemas/modules) + #[arg(long)] + ontoref_root: Option, + + /// HTTP listen port (overridden by config if present) + #[arg(long, default_value_t = 7891)] + port: u16, + + /// Seconds of inactivity before auto-shutdown (overridden by config) + #[arg(long, default_value_t = 1800)] + idle_timeout: u64, + + /// Full cache invalidation interval in seconds (overridden by config) + #[arg(long, default_value_t = 60)] + invalidation_interval: u64, + + /// PID file path + #[arg(long)] + pid_file: Option, + + /// Actor sweep interval in seconds (reap stale sessions) + #[arg(long, default_value_t = 30)] + actor_sweep_interval: u64, + + /// Seconds before a remote actor (no `kill -0` check) is considered stale + #[arg(long, default_value_t = 120)] + actor_stale_timeout: u64, + + /// Maximum notifications to retain per project (ring buffer) + #[arg(long, default_value_t = 1000)] + max_notifications: usize, + + /// Directories requiring notification acknowledgment before commit + #[arg(long, value_delimiter = ',')] + notification_ack_required: Vec, + + /// Directory containing Tera HTML templates for the web UI + #[cfg(feature = "ui")] + #[arg(long)] + templates_dir: Option, + + /// Directory to serve as /public (CSS, JS assets) + #[cfg(feature = "ui")] + #[arg(long)] + public_dir: Option, + + /// Path to registry.toml for multi-project mode + #[cfg(feature = "ui")] + #[arg(long)] + registry: Option, + + /// Hash a password with argon2id and print the PHC string, then exit + #[cfg(feature = "ui")] + #[arg(long, value_name = "PASSWORD")] + hash_password: Option, + + /// Run as an MCP server over stdin/stdout (for Claude Desktop, Cursor, + /// etc.). No HTTP server is started in this mode. + #[cfg(feature = "mcp")] + #[arg(long)] + mcp_stdio: bool, + + /// TLS certificate file (PEM). Enables HTTPS when combined with --tls-key + #[cfg(feature = "tls")] + #[arg(long)] + tls_cert: Option, + + /// TLS private key file (PEM). Enables HTTPS when combined with --tls-cert + #[cfg(feature = "tls")] + #[arg(long)] + tls_key: Option, + + /// SurrealDB remote WebSocket URL (e.g., ws://127.0.0.1:8000) + #[cfg(feature = "db")] + #[arg(long)] + db_url: Option, + + /// SurrealDB namespace for this daemon instance + #[cfg(feature = "db")] + #[arg(long)] + db_namespace: Option, + + /// SurrealDB username + #[cfg(feature = "db")] + #[arg(long, default_value = "root")] + db_username: String, + + /// SurrealDB password + #[cfg(feature = "db")] + #[arg(long, default_value = "root")] + db_password: String, +} + +#[tokio::main] +async fn main() { + // Parse CLI first so we can redirect logs to stderr in stdio MCP mode. + // In stdio mode stdout is the MCP JSON-RPC transport; any log line there + // corrupts the framing and the client silently drops or errors. + let mut cli = Cli::parse(); + + #[cfg(feature = "mcp")] + let use_stderr = cli.mcp_stdio; + #[cfg(not(feature = "mcp"))] + let use_stderr = false; + + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "ontoref_daemon=info,tower_http=debug".into()); + + if use_stderr { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(std::io::stderr) + .init(); + } else { + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + } + + #[cfg(feature = "ui")] + if let Some(ref password) = cli.hash_password { + use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, + }; + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .expect("argon2 hash failed") + .to_string(); + println!("{hash}"); + return; + } + + // Read config from project's .ontoref/config.ncl and override CLI defaults + let nickel_import_path = load_config_overrides(&mut cli); + + let project_root = match cli.project_root.canonicalize() { + Ok(p) if p.is_dir() => p, + Ok(p) => { + error!( + path = %p.display(), + "project_root is not a directory — aborting" + ); + std::process::exit(1); + } + Err(e) => { + error!( + path = %cli.project_root.display(), + error = %e, + "project_root does not exist or is inaccessible — aborting" + ); + std::process::exit(1); + } + }; + + info!( + project_root = %project_root.display(), + port = cli.port, + idle_timeout = cli.idle_timeout, + "starting ontoref-daemon" + ); + + let cache = Arc::new(NclCache::new()); + + #[cfg(feature = "ui")] + let registry: Option> = { + let reg_path: Option = cli.registry.clone().or_else(|| { + let candidate = project_root.join(".ontoref").join("registry.toml"); + if candidate.exists() { + info!(path = %candidate.display(), "auto-discovered registry.toml"); + Some(candidate) + } else { + None + } + }); + + if let Some(ref reg_path) = reg_path { + match ontoref_daemon::registry::ProjectRegistry::load( + reg_path, + cli.actor_stale_timeout, + cli.max_notifications, + ) { + Ok(r) => { + info!(path = %reg_path.display(), projects = r.count(), "registry loaded"); + Some(Arc::new(r)) + } + Err(e) => { + error!(error = %e, path = %reg_path.display(), "failed to load registry — aborting"); + std::process::exit(1); + } + } + } else { + None + } + }; + + #[cfg(feature = "ui")] + let sessions = Arc::new(ontoref_daemon::session::SessionStore::new()); + + let actors = Arc::new(ActorRegistry::new(cli.actor_stale_timeout)); + + // Initialize Tera template engine from the configured templates directory. + #[cfg(feature = "ui")] + let tera_instance: Option>> = { + if let Some(ref tdir) = cli.templates_dir { + let glob = format!("{}/**/*.html", tdir.display()); + match tera::Tera::new(&glob) { + Ok(t) => { + info!(templates_dir = %tdir.display(), "Tera templates loaded"); + Some(Arc::new(tokio::sync::RwLock::new(t))) + } + Err(e) => { + warn!(error = %e, templates_dir = %tdir.display(), "Tera init failed — UI disabled"); + None + } + } + } else { + info!("--templates-dir not set — web UI disabled"); + None + } + }; + + // Notification store with configurable capacity and ack requirements + let ack_required = if cli.notification_ack_required.is_empty() { + vec![".ontology".to_string(), "adrs".to_string()] + } else { + cli.notification_ack_required.clone() + }; + let notifications = Arc::new(NotificationStore::new(cli.max_notifications, ack_required)); + + // Optional DB connection with health check + #[cfg(feature = "db")] + let db = { + if cli.db_url.is_some() { + info!(url = %cli.db_url.as_deref().unwrap_or(""), "connecting to SurrealDB..."); + connect_db(&cli).await + } else { + info!("SurrealDB not configured — running cache-only"); + None + } + }; + + // Seed ontology tables from local NCL files → DB projection. + #[cfg(feature = "db")] + { + if let Some(ref db) = db { + info!("seeding ontology tables from local files..."); + ontoref_daemon::seed::seed_ontology( + db, + &project_root, + &cache, + nickel_import_path.as_deref(), + ) + .await; + } + } + + // Initialize NATS publisher + #[cfg(feature = "nats")] + info!("connecting to NATS..."); + #[cfg(feature = "nats")] + let nats_publisher = { + let project_name = project_root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + match ontoref_daemon::nats::NatsPublisher::connect( + &project_root.join(".ontoref").join("config.ncl"), + project_name, + cli.port, + ) + .await + { + Ok(Some(pub_)) => { + info!("NATS publisher initialized"); + Some(Arc::new(pub_)) + } + Ok(None) => { + info!("NATS disabled or unavailable"); + None + } + Err(e) => { + warn!(error = %e, "NATS initialization failed"); + None + } + } + }; + + // Start file watcher — after DB so it can re-seed on changes + let watcher_deps = WatcherDeps { + #[cfg(feature = "db")] + db: db.clone(), + import_path: nickel_import_path.clone(), + notifications: Arc::clone(¬ifications), + actors: Arc::clone(&actors), + #[cfg(feature = "nats")] + nats: nats_publisher.clone(), + }; + let _watcher = match FileWatcher::start( + &project_root, + Arc::clone(&cache), + cli.invalidation_interval, + watcher_deps, + ) { + Ok(w) => Some(w), + Err(e) => { + error!(error = %e, "file watcher failed to start — running without auto-invalidation"); + None + } + }; + + let epoch_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let last_activity = Arc::new(AtomicU64::new(epoch_secs)); + + // Capture display values before they are moved into AppState. + #[cfg(feature = "ui")] + let project_root_str = project_root.display().to_string(); + #[cfg(feature = "ui")] + let ui_startup: Option<(String, String)> = cli.templates_dir.as_ref().map(|tdir| { + let public = cli + .public_dir + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "—".to_string()); + (tdir.display().to_string(), public) + }); + + let state = { + #[cfg(feature = "nats")] + { + AppState { + cache, + project_root, + ontoref_root: cli.ontoref_root, + started_at: Instant::now(), + last_activity: Arc::clone(&last_activity), + actors: Arc::clone(&actors), + notifications: Arc::clone(¬ifications), + nickel_import_path: nickel_import_path.clone(), + #[cfg(feature = "db")] + db, + nats: nats_publisher.clone(), + #[cfg(feature = "ui")] + tera: tera_instance, + #[cfg(feature = "ui")] + public_dir: cli.public_dir, + #[cfg(feature = "ui")] + registry: registry.clone(), + #[cfg(feature = "ui")] + sessions: Arc::clone(&sessions), + #[cfg(feature = "mcp")] + mcp_current_project: Arc::new(std::sync::RwLock::new(None)), + } + } + #[cfg(not(feature = "nats"))] + { + AppState { + cache, + project_root, + ontoref_root: cli.ontoref_root, + started_at: Instant::now(), + last_activity: Arc::clone(&last_activity), + actors: Arc::clone(&actors), + notifications: Arc::clone(¬ifications), + nickel_import_path: nickel_import_path.clone(), + #[cfg(feature = "db")] + db, + #[cfg(feature = "ui")] + tera: tera_instance, + #[cfg(feature = "ui")] + public_dir: cli.public_dir, + #[cfg(feature = "ui")] + registry: registry.clone(), + #[cfg(feature = "ui")] + sessions: Arc::clone(&sessions), + #[cfg(feature = "mcp")] + mcp_current_project: Arc::new(std::sync::RwLock::new(None)), + } + } + }; + + // Start template hot-reload watcher if templates dir is configured. + #[cfg(feature = "ui")] + let _template_watcher = { + if let (Some(ref tdir), Some(ref tera)) = (&cli.templates_dir, &state.tera) { + match ontoref_daemon::ui::TemplateWatcher::start(tdir, Arc::clone(tera)) { + Ok(w) => Some(w), + Err(e) => { + warn!(error = %e, "template watcher failed to start — hot-reload disabled"); + None + } + } + } else { + None + } + }; + + // Start passive drift observer (scan+diff, no apply). + #[cfg(feature = "ui")] + let _drift_watcher = { + let project_name = state.default_project_name(); + let notif_store = Arc::clone(&state.notifications); + match ontoref_daemon::ui::DriftWatcher::start( + &state.project_root, + project_name, + notif_store, + ) { + Ok(w) => { + info!("drift watcher started"); + Some(w) + } + Err(e) => { + warn!(error = %e, "drift watcher failed to start"); + None + } + } + }; + + // MCP stdio mode — skips HTTP entirely; serves stdin/stdout to AI client. + #[cfg(feature = "mcp")] + if cli.mcp_stdio { + if let Err(e) = ontoref_daemon::mcp::serve_stdio(state).await { + error!(error = %e, "MCP stdio server error"); + std::process::exit(1); + } + return; + } + + let app = api::router(state).layer(TraceLayer::new_for_http()); + + let addr = SocketAddr::from(([127, 0, 0, 1], cli.port)); + let listener = TcpListener::bind(addr).await.unwrap_or_else(|e| { + error!(addr = %addr, error = %e, "failed to bind"); + std::process::exit(1); + }); + + // Write PID file only after successful bind + if let Some(ref pid_path) = cli.pid_file { + if let Err(e) = write_pid_file(pid_path) { + error!(path = %pid_path.display(), error = %e, "failed to write PID file"); + } + } + + info!(addr = %addr, "listening"); + + #[cfg(feature = "ui")] + if let Some((ref tdir, ref public)) = ui_startup { + #[cfg(feature = "tls")] + let scheme = if cli.tls_cert.is_some() && cli.tls_key.is_some() { + "https" + } else { + "http" + }; + #[cfg(not(feature = "tls"))] + let scheme = "http"; + info!( + url = %format!("{scheme}://{addr}/ui/"), + project_root = %project_root_str, + templates_dir = %tdir, + public_dir = %public, + "web UI available" + ); + } + + // Publish daemon.started event + #[cfg(feature = "nats")] + { + if let Some(ref nats) = nats_publisher { + if let Err(e) = nats.publish_started().await { + warn!(error = %e, "failed to publish daemon.started event"); + } + } + } + + // Spawn NATS event polling handler if enabled + #[cfg(feature = "nats")] + let _nats_handler = if let Some(ref nats) = nats_publisher { + let nats_clone = Arc::clone(nats); + let handle = tokio::spawn(async move { + handle_nats_events(nats_clone).await; + }); + Some(handle) + } else { + None + }; + + // Spawn actor sweep task — reaps stale sessions periodically + let sweep_actors = Arc::clone(&actors); + #[cfg(feature = "nats")] + let sweep_nats = nats_publisher.clone(); + let sweep_interval = cli.actor_sweep_interval; + let _sweep_task = tokio::spawn(async move { + actor_sweep_loop( + sweep_actors, + sweep_interval, + #[cfg(feature = "nats")] + sweep_nats, + ) + .await; + }); + + // Idle timeout: spawn a watchdog that signals shutdown via watch channel. + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + if cli.idle_timeout > 0 { + let idle_secs = cli.idle_timeout; + let activity = Arc::clone(&last_activity); + tokio::spawn(idle_watchdog(activity, idle_secs, shutdown_tx)); + } + + // TLS serve path — takes priority when cert + key are both configured. + #[cfg(feature = "tls")] + if let (Some(cert), Some(key)) = (&cli.tls_cert, &cli.tls_key) { + let tls_config = match axum_server::tls_rustls::RustlsConfig::from_pem_file(cert, key).await + { + Ok(c) => c, + Err(e) => { + error!(error = %e, cert = %cert.display(), key = %key.display(), + "TLS config failed — aborting"); + std::process::exit(1); + } + }; + + let handle = axum_server::Handle::new(); + let shutdown_handle = handle.clone(); + let mut tls_rx = shutdown_rx.clone(); + tokio::spawn(async move { + let _ = tls_rx.wait_for(|&v| v).await; + shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(30))); + }); + + let std_listener = listener.into_std().unwrap_or_else(|e| { + error!(error = %e, "listener conversion failed"); + std::process::exit(1); + }); + + #[cfg(feature = "nats")] + let tls_start = Instant::now(); + + if let Err(e) = axum_server::from_tcp_rustls(std_listener, tls_config) + .handle(handle) + .serve(app.into_make_service()) + .await + { + error!(error = %e, "TLS server error"); + } + + #[cfg(feature = "nats")] + if let Some(ref nats) = nats_publisher { + let _ = nats.publish_stopped(tls_start.elapsed().as_secs()).await; + } + + if let Some(ref pid_path) = cli.pid_file { + let _ = std::fs::remove_file(pid_path); + } + return; + } + + // Plain HTTP serve path. + #[cfg(feature = "nats")] + let startup_instant = Instant::now(); + let graceful = async move { + let _ = shutdown_rx.wait_for(|&v| v).await; + }; + + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(graceful) + .await + { + error!(error = %e, "server error"); + } + + // Publish daemon.stopped event on graceful shutdown + #[cfg(feature = "nats")] + { + if let Some(ref nats) = nats_publisher { + let uptime_secs = startup_instant.elapsed().as_secs(); + if let Err(e) = nats.publish_stopped(uptime_secs).await { + warn!(error = %e, "failed to publish daemon.stopped event"); + } + } + } + + // Cleanup PID file + if let Some(ref pid_path) = cli.pid_file { + let _ = std::fs::remove_file(pid_path); + } +} + +async fn idle_watchdog(activity: Arc, idle_secs: u64, shutdown: watch::Sender) { + let check_interval = std::time::Duration::from_secs(30); + loop { + tokio::time::sleep(check_interval).await; + let last = activity.load(Ordering::Relaxed); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let idle = now.saturating_sub(last); + if idle >= idle_secs { + info!(idle, idle_secs, "idle timeout reached — shutting down"); + let _ = shutdown.send(true); + return; + } + } +} + +/// Periodic sweep of stale actor sessions. +/// Local actors: checked via `kill -0 `. Remote actors: `last_seen` +/// timeout. Publishes `actor.deregistered` events via NATS for reaped sessions. +async fn actor_sweep_loop( + actors: Arc, + interval_secs: u64, + #[cfg(feature = "nats")] nats: Option>, +) { + let interval = std::time::Duration::from_secs(interval_secs); + loop { + tokio::time::sleep(interval).await; + let reaped = actors.sweep_stale(); + + #[cfg(feature = "nats")] + publish_reaped_actors(&nats, &reaped).await; + + #[cfg(not(feature = "nats"))] + let _ = reaped; + } +} + +#[cfg(feature = "nats")] +async fn publish_reaped_actors( + nats: &Option>, + reaped: &[String], +) { + let Some(ref nats) = nats else { return }; + for token in reaped { + if let Err(e) = nats.publish_actor_deregistered(token, "stale_sweep").await { + warn!(error = %e, token = %token, "failed to publish actor.deregistered"); + } + } +} + +#[cfg(feature = "db")] +async fn connect_db(cli: &Cli) -> Option> { + let db_url = cli.db_url.as_ref()?; + let namespace = cli.db_namespace.as_deref().unwrap_or("ontoref"); + + let connect_timeout = std::time::Duration::from_secs(5); + let db = match tokio::time::timeout( + connect_timeout, + stratum_db::StratumDb::connect_remote( + db_url, + namespace, + "daemon", + &cli.db_username, + &cli.db_password, + ), + ) + .await + { + Ok(Ok(db)) => db, + Ok(Err(e)) => { + error!(error = %e, "SurrealDB connection failed — running without persistence"); + return None; + } + Err(_) => { + error!(url = %db_url, "SurrealDB connection timed out (5s) — running without persistence"); + return None; + } + }; + + let health_timeout = std::time::Duration::from_secs(5); + match tokio::time::timeout(health_timeout, db.health_check()).await { + Ok(Ok(())) => { + info!(url = %db_url, namespace = %namespace, "SurrealDB connected and healthy"); + } + Ok(Err(e)) => { + error!(error = %e, "SurrealDB health check failed — running without persistence"); + return None; + } + Err(_) => { + error!("SurrealDB health check timed out — running without persistence"); + return None; + } + } + + if let Err(e) = db.initialize_tables().await { + warn!(error = %e, "table initialization failed — proceeding with cache only"); + return None; + } + + info!("Level 1 ontology tables initialized"); + Some(Arc::new(db)) +} + +#[cfg(feature = "ui")] +fn apply_ui_config(cli: &mut Cli, config: &serde_json::Value) { + let Some(ui) = config.get("ui").and_then(|u| u.as_object()) else { + return; + }; + if cli.templates_dir.is_none() { + let dir = ui + .get("templates_dir") + .and_then(|d| d.as_str()) + .unwrap_or(""); + if !dir.is_empty() { + cli.templates_dir = Some(cli.project_root.join(dir)); + } + } + if cli.public_dir.is_none() { + let dir = ui.get("public_dir").and_then(|d| d.as_str()).unwrap_or(""); + if !dir.is_empty() { + cli.public_dir = Some(cli.project_root.join(dir)); + } + } + #[cfg(feature = "tls")] + { + if cli.tls_cert.is_none() { + let p = ui.get("tls_cert").and_then(|d| d.as_str()).unwrap_or(""); + if !p.is_empty() { + cli.tls_cert = Some(cli.project_root.join(p)); + } + } + if cli.tls_key.is_none() { + let p = ui.get("tls_key").and_then(|d| d.as_str()).unwrap_or(""); + if !p.is_empty() { + cli.tls_key = Some(cli.project_root.join(p)); + } + } + } +} + +fn write_pid_file(path: &PathBuf) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, std::process::id().to_string()) +} + +/// Poll NATS JetStream for incoming events. +#[cfg(feature = "nats")] +async fn handle_nats_events(nats: Arc) { + loop { + let events = match nats.pull_events(10).await { + Ok(ev) => ev, + Err(e) => { + warn!("NATS poll error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + continue; + } + }; + + for (subject, payload) in events { + dispatch_nats_event(&subject, &payload); + } + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } +} + +#[cfg(feature = "nats")] +fn dispatch_nats_event(subject: &str, payload: &serde_json::Value) { + use ontoref_daemon::nats::NatsPublisher; + + if subject != "ecosystem.reflection.request" { + return; + } + + if let Some((mode_id, _params)) = NatsPublisher::parse_reflection_request(payload) { + info!(mode_id = %mode_id, "received reflection.request via JetStream"); + } +} diff --git a/crates/ontoref-daemon/src/mcp/mod.rs b/crates/ontoref-daemon/src/mcp/mod.rs new file mode 100644 index 0000000..725b81c --- /dev/null +++ b/crates/ontoref-daemon/src/mcp/mod.rs @@ -0,0 +1,1772 @@ +use std::{borrow::Cow, path::PathBuf, sync::Arc}; + +use rmcp::{ + handler::server::{ + router::tool::{AsyncTool, ToolBase}, + tool::ToolRouter, + }, + model::*, + tool_handler, ErrorData, ServerHandler, ServiceExt, +}; +use schemars::JsonSchema; +use serde::Deserialize; +use tracing::{debug, warn}; + +use crate::{api::AppState, cache::NclCache}; + +// ── Error ─────────────────────────────────────────────────────────────────────── + +struct ToolError(String); + +impl From for ErrorData { + fn from(e: ToolError) -> ErrorData { + ErrorData::internal_error(e.0, None) + } +} + +impl From for ToolError { + fn from(e: serde_json::Error) -> Self { + ToolError(e.to_string()) + } +} + +impl From for ToolError { + fn from(s: String) -> Self { + ToolError(s) + } +} + +// ── Project context +// ───────────────────────────────────────────────────────────── + +struct ProjectCtx { + root: PathBuf, + cache: Arc, + import_path: Option, +} + +// ── Tool parameter types +// ──────────────────────────────────────────────────────── + +#[derive(Deserialize, JsonSchema, Default)] +struct NoInput {} + +#[derive(Deserialize, JsonSchema, Default)] +struct ProjectParam { + /// Project slug. Omit to use the default (single-project) project. + project: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct SearchInput { + /// Full-text search query across ontology nodes, ADRs, and reflection + /// modes. + query: String, + /// Project slug. Omit to use the default project. + project: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct GetItemInput { + /// Item id or partial filename stem (e.g. `adr-001`, `plan-mode`). + id: String, + /// Project slug. Omit to use the default project. + project: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct BacklogInput { + /// Project slug. Omit to use the default project. + project: Option, + /// Filter by status: `Open` | `InProgress` | `Done` | `Cancelled`. + status: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct GetInput { + /// Item id as returned by `ontoref_search` (e.g. `backend-agnostic-core`, + /// `adr-001`, `plan-mode`). + id: String, + /// Kind of item: `"node"` | `"adr"` | `"mode"`. + kind: String, + /// Project slug. Omit to use the current/default project. + project: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct SetProjectInput { + /// Project slug to set as the current default for this MCP session. + project: String, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct BacklogOpInput { + /// Operation: `"add"` to create a new item, `"update_status"` to change + /// status. + operation: String, + /// Project slug. Omit to use the current/default project. + project: Option, + // ── update_status fields ── + /// Item id (e.g. `bl-001`). Required for `update_status`. + id: Option, + /// New status: `Open` | `InProgress` | `Done` | `Cancelled`. Required for + /// `update_status`. + status: Option, + // ── add fields ── + /// Item title. Required for `add`. + title: Option, + /// Item kind: `Feature` | `Bug` | `Chore` | `Research`. Required for `add`. + kind: Option, + /// Item priority: `Critical` | `High` | `Medium` | `Low`. Required for + /// `add`. + priority: Option, + /// Optional detail or acceptance criteria for `add`. + detail: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct QaListInput { + /// Project slug. Omit to use the default project. + project: Option, + /// Optional substring filter on question text. + filter: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct QaAddInput { + /// The question text. + question: String, + /// The answer text. May be left empty to fill in later. + answer: Option, + /// Actor recording this entry. Defaults to `"agent"`. + actor: Option, + /// Optional tags for categorisation. + tags: Option>, + /// Optional references to ontology node ids or ADR ids. + related: Option>, + /// Project slug. Omit to use the default project. + project: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct ActionListInput { + /// Project slug. Omit to use the default project. + project: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +struct ActionAddInput { + /// Short unique slug for the mode file (e.g. `gen-docs`). + id: String, + /// Human-readable label shown in the UI. + label: String, + /// Natural-language trigger description. + trigger: String, + /// Steps as an ordered list of strings. + steps: Vec, + /// Icon hint: `"book-open"` | `"refresh"` | `"code"` | `"bolt"`. + icon: Option, + /// Category: `"docs"` | `"sync"` | `"analysis"` | `"test"`. + category: Option, + /// Actors who can run this action: `"developer"` | `"agent"` | `"ci"`. + actors: Option>, + /// Project slug. Omit to use the default project. + project: Option, +} + +// ── Server ────────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct OntoreServer { + state: AppState, + tool_router: ToolRouter, +} + +impl OntoreServer { + pub fn new(state: AppState) -> Self { + Self { + state, + tool_router: Self::tool_router(), + } + } + + fn tool_router() -> ToolRouter { + ToolRouter::new() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + .with_async_tool::() + } + + fn project_ctx(&self, slug: Option<&str>) -> ProjectCtx { + // Explicit slug > mcp_current_project > default project. + let current = self + .state + .mcp_current_project + .read() + .ok() + .and_then(|g| g.clone()); + let effective = slug.map(str::to_string).or(current); + + #[cfg(feature = "ui")] + if let (Some(s), Some(registry)) = (effective.as_deref(), &self.state.registry) { + if let Some(ctx) = registry.get(s) { + return ProjectCtx { + root: ctx.root.clone(), + cache: ctx.cache.clone(), + import_path: ctx.import_path.clone(), + }; + } + } + let _ = effective; + ProjectCtx { + root: self.state.project_root.clone(), + cache: self.state.cache.clone(), + import_path: self.state.nickel_import_path.clone(), + } + } + + fn available_projects(&self) -> Vec { + #[cfg(feature = "ui")] + if let Some(ref registry) = self.state.registry { + return registry.all().into_iter().map(|p| p.slug.clone()).collect(); + } + vec![self.state.default_project_name()] + } +} + +// ── Tool: list_projects +// ───────────────────────────────────────────────────────── + +struct ListProjectsTool; + +impl ToolBase for ListProjectsTool { + type Parameter = NoInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_list_projects".into() + } + + fn description() -> Option> { + Some( + "List all available projects. In single-project mode returns the current project name." + .into(), + ) + } + + fn input_schema() -> Option> { + None + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for ListProjectsTool { + async fn invoke( + service: &OntoreServer, + _param: NoInput, + ) -> Result { + debug!(tool = "list_projects"); + let projects = service.available_projects(); + Ok(serde_json::json!({ "projects": projects })) + } +} + +// ── Tool: search +// ──────────────────────────────────────────────────────────────── + +struct SearchTool; + +impl ToolBase for SearchTool { + type Parameter = SearchInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_search".into() + } + + fn description() -> Option> { + Some( + "Free-text search across ontology nodes, ADRs, and reflection modes. Returns ranked \ + results with kind, title, and description." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for SearchTool { + async fn invoke( + service: &OntoreServer, + param: SearchInput, + ) -> Result { + debug!(tool = "search", query = %param.query, project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let results = crate::search::search_project( + &ctx.root, + &ctx.cache, + ctx.import_path.as_deref(), + ¶m.query, + ) + .await; + serde_json::to_value(results).map_err(ToolError::from) + } +} + +// ── Tool: describe_project +// ────────────────────────────────────────────────────── + +struct DescribeProjectTool; + +impl ToolBase for DescribeProjectTool { + type Parameter = ProjectParam; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_describe".into() + } + + fn description() -> Option> { + Some( + "Describe a project: README summary, manifest layers, operational modes, default \ + mode, and repo kind." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for DescribeProjectTool { + async fn invoke( + service: &OntoreServer, + param: ProjectParam, + ) -> Result { + debug!(tool = "describe_project", project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let manifest_path = ctx.root.join(".ontology").join("manifest.ncl"); + + let manifest = if manifest_path.exists() { + match ctx + .cache + .export(&manifest_path, ctx.import_path.as_deref()) + .await + { + Ok((v, _)) => v, + Err(e) => { + warn!(error = %e, "describe_project: manifest export failed"); + serde_json::Value::Null + } + } + } else { + serde_json::Value::Null + }; + + let description = crate::ui::handlers::readme_description(&ctx.root); + Ok(serde_json::json!({ + "root": ctx.root.display().to_string(), + "description": description, + "manifest": manifest, + })) + } +} + +// ── Tool: list_adrs +// ───────────────────────────────────────────────────────────── + +struct ListAdrsTool; + +impl ToolBase for ListAdrsTool { + type Parameter = ProjectParam; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_list_adrs".into() + } + + fn description() -> Option> { + Some( + "List all Architecture Decision Records (ADRs) with id, title, status, and constraint \ + counts." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for ListAdrsTool { + async fn invoke( + service: &OntoreServer, + param: ProjectParam, + ) -> Result { + debug!(tool = "list_adrs", project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let adrs_dir = ctx.root.join("adrs"); + + let Ok(entries) = std::fs::read_dir(&adrs_dir) else { + return Ok(serde_json::json!({ "adrs": [] })); + }; + + let mut adrs: Vec = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let rel = path + .strip_prefix(&ctx.root) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + + match ctx.cache.export(&path, ctx.import_path.as_deref()).await { + Ok((v, _)) => { + let hard_count = v + .get("constraints") + .and_then(|c| c.get("hard")) + .and_then(|h| h.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let soft_count = v + .get("constraints") + .and_then(|c| c.get("soft")) + .and_then(|s| s.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + adrs.push(serde_json::json!({ + "id": v.get("id").and_then(|i| i.as_str()).unwrap_or(""), + "title": v.get("title").and_then(|t| t.as_str()).unwrap_or(""), + "status": v.get("status").and_then(|s| s.as_str()).unwrap_or(""), + "hard_constraints": hard_count, + "soft_constraints": soft_count, + "file": rel, + })); + } + Err(e) => warn!(path = %rel, error = %e, "list_adrs: export failed"), + } + } + + adrs.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string()); + Ok(serde_json::json!({ "adrs": adrs })) + } +} + +// ── Tool: get_adr +// ─────────────────────────────────────────────────────────────── + +struct GetAdrTool; + +impl ToolBase for GetAdrTool { + type Parameter = GetItemInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_get_adr".into() + } + + fn description() -> Option> { + Some( + "Get full details of an Architecture Decision Record by id or partial filename stem \ + (e.g. `adr-001`)." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for GetAdrTool { + async fn invoke( + service: &OntoreServer, + param: GetItemInput, + ) -> Result { + debug!(tool = "get_adr", id = %param.id, project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let adrs_dir = ctx.root.join("adrs"); + + let entries = std::fs::read_dir(&adrs_dir) + .map_err(|e| ToolError(format!("adrs directory not found: {e}")))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + if !stem.contains(param.id.as_str()) { + continue; + } + return match ctx.cache.export(&path, ctx.import_path.as_deref()).await { + Ok((v, _)) => Ok(v), + Err(e) => Err(ToolError(e.to_string())), + }; + } + + Err(ToolError(format!("ADR '{}' not found", param.id))) + } +} + +// ── Tool: list_modes +// ──────────────────────────────────────────────────────────── + +struct ListModesTool; + +impl ToolBase for ListModesTool { + type Parameter = ProjectParam; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_list_modes".into() + } + + fn description() -> Option> { + Some("List all reflection modes with id, trigger description, and step count.".into()) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for ListModesTool { + async fn invoke( + service: &OntoreServer, + param: ProjectParam, + ) -> Result { + debug!(tool = "list_modes", project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let modes_dir = ctx.root.join("reflection").join("modes"); + + let Ok(entries) = std::fs::read_dir(&modes_dir) else { + return Ok(serde_json::json!({ "modes": [] })); + }; + + let mut modes: Vec = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + match ctx.cache.export(&path, ctx.import_path.as_deref()).await { + Ok((v, _)) => { + let step_count = v + .get("steps") + .and_then(|s| s.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + modes.push(serde_json::json!({ + "id": v.get("id").and_then(|i| i.as_str()).unwrap_or(""), + "trigger": v.get("trigger").and_then(|t| t.as_str()).unwrap_or(""), + "steps": step_count, + })); + } + Err(e) => { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + warn!(mode = %stem, error = %e, "list_modes: export failed"); + } + } + } + + modes.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string()); + Ok(serde_json::json!({ "modes": modes })) + } +} + +// ── Tool: get_mode +// ────────────────────────────────────────────────────────────── + +struct GetModeTool; + +impl ToolBase for GetModeTool { + type Parameter = GetItemInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_get_mode".into() + } + + fn description() -> Option> { + Some( + "Get full details of a reflection mode including all steps, preconditions, and \ + commands." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for GetModeTool { + async fn invoke( + service: &OntoreServer, + param: GetItemInput, + ) -> Result { + debug!(tool = "get_mode", id = %param.id, project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let modes_dir = ctx.root.join("reflection").join("modes"); + + let entries = std::fs::read_dir(&modes_dir) + .map_err(|_| ToolError("reflection/modes directory not found".to_string()))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + if stem != param.id && !stem.contains(param.id.as_str()) { + continue; + } + return match ctx.cache.export(&path, ctx.import_path.as_deref()).await { + Ok((v, _)) => Ok(v), + Err(e) => Err(ToolError(e.to_string())), + }; + } + + Err(ToolError(format!("Mode '{}' not found", param.id))) + } +} + +// ── Tool: get_backlog +// ─────────────────────────────────────────────────────────── + +struct GetBacklogTool; + +impl ToolBase for GetBacklogTool { + type Parameter = BacklogInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_backlog_list".into() + } + + fn description() -> Option> { + Some( + "Get project backlog items. Optionally filter by status: Open | InProgress | Done | \ + Cancelled." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for GetBacklogTool { + async fn invoke( + service: &OntoreServer, + param: BacklogInput, + ) -> Result { + debug!(tool = "get_backlog", project = ?param.project, status = ?param.status); + let ctx = service.project_ctx(param.project.as_deref()); + let backlog_path = ctx.root.join("reflection").join("backlog.ncl"); + + if !backlog_path.exists() { + return Ok(serde_json::json!({ "items": [] })); + } + + let (v, _) = ctx + .cache + .export(&backlog_path, ctx.import_path.as_deref()) + .await + .map_err(|e| ToolError(e.to_string()))?; + + let items: Vec = v + .get("items") + .and_then(|i| i.as_array()) + .map(|arr| { + arr.iter() + .filter(|it| match ¶m.status { + Some(f) => it + .get("status") + .and_then(|s| s.as_str()) + .map(|s| s.eq_ignore_ascii_case(f)) + .unwrap_or(false), + None => true, + }) + .cloned() + .collect() + }) + .unwrap_or_default(); + + Ok(serde_json::json!({ "items": items })) + } +} + +// ── Tool: get_node +// ────────────────────────────────────────────────────────────── + +struct GetNodeTool; + +impl ToolBase for GetNodeTool { + type Parameter = GetItemInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_get_node".into() + } + + fn description() -> Option> { + Some( + concat!( + "Get full details of an ontology node by id. ", + "Use after `search` to retrieve complete node data — ", + "axioms, tensions, practices, pole, and level.", + ) + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for GetNodeTool { + async fn invoke( + service: &OntoreServer, + param: GetItemInput, + ) -> Result { + debug!(tool = "get_node", id = %param.id, project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let core_path = ctx.root.join(".ontology").join("core.ncl"); + + let (json, _) = ctx + .cache + .export(&core_path, ctx.import_path.as_deref()) + .await + .map_err(|e| ToolError(e.to_string()))?; + + let nodes = json + .get("nodes") + .and_then(|n| n.as_array()) + .ok_or_else(|| ToolError("core.ncl has no nodes array".to_string()))?; + + let id_lower = param.id.to_lowercase(); + for node in nodes { + let node_id = node.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let node_name = node.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if node_id.to_lowercase().contains(&id_lower) + || node_name.to_lowercase().contains(&id_lower) + { + return Ok(node.clone()); + } + } + + Err(ToolError(format!("node '{}' not found", param.id))) + } +} + +// ── Tool: ontoref_get (unified dispatcher) ─────────────────────────────────── + +struct GetTool; + +impl ToolBase for GetTool { + type Parameter = GetInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_get".into() + } + + fn description() -> Option> { + Some( + concat!( + "Retrieve full details of any item by id and kind. ", + "Use the `id` and `kind` fields returned by `ontoref_search`. ", + "kind: `\"node\"` → ontology node, `\"adr\"` → Architecture Decision Record, ", + "`\"mode\"` → reflection workflow.", + ) + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for GetTool { + async fn invoke( + service: &OntoreServer, + param: GetInput, + ) -> Result { + debug!(tool = "ontoref_get", kind = %param.kind, id = %param.id); + let item = GetItemInput { + id: param.id, + project: param.project, + }; + match param.kind.as_str() { + "node" => GetNodeTool::invoke(service, item).await, + "adr" => GetAdrTool::invoke(service, item).await, + "mode" => GetModeTool::invoke(service, item).await, + other => Err(ToolError(format!( + "unknown kind '{other}'; expected node, adr, or mode" + ))), + } + } +} + +// ── Tool: ontoref_help +// ──────────────────────────────────────────────────────── + +struct HelpTool; + +impl ToolBase for HelpTool { + type Parameter = NoInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_help".into() + } + + fn description() -> Option> { + Some( + "List all available ontoref MCP tools with descriptions, parameters, and the current \ + active project." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for HelpTool { + async fn invoke( + service: &OntoreServer, + _param: NoInput, + ) -> Result { + debug!(tool = "ontoref_help"); + let current_project = service + .state + .mcp_current_project + .read() + .ok() + .and_then(|g| g.clone()); + let available = service.available_projects(); + + let tools = serde_json::json!([ + { "name": "ontoref_help", "description": "This help. Lists all tools, parameters, and current project.", "params": [] }, + { "name": "ontoref_list_projects", "description": "List all registered projects.", "params": [] }, + { "name": "ontoref_set_project", "description": "Set the default project for this session.", + "params": [{"name": "project", "required": true, "description": "Project slug from ontoref_list_projects"}] }, + { "name": "ontoref_status", "description": "Full dashboard: actors, notifications, backlog stats, ADR/mode counts, manifest.", + "params": [{"name": "project", "required": false}] }, + { "name": "ontoref_search", "description": "Free-text search across nodes, ADRs, and modes. Returns kind+id for use with ontoref_get.", + "params": [{"name": "query", "required": true}, {"name": "project", "required": false}] }, + { "name": "ontoref_get", "description": "Retrieve full details of any item by kind+id (from ontoref_search results).", + "params": [{"name": "id", "required": true}, {"name": "kind", "required": true, "values": ["node", "adr", "mode"]}, {"name": "project", "required": false}] }, + { "name": "ontoref_describe", "description": "Architecture overview: README summary and manifest.", + "params": [{"name": "project", "required": false}] }, + { "name": "ontoref_list_adrs", "description": "List all ADRs with id, title, status, constraint counts.", + "params": [{"name": "project", "required": false}] }, + { "name": "ontoref_get_adr", "description": "Full ADR by id or partial stem (e.g. adr-001).", + "params": [{"name": "id", "required": true}, {"name": "project", "required": false}] }, + { "name": "ontoref_list_modes", "description": "List all reflection modes with id, trigger, step count.", + "params": [{"name": "project", "required": false}] }, + { "name": "ontoref_get_mode", "description": "Full reflection mode including all steps and preconditions.", + "params": [{"name": "id", "required": true}, {"name": "project", "required": false}] }, + { "name": "ontoref_get_node", "description": "Full ontology node by id substring match.", + "params": [{"name": "id", "required": true}, {"name": "project", "required": false}] }, + { "name": "ontoref_backlog_list", "description": "List backlog items, optionally filtered by status.", + "params": [{"name": "project", "required": false}, {"name": "status", "required": false, "values": ["Open", "InProgress", "Done", "Cancelled"]}] }, + { "name": "ontoref_backlog", "description": "Mutate backlog: add a new item or update an item's status.", + "params": [ + {"name": "operation", "required": true, "values": ["add", "update_status"]}, + {"name": "project", "required": false}, + {"name": "id", "required": false, "note": "required for update_status"}, + {"name": "status", "required": false, "note": "required for update_status", "values": ["Open", "InProgress", "Done", "Cancelled"]}, + {"name": "title", "required": false, "note": "required for add"}, + {"name": "kind", "required": false, "values": ["Feature", "Bug", "Chore", "Research"]}, + {"name": "priority", "required": false, "values": ["Critical", "High", "Medium", "Low"]}, + {"name": "detail", "required": false} + ] }, + { "name": "ontoref_constraints", "description": "All hard and soft architectural constraints extracted from all ADRs.", + "params": [{"name": "project", "required": false}] }, + { "name": "ontoref_qa_list", "description": "List Q&A entries from reflection/qa.ncl. Optionally filter by question substring.", + "params": [{"name": "project", "required": false}, {"name": "filter", "required": false}] }, + { "name": "ontoref_qa_add", "description": "Add a Q&A entry to reflection/qa.ncl (persisted to disk). Use to record architectural knowledge.", + "params": [ + {"name": "question", "required": true}, + {"name": "answer", "required": false}, + {"name": "actor", "required": false, "default": "agent"}, + {"name": "tags", "required": false, "note": "array of strings"}, + {"name": "related", "required": false, "note": "array of node/ADR ids"}, + {"name": "project", "required": false} + ] }, + { "name": "ontoref_action_list", "description": "List quick actions from .ontoref/config.ncl quick_actions array.", + "params": [{"name": "project", "required": false}] }, + { "name": "ontoref_action_add", "description": "Create a new reflection mode file in reflection/modes/.ncl.", + "params": [ + {"name": "id", "required": true, "note": "short slug, e.g. gen-docs"}, + {"name": "label", "required": true}, + {"name": "trigger", "required": true}, + {"name": "steps", "required": true, "note": "array of step strings"}, + {"name": "icon", "required": false, "values": ["book-open", "refresh", "code", "bolt"]}, + {"name": "category", "required": false, "values": ["docs", "sync", "analysis", "test"]}, + {"name": "actors", "required": false, "note": "array: developer | agent | ci"}, + {"name": "project", "required": false} + ] }, + ]); + + Ok(serde_json::json!({ + "current_project": current_project, + "available_projects": available, + "tip": "Run ontoref_set_project to avoid specifying 'project' on every call.", + "tools": tools, + })) + } +} + +// ── Tool: set_project ──────────────────────────────────────────────────────── + +struct SetProjectTool; + +impl ToolBase for SetProjectTool { + type Parameter = SetProjectInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_set_project".into() + } + + fn description() -> Option> { + Some( + concat!( + "Set the current default project for this MCP session. ", + "Once set, all tools that accept an optional `project` field will use it ", + "automatically without needing to specify it on every call.", + ) + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for SetProjectTool { + async fn invoke( + service: &OntoreServer, + param: SetProjectInput, + ) -> Result { + debug!(tool = "set_project", project = %param.project); + // Verify project exists when registry is available. + #[cfg(feature = "ui")] + if let Some(ref registry) = service.state.registry { + if registry.get(¶m.project).is_none() { + let available = service.available_projects(); + return Err(ToolError(format!( + "project '{}' not found; available: {}", + param.project, + available.join(", ") + ))); + } + } + { + let mut guard = service + .state + .mcp_current_project + .write() + .map_err(|_| ToolError("lock poisoned".to_string()))?; + *guard = Some(param.project.clone()); + } + Ok(serde_json::json!({ + "ok": true, + "project": param.project, + "message": format!("Current project set to '{}'", param.project), + })) + } +} + +// ── Tool: project_status ───────────────────────────────────────────────────── + +struct ProjectStatusTool; + +impl ToolBase for ProjectStatusTool { + type Parameter = ProjectParam; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_status".into() + } + + fn description() -> Option> { + Some( + concat!( + "Full dashboard snapshot: actors, notifications, backlog stats ", + "(open/in-progress/done/cancelled), ADR count, mode count, and manifest.", + ) + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for ProjectStatusTool { + async fn invoke( + service: &OntoreServer, + param: ProjectParam, + ) -> Result { + debug!(tool = "project_status", project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + + // Actors + let actor_list: Vec = service + .state + .actors + .list() + .into_iter() + .map(|(token, view)| { + serde_json::json!({ + "token": token, + "actor_type": view.actor_type, + "pid": view.pid, + "project": view.project, + }) + }) + .collect(); + + // Notifications — recent count (all projects, last slice) + let recent_notifications = service.state.notifications.all_recent().len(); + + // Backlog stats + let backlog_path = ctx.root.join("reflection").join("backlog.ncl"); + let backlog_stats = if backlog_path.exists() { + match ctx + .cache + .export(&backlog_path, ctx.import_path.as_deref()) + .await + { + Ok((v, _)) => { + let items = v + .get("items") + .and_then(|i| i.as_array()) + .cloned() + .unwrap_or_default(); + let stats = backlog_item_stats(&items); + serde_json::json!({ + "total": items.len(), + "open": stats.0, + "in_progress": stats.1, + "done": stats.2, + "cancelled": stats.3, + "critical": stats.4, + }) + } + Err(_) => serde_json::json!({ "error": "backlog export failed" }), + } + } else { + serde_json::json!({ "total": 0, "open": 0, "in_progress": 0, "done": 0, "cancelled": 0, "critical": 0 }) + }; + + // ADR count + let adr_count = std::fs::read_dir(ctx.root.join("adrs")) + .map(|rd| { + rd.flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl")) + .count() + }) + .unwrap_or(0); + + // Mode count + let mode_count = std::fs::read_dir(ctx.root.join("reflection").join("modes")) + .map(|rd| { + rd.flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl")) + .count() + }) + .unwrap_or(0); + + // Manifest + let manifest_path = ctx.root.join(".ontology").join("manifest.ncl"); + let manifest = if manifest_path.exists() { + match ctx + .cache + .export(&manifest_path, ctx.import_path.as_deref()) + .await + { + Ok((v, _)) => v, + Err(e) => { + warn!(error = %e, "project_status: manifest export failed"); + serde_json::Value::Null + } + } + } else { + serde_json::Value::Null + }; + + Ok(serde_json::json!({ + "project": ctx.root.file_name().and_then(|n| n.to_str()).unwrap_or("unknown"), + "root": ctx.root.display().to_string(), + "actors": { "count": actor_list.len(), "list": actor_list }, + "notifications": { "recent": recent_notifications }, + "backlog": backlog_stats, + "adrs": adr_count, + "modes": mode_count, + "manifest": manifest, + })) + } +} + +// ── Tool: backlog +// ───────────────────────────────────────────────────────────── + +struct BacklogOpTool; + +impl ToolBase for BacklogOpTool { + type Parameter = BacklogOpInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_backlog".into() + } + + fn description() -> Option> { + Some( + concat!( + "Manage the project backlog. ", + "`operation: \"add\"` — create a new item (required: title, kind, priority; \ + optional: detail). ", + "`operation: \"update_status\"` — change item status (required: id, status). ", + "Valid kinds: Feature | Bug | Chore | Research. ", + "Valid priorities: Critical | High | Medium | Low. ", + "Valid statuses: Open | InProgress | Done | Cancelled.", + ) + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for BacklogOpTool { + async fn invoke( + service: &OntoreServer, + param: BacklogOpInput, + ) -> Result { + debug!(tool = "backlog", operation = %param.operation, project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let backlog_path = ctx.root.join("reflection").join("backlog.ncl"); + + if !backlog_path.exists() { + return Err(ToolError(format!( + "backlog.ncl not found at {}", + backlog_path.display() + ))); + } + + let today = today_iso(); + + match param.operation.as_str() { + "update_status" => { + let id = param + .id + .as_deref() + .ok_or_else(|| ToolError("`id` is required for update_status".to_string()))?; + let status = param.status.as_deref().ok_or_else(|| { + ToolError("`status` is required for update_status".to_string()) + })?; + + crate::ui::backlog_ncl::update_status(&backlog_path, id, status, &today) + .map_err(|e| ToolError(e.to_string()))?; + + // Invalidate cache so subsequent get_backlog reads the updated file. + ctx.cache.invalidate_file(&backlog_path); + + Ok(serde_json::json!({ + "ok": true, + "id": id, + "status": status, + "updated": today, + })) + } + "add" => { + let title = param + .title + .as_deref() + .ok_or_else(|| ToolError("`title` is required for add".to_string()))?; + let kind = param.kind.as_deref().unwrap_or("Feature"); + let priority = param.priority.as_deref().unwrap_or("Medium"); + let detail = param.detail.as_deref().unwrap_or(""); + + let new_id = crate::ui::backlog_ncl::add_item( + &backlog_path, + title, + kind, + priority, + detail, + &today, + ) + .map_err(|e| ToolError(e.to_string()))?; + + ctx.cache.invalidate_file(&backlog_path); + + Ok(serde_json::json!({ + "ok": true, + "id": new_id, + "title": title, + "kind": kind, + "priority": priority, + "status": "Open", + "created": today, + })) + } + other => Err(ToolError(format!( + "unknown operation '{}'; expected 'add' or 'update_status'", + other + ))), + } + } +} + +/// Returns (open, in_progress, done, cancelled, critical) counts. +fn backlog_item_stats(items: &[serde_json::Value]) -> (usize, usize, usize, usize, usize) { + let mut open = 0usize; + let mut in_progress = 0usize; + let mut done = 0usize; + let mut cancelled = 0usize; + let mut critical = 0usize; + for item in items { + match item.get("status").and_then(|s| s.as_str()) { + Some("Open") => open += 1, + Some("InProgress") => in_progress += 1, + Some("Done") => done += 1, + Some("Cancelled") => cancelled += 1, + _ => {} + } + if item.get("priority").and_then(|p| p.as_str()) == Some("Critical") { + critical += 1; + } + } + (open, in_progress, done, cancelled, critical) +} + +fn today_iso() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let days = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + / 86400; + // Hinnant's civil_from_days algorithm (proleptic Gregorian). + let z = days + 719_468; + let era = z / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y0 = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y0 + 1 } else { y0 }; + format!("{y:04}-{m:02}-{d:02}") +} + +fn collect_constraints( + arr: Option<&serde_json::Value>, + adr_id: &str, + adr_title: &str, + out: &mut Vec, +) { + let Some(items) = arr.and_then(|v| v.as_array()) else { + return; + }; + for text in items { + out.push(serde_json::json!({ + "adr": adr_id, + "adr_title": adr_title, + "text": text.as_str().unwrap_or(""), + })); + } +} + +// ── Tool: get_constraints +// ─────────────────────────────────────────────────────── + +struct GetConstraintsTool; + +impl ToolBase for GetConstraintsTool { + type Parameter = ProjectParam; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_constraints".into() + } + + fn description() -> Option> { + Some( + "Extract all hard and soft architectural constraints from all ADRs in the project." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for GetConstraintsTool { + async fn invoke( + service: &OntoreServer, + param: ProjectParam, + ) -> Result { + debug!(tool = "get_constraints", project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let adrs_dir = ctx.root.join("adrs"); + + let Ok(entries) = std::fs::read_dir(&adrs_dir) else { + return Ok(serde_json::json!({ "hard": [], "soft": [] })); + }; + + let mut hard: Vec = Vec::new(); + let mut soft: Vec = Vec::new(); + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let Ok((v, _)) = ctx.cache.export(&path, ctx.import_path.as_deref()).await else { + continue; + }; + let adr_id = v + .get("id") + .and_then(|i| i.as_str()) + .unwrap_or("") + .to_string(); + let adr_title = v + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + let Some(c) = v.get("constraints").and_then(|c| c.as_object()) else { + continue; + }; + collect_constraints(c.get("hard"), &adr_id, &adr_title, &mut hard); + collect_constraints(c.get("soft"), &adr_id, &adr_title, &mut soft); + } + + Ok(serde_json::json!({ "hard": hard, "soft": soft })) + } +} + +// ── Tool: qa_list +// ──────────────────────────────────────────────────────────────── + +struct QaListTool; + +impl ToolBase for QaListTool { + type Parameter = QaListInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_qa_list".into() + } + + fn description() -> Option> { + Some( + "List Q&A entries stored in reflection/qa.ncl. Optionally filter by question \ + substring." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for QaListTool { + async fn invoke( + service: &OntoreServer, + param: QaListInput, + ) -> Result { + debug!(tool = "qa_list", project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let qa_path = ctx.root.join("reflection").join("qa.ncl"); + + if !qa_path.exists() { + return Ok(serde_json::json!({ "entries": [], "count": 0 })); + } + + let (json, _) = ctx + .cache + .export(&qa_path, ctx.import_path.as_deref()) + .await + .map_err(|e| ToolError(e.to_string()))?; + + let mut entries: Vec = json + .get("entries") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if let Some(filter) = param.filter.as_deref() { + let lc = filter.to_lowercase(); + entries.retain(|e| { + e.get("question") + .and_then(|q| q.as_str()) + .map(|q| q.to_lowercase().contains(&lc)) + .unwrap_or(false) + }); + } + + let count = entries.len(); + Ok(serde_json::json!({ "entries": entries, "count": count })) + } +} + +// ── Tool: qa_add +// ───────────────────────────────────────────────────────────────── + +struct QaAddTool; + +impl ToolBase for QaAddTool { + type Parameter = QaAddInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_qa_add".into() + } + + fn description() -> Option> { + Some( + concat!( + "Add a Q&A entry to reflection/qa.ncl (persisted to disk, git-versioned). ", + "Use this to record architectural questions and answers the AI discovers during \ + analysis. ", + "Required: question. Optional: answer, actor, tags (array), related (node/ADR \ + ids).", + ) + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for QaAddTool { + async fn invoke( + service: &OntoreServer, + param: QaAddInput, + ) -> Result { + debug!(tool = "qa_add", project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let qa_path = ctx.root.join("reflection").join("qa.ncl"); + + if !qa_path.exists() { + return Err(ToolError(format!( + "qa.ncl not found at {} — create reflection/qa.ncl first", + qa_path.display() + ))); + } + + let answer = param.answer.as_deref().unwrap_or(""); + let actor = param.actor.as_deref().unwrap_or("agent"); + let tags = param.tags.as_deref().unwrap_or(&[]); + let related = param.related.as_deref().unwrap_or(&[]); + let now = today_iso(); + + let id = crate::ui::qa_ncl::add_entry( + &qa_path, + ¶m.question, + answer, + actor, + &now, + tags, + related, + ) + .map_err(|e| ToolError(e.to_string()))?; + + ctx.cache.invalidate_file(&qa_path); + + Ok(serde_json::json!({ + "ok": true, + "id": id, + "created_at": now, + "question": param.question, + "answer": answer, + })) + } +} + +// ── Tool: action_list +// ──────────────────────────────────────────────────────────── + +struct ActionListTool; + +impl ToolBase for ActionListTool { + type Parameter = ActionListInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_action_list".into() + } + + fn description() -> Option> { + Some( + "List quick actions configured in .ontoref/config.ncl (`quick_actions` array). \ + Returns id, label, category, icon, actors, and mode for each action." + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for ActionListTool { + async fn invoke( + service: &OntoreServer, + param: ActionListInput, + ) -> Result { + debug!(tool = "action_list", project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let config_path = ctx.root.join(".ontoref").join("config.ncl"); + + if !config_path.exists() { + return Ok(serde_json::json!({ "actions": [], "count": 0 })); + } + + let (json, _) = ctx + .cache + .export(&config_path, ctx.import_path.as_deref()) + .await + .map_err(|e| ToolError(e.to_string()))?; + + let actions = json + .get("quick_actions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let count = actions.len(); + Ok(serde_json::json!({ "actions": actions, "count": count })) + } +} + +// ── Tool: action_add +// ───────────────────────────────────────────────────────────── + +struct ActionAddTool; + +impl ToolBase for ActionAddTool { + type Parameter = ActionAddInput; + type Output = serde_json::Value; + type Error = ToolError; + + fn name() -> Cow<'static, str> { + "ontoref_action_add".into() + } + + fn description() -> Option> { + Some( + concat!( + "Create a new reflection mode file in reflection/modes/.ncl. ", + "The mode is immediately available as a runnable action via the CLI and UI. ", + "Required: id (slug), label, trigger, steps (array of step descriptions). ", + "Optional: icon, category, actors.", + ) + .into(), + ) + } + + fn output_schema() -> Option> { + None + } +} + +impl AsyncTool for ActionAddTool { + async fn invoke( + service: &OntoreServer, + param: ActionAddInput, + ) -> Result { + debug!(tool = "action_add", id = %param.id, project = ?param.project); + let ctx = service.project_ctx(param.project.as_deref()); + let modes_dir = ctx.root.join("reflection").join("modes"); + + if !modes_dir.exists() { + return Err(ToolError(format!( + "reflection/modes/ directory not found at {}", + modes_dir.display() + ))); + } + + let mode_path = modes_dir.join(format!("{}.ncl", param.id)); + if mode_path.exists() { + return Err(ToolError(format!( + "mode {} already exists — choose a different id", + param.id + ))); + } + + let icon = param.icon.as_deref().unwrap_or("bolt"); + let category = param.category.as_deref().unwrap_or("analysis"); + let actors = param.actors.as_deref().unwrap_or(&[]); + let actors_ncl = ncl_str_array(actors); + + let steps_ncl = param + .steps + .iter() + .map(|s| format!(" \"{}\",\n", escape_mcp(s))) + .collect::(); + + let content = format!( + r#"{{ + id = "{id}", + label = "{label}", + trigger = "{trigger}", + icon = "{icon}", + category = "{category}", + actors = {actors}, + steps = [ +{steps} ], +}} +"#, + id = escape_mcp(¶m.id), + label = escape_mcp(¶m.label), + trigger = escape_mcp(¶m.trigger), + icon = icon, + category = category, + actors = actors_ncl, + steps = steps_ncl, + ); + + std::fs::write(&mode_path, content).map_err(|e| ToolError(e.to_string()))?; + + // Invalidate any cached export for the modes directory items. + ctx.cache.invalidate_file(&mode_path); + + Ok(serde_json::json!({ + "ok": true, + "id": param.id, + "path": mode_path.display().to_string(), + "label": param.label, + })) + } +} + +fn ncl_str_array(items: &[String]) -> String { + if items.is_empty() { + return "[]".to_string(); + } + let inner: Vec = items + .iter() + .map(|s| format!("\"{}\"", escape_mcp(s))) + .collect(); + format!("[{}]", inner.join(", ")) +} + +fn escape_mcp(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +// ── ServerHandler +// ─────────────────────────────────────────────────────────────── + +#[tool_handler] +impl ServerHandler for OntoreServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_protocol_version(ProtocolVersion::LATEST) + .with_instructions(concat!( + "Ontoref semantic knowledge graph. All tools are prefixed `ontoref_`. ", + "Start with `ontoref_help` to see all tools and the current active project. ", + "Use `ontoref_set_project` once to avoid repeating `project` on every call. ", + "Use `ontoref_search` for queries; then `ontoref_get` with the returned kind+id \ + for details. ", + "Use `ontoref_status` for a full project dashboard. ", + "Use `ontoref_backlog` to add items or update status.", + )) + } +} + +// ── Entry points +// ──────────────────────────────────────────────────────────────── + +/// Run the MCP server over stdin/stdout — for use as a `command`-mode MCP +/// server in Claude Desktop, Cursor, or any stdio-compatible AI client. +pub async fn serve_stdio(state: AppState) -> anyhow::Result<()> { + let server = OntoreServer::new(state); + let ct = server + .serve(rmcp::transport::stdio()) + .await + .map_err(|e| anyhow::anyhow!("MCP serve error: {e}"))?; + ct.waiting() + .await + .map_err(|e| anyhow::anyhow!("MCP wait error: {e}"))?; + Ok(()) +} diff --git a/crates/ontoref-daemon/src/nats.rs b/crates/ontoref-daemon/src/nats.rs new file mode 100644 index 0000000..727e0e6 --- /dev/null +++ b/crates/ontoref-daemon/src/nats.rs @@ -0,0 +1,433 @@ +#[cfg(feature = "nats")] +use std::path::PathBuf; +#[cfg(feature = "nats")] +use std::process::Command; +#[cfg(feature = "nats")] +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(feature = "nats")] +use anyhow::{anyhow, Result}; +#[cfg(feature = "nats")] +use bytes::Bytes; +#[cfg(feature = "nats")] +use platform_nats::{EventStream, NatsConnectionConfig, TopologyConfig}; +#[cfg(feature = "nats")] +use serde_json::{json, Value}; +#[cfg(feature = "nats")] +use tracing::{info, warn}; + +/// NATS JetStream publisher for daemon lifecycle events. +/// +/// Uses platform-nats `connect_client()` — connection + auth only. +/// Stream/consumer topology comes entirely from `nats/streams.json` (or +/// equivalent), referenced by `nats_events.streams_config` in +/// `.ontoref/config.ncl`. +/// +/// Gracefully degrades if NATS is unavailable or disabled in config. +#[cfg(feature = "nats")] +pub struct NatsPublisher { + stream: EventStream, + project: String, + port: u16, +} + +#[cfg(feature = "nats")] +impl NatsPublisher { + /// Connect to NATS JetStream, apply topology from config, bind consumer. + /// Reads `nats_events` section from `.ontoref/config.ncl`. + /// Returns `Ok(None)` if disabled or unavailable (graceful degradation). + pub async fn connect( + config_path: &PathBuf, + project: String, + port: u16, + ) -> Result> { + let config = load_nats_config(config_path)?; + + let nats_section = match config.get("nats_events") { + Some(section) => section, + None => return Ok(None), + }; + + let enabled = nats_section + .get("enabled") + .and_then(|e| e.as_bool()) + .unwrap_or(false); + + if !enabled { + return Ok(None); + } + + let url = nats_section + .get("url") + .and_then(|u| u.as_str()) + .unwrap_or("nats://localhost:4222") + .to_string(); + + let nkey_seed = nats_section + .get("nkey_seed") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()); + + let require_signed = nats_section + .get("require_signed_messages") + .and_then(|r| r.as_bool()) + .unwrap_or(false); + + let trusted_nkeys = nats_section + .get("trusted_nkeys") + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let conn_cfg = NatsConnectionConfig { + url: url.clone(), + nkey_seed, + require_signed_messages: require_signed, + trusted_nkeys, + }; + + let mut stream = match tokio::time::timeout( + std::time::Duration::from_secs(3), + EventStream::connect_client(&conn_cfg), + ) + .await + { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + warn!(error = %e, url = %url, "NATS connection failed — running without events"); + return Ok(None); + } + Err(_) => { + warn!(url = %url, "NATS connection timed out — running without events"); + return Ok(None); + } + }; + + info!(url = %url, "NATS connected"); + + // Apply topology from streams_config file declared in project config. + // Fallback: NATS_STREAMS_CONFIG env var. + let topology_path = nats_section + .get("streams_config") + .and_then(|s| s.as_str()) + .map(std::path::PathBuf::from); + + let topology = match TopologyConfig::load(topology_path.as_deref()) { + Ok(Some(t)) => Some(t), + Ok(None) => { + warn!("no topology config found — publish-only mode (no consumer bound)"); + None + } + Err(e) => { + warn!(error = %e, "topology config load failed — publish-only mode"); + None + } + }; + + if let Some(ref topo) = topology { + match stream.apply_topology(topo).await { + Ok(report) => info!( + streams = report.streams_applied, + consumers = report.consumers_applied, + "topology applied" + ), + Err(e) => warn!(error = %e, "topology apply failed — publish-only mode"), + } + } + + // Bind to daemon consumer on the first stream (convention: "daemon-{project}"). + if let Some(ref topo) = topology { + if let Some(first_stream) = topo.streams.first() { + let consumer_name = format!("daemon-{project}"); + if let Err(e) = stream + .bind_consumer(&first_stream.name, &consumer_name) + .await + { + warn!(error = %e, "failed to bind daemon consumer — pull_events disabled"); + } + } + } + + Ok(Some(Self { + stream, + project, + port, + })) + } + + pub async fn publish_started(&self) -> Result<()> { + let payload = json!({ + "project": self.project, + "port": self.port, + "timestamp": iso8601_now(), + }); + self.stream + .publish("ecosystem.daemon.started", Bytes::from(payload.to_string())) + .await?; + info!(port = self.port, "published daemon.started"); + Ok(()) + } + + pub async fn publish_stopped(&self, uptime_secs: u64) -> Result<()> { + let payload = json!({ + "project": self.project, + "uptime_seconds": uptime_secs, + "timestamp": iso8601_now(), + }); + self.stream + .publish("ecosystem.daemon.stopped", Bytes::from(payload.to_string())) + .await?; + info!(uptime = uptime_secs, "published daemon.stopped"); + Ok(()) + } + + /// Publish a file change notification for a specific project and event + /// type. + pub async fn publish_notification( + &self, + project: &str, + event: &crate::notifications::NotificationEvent, + files: &[String], + ) -> Result<()> { + let subject = format!("ecosystem.{project}.{}", event.nats_suffix()); + let payload = json!({ + "project": project, + "event": format!("{event:?}"), + "files": files, + "timestamp": iso8601_now(), + }); + self.stream + .publish(&subject, Bytes::from(payload.to_string())) + .await?; + info!(subject = %subject, files = files.len(), "published notification"); + Ok(()) + } + + /// Publish an actor registration event. + pub async fn publish_actor_registered( + &self, + token: &str, + actor_type: &str, + project: &str, + ) -> Result<()> { + let payload = json!({ + "token": token, + "actor_type": actor_type, + "project": project, + "timestamp": iso8601_now(), + }); + self.stream + .publish( + "ecosystem.actor.registered", + Bytes::from(payload.to_string()), + ) + .await?; + info!(token = %token, "published actor.registered"); + Ok(()) + } + + /// Publish an actor deregistration event. + pub async fn publish_actor_deregistered(&self, token: &str, reason: &str) -> Result<()> { + let payload = json!({ + "token": token, + "reason": reason, + "timestamp": iso8601_now(), + }); + self.stream + .publish( + "ecosystem.actor.deregistered", + Bytes::from(payload.to_string()), + ) + .await?; + info!(token = %token, reason = %reason, "published actor.deregistered"); + Ok(()) + } + + /// Publish a file change event for a project (general, not requiring ack). + pub async fn publish_file_changed(&self, project: &str, files: &[String]) -> Result<()> { + let subject = format!("ecosystem.{project}.file.changed"); + let payload = json!({ + "project": project, + "files": files, + "timestamp": iso8601_now(), + }); + self.stream + .publish(&subject, Bytes::from(payload.to_string())) + .await?; + Ok(()) + } + + pub async fn publish_cache_invalidated(&self, reason: &str) -> Result<()> { + let payload = json!({ + "project": self.project, + "reason": reason, + "affected_keys": [], + "timestamp": iso8601_now(), + }); + self.stream + .publish( + "ecosystem.daemon.cache.invalidated", + Bytes::from(payload.to_string()), + ) + .await?; + info!(reason = %reason, "published daemon.cache.invalidated"); + Ok(()) + } + + /// Pull pending messages from the bound JetStream consumer. + /// Returns (subject, parsed_json) for each valid message. + /// Returns empty vec if no consumer is bound (publish-only mode). + pub async fn pull_events(&self, max_msgs: usize) -> Result> { + let batch = match self.stream.pull_batch(max_msgs).await { + Ok(b) => b, + Err(e) => { + let msg = e.to_string(); + if msg.contains("no consumer bound") { + return Ok(Vec::new()); + } + return Err(e); + } + }; + + let mut events = Vec::with_capacity(batch.len()); + + for (subject, payload_bytes, msg) in batch { + match serde_json::from_slice::(&payload_bytes) { + Ok(json) => events.push((subject, json)), + Err(e) => { + warn!(error = %e, subject = %subject, "invalid JSON in NATS message — skipping"); + } + } + let _ = msg.ack().await; + } + + Ok(events) + } + + /// Extract (mode_id, params) from a reflection.request payload. + pub fn parse_reflection_request(payload: &Value) -> Option<(String, Value)> { + let mode_id = payload.get("mode_id")?.as_str()?.to_string(); + let params = payload + .get("params") + .cloned() + .unwrap_or(Value::Object(Default::default())); + Some((mode_id, params)) + } +} + +/// Load project NATS config from .ontoref/config.ncl via nickel export. +#[cfg(feature = "nats")] +fn load_nats_config(config_path: &PathBuf) -> Result { + if !config_path.exists() { + return Ok(json!({})); + } + + let output = Command::new("nickel") + .arg("export") + .arg(config_path) + .output() + .map_err(|e| anyhow!("running nickel export: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("nickel export failed: {stderr}")); + } + + serde_json::from_slice(&output.stdout).map_err(|e| anyhow!("parsing nickel export output: {e}")) +} + +/// ISO 8601 timestamp (UTC) without external dependency. +#[cfg(feature = "nats")] +fn iso8601_now() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + + let secs = now.as_secs(); + let micros = now.subsec_micros(); + + let days = secs / 86400; + let day_secs = secs % 86400; + + // Gregorian approximation (valid for 1970–2099) + let year = 1970 + (days / 365); + let month = ((days % 365) / 30) + 1; + let day = ((days % 365) % 30) + 1; + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z", + year, + month, + day, + day_secs / 3600, + (day_secs % 3600) / 60, + day_secs % 60, + micros, + ) +} + +// ── No-op implementation when nats feature is disabled ──────────────── + +#[cfg(not(feature = "nats"))] +pub struct NatsPublisher; + +#[cfg(not(feature = "nats"))] +impl NatsPublisher { + pub async fn connect( + _config_path: &std::path::PathBuf, + _project: String, + _port: u16, + ) -> anyhow::Result> { + Ok(None) + } + + pub async fn publish_started(&self) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn publish_stopped(&self, _uptime_secs: u64) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn publish_notification( + &self, + _project: &str, + _event: &crate::notifications::NotificationEvent, + _files: &[String], + ) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn publish_actor_registered( + &self, + _token: &str, + _actor_type: &str, + _project: &str, + ) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn publish_actor_deregistered( + &self, + _token: &str, + _reason: &str, + ) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn publish_file_changed( + &self, + _project: &str, + _files: &[String], + ) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn publish_cache_invalidated(&self, _reason: &str) -> anyhow::Result<()> { + Ok(()) + } +} diff --git a/crates/ontoref-daemon/src/notifications.rs b/crates/ontoref-daemon/src/notifications.rs new file mode 100644 index 0000000..4f51bf7 --- /dev/null +++ b/crates/ontoref-daemon/src/notifications.rs @@ -0,0 +1,470 @@ +use std::collections::HashSet; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +/// Notification events that require acknowledgment before git commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationEvent { + OntologyChanged, + AdrChanged, + ReflectionChanged, + /// User-emitted notification with a free-form kind and payload. + Custom, +} + +impl NotificationEvent { + /// Map a relative file path to its notification event type. + /// Returns `None` for paths outside watched directories. + pub fn from_path(relative_path: &str) -> Option { + if relative_path.starts_with(".ontology/") || relative_path.starts_with(".ontology\\") { + Some(Self::OntologyChanged) + } else if relative_path.starts_with("adrs/") || relative_path.starts_with("adrs\\") { + Some(Self::AdrChanged) + } else if relative_path.starts_with("reflection/") + || relative_path.starts_with("reflection\\") + { + Some(Self::ReflectionChanged) + } else { + None + } + } + + /// NATS subject suffix for this event type. + pub fn nats_suffix(&self) -> &'static str { + match self { + Self::OntologyChanged => "notification.ontology", + Self::AdrChanged => "notification.adr", + Self::ReflectionChanged => "notification.reflection", + Self::Custom => "notification.custom", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Notification { + pub id: u64, + pub project: String, + pub event: NotificationEvent, + pub files: Vec, + pub timestamp: u64, + pub source_actor: Option, + pub acked_by: HashSet, + /// Populated for `NotificationEvent::Custom` notifications only. + pub custom_kind: Option, + pub custom_title: Option, + pub custom_payload: Option, + /// Source project slug for cross-project notifications. + pub source_project: Option, +} + +/// Request body for user-emitted notifications (REST + UI form). +#[derive(Debug, Deserialize)] +pub struct EmitRequest { + /// Target project slug. Required in multi-project mode. + #[serde(default)] + pub target_project: Option, + /// Free-form kind label, e.g. "backlog_delegation", "alert", "cross_ref". + pub kind: String, + pub title: String, + #[serde(default)] + pub payload: Option, + #[serde(default)] + pub source_actor: Option, +} + +/// Per-project notification ring buffer. +/// +/// Stores the last `capacity` notifications. Thread-safe via DashMap +/// for per-project isolation + AtomicU64 for the global sequence counter. +pub struct NotificationStore { + /// project → ordered notifications (newest last) + projects: DashMap>, + sequence: AtomicU64, + capacity: usize, + /// Directories that require acknowledgment (e.g., [".ontology", "adrs"]) + ack_required: Vec, +} + +impl NotificationStore { + pub fn new(capacity: usize, ack_required: Vec) -> Self { + Self { + projects: DashMap::new(), + sequence: AtomicU64::new(1), + capacity, + ack_required, + } + } + + /// Push notifications for changed files in a project, grouped by event + /// type. + /// + /// A single file batch may contain files from multiple watched directories + /// (e.g., `.ontology/` and `adrs/`). This method creates one notification + /// per distinct event type, so no changes are silently swallowed. + /// + /// Returns the IDs of created notifications (empty if none required ack). + pub fn push( + &self, + project: &str, + files: Vec, + source_actor: Option, + ) -> Vec { + // Group files by event type — each group becomes a separate notification + let mut by_event: std::collections::HashMap> = + std::collections::HashMap::new(); + for file in files { + if let Some(event) = NotificationEvent::from_path(&file) { + by_event.entry(event).or_default().push(file); + } + } + + let mut ids = Vec::new(); + let now = epoch_secs(); + + for (event, event_files) in by_event { + if !self.requires_ack(&event) { + continue; + } + + let id = self.sequence.fetch_add(1, Ordering::Relaxed); + + let notification = Notification { + id, + project: project.to_string(), + event, + files: event_files, + timestamp: now, + source_actor: source_actor.clone(), + acked_by: HashSet::new(), + custom_kind: None, + custom_title: None, + custom_payload: None, + source_project: None, + }; + + debug!( + id, + project, + event = ?notification.event, + file_count = notification.files.len(), + "notification created" + ); + + let mut ring = self.projects.entry(project.to_string()).or_default(); + ring.push(notification); + + // Trim to capacity + if ring.len() > self.capacity { + let excess = ring.len() - self.capacity; + ring.drain(..excess); + } + + ids.push(id); + } + + ids + } + + /// Emit a user-authored notification directly into this project's ring + /// buffer. + /// + /// Unlike `push()`, this bypasses file-path classification and is always + /// stored. Returns the new notification ID. + pub fn push_custom( + &self, + project: &str, + kind: impl Into, + title: impl Into, + payload: Option, + source_actor: Option, + source_project: Option, + ) -> u64 { + let id = self.sequence.fetch_add(1, Ordering::Relaxed); + let notification = Notification { + id, + project: project.to_string(), + event: NotificationEvent::Custom, + files: vec![], + timestamp: epoch_secs(), + source_actor, + acked_by: HashSet::new(), + custom_kind: Some(kind.into()), + custom_title: Some(title.into()), + custom_payload: payload, + source_project, + }; + let mut ring = self.projects.entry(project.to_string()).or_default(); + ring.push(notification); + if ring.len() > self.capacity { + let excess = ring.len() - self.capacity; + ring.drain(..excess); + } + id + } + + /// Get pending (unacknowledged) notifications for a specific actor token. + pub fn pending(&self, project: &str, token: &str) -> Vec { + let ring = match self.projects.get(project) { + Some(r) => r, + None => return Vec::new(), + }; + + ring.iter() + .filter(|n| !n.acked_by.contains(token)) + .map(NotificationView::from) + .collect() + } + + /// Count of pending notifications for a token. + pub fn pending_count(&self, project: &str, token: &str) -> usize { + let ring = match self.projects.get(project) { + Some(r) => r, + None => return 0, + }; + ring.iter().filter(|n| !n.acked_by.contains(token)).count() + } + + /// Acknowledge all pending notifications for a token. + pub fn ack_all(&self, project: &str, token: &str) -> usize { + let mut ring = match self.projects.get_mut(project) { + Some(r) => r, + None => return 0, + }; + + let mut count = 0; + for n in ring.iter_mut() { + if n.acked_by.insert(token.to_string()) { + count += 1; + } + } + + debug!(token, project, acked = count, "notifications acknowledged"); + count + } + + /// Acknowledge a specific notification by ID. + pub fn ack_one(&self, project: &str, token: &str, notification_id: u64) -> bool { + let mut ring = match self.projects.get_mut(project) { + Some(r) => r, + None => return false, + }; + + ring.iter_mut() + .find(|n| n.id == notification_id) + .map(|n| n.acked_by.insert(token.to_string())) + .unwrap_or(false) + } + + /// Check whether a notification event requires acknowledgment. + fn requires_ack(&self, event: &NotificationEvent) -> bool { + // Custom (user-emitted) notifications are always stored. + if matches!(event, NotificationEvent::Custom) { + return true; + } + if self.ack_required.is_empty() { + // Default: ontology and ADR changes require ack; Custom already handled above. + matches!( + event, + NotificationEvent::OntologyChanged + | NotificationEvent::AdrChanged + | NotificationEvent::Custom + ) + } else { + let dir = match event { + NotificationEvent::OntologyChanged => ".ontology", + NotificationEvent::AdrChanged => "adrs", + NotificationEvent::ReflectionChanged => "reflection", + NotificationEvent::Custom => return true, + }; + self.ack_required.iter().any(|r| r == dir) + } + } + + /// Get a single notification by ID across all projects. + pub fn get_one(&self, id: u64) -> Option { + for entry in self.projects.iter() { + if let Some(n) = entry.value().iter().find(|n| n.id == id) { + return Some(NotificationView::from(n)); + } + } + None + } + + /// All projects with stored notifications. + pub fn projects(&self) -> Vec { + self.projects.iter().map(|e| e.key().clone()).collect() + } + + /// All notifications across all projects, newest-last order per project. + /// Used by the web UI to display a global feed without a per-actor token. + pub fn all_recent(&self) -> Vec { + self.projects + .iter() + .flat_map(|entry| { + entry + .value() + .iter() + .map(NotificationView::from) + .collect::>() + }) + .collect() + } +} + +/// Serializable view without the mutable `acked_by` set. +#[derive(Debug, Clone, Serialize)] +pub struct NotificationView { + pub id: u64, + pub project: String, + pub event: NotificationEvent, + pub files: Vec, + pub timestamp: u64, + pub source_actor: Option, + pub custom_kind: Option, + pub custom_title: Option, + pub custom_payload: Option, + pub source_project: Option, +} + +impl From<&Notification> for NotificationView { + fn from(n: &Notification) -> Self { + Self { + id: n.id, + project: n.project.clone(), + event: n.event, + files: n.files.clone(), + timestamp: n.timestamp, + source_actor: n.source_actor.clone(), + custom_kind: n.custom_kind.clone(), + custom_title: n.custom_title.clone(), + custom_payload: n.custom_payload.clone(), + source_project: n.source_project.clone(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct AckRequest { + pub token: String, + #[serde(default)] + pub project: Option, + #[serde(default)] + pub all: bool, + #[serde(default)] + pub notification_id: Option, +} + +fn epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> NotificationStore { + NotificationStore::new(100, vec![".ontology".into(), "adrs".into()]) + } + + #[test] + fn event_from_path() { + assert_eq!( + NotificationEvent::from_path(".ontology/core.ncl"), + Some(NotificationEvent::OntologyChanged) + ); + assert_eq!( + NotificationEvent::from_path("adrs/adr-001.ncl"), + Some(NotificationEvent::AdrChanged) + ); + assert_eq!( + NotificationEvent::from_path("reflection/modes/sync.ncl"), + Some(NotificationEvent::ReflectionChanged) + ); + assert_eq!(NotificationEvent::from_path("src/main.rs"), None); + } + + #[test] + fn push_and_pending() { + let store = store(); + let files = vec![".ontology/core.ncl".into()]; + let ids = store.push("proj", files, None); + assert_eq!(ids.len(), 1); + + let pending = store.pending("proj", "dev:host:1"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].event, NotificationEvent::OntologyChanged); + } + + #[test] + fn push_groups_by_event_type() { + let store = store(); + let files = vec![ + ".ontology/core.ncl".into(), + "adrs/adr-001.ncl".into(), + ".ontology/state.ncl".into(), + ]; + let ids = store.push("proj", files, None); + // Should create 2 notifications: OntologyChanged + AdrChanged + assert_eq!(ids.len(), 2); + + let pending = store.pending("proj", "dev:host:1"); + assert_eq!(pending.len(), 2); + + let events: HashSet = pending.iter().map(|n| n.event).collect(); + assert!(events.contains(&NotificationEvent::OntologyChanged)); + assert!(events.contains(&NotificationEvent::AdrChanged)); + } + + #[test] + fn ack_clears_pending() { + let store = store(); + store.push("proj", vec![".ontology/state.ncl".into()], None); + store.push("proj", vec!["adrs/adr-002.ncl".into()], None); + + let token = "dev:host:1"; + assert_eq!(store.pending_count("proj", token), 2); + + let acked = store.ack_all("proj", token); + assert_eq!(acked, 2); + assert_eq!(store.pending_count("proj", token), 0); + } + + #[test] + fn ack_one_specific() { + let store = store(); + let ids = store.push("proj", vec![".ontology/core.ncl".into()], None); + let id1 = ids[0]; + store.push("proj", vec!["adrs/adr-001.ncl".into()], None); + + let token = "dev:host:1"; + assert!(store.ack_one("proj", token, id1)); + assert_eq!(store.pending_count("proj", token), 1); + } + + #[test] + fn ring_buffer_eviction() { + let store = NotificationStore::new(3, vec![".ontology".into()]); + for i in 0..5 { + store.push("proj", vec![format!(".ontology/file{i}.ncl")], None); + } + // Only last 3 retained + let pending = store.pending("proj", "token"); + assert_eq!(pending.len(), 3); + } + + #[test] + fn reflection_not_ack_required_by_default_config() { + let store = store(); // ack_required = [".ontology", "adrs"] + let ids = store.push("proj", vec!["reflection/modes/sync.ncl".into()], None); + // reflection not in ack_required → push returns empty + assert!(ids.is_empty()); + } +} diff --git a/crates/ontoref-daemon/src/registry.rs b/crates/ontoref-daemon/src/registry.rs new file mode 100644 index 0000000..9d2a444 --- /dev/null +++ b/crates/ontoref-daemon/src/registry.rs @@ -0,0 +1,288 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +use crate::actors::ActorRegistry; +use crate::cache::NclCache; +use crate::notifications::NotificationStore; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Admin, + Viewer, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyEntry { + pub role: Role, + /// Argon2id PHC string — produced by `ontoref-daemon --hash-password ` + pub hash: String, +} + +/// Serialisable entry used for TOML read/write. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryEntry { + pub slug: String, + pub root: PathBuf, + #[serde(default)] + pub keys: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RegistryFile { + projects: Vec, +} + +/// Per-project runtime state owned by the registry. +pub struct ProjectContext { + pub slug: String, + pub root: PathBuf, + pub import_path: Option, + pub cache: Arc, + pub actors: Arc, + pub notifications: Arc, + /// Stored for TOML serialisation round-trips. + pub keys: Vec, +} + +impl ProjectContext { + pub fn auth_enabled(&self) -> bool { + !self.keys.is_empty() + } + + /// Returns the role of the first key whose argon2id hash matches + /// `password`. + pub fn verify_key(&self, password: &str) -> Option { + use argon2::{Argon2, PasswordHash, PasswordVerifier}; + for key in &self.keys { + let Ok(parsed) = PasswordHash::new(&key.hash) else { + continue; + }; + if Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() + { + return Some(key.role); + } + } + None + } +} + +pub struct ProjectRegistry { + contexts: DashMap>, + pub path: PathBuf, + stale_actor_timeout: u64, + max_notifications: usize, +} + +impl ProjectRegistry { + /// Load and parse a TOML registry file, creating per-project runtime state. + pub fn load( + path: &Path, + stale_actor_timeout: u64, + max_notifications: usize, + ) -> anyhow::Result { + let registry = Self { + contexts: DashMap::new(), + path: path.to_path_buf(), + stale_actor_timeout, + max_notifications, + }; + registry.reload_from_file(path)?; + Ok(registry) + } + + /// Hot-reload from the registry file; preserves existing caches for + /// unchanged slugs. + pub fn reload(&self) -> anyhow::Result<()> { + self.reload_from_file(&self.path.clone()) + } + + fn reload_from_file(&self, path: &Path) -> anyhow::Result<()> { + let contents = std::fs::read_to_string(path)?; + let file: RegistryFile = toml::from_str(&contents)?; + + let new_slugs: std::collections::HashSet = + file.projects.iter().map(|p| p.slug.clone()).collect(); + + // Remove projects no longer in the file. + self.contexts + .retain(|slug, _| new_slugs.contains(slug.as_str())); + + for entry in file.projects { + let root = entry + .root + .canonicalize() + .map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?; + + if let Some(existing) = self.contexts.get(&entry.slug) { + // Project already loaded — update keys only, reuse warm cache/actors. + if existing.root == root { + // Re-insert a new Arc with updated keys but same internals. + let ctx = ProjectContext { + slug: existing.slug.clone(), + root: existing.root.clone(), + import_path: existing.import_path.clone(), + cache: Arc::clone(&existing.cache), + actors: Arc::clone(&existing.actors), + notifications: Arc::clone(&existing.notifications), + keys: entry.keys, + }; + drop(existing); + self.contexts.insert(entry.slug, Arc::new(ctx)); + continue; + } + drop(existing); + } + + // New project — create fresh context. + let import_path = load_import_path(&root); + let ctx = make_context( + entry.slug.clone(), + root, + import_path, + entry.keys, + self.stale_actor_timeout, + self.max_notifications, + ); + self.contexts.insert(entry.slug, Arc::new(ctx)); + } + Ok(()) + } + + /// Add a project at runtime and persist to the TOML file. + pub fn add_project(&self, entry: RegistryEntry) -> anyhow::Result<()> { + if self.contexts.contains_key(&entry.slug) { + anyhow::bail!("project '{}' already exists", entry.slug); + } + let root = entry + .root + .canonicalize() + .map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?; + let import_path = load_import_path(&root); + let ctx = make_context( + entry.slug.clone(), + root, + import_path, + entry.keys, + self.stale_actor_timeout, + self.max_notifications, + ); + self.contexts.insert(entry.slug, Arc::new(ctx)); + self.write_toml() + } + + /// Remove a project by slug and persist to the TOML file. + pub fn remove_project(&self, slug: &str) -> anyhow::Result<()> { + self.contexts.remove(slug); + self.write_toml() + } + + /// Serialise current in-memory state back to the registry TOML file. + pub fn write_toml(&self) -> anyhow::Result<()> { + let mut projects: Vec = self + .contexts + .iter() + .map(|r| RegistryEntry { + slug: r.slug.clone(), + root: r.root.clone(), + keys: r.keys.clone(), + }) + .collect(); + projects.sort_by(|a, b| a.slug.cmp(&b.slug)); + + let file = RegistryFile { projects }; + let toml = toml::to_string_pretty(&file)?; + + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&self.path, toml)?; + Ok(()) + } + + pub fn get(&self, slug: &str) -> Option> { + self.contexts.get(slug).map(|r| Arc::clone(&*r)) + } + + pub fn all(&self) -> Vec> { + let mut list: Vec<_> = self.contexts.iter().map(|r| Arc::clone(&*r)).collect(); + list.sort_by(|a, b| a.slug.cmp(&b.slug)); + list + } + + pub fn count(&self) -> usize { + self.contexts.len() + } +} + +fn make_context( + slug: String, + root: PathBuf, + import_path: Option, + keys: Vec, + stale_actor_timeout: u64, + max_notifications: usize, +) -> ProjectContext { + let sessions_dir = root.join(".ontoref").join("sessions"); + let actors = Arc::new(ActorRegistry::new(stale_actor_timeout).with_persist_dir(sessions_dir)); + actors.load_persisted(); + + ProjectContext { + slug, + root, + import_path, + cache: Arc::new(NclCache::new()), + actors, + notifications: Arc::new(NotificationStore::new( + max_notifications, + vec![".ontology".into(), "adrs".into()], + )), + keys, + } +} + +fn load_import_path(root: &Path) -> Option { + use std::process::Command; + let config_path = root.join(".ontoref").join("config.ncl"); + if !config_path.exists() { + return None; + } + let output = Command::new("nickel") + .arg("export") + .arg(&config_path) + .output() + .ok()?; + if !output.status.success() { + warn!(path = %config_path.display(), "nickel export failed for registry project config"); + return None; + } + let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + let paths = json.get("nickel_import_paths")?.as_array()?; + // Resolve each path against the project root so that relative entries like + // "." or "reflection/schemas" work correctly in multi-project mode where the + // daemon CWD is not the project root. + let joined = paths + .iter() + .filter_map(|v| v.as_str()) + .map(|p| { + let candidate = std::path::Path::new(p); + if candidate.is_absolute() { + p.to_string() + } else { + root.join(candidate).display().to_string() + } + }) + .collect::>() + .join(":"); + if joined.is_empty() { + None + } else { + Some(joined) + } +} diff --git a/crates/ontoref-daemon/src/search.rs b/crates/ontoref-daemon/src/search.rs new file mode 100644 index 0000000..b32af4d --- /dev/null +++ b/crates/ontoref-daemon/src/search.rs @@ -0,0 +1,479 @@ +use std::path::Path; +use std::sync::Arc; + +use serde::Serialize; +use tracing::warn; + +use crate::cache::NclCache; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResultKind { + Node, + Adr, + Mode, +} + +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub kind: ResultKind, + pub id: String, + pub title: String, + pub description: String, + pub detail_html: String, + pub path: String, + pub pole: Option, + pub level: Option, +} + +/// Case-insensitive full-text search across ontology nodes, ADRs, and +/// reflection modes. +pub async fn search_project( + root: &Path, + cache: &Arc, + import_path: Option<&str>, + query: &str, +) -> Vec { + let q = query.to_lowercase(); + let mut results = Vec::new(); + + // Ensure root is always in NICKEL_IMPORT_PATH so that cross-directory imports + // like `import "reflection/defaults.ncl"` resolve from the project root, + // regardless of whether the project has an explicit nickel_import_paths config. + let root_str = root.display().to_string(); + let effective_ip = match import_path { + Some(ip) if !ip.is_empty() => format!("{root_str}:{ip}"), + _ => root_str, + }; + let ip = Some(effective_ip.as_str()); + + search_nodes(root, cache, ip, &q, &mut results).await; + search_adrs(root, cache, ip, &q, &mut results).await; + search_modes(root, cache, ip, &q, &mut results).await; + + results +} + +async fn search_nodes( + root: &Path, + cache: &Arc, + import_path: Option<&str>, + q: &str, + out: &mut Vec, +) { + let core_path = root.join(".ontology").join("core.ncl"); + if !core_path.exists() { + return; + } + let Ok((json, _)) = cache.export(&core_path, import_path).await else { + return; + }; + let Some(nodes) = json.get("nodes").and_then(|n| n.as_array()) else { + return; + }; + for node in nodes { + let id = str_field(node, "id"); + let name = str_field(node, "name"); + let desc = str_field(node, "description"); + if matches(q, &[id, name, desc]) { + out.push(SearchResult { + kind: ResultKind::Node, + id: str_field(node, "id").to_string(), + title: str_field(node, "name").to_string(), + description: truncate(str_field(node, "description"), 180), + detail_html: node_html(node), + path: ".ontology/core.ncl".to_string(), + pole: node.get("pole").and_then(|v| v.as_str()).map(String::from), + level: node.get("level").and_then(|v| v.as_str()).map(String::from), + }); + } + } +} + +async fn search_adrs( + root: &Path, + cache: &Arc, + import_path: Option<&str>, + q: &str, + out: &mut Vec, +) { + let adrs_dir = root.join("adrs"); + let Ok(entries) = std::fs::read_dir(&adrs_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Only process actual ADR records: adr-NNN-*.ncl + if !is_adr_record(fname) { + continue; + } + match cache.export(&path, import_path).await { + Ok((json, _)) => { + let id = str_field(&json, "id"); + let title = str_field(&json, "title"); + let context = str_field(&json, "context"); + let decision = str_field(&json, "decision"); + // Also search constraint claims + let constraint_text = json + .get("constraints") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|c| c.get("claim").and_then(|v| v.as_str())) + .collect::>() + .join(" ") + }) + .unwrap_or_default(); + + if matches(q, &[id, title, context, decision, &constraint_text]) { + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + out.push(SearchResult { + kind: ResultKind::Adr, + id: str_field(&json, "id").to_string(), + title: str_field(&json, "title").to_string(), + description: truncate(str_field(&json, "context"), 180), + detail_html: adr_html(&json), + path: rel, + pole: None, + level: None, + }); + } + } + Err(e) => warn!(path = %path.display(), error = %e, "search: adr export failed"), + } + } +} + +async fn search_modes( + root: &Path, + cache: &Arc, + import_path: Option<&str>, + q: &str, + out: &mut Vec, +) { + let modes_dir = root.join("reflection").join("modes"); + let Ok(entries) = std::fs::read_dir(&modes_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + match cache.export(&path, import_path).await { + Ok((json, _)) => { + let id = str_field(&json, "id"); + let desc = str_field(&json, "description"); + if matches(q, &[id, desc]) { + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + out.push(SearchResult { + kind: ResultKind::Mode, + id: str_field(&json, "id").to_string(), + title: str_field(&json, "id").to_string(), + description: truncate(str_field(&json, "description"), 180), + detail_html: mode_html(&json), + path: rel, + pole: None, + level: None, + }); + } + } + Err(e) => warn!(path = %path.display(), error = %e, "search: mode export failed"), + } + } +} + +// ── Detail HTML builders ───────────────────────────────────────────────────── + +fn node_html(n: &serde_json::Value) -> String { + let desc = str_field(n, "description"); + let level = str_field(n, "level"); + let pole = str_field(n, "pole"); + let invariant = n + .get("invariant") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let artifacts = n + .get("artifact_paths") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + + let mut h = String::new(); + h.push_str(¶(desc)); + h.push_str("
"); + h.push_str(&badge(level, "badge-ghost")); + h.push_str(&badge(pole, "badge-ghost")); + if invariant { + h.push_str(&badge("invariant", "badge-warning")); + } + h.push_str("
"); + if !artifacts.is_empty() { + h.push_str(§ion_header("Artifacts")); + h.push_str("
    "); + for a in artifacts { + h.push_str(&format!( + "
  • {}
  • ", + esc(a) + )); + } + h.push_str("
"); + } + h +} + +fn adr_html(json: &serde_json::Value) -> String { + let mut h = String::new(); + + if let Some(status) = json.get("status").and_then(|v| v.as_str()) { + h.push_str(&format!( + "
{}
", + esc(status) + )); + } + + for field in &["context", "decision"] { + let label = if *field == "context" { + "Context" + } else { + "Decision" + }; + let text = str_field(json, field); + if !text.is_empty() { + h.push_str(§ion_header(label)); + h.push_str(&text_block(text)); + } + } + + if let Some(cons) = json.get("consequences") { + h.push_str(§ion_header("Consequences")); + for (label, key) in &[("Positive", "positive"), ("Negative", "negative")] { + let items = cons + .get(key) + .and_then(|v| v.as_array()) + .map(Vec::as_slice) + .unwrap_or(&[]); + if !items.is_empty() { + h.push_str(&format!( + "

{label}

" + )); + h.push_str(&bullet_list(items)); + } + } + } + + if let Some(constraints) = json.get("constraints").and_then(|v| v.as_array()) { + if !constraints.is_empty() { + h.push_str(§ion_header("Constraints")); + for c in constraints { + let claim = c.get("claim").and_then(|v| v.as_str()).unwrap_or(""); + let rationale = c.get("rationale").and_then(|v| v.as_str()).unwrap_or(""); + let severity = c.get("severity").and_then(|v| v.as_str()).unwrap_or(""); + let badge_cls = if severity == "Hard" { + "badge-error" + } else { + "badge-warning" + }; + h.push_str("
"); + h.push_str(&format!( + "
{severity}

{}

", + esc(claim) + )); + if !rationale.is_empty() { + h.push_str(&format!( + "

{}

", + esc(rationale) + )); + } + h.push_str("
"); + } + } + } + + if let Some(alts) = json + .get("alternatives_considered") + .and_then(|v| v.as_array()) + { + if !alts.is_empty() { + h.push_str(§ion_header("Alternatives Considered")); + for alt in alts { + let opt = alt.get("option").and_then(|v| v.as_str()).unwrap_or(""); + let why = alt + .get("why_rejected") + .and_then(|v| v.as_str()) + .unwrap_or(""); + h.push_str("
"); + h.push_str(&format!( + "

{}

", + esc(opt) + )); + if !why.is_empty() { + h.push_str(&format!( + "

{}

", + esc(why) + )); + } + h.push_str("
"); + } + } + } + + h +} + +fn mode_html(json: &serde_json::Value) -> String { + let mut h = String::new(); + + let desc = str_field(json, "description"); + if !desc.is_empty() { + h.push_str(¶(desc)); + } + + for (label, key) in &[ + ("Preconditions", "preconditions"), + ("Postconditions", "postconditions"), + ] { + let items = json + .get(key) + .and_then(|v| v.as_array()) + .map(Vec::as_slice) + .unwrap_or(&[]); + if !items.is_empty() { + h.push_str(§ion_header(label)); + h.push_str(&bullet_list(items)); + } + } + + if let Some(steps) = json.get("steps").and_then(|v| v.as_array()) { + if !steps.is_empty() { + h.push_str(§ion_header("Steps")); + h.push_str("
    "); + for step in steps { + let step_id = step.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let actor = step.get("actor").and_then(|v| v.as_str()).unwrap_or(""); + let note = step.get("note").and_then(|v| v.as_str()).unwrap_or(""); + let cmd = step.get("cmd").and_then(|v| v.as_str()).unwrap_or(""); + h.push_str("
  1. "); + h.push_str(&format!( + "
    {}{}
    ", + esc(step_id), + esc(actor) + )); + if !note.is_empty() { + h.push_str(&format!( + "

    {}

    ", + esc(note) + )); + } + if !cmd.is_empty() { + h.push_str(&format!( + "
    {}
    ", + esc(cmd) + )); + } + h.push_str("
  2. "); + } + h.push_str("
"); + } + } + + h +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Returns true only for actual ADR record files: `adr-NNN-*.ncl` +/// where NNN is one or more digits immediately after the second hyphen. +fn is_adr_record(fname: &str) -> bool { + let Some(rest) = fname.strip_prefix("adr-") else { + return false; + }; + // next char must be a digit (adr-001-..., adr-1-..., etc.) + rest.starts_with(|c: char| c.is_ascii_digit()) && fname.ends_with(".ncl") +} + +fn str_field<'a>(v: &'a serde_json::Value, key: &str) -> &'a str { + v.get(key).and_then(|v| v.as_str()).unwrap_or("") +} + +fn matches(q: &str, fields: &[&str]) -> bool { + fields.iter().any(|f| f.to_lowercase().contains(q)) +} + +fn truncate(s: &str, n: usize) -> String { + if s.len() <= n { + s.to_string() + } else { + format!("{}…", &s[..s.floor_char_boundary(n)]) + } +} + +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn section_header(label: &str) -> String { + format!( + "

{}

", + esc(label) + ) +} + +fn para(text: &str) -> String { + let escaped = esc(text); + // double-newline → paragraph break, single newline →
+ let paras: Vec<_> = escaped.split("\n\n").collect(); + paras + .iter() + .map(|p| { + format!( + "

{}

", + p.replace('\n', "
") + ) + }) + .collect::>() + .join("") +} + +fn text_block(text: &str) -> String { + format!( + "
{}
", + para(text) + ) +} + +fn badge(text: &str, cls: &str) -> String { + format!("{}", esc(text)) +} + +fn bullet_list(items: &[serde_json::Value]) -> String { + let mut h = String::from( + "
    ", + ); + for s in items.iter().filter_map(|v| v.as_str()) { + h.push_str(&format!("
  • {}
  • ", esc(s))); + } + h.push_str("
"); + h +} diff --git a/crates/ontoref-daemon/src/seed.rs b/crates/ontoref-daemon/src/seed.rs new file mode 100644 index 0000000..d19dfe2 --- /dev/null +++ b/crates/ontoref-daemon/src/seed.rs @@ -0,0 +1,131 @@ +use std::path::Path; + +use serde_json::Value; +use tracing::{info, warn}; + +use crate::cache::NclCache; + +/// Counts of records upserted per table during a seed pass. +pub struct SeedReport { + pub nodes: usize, + pub edges: usize, + pub dimensions: usize, + pub membranes: usize, +} + +/// Seed ontology tables from local NCL files into SurrealDB. +/// +/// Local files are the source of truth. The DB is a queryable projection +/// rebuilt from files on daemon startup and on watcher-detected changes. +/// Uses UPSERT semantics — idempotent, safe to call on every change. +#[cfg(feature = "db")] +pub async fn seed_ontology( + db: &stratum_db::StratumDb, + project_root: &Path, + cache: &NclCache, + import_path: Option<&str>, +) -> SeedReport { + let mut report = SeedReport { + nodes: 0, + edges: 0, + dimensions: 0, + membranes: 0, + }; + + let core_path = project_root.join(".ontology").join("core.ncl"); + if core_path.exists() { + match cache.export(&core_path, import_path).await { + Ok((json, _)) => { + report.nodes = seed_table_by_id(db, "node", &json, "nodes").await; + report.edges = seed_edges(db, &json).await; + } + Err(e) => warn!(error = %e, "seed: core.ncl export failed"), + } + } + + let state_path = project_root.join(".ontology").join("state.ncl"); + if state_path.exists() { + match cache.export(&state_path, import_path).await { + Ok((json, _)) => { + report.dimensions = seed_table_by_id(db, "dimension", &json, "dimensions").await; + } + Err(e) => warn!(error = %e, "seed: state.ncl export failed"), + } + } + + let gate_path = project_root.join(".ontology").join("gate.ncl"); + if gate_path.exists() { + match cache.export(&gate_path, import_path).await { + Ok((json, _)) => { + report.membranes = seed_table_by_id(db, "membrane", &json, "membranes").await; + } + Err(e) => warn!(error = %e, "seed: gate.ncl export failed"), + } + } + + info!( + nodes = report.nodes, + edges = report.edges, + dimensions = report.dimensions, + membranes = report.membranes, + "ontology seeded from local files" + ); + + report +} + +/// Generic upsert: extract `json[array_key]`, iterate, use each item's `id` +/// field as record key. +#[cfg(feature = "db")] +async fn seed_table_by_id( + db: &stratum_db::StratumDb, + table: &str, + json: &Value, + array_key: &str, +) -> usize { + let items = match json.get(array_key).and_then(|a| a.as_array()) { + Some(arr) => arr, + None => return 0, + }; + + let mut count = 0; + for item in items { + let id = match item.get("id").and_then(|i| i.as_str()) { + Some(id) => id, + None => continue, + }; + if let Err(e) = db.upsert(table, id, item.clone()).await { + warn!(table, id, error = %e, "seed: upsert failed"); + continue; + } + count += 1; + } + count +} + +/// Edges use a deterministic compound key: `{from}--{kind}--{to}`. +#[cfg(feature = "db")] +async fn seed_edges(db: &stratum_db::StratumDb, core_json: &Value) -> usize { + let edges = match core_json.get("edges").and_then(|e| e.as_array()) { + Some(arr) => arr, + None => return 0, + }; + + let mut count = 0; + for edge in edges { + let from = edge.get("from").and_then(|f| f.as_str()).unwrap_or(""); + let to = edge.get("to").and_then(|t| t.as_str()).unwrap_or(""); + let kind = edge + .get("kind") + .and_then(|k| k.as_str()) + .unwrap_or("unknown"); + let edge_id = format!("{from}--{kind}--{to}"); + + if let Err(e) = db.upsert("edge", &edge_id, edge.clone()).await { + warn!(edge_id, error = %e, "seed: edge upsert failed"); + continue; + } + count += 1; + } + count +} diff --git a/crates/ontoref-daemon/src/session.rs b/crates/ontoref-daemon/src/session.rs new file mode 100644 index 0000000..16dd1d3 --- /dev/null +++ b/crates/ontoref-daemon/src/session.rs @@ -0,0 +1,81 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use dashmap::DashMap; +use uuid::Uuid; + +use crate::registry::Role; + +pub const COOKIE_NAME: &str = "ontoref-session"; +const SESSION_SECS: u64 = 30 * 24 * 3600; + +#[derive(Clone)] +pub struct SessionEntry { + pub slug: String, + pub role: Role, + pub expires: u64, +} + +pub struct SessionStore { + sessions: DashMap, +} + +impl Default for SessionStore { + fn default() -> Self { + Self::new() + } +} + +impl SessionStore { + pub fn new() -> Self { + Self { + sessions: DashMap::new(), + } + } + + pub fn create(&self, slug: String, role: Role) -> String { + let token = Uuid::new_v4().to_string(); + let expires = now_secs() + SESSION_SECS; + self.sessions.insert( + token.clone(), + SessionEntry { + slug, + role, + expires, + }, + ); + token + } + + pub fn get(&self, token: &str) -> Option { + let entry = self.sessions.get(token)?; + if entry.expires < now_secs() { + drop(entry); + self.sessions.remove(token); + return None; + } + Some(entry.clone()) + } + + pub fn revoke(&self, token: &str) { + self.sessions.remove(token); + } +} + +/// Extract the value of a named cookie from a raw `Cookie:` header string. +pub fn extract_cookie(header: &str, name: &str) -> Option { + for part in header.split(';') { + if let Some((k, v)) = part.trim().split_once('=') { + if k.trim() == name { + return Some(v.trim().to_string()); + } + } + } + None +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/crates/ontoref-daemon/src/ui/auth.rs b/crates/ontoref-daemon/src/ui/auth.rs new file mode 100644 index 0000000..11a1439 --- /dev/null +++ b/crates/ontoref-daemon/src/ui/auth.rs @@ -0,0 +1,122 @@ +use axum::{ + extract::{FromRequestParts, Path}, + http::{header, request::Parts, StatusCode}, + response::{IntoResponse, Redirect, Response}, +}; + +use crate::api::AppState; +use crate::registry::Role; +use crate::session::{self, SessionEntry, COOKIE_NAME}; + +/// Injected by the `AuthUser` extractor; available as a handler parameter. +#[derive(Clone)] +pub struct AuthUser(pub SessionEntry); + +impl FromRequestParts for AuthUser { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let Path(slug) = Path::::from_request_parts(parts, state) + .await + .map_err(|_| axum::http::StatusCode::BAD_REQUEST.into_response())?; + + let Some(ref registry) = state.registry else { + return Err(axum::http::StatusCode::NOT_FOUND.into_response()); + }; + let Some(ctx) = registry.get(&slug) else { + return Err(axum::http::StatusCode::NOT_FOUND.into_response()); + }; + + if !ctx.auth_enabled() { + // Auth not required for this project — synthesise a pass-through entry. + use std::time::{SystemTime, UNIX_EPOCH}; + let expires = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + + 86400; + return Ok(AuthUser(SessionEntry { + slug, + role: Role::Admin, + expires, + })); + } + + let cookie_str = parts + .headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let Some(token) = session::extract_cookie(cookie_str, COOKIE_NAME) else { + return Err(Redirect::to(&format!("/ui/{slug}/login")).into_response()); + }; + + let Some(entry) = state.sessions.get(&token) else { + return Err(Redirect::to(&format!("/ui/{slug}/login")).into_response()); + }; + + if entry.slug != slug { + return Err(Redirect::to(&format!("/ui/{slug}/login")).into_response()); + } + + Ok(AuthUser(entry)) + } +} + +/// Global manage-page guard — no slug in path. +/// +/// Allows access when: +/// - The registry has no projects with auth enabled (open/loopback deployment). +/// - The request carries a valid admin-role session cookie for any project. +/// +/// On failure redirects to the login page of the first alphabetically sorted +/// project that has auth enabled; returns 403 if there is no such project to +/// redirect to. +pub struct AdminGuard; + +impl FromRequestParts for AdminGuard { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let Some(ref registry) = state.registry else { + // Single-project mode — no registry, manage is not meaningful but not blocked. + return Ok(AdminGuard); + }; + + let auth_projects: Vec = registry + .all() + .into_iter() + .filter(|ctx| ctx.auth_enabled()) + .map(|ctx| ctx.slug.clone()) + .collect(); + + if auth_projects.is_empty() { + // No project requires auth — open deployment, pass through. + return Ok(AdminGuard); + } + + let cookie_str = parts + .headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if let Some(token) = session::extract_cookie(cookie_str, COOKIE_NAME) { + if let Some(entry) = state.sessions.get(&token) { + if matches!(entry.role, Role::Admin) { + return Ok(AdminGuard); + } + } + } + + // Find first auth-enabled project as redirect target (stable: sorted above by + // slug). + let mut sorted = auth_projects; + sorted.sort(); + if let Some(slug) = sorted.first() { + return Err(Redirect::to(&format!("/ui/{slug}/login")).into_response()); + } + Err(StatusCode::FORBIDDEN.into_response()) + } +} diff --git a/crates/ontoref-daemon/src/ui/backlog_ncl.rs b/crates/ontoref-daemon/src/ui/backlog_ncl.rs new file mode 100644 index 0000000..2b40d42 --- /dev/null +++ b/crates/ontoref-daemon/src/ui/backlog_ncl.rs @@ -0,0 +1,189 @@ +//! In-place mutations of reflection/backlog.ncl. +//! +//! The Nickel file format is predictable enough for line-level surgery. +//! We never parse the full AST — we only do targeted replacements inside +//! item blocks identified by their unique `id` field. + +use std::path::Path; + +/// Update `status` and `updated` fields inside the item block with `id`. +pub fn update_status(path: &Path, id: &str, new_status: &str, today: &str) -> anyhow::Result<()> { + let content = std::fs::read_to_string(path)?; + let updated = mutate_item_fields( + &content, + id, + &[ + ("status", &format!("'{new_status}")), + ("updated", &format!("\"{today}\"")), + ], + ); + std::fs::write(path, updated)?; + Ok(()) +} + +/// Append a new item to the items array. +/// +/// Generates the next `bl-NNN` id, formats a Nickel record, and inserts it +/// before the closing ` ],` of the items array. +pub fn add_item( + path: &Path, + title: &str, + kind: &str, + priority: &str, + detail: &str, + today: &str, +) -> anyhow::Result { + let content = std::fs::read_to_string(path)?; + let next_id = next_item_id(&content); + + let block = format!( + r#" {{ + id = "{id}", + title = "{title}", + kind = '{kind}, + priority = '{priority}, + status = 'Open, + detail = "{detail}", + related_adrs = [], + related_modes = [], + graduates_to = 'StateTransition, + created = "{today}", + updated = "{today}", + }}, +"#, + id = next_id, + title = escape_ncl(title), + detail = escape_ncl(detail), + ); + + // Insert before the closing ` ],` of the items array. + let updated = insert_before_array_close(&content, &block)?; + std::fs::write(path, updated)?; + Ok(next_id) +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Replace the value of one or more fields within the item block identified by +/// `id`. +/// +/// For each `(field_name, new_value)` pair, finds the line +/// `field_name=...,` inside the target block and replaces the value +/// in-place. +fn mutate_item_fields(content: &str, id: &str, fields: &[(&str, &str)]) -> String { + let id_needle = format!("\"{}\"", id); + let mut in_block = false; + let mut result: Vec = Vec::with_capacity(content.lines().count() + 1); + + for line in content.lines() { + if !in_block { + if line.contains(&id_needle) && line.contains('=') { + in_block = true; + } + result.push(line.to_string()); + continue; + } + + // Inside the target block — attempt field replacement. + let trimmed = line.trim_start(); + let mut replaced = false; + for (field, new_val) in fields { + if trimmed.starts_with(field) { + let eq_pos = trimmed.find('=').unwrap_or(usize::MAX); + // Guard: must have `=` and next non-whitespace char after `=` is the value. + if eq_pos < trimmed.len() { + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + // Preserve original field alignment by keeping spacing up to `=`. + let before_eq = &trimmed[..eq_pos]; + result.push(format!( + "{}{} {} {},", + indent, + before_eq.trim_end(), + "=", + new_val + )); + replaced = true; + break; + } + } + } + if !replaced { + result.push(line.to_string()); + } + + // Detect end of block: a line whose trimmed form is `},` + if trimmed == "}," { + in_block = false; + } + } + + result.join("\n") +} + +/// Find the highest `bl-NNN` id and return `bl-(NNN+1)` zero-padded to 3 +/// digits. +fn next_item_id(content: &str) -> String { + let max = content + .lines() + .filter_map(|line| { + let t = line.trim(); + let rest = t.strip_prefix("id")?; + let val = rest.split('"').nth(1)?; + let num_str = val.strip_prefix("bl-")?; + num_str.parse::().ok() + }) + .max() + .unwrap_or(0); + format!("bl-{:03}", max + 1) +} + +/// Insert `block` before the first occurrence of ` ],` (items array close). +fn insert_before_array_close(content: &str, block: &str) -> anyhow::Result { + // We look for a line whose trimmed content is `],` — this is the array closing. + // To avoid matching other arrays, we require exactly two leading spaces. + let needle = " ],"; + let pos = content.find(needle).ok_or_else(|| { + anyhow::anyhow!("could not locate items array closing ` ],` in backlog.ncl") + })?; + let mut result = String::with_capacity(content.len() + block.len()); + result.push_str(&content[..pos]); + result.push_str(block); + result.push_str(&content[pos..]); + Ok(result) +} + +/// Minimal escaping for string values embedded in Nickel double-quoted strings. +fn escape_ncl(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_id_empty() { + assert_eq!(next_item_id(""), "bl-001"); + } + + #[test] + fn next_id_increments() { + let content = r#"id = "bl-003","#; + assert_eq!(next_item_id(content), "bl-004"); + } + + #[test] + fn mutate_status() { + let content = "id = \"bl-001\",\nstatus = 'Open,\nupdated = \"2026-01-01\",\n},\n"; + let result = mutate_item_fields( + content, + "bl-001", + &[("status", "'Done"), ("updated", "\"2026-03-12\"")], + ); + assert!(result.contains("'Done"), "status not updated: {result}"); + assert!( + result.contains("\"2026-03-12\""), + "updated not changed: {result}" + ); + } +} diff --git a/crates/ontoref-daemon/src/ui/drift_watcher.rs b/crates/ontoref-daemon/src/ui/drift_watcher.rs new file mode 100644 index 0000000..e391607 --- /dev/null +++ b/crates/ontoref-daemon/src/ui/drift_watcher.rs @@ -0,0 +1,219 @@ +//! Passive drift observer. +//! +//! Watches `crates/`, `.ontology/`, and `adrs/` for changes. After a debounce +//! window, spawns `./ontoref sync scan` followed by `./ontoref sync diff`. If +//! the diff output contains MISSING, STALE, DRIFT, or BROKEN items it pushes a +//! custom notification into every registered project's notification ring +//! buffer. +//! +//! Intentionally read-only: no `apply` step is ever triggered automatically. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +use crate::notifications::NotificationStore; + +const DEBOUNCE: Duration = Duration::from_secs(15); + +pub struct DriftWatcher { + _watcher: RecommendedWatcher, + _task: tokio::task::JoinHandle<()>, +} + +impl DriftWatcher { + /// Start watching `project_root` for source and ontology changes. + /// + /// Watches `crates/`, `.ontology/`, `adrs/` (and `reflection/modes/`). + /// When drift is detected after `./ontoref sync scan && sync diff`, emits + /// a custom notification via `notifications`. + pub fn start( + project_root: &Path, + project_name: String, + notifications: Arc, + ) -> Result { + let root = project_root.to_path_buf(); + let (tx, rx) = mpsc::channel::(64); + + let tx2 = tx.clone(); + let mut watcher = RecommendedWatcher::new( + move |res: std::result::Result| match res { + Ok(event) => { + for path in event.paths { + let _ = tx2.try_send(path); + } + } + Err(e) => warn!(error = %e, "drift watcher notify error"), + }, + Config::default(), + ) + .map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?; + + // Watch relevant subtrees; ignore missing dirs gracefully. + for subdir in &["crates", ".ontology", "adrs", "reflection/modes"] { + let path = root.join(subdir); + if path.exists() { + watcher + .watch(&path, RecursiveMode::Recursive) + .map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?; + debug!(path = %path.display(), "drift watcher: watching"); + } + } + + info!(root = %root.display(), "drift watcher started"); + + let task = tokio::spawn(drift_loop(rx, root, project_name, notifications)); + + Ok(Self { + _watcher: watcher, + _task: task, + }) + } +} + +async fn drift_loop( + mut rx: mpsc::Receiver, + root: PathBuf, + project: String, + notifications: Arc, +) { + loop { + // Block until first change arrives. + let Some(_first) = rx.recv().await else { + return; + }; + + // Drain events within debounce window. + tokio::time::sleep(DEBOUNCE).await; + while rx.try_recv().is_ok() {} + + info!( + project, + "drift watcher: change detected — running scan+diff" + ); + run_scan_diff(&root, &project, ¬ifications).await; + } +} + +async fn run_scan_diff(root: &Path, project: &str, notifications: &Arc) { + let ontoref_bin = root.join("ontoref"); + if !ontoref_bin.exists() { + debug!("drift watcher: ontoref binary not found, skipping scan+diff"); + return; + } + + // Phase 1: scan. + let scan_ok = tokio::process::Command::new(&ontoref_bin) + .args(["sync", "scan"]) + .current_dir(root) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false); + + if !scan_ok { + warn!(project, "drift watcher: sync scan failed"); + return; + } + + // Phase 2: diff — capture stdout to detect drift. + let diff_out = match tokio::process::Command::new(&ontoref_bin) + .args(["sync", "diff"]) + .current_dir(root) + .output() + .await + { + Ok(o) => o, + Err(e) => { + warn!(project, error = %e, "drift watcher: sync diff spawn failed"); + return; + } + }; + + let stdout = String::from_utf8_lossy(&diff_out.stdout); + let stderr = String::from_utf8_lossy(&diff_out.stderr); + let combined = format!("{stdout}{stderr}"); + + let counts = DriftCounts::parse(&combined); + + if counts.is_clean() { + debug!(project, "drift watcher: no drift detected"); + return; + } + + let summary = counts.summary(); + info!(project, %summary, "drift watcher: ontology drift detected"); + + notifications.push_custom( + project, + "ontology_drift", + format!("Ontology drift detected — {summary}"), + Some(serde_json::json!({ + "kind": "drift", + "counts": { + "missing": counts.missing, + "stale": counts.stale, + "drift": counts.drift, + "broken": counts.broken, + }, + "hint": "Run `./ontoref sync-ontology` to review and apply patches.", + })), + Some("drift-watcher".to_string()), + None, + ); +} + +struct DriftCounts { + missing: usize, + stale: usize, + drift: usize, + broken: usize, +} + +impl DriftCounts { + /// Parse diff output looking for MISSING / STALE / DRIFT / BROKEN markers. + /// + /// The format from `./ontoref sync diff` uses these keywords as line + /// prefixes or inline labels. We count distinct occurrences. + fn parse(output: &str) -> Self { + let missing = count_marker(output, "MISSING"); + let stale = count_marker(output, "STALE"); + let drift = count_marker(output, "DRIFT"); + let broken = count_marker(output, "BROKEN"); + Self { + missing, + stale, + drift, + broken, + } + } + + fn is_clean(&self) -> bool { + self.missing == 0 && self.stale == 0 && self.drift == 0 && self.broken == 0 + } + + fn summary(&self) -> String { + let mut parts = Vec::new(); + if self.missing > 0 { + parts.push(format!("{} MISSING", self.missing)); + } + if self.stale > 0 { + parts.push(format!("{} STALE", self.stale)); + } + if self.drift > 0 { + parts.push(format!("{} DRIFT", self.drift)); + } + if self.broken > 0 { + parts.push(format!("{} BROKEN", self.broken)); + } + parts.join(", ") + } +} + +fn count_marker(output: &str, marker: &str) -> usize { + output.lines().filter(|l| l.contains(marker)).count() +} diff --git a/crates/ontoref-daemon/src/ui/handlers.rs b/crates/ontoref-daemon/src/ui/handlers.rs new file mode 100644 index 0000000..da6019f --- /dev/null +++ b/crates/ontoref-daemon/src/ui/handlers.rs @@ -0,0 +1,2467 @@ +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::extract::{Form, Path, State}; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use tera::Context; +use tokio::sync::RwLock; +use tracing::warn; + +use super::auth::AuthUser; +use crate::api::AppState; + +// ── Error ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum UiError { + #[error("template render error: {0}")] + Render(#[from] tera::Error), + #[error("ncl export failed for {path}: {reason}")] + NclExport { path: String, reason: String }, + #[error("ui is not configured — start the daemon with --templates-dir")] + NotConfigured, + #[error("bad request: {0}")] + BadRequest(String), + #[error("forbidden: {0}")] + Forbidden(String), +} + +impl IntoResponse for UiError { + fn into_response(self) -> Response { + let status = match &self { + UiError::BadRequest(_) => StatusCode::BAD_REQUEST, + UiError::Forbidden(_) => StatusCode::FORBIDDEN, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + let html = format!( + r#" +

UI Error

+
{self}
+"# + ); + (status, Html(html)).into_response() + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Acquire read lock on Tera and render a template. +/// The lock is held only for the duration of `render()` — not across the +/// async NCL export — so reloads are never blocked by slow nickel subprocess +/// calls. +pub(crate) async fn render( + tera: &Arc>, + template: &str, + ctx: &Context, +) -> Result, UiError> { + let guard = tera.read().await; + let html = guard.render(template, ctx)?; + Ok(Html(html)) +} + +pub(crate) fn tera_ref(state: &AppState) -> Result<&Arc>, UiError> { + state.tera.as_ref().ok_or(UiError::NotConfigured) +} + +async fn ncl_export(state: &AppState, relative_path: &str) -> Result { + let path = state.project_root.join(relative_path); + let (value, _hit) = state + .cache + .export(&path, state.nickel_import_path.as_deref()) + .await + .map_err(|e| UiError::NclExport { + path: relative_path.to_string(), + reason: e.to_string(), + })?; + Ok(value) +} + +pub(crate) fn epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +// ── Auth helpers ───────────────────────────────────────────────────────────── + +/// Map the HTTP auth role to a lowercase string for Tera context. +/// Templates gate admin-only sections on `current_role == "admin"`. +fn auth_role_str(auth: &AuthUser) -> &'static str { + use crate::registry::Role; + match auth.0.role { + Role::Admin => "admin", + Role::Viewer => "viewer", + } +} + +// ── Brand context ──────────────────────────────────────────────────────────── + +/// Resolve a logo config value to a URL. +/// Bare paths (no `/` or scheme prefix) are served from `{base_url}/assets/`. +fn resolve_logo_url(raw: &str, base_url: &str) -> String { + if raw.starts_with('/') || raw.starts_with("http://") || raw.starts_with("https://") { + raw.to_string() + } else { + format!("{base_url}/assets/{raw}") + } +} + +/// Load logo URLs from `.ontoref/config.ncl` ui section. +/// Returns `(logo_light_url, logo_dark_url)` — either may be `None`. +async fn load_logos( + root: &std::path::Path, + cache: &Arc, + import_path: Option<&str>, + base_url: &str, +) -> (Option, Option) { + let config_path = root.join(".ontoref").join("config.ncl"); + if !config_path.exists() { + return (None, None); + } + let Ok((json, _)) = cache.export(&config_path, import_path).await else { + return (None, None); + }; + let ui = json.get("ui"); + let logo = ui + .and_then(|u| u.get("logo")) + .and_then(|v| v.as_str()) + .map(|raw| resolve_logo_url(raw, base_url)); + let logo_dark = ui + .and_then(|u| u.get("logo_dark")) + .and_then(|v| v.as_str()) + .map(|raw| resolve_logo_url(raw, base_url)); + (logo, logo_dark) +} + +/// Insert logo and MCP metadata into a Tera context. +/// Logos are loaded from `.ontoref/config.ncl`; MCP availability is +/// compile-time. +async fn insert_brand_ctx( + ctx: &mut Context, + root: &std::path::Path, + cache: &Arc, + import_path: Option<&str>, + base_url: &str, +) { + let (logo, logo_dark) = load_logos(root, cache, import_path, base_url).await; + ctx.insert("logo", &logo); + ctx.insert("logo_dark", &logo_dark); + insert_mcp_ctx(ctx); +} + +/// Insert MCP metadata and daemon version into a Tera context. +fn insert_mcp_ctx(ctx: &mut Context) { + ctx.insert("daemon_version", env!("CARGO_PKG_VERSION")); + #[cfg(feature = "mcp")] + { + ctx.insert("mcp_active", &true); + ctx.insert( + "mcp_tools", + &[ + "ontoref_help", + "ontoref_list_projects", + "ontoref_set_project", + "ontoref_search", + "ontoref_get", + "ontoref_status", + "ontoref_describe", + "ontoref_list_adrs", + "ontoref_get_adr", + "ontoref_list_modes", + "ontoref_get_mode", + "ontoref_get_node", + "ontoref_backlog_list", + "ontoref_backlog", + "ontoref_constraints", + ], + ); + } + #[cfg(not(feature = "mcp"))] + { + ctx.insert("mcp_active", &false); + ctx.insert("mcp_tools", &Vec::<&str>::new()); + } +} + +// ── Dashboard ──────────────────────────────────────────────────────────────── + +pub async fn dashboard(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + + let hits = state.cache.hit_count(); + let misses = state.cache.miss_count(); + let total = hits + misses; + let hit_rate = if total > 0 { + format!("{:.1}%", hits as f64 / total as f64 * 100.0) + } else { + "—".to_string() + }; + + let mut ctx = Context::new(); + ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs()); + ctx.insert("cache_entries", &state.cache.len()); + ctx.insert("cache_hits", &hits); + ctx.insert("cache_misses", &misses); + ctx.insert("cache_hit_rate", &hit_rate); + ctx.insert("active_actors", &state.actors.count()); + ctx.insert( + "notification_count", + &state.notifications.all_recent().len(), + ); + ctx.insert("project_root", &state.project_root.display().to_string()); + ctx.insert("now", &epoch_secs()); + ctx.insert("base_url", "/ui"); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + + render(tera, "pages/dashboard.html", &ctx).await +} + +// ── Graph ──────────────────────────────────────────────────────────────────── + +pub async fn graph(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + + let core_json = match ncl_export(&state, ".ontology/core.ncl").await { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "failed to load .ontology/core.ncl for graph"); + serde_json::json!({ "nodes": [], "edges": [] }) + } + }; + + let graph_json = serde_json::to_string(&core_json).unwrap_or_else(|_| "{}".to_string()); + + let mut ctx = Context::new(); + ctx.insert("graph_json", &graph_json); + ctx.insert("project_root", &state.project_root.display().to_string()); + ctx.insert("base_url", "/ui"); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + + render(tera, "pages/graph.html", &ctx).await +} + +// ── Sessions ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct SessionRow { + token: String, + actor_type: String, + hostname: String, + pid: u32, + project: String, + role: String, + registered_ago: u64, + last_seen_ago: u64, + pending_notifications: u64, + has_preferences: bool, +} + +pub async fn sessions(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + + let now = epoch_secs(); + let rows: Vec = state + .actors + .list() + .into_iter() + .map(|(token, s)| SessionRow { + token, + actor_type: s.actor_type, + hostname: s.hostname, + pid: s.pid, + project: s.project, + role: s.role, + registered_ago: now.saturating_sub(s.registered_at), + last_seen_ago: now.saturating_sub(s.last_seen), + pending_notifications: s.pending_notifications, + has_preferences: !s + .preferences + .as_object() + .map(|m| m.is_empty()) + .unwrap_or(true), + }) + .collect(); + + let mut ctx = Context::new(); + ctx.insert("sessions", &rows); + ctx.insert("total", &rows.len()); + ctx.insert("base_url", "/ui"); + ctx.insert("current_role", "admin"); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + + render(tera, "pages/sessions.html", &ctx).await +} + +// ── Notifications +// ───────────────────────────────────────────────────────────── + +pub async fn notifications_page(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + + let now = epoch_secs(); + + #[derive(Serialize)] + struct NotifRow { + id: u64, + project: String, + event: String, + files: Vec, + age_secs: u64, + source_actor: Option, + } + + let rows: Vec = state + .notifications + .all_recent() + .into_iter() + .map(|n| NotifRow { + id: n.id, + project: n.project, + event: format!("{:?}", n.event), + files: n.files, + age_secs: now.saturating_sub(n.timestamp), + source_actor: n.source_actor, + }) + .collect(); + + let mut ctx = Context::new(); + ctx.insert("notifications", &rows); + ctx.insert("total", &rows.len()); + ctx.insert("base_url", "/ui"); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + + render(tera, "pages/notifications.html", &ctx).await +} + +// ── Search ──────────────────────────────────────────────────────────────────── + +pub async fn search_page(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + let mut ctx = Context::new(); + ctx.insert("base_url", "/ui"); + ctx.insert("slug", &Option::::None); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + render(tera, "pages/search.html", &ctx).await +} + +pub async fn search_page_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + let base_url = format!("/ui/{slug}"); + let mut ctx = Context::new(); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &auth_role_str(&auth)); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + render(tera, "pages/search.html", &ctx).await +} + +// ── Modes ───────────────────────────────────────────────────────────────────── + +pub async fn modes(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + + let modes_dir = state.project_root.join("reflection").join("modes"); + let mut mode_entries: Vec = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&modes_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let rel = path + .strip_prefix(&state.project_root) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + + match state + .cache + .export(&path, state.nickel_import_path.as_deref()) + .await + { + Ok((mut json, _)) => { + json.as_object_mut() + .map(|o| o.insert("_file".to_string(), serde_json::Value::String(rel))); + mode_entries.push(json); + } + Err(e) => { + warn!(path = %rel, error = %e, "failed to export reflection mode"); + mode_entries.push(serde_json::json!({ + "_file": rel, + "id": path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"), + "_error": e.to_string(), + })); + } + } + } + } + + mode_entries.sort_by_key(|v| { + v.get("id") + .and_then(|i| i.as_str()) + .unwrap_or("") + .to_string() + }); + + let showcase = detect_showcase(&state.project_root, "/ui"); + let generated = detect_generated(&state.project_root, "/ui"); + + let mut ctx = Context::new(); + ctx.insert("modes", &mode_entries); + ctx.insert("total", &mode_entries.len()); + ctx.insert("showcase", &showcase); + ctx.insert("generated", &generated); + ctx.insert("base_url", "/ui"); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + + render(tera, "pages/modes.html", &ctx).await +} + +// ── Project Picker +// ──────────────────────────────────────────────────────────── + +pub async fn project_picker(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + + let registry = match state.registry.as_ref() { + Some(r) => r, + None => { + let mut ctx = Context::new(); + ctx.insert("projects", &serde_json::json!([])); + ctx.insert("base_url", "/ui"); + ctx.insert("hide_project_nav", &true); + insert_mcp_ctx(&mut ctx); + return render(tera, "pages/project_picker.html", &ctx).await; + } + }; + + let now = epoch_secs(); + let mut projects: Vec = Vec::new(); + + for proj in registry.all() { + // Sessions + let actor_list = proj.actors.list(); + let sessions: Vec = actor_list + .iter() + .map(|(token, s)| { + serde_json::json!({ + "token": &token[..8.min(token.len())], + "actor_type": s.actor_type, + "hostname": s.hostname, + "pid": s.pid, + "last_seen_ago": now.saturating_sub(s.last_seen), + }) + }) + .collect(); + + // Notifications + let notif_list = proj.notifications.all_recent(); + let notifications: Vec = notif_list + .iter() + .take(8) + .map(|n| { + serde_json::json!({ + "id": n.id, + "event": format!("{:?}", n.event), + "files": n.files.iter().take(3).collect::>(), + "age_secs": now.saturating_sub(n.timestamp), + }) + }) + .collect(); + + // Backlog — optional NCL export + let backlog_path = proj.root.join("reflection").join("backlog.ncl"); + let (backlog_items, backlog_open) = if backlog_path.exists() { + match proj + .cache + .export(&backlog_path, proj.import_path.as_deref()) + .await + { + Ok((json, _)) => { + let items: Vec = json + .get("items") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .map(|it| serde_json::json!({ + "id": it.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "title": it.get("title").and_then(|v| v.as_str()).unwrap_or(""), + "status": it.get("status").and_then(|v| v.as_str()).unwrap_or(""), + "priority": it.get("priority").and_then(|v| v.as_str()).unwrap_or(""), + "kind": it.get("kind").and_then(|v| v.as_str()).unwrap_or(""), + })) + .collect() + }) + .unwrap_or_default(); + let open = items + .iter() + .filter(|it| it.get("status").and_then(|v| v.as_str()) == Some("Open")) + .count(); + (items, open) + } + Err(_) => (vec![], 0), + } + } else { + (vec![], 0) + }; + + // Manifest — layers, operational_modes, default_mode, repo_kind + let manifest_path = proj.root.join(".ontology").join("manifest.ncl"); + let (layers, op_modes, default_mode, repo_kind) = if manifest_path.exists() { + match proj + .cache + .export(&manifest_path, proj.import_path.as_deref()) + .await + { + Ok((json, _)) => { + let layers: Vec = json + .get("layers") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().map(|l| serde_json::json!({ + "id": l.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "description": l.get("description").and_then(|v| v.as_str()).unwrap_or(""), + })).collect()) + .unwrap_or_default(); + let op_modes: Vec = json + .get("operational_modes") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().map(|m| serde_json::json!({ + "id": m.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "description": m.get("description").and_then(|v| v.as_str()).unwrap_or(""), + })).collect()) + .unwrap_or_default(); + let default_mode = json + .get("default_mode") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let repo_kind = json + .get("repo_kind") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + (layers, op_modes, default_mode, repo_kind) + } + Err(_) => (vec![], vec![], String::new(), String::new()), + } + } else { + (vec![], vec![], String::new(), String::new()) + }; + + // Description — first meaningful text line from README.md + let description = readme_description(&proj.root); + + let proj_base = format!("/ui/{}", proj.slug); + let showcase = detect_showcase(&proj.root, &proj_base); + let generated = detect_generated(&proj.root, &proj_base); + + let repos = git_remotes(&proj.root); + + projects.push(serde_json::json!({ + "slug": proj.slug, + "root": proj.root.display().to_string(), + "auth": proj.auth_enabled(), + "description": description, + "default_mode": default_mode, + "repo_kind": repo_kind, + "repos": repos, + "session_count": sessions.len(), + "sessions": sessions, + "notif_count": notifications.len(), + "notifications": notifications, + "backlog_open": backlog_open, + "backlog_items": backlog_items, + "layers": layers, + "op_modes": op_modes, + "showcase": showcase, + "generated": generated, + })); + } + + let mut ctx = Context::new(); + ctx.insert("projects", &projects); + ctx.insert("base_url", "/ui"); + ctx.insert("hide_project_nav", &true); + insert_mcp_ctx(&mut ctx); + render(tera, "pages/project_picker.html", &ctx).await +} + +/// Read git remote names+URLs from `{root}/.git/config` by parsing the ini file +/// directly. Returns `[{ "name": "origin", "url": "git@github.com:..." }, +/// ...]`. +pub(crate) fn git_remotes(root: &std::path::Path) -> Vec { + let Ok(content) = std::fs::read_to_string(root.join(".git").join("config")) else { + return vec![]; + }; + let mut remotes = Vec::new(); + let mut current_name: Option = None; + for line in content.lines() { + let t = line.trim(); + if let Some(inner) = t + .strip_prefix("[remote \"") + .and_then(|s| s.strip_suffix("\"]")) + { + current_name = Some(inner.to_string()); + } else if t.starts_with('[') { + current_name = None; + } else if let Some(name) = ¤t_name { + if let Some(url) = t.strip_prefix("url = ") { + remotes.push(serde_json::json!({ "name": name, "url": url })); + current_name = None; + } + } + } + remotes +} + +/// Extract the first meaningful prose line from README.md. +/// Skips HTML tags, Markdown headings, image/badge lines, blockquotes, and +/// blank lines. +pub(crate) fn readme_description(root: &std::path::Path) -> String { + let readme = root.join("README.md"); + let Ok(content) = std::fs::read_to_string(&readme) else { + return String::new(); + }; + for raw in content.lines() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + // Skip: HTML tags, headings, images, badges, horizontal rules, blockquotes + if line.starts_with('<') + || line.starts_with('#') + || line.starts_with('!') + || line.starts_with("---") + || line.starts_with("===") + || line.contains("badge") + || line.contains("img.shields") + || line.contains("') + // blockquote + { + continue; + } + // Strip leading Markdown bold/italic markers and quote chars + let text = line.trim_start_matches(['*', '_', '`', '>', ' ']); + if text.len() > 10 { + // Truncate at 160 chars for the card + return if text.len() > 160 { + format!("{}…", &text[..text.floor_char_boundary(160)]) + } else { + text.to_string() + }; + } + } + String::new() +} + +fn count_ncl_files(dir: &std::path::Path) -> usize { + std::fs::read_dir(dir) + .map(|rd| { + rd.flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl")) + .count() + }) + .unwrap_or(0) +} + +// ── Multi-project handlers +// ──────────────────────────────────────────────────── + +pub async fn dashboard_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let hits = ctx_ref.cache.hit_count(); + let misses = ctx_ref.cache.miss_count(); + let total = hits + misses; + let hit_rate = if total > 0 { + format!("{:.1}%", hits as f64 / total as f64 * 100.0) + } else { + "—".to_string() + }; + + let base_url = format!("/ui/{slug}"); + let backlog_path = ctx_ref.root.join("reflection").join("backlog.ncl"); + let backlog_items = if backlog_path.exists() { + load_backlog_items( + &ctx_ref.cache, + &backlog_path, + ctx_ref.import_path.as_deref(), + ) + .await + } else { + vec![] + }; + let backlog = backlog_stats(&backlog_items); + let adr_count = count_ncl_files(&ctx_ref.root.join("adrs")); + let mode_count = count_ncl_files(&ctx_ref.root.join("reflection").join("modes")); + + let mut ctx = Context::new(); + ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs()); + ctx.insert("cache_entries", &ctx_ref.cache.len()); + ctx.insert("cache_hits", &hits); + ctx.insert("cache_misses", &misses); + ctx.insert("cache_hit_rate", &hit_rate); + ctx.insert("active_actors", &ctx_ref.actors.count()); + ctx.insert( + "notification_count", + &ctx_ref.notifications.all_recent().len(), + ); + ctx.insert("project_root", &ctx_ref.root.display().to_string()); + ctx.insert("now", &epoch_secs()); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("backlog", &backlog); + ctx.insert("adr_count", &adr_count); + ctx.insert("mode_count", &mode_count); + ctx.insert("current_role", &auth_role_str(&auth)); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + + render(tera, "pages/dashboard.html", &ctx).await +} + +pub async fn graph_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let core_path = ctx_ref.root.join(".ontology/core.ncl"); + let core_json = match ctx_ref + .cache + .export(&core_path, ctx_ref.import_path.as_deref()) + .await + { + Ok((v, _)) => v, + Err(e) => { + warn!(error = %e, slug = %slug, "failed to load .ontology/core.ncl for graph"); + serde_json::json!({ "nodes": [], "edges": [] }) + } + }; + + let graph_json = serde_json::to_string(&core_json).unwrap_or_else(|_| "{}".to_string()); + + let base_url = format!("/ui/{slug}"); + let mut ctx = Context::new(); + ctx.insert("graph_json", &graph_json); + ctx.insert("project_root", &ctx_ref.root.display().to_string()); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &auth_role_str(&auth)); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + + render(tera, "pages/graph.html", &ctx).await +} + +pub async fn sessions_mp( + State(state): State, + Path(slug): Path, + _auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let now = epoch_secs(); + let rows: Vec = ctx_ref + .actors + .list() + .into_iter() + .map(|(token, s)| SessionRow { + token, + actor_type: s.actor_type, + hostname: s.hostname, + pid: s.pid, + project: s.project, + role: s.role, + registered_ago: now.saturating_sub(s.registered_at), + last_seen_ago: now.saturating_sub(s.last_seen), + pending_notifications: s.pending_notifications, + has_preferences: !s + .preferences + .as_object() + .map(|m| m.is_empty()) + .unwrap_or(true), + }) + .collect(); + + let base_url = format!("/ui/{slug}"); + let http_role = auth_role_str(&_auth); + let mut ctx = Context::new(); + ctx.insert("sessions", &rows); + ctx.insert("total", &rows.len()); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &http_role); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + + render(tera, "pages/sessions.html", &ctx).await +} + +pub async fn notifications_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let now = epoch_secs(); + + #[derive(Serialize)] + struct NotifRow { + id: u64, + project: String, + event: String, + is_custom: bool, + files: Vec, + age_secs: u64, + source_actor: Option, + custom_kind: Option, + custom_title: Option, + custom_payload: Option, + source_project: Option, + } + + let rows: Vec = ctx_ref + .notifications + .all_recent() + .into_iter() + .map(|n| { + let is_custom = matches!(n.event, crate::notifications::NotificationEvent::Custom); + NotifRow { + id: n.id, + project: n.project, + event: format!("{:?}", n.event), + is_custom, + files: n.files, + age_secs: now.saturating_sub(n.timestamp), + source_actor: n.source_actor, + custom_kind: n.custom_kind, + custom_title: n.custom_title, + custom_payload: n.custom_payload, + source_project: n.source_project, + } + }) + .collect(); + + // All other registered projects for the emit-to selector + let other_projects: Vec = state + .registry + .as_ref() + .map(|r| r.all().into_iter().map(|p| p.slug.clone()).collect()) + .unwrap_or_default(); + + let base_url = format!("/ui/{slug}"); + let mut ctx = Context::new(); + ctx.insert("notifications", &rows); + ctx.insert("total", &rows.len()); + let http_role = auth_role_str(&auth); + // Viewers can see notifications but cannot emit them — template gates on + // current_role. + let empty: Vec = vec![]; + let visible_projects: &Vec = if http_role == "admin" { + &other_projects + } else { + &empty + }; + ctx.insert("other_projects", visible_projects); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &http_role); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + + render(tera, "pages/notifications.html", &ctx).await +} + +// ── Notification emit +// ───────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct EmitNotifForm { + pub target_slug: String, + pub kind: String, + pub title: String, + #[serde(default)] + pub payload: String, + #[serde(default)] + pub source_actor: String, +} + +// ── Notification DAG action execution ──────────────────────────────────────── + +#[derive(Deserialize)] +pub struct NotifActionForm { + pub action_id: String, +} + +pub async fn notification_action_mp( + State(state): State, + Path((slug, notif_id)): Path<(String, u64)>, + _auth: AuthUser, + Form(form): Form, +) -> Result { + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let notif = ctx_ref + .notifications + .get_one(notif_id) + .ok_or_else(|| UiError::BadRequest(format!("notification {notif_id} not found")))?; + + let actions: Vec = notif + .custom_payload + .as_ref() + .and_then(|p| p.get("actions")) + .and_then(|a| a.as_array()) + .cloned() + .unwrap_or_default(); + + let action = actions + .iter() + .find(|a| a.get("id").and_then(|v| v.as_str()) == Some(form.action_id.as_str())) + .ok_or_else(|| UiError::BadRequest(format!("action '{}' not found", form.action_id)))?; + + let mode = action + .get("mode") + .and_then(|v| v.as_str()) + .unwrap_or("manual"); + let dag_id = action.get("dag").and_then(|v| v.as_str()).unwrap_or(""); + + if !dag_id.is_empty() && matches!(mode, "auto" | "semi") { + let ontoref_bin = ctx_ref.root.join("ontoref"); + if ontoref_bin.exists() { + tokio::process::Command::new(&ontoref_bin) + .arg(dag_id) + .current_dir(&ctx_ref.root) + .spawn() + .map_err(|e| UiError::BadRequest(format!("dag spawn failed: {e}")))?; + } else { + return Err(UiError::BadRequest(format!( + "ontoref binary not found at {}", + ontoref_bin.display() + ))); + } + } + + Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response()) +} + +pub async fn emit_notification_mp( + State(state): State, + Path(slug): Path, + _auth: AuthUser, + Form(form): Form, +) -> Result { + let registry = state.registry.as_ref().ok_or(UiError::NotConfigured)?; + let target = registry.get(&form.target_slug).ok_or_else(|| { + UiError::BadRequest(format!("unknown target project: {}", form.target_slug)) + })?; + + let payload = if form.payload.trim().is_empty() { + None + } else { + serde_json::from_str::(&form.payload).ok() + }; + let actor = if form.source_actor.trim().is_empty() { + None + } else { + Some(form.source_actor) + }; + + target.notifications.push_custom( + &form.target_slug, + &form.kind, + &form.title, + payload, + actor, + Some(slug.clone()), + ); + + Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response()) +} + +// ── Asset serving ──────────────────────────────────────────────────────────── + +/// Serve a file from `{project_root}/assets/` by slug + relative path. +/// Validates that the resolved path stays under the assets directory. +pub async fn serve_asset_mp( + State(state): State, + Path((slug, asset_path)): Path<(String, String)>, +) -> Result { + let proj = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + serve_asset_from(&proj.root, &asset_path).await +} + +pub async fn serve_asset_single( + State(state): State, + Path(asset_path): Path, +) -> Result { + serve_asset_from(&state.project_root, &asset_path).await +} + +async fn serve_asset_from(root: &std::path::Path, rel: &str) -> Result { + let assets_root = root.join("assets"); + let Ok(canonical_root) = assets_root.canonicalize() else { + return Err(UiError::NotConfigured); + }; + + // Resolve the requested path (tolerate leading `/`) + let requested = canonical_root.join(rel.trim_start_matches('/')); + let resolved = match requested.canonicalize() { + Ok(p) => p, + // Try appending index.html for bare directory URLs + Err(_) => { + let with_index = requested.join("index.html"); + with_index + .canonicalize() + .map_err(|_| UiError::NotConfigured)? + } + }; + + // Security: must stay within assets root + if !resolved.starts_with(&canonical_root) { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + + // Directory → serve index.html + let file_path = if resolved.is_dir() { + let idx = resolved.join("index.html"); + if idx.exists() { + idx + } else { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + } else { + resolved + }; + + let bytes = tokio::fs::read(&file_path) + .await + .map_err(|_| UiError::NotConfigured)?; + + let ct = asset_content_type(&file_path); + let mut resp = bytes.into_response(); + resp.headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); + Ok(resp) +} + +fn asset_content_type(path: &std::path::Path) -> &'static str { + match path.extension().and_then(|e| e.to_str()) { + Some("html") | Some("htm") => "text/html; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("css") => "text/css", + Some("js") => "application/javascript", + Some("json") => "application/json", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("woff2") => "font/woff2", + Some("woff") => "font/woff", + Some("md") => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +/// Detect known showcase pages in `assets/` (committed artefacts). +pub fn detect_showcase(root: &std::path::Path, base_url: &str) -> Vec { + let candidates = [ + ( + "branding", + "Branding", + &["branding/index.html", "index.html"] as &[&str], + ), + ("web", "Website", &["web/index.html"]), + ( + "presentation", + "Presentation", + &[ + "presentation/dist/index.html", + "presentation/slides/index.html", + ], + ), + ]; + candidates + .iter() + .filter_map(|(id, label, paths)| { + let found = paths + .iter() + .find(|p| root.join("assets").join(p).exists())?; + Some(serde_json::json!({ + "id": id, + "label": label, + "url": format!("{base_url}/assets/{found}"), + })) + }) + .collect() +} + +/// Scan `public/` for generated artefacts — any subdirectory that contains +/// an `index.html` becomes a served link. +/// +/// Well-known subdirectory names get a display label; unknown names are +/// title-cased from the directory name. +pub fn detect_generated(root: &std::path::Path, base_url: &str) -> Vec { + let public_dir = root.join("public"); + let Ok(entries) = std::fs::read_dir(&public_dir) else { + return vec![]; + }; + let known_labels: std::collections::HashMap<&str, &str> = [ + ("cargo-doc", "API Docs (cargo doc)"), + ("docs", "Documentation"), + ("mdbook", "MDBook"), + ("book", "Book"), + ("coverage", "Coverage Report"), + ("typedoc", "TypeDoc"), + ("storybook", "Storybook"), + ] + .into_iter() + .collect(); + + let mut items: Vec = entries + .flatten() + .filter_map(|e| { + let path = e.path(); + if !path.is_dir() { + return None; + } + // Must have an index.html to be servable + if !path.join("index.html").exists() { + return None; + } + let dir_name = path.file_name()?.to_str()?.to_string(); + let label = known_labels + .get(dir_name.as_str()) + .copied() + .unwrap_or(&dir_name) + .to_string(); + Some(serde_json::json!({ + "id": dir_name, + "label": label, + "url": format!("{base_url}/public/{dir_name}/"), + })) + }) + .collect(); + + items.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string()); + items +} + +/// Serve a file from `{project_root}/public/` — generated, gitignored +/// artefacts. +pub async fn serve_public_mp( + State(state): State, + Path((slug, pub_path)): Path<(String, String)>, +) -> Result { + let proj = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + serve_dir_from(&proj.root.join("public"), &pub_path).await +} + +pub async fn serve_public_single( + State(state): State, + Path(pub_path): Path, +) -> Result { + serve_dir_from(&state.project_root.join("public"), &pub_path).await +} + +/// Generic file server for an arbitrary base directory. +async fn serve_dir_from(base: &std::path::Path, rel: &str) -> Result { + let Ok(canonical_base) = base.canonicalize() else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + let requested = canonical_base.join(rel.trim_start_matches('/')); + let resolved = match requested.canonicalize() { + Ok(p) => p, + Err(_) => { + let with_index = requested.join("index.html"); + match with_index.canonicalize() { + Ok(p) => p, + Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()), + } + } + }; + if !resolved.starts_with(&canonical_base) { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let file_path = if resolved.is_dir() { + let idx = resolved.join("index.html"); + if idx.exists() { + idx + } else { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + } else { + resolved + }; + let bytes = tokio::fs::read(&file_path) + .await + .map_err(|_| UiError::NotConfigured)?; + let ct = asset_content_type(&file_path); + let mut resp = bytes.into_response(); + resp.headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); + Ok(resp) +} + +// ── Backlog +// ─────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct BacklogStatusForm { + pub id: String, + pub status: String, +} + +#[derive(Deserialize)] +pub struct BacklogAddForm { + pub title: String, + pub kind: String, + pub priority: String, + pub detail: String, +} + +fn today_iso() -> String { + // No chrono dep — derive from SystemTime epoch arithmetic. + let secs = epoch_secs(); + let days_since_epoch = secs / 86400; + // Gregorian calendar from Julian Day Number (JDN 2440588 = 1970-01-01) + let jdn = days_since_epoch as i64 + 2440588; + let (y, m, d) = jdn_to_ymd(jdn); + format!("{y:04}-{m:02}-{d:02}") +} + +fn jdn_to_ymd(jdn: i64) -> (i64, i64, i64) { + let a = jdn + 32044; + let b = (4 * a + 3) / 146097; + let c = a - (146097 * b) / 4; + let dd = (4 * c + 3) / 1461; + let e = c - (1461 * dd) / 4; + let mm = (5 * e + 2) / 153; + let d = e - (153 * mm + 2) / 5 + 1; + let m = mm + 3 - 12 * (mm / 10); + let y = 100 * b + dd - 4800 + mm / 10; + (y, m, d) +} + +#[derive(Serialize)] +struct BacklogStats { + total: usize, + open: usize, + inprog: usize, + done: usize, + cancelled: usize, + critical: usize, +} + +async fn load_backlog_items( + cache: &Arc, + backlog_path: &std::path::Path, + import_path: Option<&str>, +) -> Vec { + let Ok((json, _)) = cache.export(backlog_path, import_path).await else { + return vec![]; + }; + json.get("items") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +fn backlog_stats(items: &[serde_json::Value]) -> BacklogStats { + let count = |key: &str, val: &str| { + items + .iter() + .filter(|it| it.get(key).and_then(|v| v.as_str()) == Some(val)) + .count() + }; + BacklogStats { + total: items.len(), + open: count("status", "Open"), + inprog: count("status", "InProgress"), + done: count("status", "Done"), + cancelled: count("status", "Cancelled"), + critical: count("priority", "Critical"), + } +} + +pub async fn backlog_page(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + let backlog_path = state.project_root.join("reflection").join("backlog.ncl"); + let items = if backlog_path.exists() { + load_backlog_items( + &state.cache, + &backlog_path, + state.nickel_import_path.as_deref(), + ) + .await + } else { + vec![] + }; + let stats = backlog_stats(&items); + let mut ctx = Context::new(); + ctx.insert("items", &items); + ctx.insert("stats", &stats); + ctx.insert("has_backlog", &backlog_path.exists()); + ctx.insert("base_url", "/ui"); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + render(tera, "pages/backlog.html", &ctx).await +} + +pub async fn backlog_page_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let proj = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + let backlog_path = proj.root.join("reflection").join("backlog.ncl"); + let items = if backlog_path.exists() { + load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await + } else { + vec![] + }; + proj.cache + .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone())); + let stats = backlog_stats(&items); + let base_url = format!("/ui/{slug}"); + let mut ctx = Context::new(); + ctx.insert("items", &items); + ctx.insert("stats", &stats); + ctx.insert("has_backlog", &backlog_path.exists()); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &auth_role_str(&auth)); + insert_brand_ctx( + &mut ctx, + &proj.root, + &proj.cache, + proj.import_path.as_deref(), + &base_url, + ) + .await; + render(tera, "pages/backlog.html", &ctx).await +} + +pub async fn backlog_update_status( + State(state): State, + Form(form): Form, +) -> Result { + let backlog_path = state.project_root.join("reflection").join("backlog.ncl"); + super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso()) + .map_err(|e| UiError::NclExport { + path: "backlog.ncl".into(), + reason: e.to_string(), + })?; + state + .cache + .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path)); + Ok(Redirect::to("/ui/backlog").into_response()) +} + +pub async fn backlog_update_status_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, + Form(form): Form, +) -> Result { + if auth_role_str(&auth) != "admin" { + return Err(UiError::Forbidden( + "admin role required for backlog writes".into(), + )); + } + let proj = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + let backlog_path = proj.root.join("reflection").join("backlog.ncl"); + super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso()) + .map_err(|e| UiError::NclExport { + path: "backlog.ncl".into(), + reason: e.to_string(), + })?; + proj.cache + .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path)); + Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response()) +} + +pub async fn backlog_add( + State(state): State, + Form(form): Form, +) -> Result { + let backlog_path = state.project_root.join("reflection").join("backlog.ncl"); + super::backlog_ncl::add_item( + &backlog_path, + &form.title, + &form.kind, + &form.priority, + &form.detail, + &today_iso(), + ) + .map_err(|e| UiError::NclExport { + path: "backlog.ncl".into(), + reason: e.to_string(), + })?; + state + .cache + .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path)); + Ok(Redirect::to("/ui/backlog").into_response()) +} + +pub async fn backlog_add_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, + Form(form): Form, +) -> Result { + if auth_role_str(&auth) != "admin" { + return Err(UiError::Forbidden( + "admin role required for backlog writes".into(), + )); + } + let proj = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + let backlog_path = proj.root.join("reflection").join("backlog.ncl"); + super::backlog_ncl::add_item( + &backlog_path, + &form.title, + &form.kind, + &form.priority, + &form.detail, + &today_iso(), + ) + .map_err(|e| UiError::NclExport { + path: "backlog.ncl".into(), + reason: e.to_string(), + })?; + proj.cache + .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path)); + Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response()) +} + +// ── Manage ──────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct AddProjectForm { + pub slug: String, + pub root: String, +} + +#[derive(Deserialize)] +pub struct RemoveProjectForm { + pub slug: String, +} + +/// Guarded variant: requires `AdminGuard` (any admin session or no auth +/// configured). +pub async fn manage_page_guarded( + State(state): State, + _guard: super::auth::AdminGuard, +) -> Result, UiError> { + manage_page(State(state)).await +} + +pub async fn manage_add_guarded( + State(state): State, + _guard: super::auth::AdminGuard, + Form(form): Form, +) -> Result { + manage_add(State(state), Form(form)).await +} + +pub async fn manage_remove_guarded( + State(state): State, + _guard: super::auth::AdminGuard, + Form(form): Form, +) -> Result { + manage_remove(State(state), Form(form)).await +} + +pub async fn manage_page(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + + let projects: Vec = state + .registry + .as_ref() + .map(|r| { + r.all() + .into_iter() + .map(|ctx| { + serde_json::json!({ + "slug": ctx.slug, + "root": ctx.root.display().to_string(), + "auth": ctx.auth_enabled(), + }) + }) + .collect() + }) + .unwrap_or_default(); + + let registry_path = state + .registry + .as_ref() + .map(|r| r.path.display().to_string()) + .unwrap_or_default(); + + let mut ctx = Context::new(); + ctx.insert("projects", &projects); + ctx.insert("registry_path", ®istry_path); + ctx.insert("base_url", "/ui"); + ctx.insert("hide_project_nav", &true); + ctx.insert("error", &Option::::None); + insert_mcp_ctx(&mut ctx); + render(tera, "pages/manage.html", &ctx).await +} + +pub async fn manage_add( + State(state): State, + Form(form): Form, +) -> Result { + let registry = state.registry.as_ref().ok_or(UiError::NotConfigured)?; + + let entry = crate::registry::RegistryEntry { + slug: form.slug.trim().to_string(), + root: std::path::PathBuf::from(form.root.trim()), + keys: vec![], + }; + + if let Err(e) = registry.add_project(entry) { + let tera = tera_ref(&state)?; + let projects: Vec = registry + .all() + .into_iter() + .map(|ctx| { + serde_json::json!({ + "slug": ctx.slug, + "root": ctx.root.display().to_string(), + "auth": ctx.auth_enabled(), + }) + }) + .collect(); + let registry_path = registry.path.display().to_string(); + let mut ctx = Context::new(); + ctx.insert("projects", &projects); + ctx.insert("registry_path", ®istry_path); + ctx.insert("base_url", "/ui"); + ctx.insert("error", &e.to_string()); + return render(tera, "pages/manage.html", &ctx) + .await + .map(IntoResponse::into_response); + } + + Ok(Redirect::to("/ui/manage").into_response()) +} + +pub async fn manage_remove( + State(state): State, + Form(form): Form, +) -> Result { + let registry = state.registry.as_ref().ok_or(UiError::NotConfigured)?; + registry + .remove_project(&form.slug) + .map_err(|e| UiError::NclExport { + path: form.slug.clone(), + reason: e.to_string(), + })?; + Ok(Redirect::to("/ui/manage").into_response()) +} + +pub async fn modes_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let modes_dir = ctx_ref.root.join("reflection").join("modes"); + let mut mode_entries: Vec = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&modes_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let rel = path + .strip_prefix(&ctx_ref.root) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + + match ctx_ref + .cache + .export(&path, ctx_ref.import_path.as_deref()) + .await + { + Ok((mut json, _)) => { + json.as_object_mut() + .map(|o| o.insert("_file".to_string(), serde_json::Value::String(rel))); + mode_entries.push(json); + } + Err(e) => { + warn!(path = %rel, error = %e, "failed to export reflection mode"); + mode_entries.push(serde_json::json!({ + "_file": rel, + "id": path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"), + "_error": e.to_string(), + })); + } + } + } + } + + mode_entries.sort_by_key(|v| { + v.get("id") + .and_then(|i| i.as_str()) + .unwrap_or("") + .to_string() + }); + + let base_url = format!("/ui/{slug}"); + let showcase = detect_showcase(&ctx_ref.root, &base_url); + let generated = detect_generated(&ctx_ref.root, &base_url); + + let mut ctx = Context::new(); + ctx.insert("modes", &mode_entries); + ctx.insert("total", &mode_entries.len()); + ctx.insert("showcase", &showcase); + ctx.insert("generated", &generated); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &auth_role_str(&auth)); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + + render(tera, "pages/modes.html", &ctx).await +} + +// ── Agent task composer +// ─────────────────────────────────────────────────────── + +/// Default LLM providers used when config.ncl has no `providers` array. +fn default_providers() -> Vec { + vec![ + serde_json::json!({ "id": "anthropic", "label": "Anthropic (Claude)", "api_url": "", "model": "claude-opus-4-6", "key_env": "ANTHROPIC_API_KEY", "kind": "anthropic" }), + serde_json::json!({ "id": "openai", "label": "OpenAI (GPT-4o)", "api_url": "", "model": "gpt-4o", "key_env": "OPENAI_API_KEY", "kind": "openai" }), + serde_json::json!({ "id": "local", "label": "Local (Ollama)", "api_url": "http://localhost:11434/v1/chat/completions", "model": "llama3.2", "key_env": "", "kind": "openai" }), + ] +} + +async fn read_providers( + root: &std::path::Path, + cache: &Arc, + import_path: Option<&str>, +) -> Vec { + let config_path = root.join(".ontoref").join("config.ncl"); + if config_path.exists() { + if let Ok((json, _)) = cache.export(&config_path, import_path).await { + if let Some(arr) = json.get("providers").and_then(|v| v.as_array()) { + if !arr.is_empty() { + return arr.clone(); + } + } + } + } + default_providers() +} + +pub async fn compose_page_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let forms_dir = ctx_ref.root.join("reflection").join("forms"); + let mut forms: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&forms_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("ncl") { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + forms.push(serde_json::json!({ + "id": stem, + "label": stem.replace('_', " "), + })); + } + } + forms.sort_by_key(|v| { + v.get("id") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string() + }); + + let providers = read_providers( + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + ) + .await; + + let base_url = format!("/ui/{slug}"); + let mut ctx = Context::new(); + ctx.insert("forms", &forms); + ctx.insert("providers", &providers); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &auth_role_str(&auth)); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + + render(tera, "pages/compose.html", &ctx).await +} + +pub async fn compose_form_schema_mp( + State(state): State, + Path((slug, form_id)): Path<(String, String)>, + _auth: AuthUser, +) -> Result, UiError> { + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let form_path = ctx_ref + .root + .join("reflection") + .join("forms") + .join(format!("{form_id}.ncl")); + + if !form_path.exists() { + return Err(UiError::BadRequest(format!("form not found: {form_id}"))); + } + + let (json, _) = ctx_ref + .cache + .export(&form_path, ctx_ref.import_path.as_deref()) + .await + .map_err(|e| UiError::NclExport { + path: form_id.clone(), + reason: e.to_string(), + })?; + + Ok(axum::Json(json)) +} + +#[derive(serde::Deserialize)] +pub struct ComposeSendBody { + pub provider_id: String, + pub prompt: String, + #[serde(default)] + pub system: String, +} + +pub async fn compose_send_mp( + State(state): State, + Path(slug): Path, + _auth: AuthUser, + axum::Json(body): axum::Json, +) -> Result, UiError> { + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + + let providers = read_providers( + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + ) + .await; + + let provider = providers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(body.provider_id.as_str())) + .ok_or_else(|| UiError::BadRequest(format!("unknown provider: {}", body.provider_id)))? + .clone(); + + let api_url = provider + .get("api_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let model = provider + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("claude-opus-4-6") + .to_string(); + let key_env = provider + .get("key_env") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let kind = provider + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("anthropic") + .to_string(); + + let api_key = if key_env.is_empty() { + String::new() + } else { + std::env::var(&key_env).unwrap_or_default() + }; + + if api_key.is_empty() && !matches!(kind.as_str(), "openai") { + return Err(UiError::BadRequest(format!( + "API key not configured: set {} environment variable", + key_env + ))); + } + + let result = match kind.as_str() { + "openai" => call_openai_api(&api_url, &api_key, &model, &body.system, &body.prompt).await, + _ => call_anthropic_api(&api_url, &api_key, &model, &body.system, &body.prompt).await, + }; + + result + .map(axum::Json) + .map_err(|e| UiError::BadRequest(e.to_string())) +} + +async fn call_anthropic_api( + url: &str, + key: &str, + model: &str, + system: &str, + prompt: &str, +) -> anyhow::Result { + let effective_url = if url.is_empty() { + "https://api.anthropic.com/v1/messages" + } else { + url + }; + let mut req = serde_json::json!({ + "model": model, + "max_tokens": 4096, + "messages": [{"role": "user", "content": prompt}] + }); + if !system.is_empty() { + req["system"] = serde_json::Value::String(system.to_string()); + } + let resp = reqwest::Client::new() + .post(effective_url) + .header("x-api-key", key) + .header("anthropic-version", "2023-06-01") + .json(&req) + .send() + .await? + .json::() + .await?; + Ok(resp) +} + +async fn call_openai_api( + url: &str, + key: &str, + model: &str, + system: &str, + prompt: &str, +) -> anyhow::Result { + let effective_url = if url.is_empty() { + "https://api.openai.com/v1/chat/completions" + } else { + url + }; + let mut messages: Vec = Vec::new(); + if !system.is_empty() { + messages.push(serde_json::json!({"role": "system", "content": system})); + } + messages.push(serde_json::json!({"role": "user", "content": prompt})); + let resp = reqwest::Client::new() + .post(effective_url) + .header("Authorization", format!("Bearer {key}")) + .json(&serde_json::json!({ "model": model, "messages": messages })) + .send() + .await? + .json::() + .await?; + Ok(resp) +} + +// ── Actions +// ─────────────────────────────────────────────────────────────────── + +/// Load `quick_actions` from `.ontoref/config.ncl`. +async fn load_quick_actions( + cache: &Arc, + root: &std::path::Path, + import_path: Option<&str>, +) -> Vec { + let config_path = root.join(".ontoref").join("config.ncl"); + if !config_path.exists() { + return vec![]; + } + let Ok((json, _)) = cache.export(&config_path, import_path).await else { + return vec![]; + }; + json.get("quick_actions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +pub async fn actions_page(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + let actions = load_quick_actions( + &state.cache, + &state.project_root, + state.nickel_import_path.as_deref(), + ) + .await; + let mut ctx = Context::new(); + ctx.insert("actions", &actions); + ctx.insert("base_url", "/ui"); + ctx.insert("slug", &Option::::None); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + render(tera, "pages/actions.html", &ctx).await +} + +pub async fn actions_page_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + let actions = load_quick_actions( + &ctx_ref.cache, + &ctx_ref.root, + ctx_ref.import_path.as_deref(), + ) + .await; + let base_url = format!("/ui/{slug}"); + let mut ctx = Context::new(); + ctx.insert("actions", &actions); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &auth_role_str(&auth)); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + render(tera, "pages/actions.html", &ctx).await +} + +// ── Q&A ─────────────────────────────────────────────────────────────────────── + +pub async fn qa_page(State(state): State) -> Result, UiError> { + let tera = tera_ref(&state)?; + let mut ctx = Context::new(); + ctx.insert("base_url", "/ui"); + ctx.insert("slug", &Option::::None); + let entries = load_qa_entries( + &state.cache, + &state.project_root, + state.nickel_import_path.as_deref(), + ) + .await; + ctx.insert("entries", &entries); + insert_brand_ctx( + &mut ctx, + &state.project_root, + &state.cache, + state.nickel_import_path.as_deref(), + "/ui", + ) + .await; + render(tera, "pages/qa.html", &ctx).await +} + +pub async fn qa_page_mp( + State(state): State, + Path(slug): Path, + auth: AuthUser, +) -> Result, UiError> { + let tera = tera_ref(&state)?; + let ctx_ref = state + .registry + .as_ref() + .and_then(|r| r.get(&slug)) + .ok_or(UiError::NotConfigured)?; + let base_url = format!("/ui/{slug}"); + let entries = load_qa_entries( + &ctx_ref.cache, + &ctx_ref.root, + ctx_ref.import_path.as_deref(), + ) + .await; + let mut ctx = Context::new(); + ctx.insert("base_url", &base_url); + ctx.insert("slug", &slug); + ctx.insert("current_role", &auth_role_str(&auth)); + ctx.insert("entries", &entries); + insert_brand_ctx( + &mut ctx, + &ctx_ref.root, + &ctx_ref.cache, + ctx_ref.import_path.as_deref(), + &base_url, + ) + .await; + render(tera, "pages/qa.html", &ctx).await +} + +// ── Q&A mutation ───────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct QaAddRequest { + pub question: String, + pub answer: Option, + pub actor: Option, + pub tags: Option>, + pub related: Option>, + pub slug: Option, +} + +pub async fn qa_add( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let (root, cache) = if let (Some(slug), Some(reg)) = (body.slug.as_deref(), &state.registry) { + if let Some(ctx) = reg.get(slug) { + (ctx.root.clone(), ctx.cache.clone()) + } else { + (state.project_root.clone(), state.cache.clone()) + } + } else { + (state.project_root.clone(), state.cache.clone()) + }; + + let qa_path = root.join("reflection").join("qa.ncl"); + if !qa_path.exists() { + return ( + StatusCode::NOT_FOUND, + Json( + serde_json::json!({ "error": "qa.ncl not found — create reflection/qa.ncl first" }), + ), + ); + } + + let answer = body.answer.as_deref().unwrap_or(""); + let actor = body.actor.as_deref().unwrap_or("human"); + let tags = body.tags.as_deref().unwrap_or(&[]); + let related = body.related.as_deref().unwrap_or(&[]); + let now = now_iso(); + + match super::qa_ncl::add_entry(&qa_path, &body.question, answer, actor, &now, tags, related) { + Ok(id) => { + cache.invalidate_file(&qa_path); + ( + StatusCode::OK, + Json(serde_json::json!({ + "ok": true, + "id": id, + "created_at": now, + })), + ) + } + Err(e) => { + warn!(error = %e, "qa_add failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + } +} + +fn now_iso() -> String { + let total_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let secs_in_day = total_secs % 86400; + let days = total_secs / 86400; + let h = secs_in_day / 3600; + let m = (secs_in_day % 3600) / 60; + let s = secs_in_day % 60; + let z = days + 719_468; + let era = z / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y0 = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if mo <= 2 { y0 + 1 } else { y0 }; + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +async fn load_qa_entries( + cache: &Arc, + root: &std::path::Path, + import_path: Option<&str>, +) -> Vec { + let qa_path = root.join("reflection").join("qa.ncl"); + if !qa_path.exists() { + return vec![]; + } + match cache.export(&qa_path, import_path).await { + Ok((json, _)) => json + .get("entries") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(), + Err(_) => vec![], + } +} + +// ── Q&A delete / update +// ─────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct QaDeleteRequest { + pub id: String, + pub slug: Option, +} + +pub async fn qa_delete( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let (root, cache) = resolve_qa_ctx(&state, body.slug.as_deref()); + let qa_path = root.join("reflection").join("qa.ncl"); + if !qa_path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "qa.ncl not found" })), + ); + } + match super::qa_ncl::remove_entry(&qa_path, &body.id) { + Ok(()) => { + cache.invalidate_file(&qa_path); + ( + StatusCode::OK, + Json(serde_json::json!({ "ok": true, "id": body.id })), + ) + } + Err(e) => { + warn!(error = %e, id = %body.id, "qa_delete failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + } +} + +#[derive(Deserialize)] +pub struct QaUpdateRequest { + pub id: String, + pub question: String, + pub answer: Option, + pub slug: Option, +} + +pub async fn qa_update( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let (root, cache) = resolve_qa_ctx(&state, body.slug.as_deref()); + let qa_path = root.join("reflection").join("qa.ncl"); + if !qa_path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "qa.ncl not found" })), + ); + } + let answer = body.answer.as_deref().unwrap_or(""); + match super::qa_ncl::update_entry(&qa_path, &body.id, &body.question, answer) { + Ok(()) => { + cache.invalidate_file(&qa_path); + ( + StatusCode::OK, + Json(serde_json::json!({ "ok": true, "id": body.id })), + ) + } + Err(e) => { + warn!(error = %e, id = %body.id, "qa_update failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + } + } +} + +fn resolve_qa_ctx( + state: &crate::api::AppState, + slug: Option<&str>, +) -> (std::path::PathBuf, Arc) { + if let (Some(s), Some(reg)) = (slug, &state.registry) { + if let Some(ctx) = reg.get(s) { + return (ctx.root.clone(), ctx.cache.clone()); + } + } + (state.project_root.clone(), state.cache.clone()) +} + +// ── Actions run +// ─────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct ActionRunForm { + pub action_id: String, +} + +pub async fn actions_run( + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + let root = state.project_root.clone(); + let cache = state.cache.clone(); + let import_path = state.nickel_import_path.clone(); + run_action_by_id(&root, &cache, import_path.as_deref(), &form.action_id).await; + axum::response::Redirect::to("/ui/actions").into_response() +} + +pub async fn actions_run_mp( + State(state): State, + Path(slug): Path, + _auth: AuthUser, + Form(form): Form, +) -> impl IntoResponse { + let redirect = format!("/ui/{slug}/actions"); + if let Some(ctx) = state.registry.as_ref().and_then(|r| r.get(&slug)) { + run_action_by_id( + &ctx.root, + &ctx.cache, + ctx.import_path.as_deref(), + &form.action_id, + ) + .await; + } + axum::response::Redirect::to(&redirect).into_response() +} + +/// Resolve `action_id` from `quick_actions`, then spawn `./ontoref {mode}`. +async fn run_action_by_id( + root: &std::path::Path, + cache: &Arc, + import_path: Option<&str>, + action_id: &str, +) { + let config_path = root.join(".ontoref").join("config.ncl"); + let Ok((json, _)) = cache.export(&config_path, import_path).await else { + warn!(action_id, "actions_run: config export failed"); + return; + }; + + let mode = json + .get("quick_actions") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|a| a.get("id").and_then(|i| i.as_str()) == Some(action_id)) + }) + .and_then(|a| a.get("mode")) + .and_then(|m| m.as_str()) + .map(str::to_string); + + let Some(mode) = mode else { + warn!(action_id, "actions_run: action not found in quick_actions"); + return; + }; + + let ontoref_bin = root.join("ontoref"); + if !ontoref_bin.exists() { + warn!(path = %ontoref_bin.display(), "actions_run: ontoref binary not found"); + return; + } + + match tokio::process::Command::new(&ontoref_bin) + .arg(&mode) + .current_dir(root) + .spawn() + { + Ok(_) => tracing::info!(action_id, mode, "action spawned"), + Err(e) => warn!(action_id, mode, error = %e, "actions_run: spawn failed"), + } +} diff --git a/crates/ontoref-daemon/src/ui/login.rs b/crates/ontoref-daemon/src/ui/login.rs new file mode 100644 index 0000000..bc9c11d --- /dev/null +++ b/crates/ontoref-daemon/src/ui/login.rs @@ -0,0 +1,98 @@ +use axum::{ + extract::{Form, Path, State}, + http::{header, StatusCode}, + response::{Html, IntoResponse, Redirect, Response}, +}; +use serde::Deserialize; +use tera::Context; + +use super::handlers::{render, UiError}; +use crate::api::AppState; +use crate::session::{extract_cookie, COOKIE_NAME}; + +pub async fn login_page( + State(state): State, + Path(slug): Path, +) -> Result, UiError> { + let tera = state.tera.as_ref().ok_or(UiError::NotConfigured)?; + let mut ctx = Context::new(); + ctx.insert("slug", &slug); + ctx.insert("error", &false); + ctx.insert("base_url", &format!("/ui/{slug}")); + render(tera, "pages/login.html", &ctx).await +} + +#[derive(Deserialize)] +pub struct LoginForm { + pub key: String, +} + +pub async fn login_submit( + State(state): State, + Path(slug): Path, + Form(form): Form, +) -> Response { + let Some(ref registry) = state.registry else { + return Redirect::to("/ui/").into_response(); + }; + let Some(ctx) = registry.get(&slug) else { + return StatusCode::NOT_FOUND.into_response(); + }; + + match ctx.verify_key(&form.key) { + Some(role) => { + let token = state.sessions.create(slug.clone(), role); + let cookie = format!( + "{}={}; Path=/ui/; HttpOnly; SameSite=Strict; Max-Age={}", + COOKIE_NAME, + token, + 30 * 24 * 3600, + ); + ( + [(header::SET_COOKIE, cookie)], + Redirect::to(&format!("/ui/{slug}/")), + ) + .into_response() + } + None => { + let tera = match state.tera.as_ref() { + Some(t) => t, + None => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + let mut tctx = Context::new(); + tctx.insert("slug", &slug); + tctx.insert("error", &true); + tctx.insert("base_url", &format!("/ui/{slug}")); + match render(tera, "pages/login.html", &tctx).await { + Ok(html) => html.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } + } +} + +pub async fn logout( + State(state): State, + Path(slug): Path, + request: axum::extract::Request, +) -> Response { + let cookie_str = request + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if let Some(token) = extract_cookie(cookie_str, COOKIE_NAME) { + state.sessions.revoke(&token); + } + + let clear = format!( + "{}=; Path=/ui/; HttpOnly; SameSite=Strict; Max-Age=0", + COOKIE_NAME + ); + ( + [(header::SET_COOKIE, clear)], + Redirect::to(&format!("/ui/{slug}/login")), + ) + .into_response() +} diff --git a/crates/ontoref-daemon/src/ui/mod.rs b/crates/ontoref-daemon/src/ui/mod.rs new file mode 100644 index 0000000..7f072a5 --- /dev/null +++ b/crates/ontoref-daemon/src/ui/mod.rs @@ -0,0 +1,96 @@ +pub mod auth; +pub mod backlog_ncl; +pub mod drift_watcher; +pub mod handlers; +pub mod login; +pub mod qa_ncl; +pub mod watcher; + +pub use drift_watcher::DriftWatcher; +pub use watcher::TemplateWatcher; + +use crate::api::AppState; + +pub fn router(state: AppState) -> axum::Router { + if state.registry.is_some() { + multi_router(state) + } else { + single_router(state) + } +} + +fn single_router(state: AppState) -> axum::Router { + use axum::routing::{get, post}; + axum::Router::new() + .route("/", get(handlers::dashboard)) + .route("/graph", get(handlers::graph)) + .route("/sessions", get(handlers::sessions)) + .route("/notifications", get(handlers::notifications_page)) + .route("/modes", get(handlers::modes)) + .route("/search", get(handlers::search_page)) + .route("/assets/{*path}", get(handlers::serve_asset_single)) + .route("/public/{*path}", get(handlers::serve_public_single)) + .route("/backlog", get(handlers::backlog_page)) + .route("/backlog/status", post(handlers::backlog_update_status)) + .route("/backlog/add", post(handlers::backlog_add)) + .route("/manage", get(handlers::manage_page)) + .route("/manage/add", post(handlers::manage_add)) + .route("/manage/remove", post(handlers::manage_remove)) + .route("/actions", get(handlers::actions_page)) + .route("/actions/run", post(handlers::actions_run)) + .route("/qa", get(handlers::qa_page)) + .route("/qa/delete", post(handlers::qa_delete)) + .route("/qa/update", post(handlers::qa_update)) + .with_state(state) +} + +fn multi_router(state: AppState) -> axum::Router { + use axum::routing::{get, post}; + axum::Router::new() + // Project picker and management at root + .route("/", get(handlers::project_picker)) + .route("/manage", get(handlers::manage_page_guarded)) + .route("/manage/add", post(handlers::manage_add_guarded)) + .route("/manage/remove", post(handlers::manage_remove_guarded)) + // Per-project routes — AuthUser extractor enforces auth per project + .route("/{slug}/", get(handlers::dashboard_mp)) + .route("/{slug}/graph", get(handlers::graph_mp)) + .route("/{slug}/sessions", get(handlers::sessions_mp)) + .route("/{slug}/notifications", get(handlers::notifications_mp)) + .route("/{slug}/modes", get(handlers::modes_mp)) + .route("/{slug}/logout", get(login::logout)) + .route("/{slug}/search", get(handlers::search_page_mp)) + .route("/{slug}/assets/{*path}", get(handlers::serve_asset_mp)) + .route("/{slug}/public/{*path}", get(handlers::serve_public_mp)) + .route("/{slug}/backlog", get(handlers::backlog_page_mp)) + .route( + "/{slug}/backlog/status", + post(handlers::backlog_update_status_mp), + ) + .route("/{slug}/backlog/add", post(handlers::backlog_add_mp)) + .route( + "/{slug}/notifications/{id}/action", + post(handlers::notification_action_mp), + ) + .route( + "/{slug}/notifications/emit", + post(handlers::emit_notification_mp), + ) + .route("/{slug}/compose", get(handlers::compose_page_mp)) + .route( + "/{slug}/compose/form/{form_id}", + get(handlers::compose_form_schema_mp), + ) + .route("/{slug}/compose/send", post(handlers::compose_send_mp)) + .route("/{slug}/actions", get(handlers::actions_page_mp)) + .route("/{slug}/actions/run", post(handlers::actions_run_mp)) + .route("/{slug}/qa", get(handlers::qa_page_mp)) + .route("/{slug}/qa/delete", post(handlers::qa_delete)) + .route("/{slug}/qa/update", post(handlers::qa_update)) + // Login is public — no AuthUser extractor + .route( + "/{slug}/login", + get(login::login_page).post(login::login_submit), + ) + .with_state(state) +} diff --git a/crates/ontoref-daemon/src/ui/qa_ncl.rs b/crates/ontoref-daemon/src/ui/qa_ncl.rs new file mode 100644 index 0000000..c3db9a6 --- /dev/null +++ b/crates/ontoref-daemon/src/ui/qa_ncl.rs @@ -0,0 +1,288 @@ +//! In-place mutations of reflection/qa.ncl. +//! +//! Mirrors backlog_ncl.rs — line-level surgery on a predictable Nickel +//! structure. The QA store has a single `entries` array of `QaEntry` records. + +use std::path::Path; + +/// Append a new Q&A entry to reflection/qa.ncl. +/// +/// Returns the generated id (`qa-NNN`). +pub fn add_entry( + path: &Path, + question: &str, + answer: &str, + actor: &str, + created_at: &str, + tags: &[String], + related: &[String], +) -> anyhow::Result { + let content = std::fs::read_to_string(path)?; + let next_id = next_entry_id(&content); + + let block = format!( + r#" {{ + id = "{id}", + question = "{question}", + answer = "{answer}", + actor = "{actor}", + created_at = "{created_at}", + tags = {tags}, + related = {related}, + verified = false, + }}, +"#, + id = next_id, + question = escape_ncl(question), + answer = escape_ncl(answer), + actor = escape_ncl(actor), + created_at = escape_ncl(created_at), + tags = ncl_string_array(tags), + related = ncl_string_array(related), + ); + + let updated = insert_before_entries_close(&content, &block)?; + std::fs::write(path, updated)?; + Ok(next_id) +} + +/// Update `question` and `answer` fields for the entry with `id`. +pub fn update_entry(path: &Path, id: &str, question: &str, answer: &str) -> anyhow::Result<()> { + let content = std::fs::read_to_string(path)?; + let updated = mutate_entry_fields( + &content, + id, + &[ + ("question", &format!("\"{}\"", escape_ncl(question))), + ("answer", &format!("\"{}\"", escape_ncl(answer))), + ], + ); + std::fs::write(path, updated)?; + Ok(()) +} + +/// Remove the entry block with `id` from the entries array. +pub fn remove_entry(path: &Path, id: &str) -> anyhow::Result<()> { + let content = std::fs::read_to_string(path)?; + let updated = delete_entry_block(&content, id)?; + std::fs::write(path, updated)?; + Ok(()) +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Replace field values inside the entry block identified by `id`. +/// +/// For each `(field, new_value)`, finds the `field = ...,` line inside the +/// block and substitutes the value in-place. +fn mutate_entry_fields(content: &str, id: &str, fields: &[(&str, &str)]) -> String { + let id_needle = format!("\"{}\"", id); + let mut in_block = false; + let mut result: Vec = Vec::with_capacity(content.lines().count() + 1); + + for line in content.lines() { + if !in_block { + if line.contains(&id_needle) && line.contains('=') { + in_block = true; + } + result.push(line.to_string()); + continue; + } + + let trimmed = line.trim_start(); + let replacement = fields.iter().find_map(|(field, new_val)| { + if !trimmed.starts_with(field) { + return None; + } + let eq_pos = trimmed.find('=')?; + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let before_eq = trimmed[..eq_pos].trim_end(); + Some(format!("{}{} = {},", indent, before_eq, new_val)) + }); + result.push(replacement.unwrap_or_else(|| line.to_string())); + + if trimmed == "}," { + in_block = false; + } + } + + result.join("\n") +} + +/// Remove the block containing `id = "qa-NNN"`. +/// +/// Scans line by line, tracking ` {` opens and ` },` closes. Removes the +/// entire block (from the ` {` through ` },` inclusive) that contains the +/// id needle. +fn delete_entry_block(content: &str, id: &str) -> anyhow::Result { + let id_needle = format!("\"{}\"", id); + let lines: Vec<&str> = content.lines().collect(); + let n = lines.len(); + + // Find the line index containing the id field. + let id_line = lines + .iter() + .position(|l| l.contains(&id_needle) && l.contains('=')) + .ok_or_else(|| anyhow::anyhow!("entry id {} not found in qa.ncl", id))?; + + // Scan backward from id_line to find ` {` (block open — exactly 4 spaces + + // `{`). + let block_start = (0..=id_line) + .rev() + .find(|&i| lines[i].trim() == "{") + .ok_or_else(|| anyhow::anyhow!("could not find block open for entry {}", id))?; + + // Scan forward from id_line to find ` },` (block close — trim == `},`). + let block_end = (id_line..n) + .find(|&i| lines[i].trim() == "},") + .ok_or_else(|| anyhow::anyhow!("could not find block close for entry {}", id))?; + + // Reconstruct without [block_start..=block_end]. + let mut result = Vec::with_capacity(n - (block_end - block_start + 1)); + for (i, line) in lines.iter().enumerate() { + if i < block_start || i > block_end { + result.push(*line); + } + } + Ok(result.join("\n")) +} + +/// Find the highest `qa-NNN` id and return `qa-(NNN+1)` zero-padded to 3 +/// digits. +fn next_entry_id(content: &str) -> String { + let max = content + .lines() + .filter_map(|line| { + let t = line.trim(); + let rest = t.strip_prefix("id")?; + let val = rest.split('"').nth(1)?; + let num_str = val.strip_prefix("qa-")?; + num_str.parse::().ok() + }) + .max() + .unwrap_or(0); + format!("qa-{:03}", max + 1) +} + +/// Insert `block` before the closing ` ],` of the entries array. +fn insert_before_entries_close(content: &str, block: &str) -> anyhow::Result { + let needle = " ],"; + let pos = content.find(needle).ok_or_else(|| { + anyhow::anyhow!("could not locate entries array closing ` ],` in qa.ncl") + })?; + let mut result = String::with_capacity(content.len() + block.len()); + result.push_str(&content[..pos]); + result.push_str(block); + result.push_str(&content[pos..]); + Ok(result) +} + +/// Format a `&[String]` as a Nickel array literal: `["a", "b"]`. +fn ncl_string_array(items: &[String]) -> String { + if items.is_empty() { + return "[]".to_string(); + } + let inner: Vec = items + .iter() + .map(|s| format!("\"{}\"", escape_ncl(s))) + .collect(); + format!("[{}]", inner.join(", ")) +} + +/// Minimal escaping for string values embedded in Nickel double-quoted strings. +fn escape_ncl(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = concat!( + "let s = import \"qa\" in\n", + "{\n", + " entries = [\n", + " {\n", + " id = \"qa-001\",\n", + " question = \"What is X?\",\n", + " answer = \"It is Y.\",\n", + " actor = \"human\",\n", + " created_at = \"2026-03-12\",\n", + " tags = [],\n", + " related = [],\n", + " verified = false,\n", + " },\n", + " {\n", + " id = \"qa-002\",\n", + " question = \"Second?\",\n", + " answer = \"Yes.\",\n", + " actor = \"agent\",\n", + " created_at = \"2026-03-12\",\n", + " tags = [],\n", + " related = [],\n", + " verified = false,\n", + " },\n", + " ],\n", + "} | s.QaStore\n", + ); + + #[test] + fn next_id_empty() { + assert_eq!(next_entry_id(""), "qa-001"); + } + + #[test] + fn next_id_increments() { + let content = r#"id = "qa-005","#; + assert_eq!(next_entry_id(content), "qa-006"); + } + + #[test] + fn array_empty() { + assert_eq!(ncl_string_array(&[]), "[]"); + } + + #[test] + fn array_values() { + let v = vec!["a".to_string(), "b".to_string()]; + assert_eq!(ncl_string_array(&v), r#"["a", "b"]"#); + } + + #[test] + fn insert_before_close() { + let content = "let s = import \"qa\" in\n{\n entries = [\n ],\n} | s.QaStore\n"; + let block = " { id = \"qa-001\" },\n"; + let result = insert_before_entries_close(content, block).unwrap(); + assert!(result.contains("{ id = \"qa-001\" }")); + assert!(result.contains(" ],")); + } + + #[test] + fn update_answer() { + let updated = mutate_entry_fields(SAMPLE, "qa-001", &[("answer", "\"New answer.\"")]); + assert!(updated.contains("\"New answer.\""), "answer not updated"); + assert!( + updated.contains("\"Second?\""), + "qa-002 should be untouched" + ); + } + + #[test] + fn delete_first_entry() { + let updated = delete_entry_block(SAMPLE, "qa-001").unwrap(); + assert!(!updated.contains("qa-001"), "qa-001 should be removed"); + assert!(updated.contains("qa-002"), "qa-002 should remain"); + } + + #[test] + fn delete_second_entry() { + let updated = delete_entry_block(SAMPLE, "qa-002").unwrap(); + assert!(updated.contains("qa-001"), "qa-001 should remain"); + assert!(!updated.contains("qa-002"), "qa-002 should be removed"); + } + + #[test] + fn delete_missing_id_errors() { + assert!(delete_entry_block(SAMPLE, "qa-999").is_err()); + } +} diff --git a/crates/ontoref-daemon/src/ui/watcher.rs b/crates/ontoref-daemon/src/ui/watcher.rs new file mode 100644 index 0000000..e7d7d90 --- /dev/null +++ b/crates/ontoref-daemon/src/ui/watcher.rs @@ -0,0 +1,83 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use tokio::sync::{mpsc, RwLock}; +use tracing::{info, warn}; + +use crate::error::DaemonError; + +/// Watches a templates directory for HTML changes and calls +/// `Tera::full_reload()` after a debounce window. Runs independently of the NCL +/// file watcher. +pub struct TemplateWatcher { + _watcher: RecommendedWatcher, + _task: tokio::task::JoinHandle<()>, +} + +impl TemplateWatcher { + /// Start watching `templates_dir` recursively for `*.html` changes. + /// On any change, reloads all Tera templates after a 150ms debounce. + pub fn start(templates_dir: &Path, tera: Arc>) -> Result { + let (tx, rx) = mpsc::channel::(64); + + let mut watcher = RecommendedWatcher::new( + move |res: std::result::Result| match res { + Ok(event) => { + for path in event.paths { + if path.extension().is_some_and(|ext| ext == "html") { + let _ = tx.try_send(path); + } + } + } + Err(e) => warn!(error = %e, "template watcher error"), + }, + Config::default(), + ) + .map_err(|e| DaemonError::Watcher(e.to_string()))?; + + watcher + .watch(templates_dir, RecursiveMode::Recursive) + .map_err(|e| DaemonError::Watcher(e.to_string()))?; + + info!(dir = %templates_dir.display(), "template watcher started"); + + let task = tokio::spawn(reload_loop(rx, tera)); + + Ok(Self { + _watcher: watcher, + _task: task, + }) + } +} + +async fn reload_loop(mut rx: mpsc::Receiver, tera: Arc>) { + let debounce = Duration::from_millis(150); + + loop { + // Block until at least one change arrives + let Some(first) = rx.recv().await else { + return; + }; + + // Drain additional events within the debounce window + let mut changed = vec![first]; + tokio::time::sleep(debounce).await; + while let Ok(path) = rx.try_recv() { + changed.push(path); + } + + let names: Vec = changed + .iter() + .filter_map(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .collect(); + + let mut guard = tera.write().await; + match guard.full_reload() { + Ok(()) => info!(files = %names.join(", "), "templates reloaded"), + Err(e) => warn!(error = %e, "template reload failed — keeping previous state"), + } + } +} diff --git a/crates/ontoref-daemon/src/watcher.rs b/crates/ontoref-daemon/src/watcher.rs new file mode 100644 index 0000000..ee617ab --- /dev/null +++ b/crates/ontoref-daemon/src/watcher.rs @@ -0,0 +1,271 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +use crate::actors::ActorRegistry; +use crate::cache::NclCache; +use crate::notifications::NotificationStore; + +/// Directories to watch for NCL changes relative to a project root. +const WATCH_DIRS: &[&str] = &[".ontology", "adrs", "reflection", "ontology"]; + +/// File watcher that invalidates the NCL cache on filesystem changes +/// and pushes notifications to the notification store. +pub struct FileWatcher { + _watcher: RecommendedWatcher, + _debounce_task: tokio::task::JoinHandle<()>, +} + +/// Optional dependencies injected into the file watcher. +pub struct WatcherDeps { + #[cfg(feature = "db")] + pub db: Option>, + pub import_path: Option, + pub notifications: Arc, + pub actors: Arc, + #[cfg(feature = "nats")] + pub nats: Option>, +} + +impl FileWatcher { + /// Start watching NCL-relevant directories under `project_root`. + /// + /// Changes are debounced (200ms) before invalidating the cache. + /// A periodic full invalidation runs every `full_invalidation_secs` as + /// safety net. + pub fn start( + project_root: &Path, + cache: Arc, + full_invalidation_secs: u64, + deps: WatcherDeps, + ) -> std::result::Result { + let (tx, rx) = mpsc::channel::>(256); + let project_root_owned = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + + let tx_notify = tx.clone(); + let mut watcher = RecommendedWatcher::new( + move |res: std::result::Result| match res { + Ok(event) => { + let ncl_paths: Vec = event + .paths + .into_iter() + .filter(|p| { + p.extension() + .is_some_and(|ext| ext == "ncl" || ext == "jsonl") + }) + .collect(); + if !ncl_paths.is_empty() { + let _ = tx_notify.try_send(ncl_paths); + } + } + Err(e) => warn!(error = %e, "file watcher error"), + }, + Config::default(), + ) + .map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?; + + let mut watched_count = 0; + for dir_name in WATCH_DIRS { + let dir = project_root.join(dir_name); + if dir.is_dir() { + if let Err(e) = watcher.watch(&dir, RecursiveMode::Recursive) { + warn!(dir = %dir.display(), error = %e, "failed to watch directory"); + } else { + info!(dir = %dir.display(), "watching directory"); + watched_count += 1; + } + } + } + info!(watched_count, "file watcher started"); + + let debounce_task = tokio::spawn(debounce_loop( + rx, + cache, + project_root_owned, + full_invalidation_secs, + deps, + )); + + Ok(Self { + _watcher: watcher, + _debounce_task: debounce_task, + }) + } +} + +/// Debounce filesystem events: collect paths over 200ms windows, then +/// invalidate once. Also runs periodic full invalidation as safety net. +/// Pushes notifications to the store and optionally publishes via NATS. +async fn debounce_loop( + mut rx: mpsc::Receiver>, + cache: Arc, + project_root: PathBuf, + full_invalidation_secs: u64, + deps: WatcherDeps, +) { + let debounce = Duration::from_millis(200); + let effective_secs = if full_invalidation_secs == 0 { + 60 + } else { + full_invalidation_secs + }; + let mut full_tick = tokio::time::interval(Duration::from_secs(effective_secs)); + full_tick.tick().await; // consume immediate first tick + + let project_name = project_root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + loop { + tokio::select! { + recv = rx.recv() => match recv { + None => { + debug!("watcher channel closed — debounce task exiting"); + return; + } + Some(paths) => { + // Collect all events within debounce window + let mut all_paths = paths; + tokio::time::sleep(debounce).await; + while let Ok(more) = rx.try_recv() { + all_paths.extend(more); + } + + // Canonicalize, deduplicate, and invalidate. + let mut canonical: Vec = all_paths + .into_iter() + .filter_map(|p| p.canonicalize().ok()) + .collect(); + canonical.sort(); + canonical.dedup(); + + let file_names: Vec = canonical + .iter() + .filter_map(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .collect(); + + for path in &canonical { + cache.invalidate_file(path); + } + + info!( + files = canonical.len(), + names = %file_names.join(", "), + "cache invalidated — files changed" + ); + + // Convert to relative paths for notification matching + let relative_paths: Vec = canonical + .iter() + .filter_map(|p| { + p.strip_prefix(&project_root) + .ok() + .map(|rel| rel.to_string_lossy().to_string()) + }) + .collect(); + + // Publish general file.changed event via NATS (all files, not just ack-required) + #[cfg(feature = "nats")] + { + if !relative_paths.is_empty() { + if let Some(ref nats) = deps.nats { + if let Err(e) = nats.publish_file_changed(&project_name, &relative_paths).await { + warn!(error = %e, "NATS file.changed publish failed"); + } + } + } + } + + // Push notifications — one per event type, actors need to ack + if !relative_paths.is_empty() { + let notification_ids = deps.notifications.push( + &project_name, + relative_paths.clone(), + None, // source_actor unknown from fs event + ); + + if !notification_ids.is_empty() { + let actor_tokens = deps.actors.tokens_for_project(&project_name); + + // Increment pending count on each actor for each notification + for token in &actor_tokens { + for _ in ¬ification_ids { + deps.actors.increment_pending(token); + } + } + + info!( + notifications = notification_ids.len(), + project = %project_name, + actors = actor_tokens.len(), + "notifications pushed" + ); + + // Publish via NATS — derive events from the file paths directly + #[cfg(feature = "nats")] + { + if let Some(ref nats) = deps.nats { + let mut published_events = std::collections::HashSet::new(); + for file in &relative_paths { + if let Some(event) = crate::notifications::NotificationEvent::from_path(file) { + if published_events.insert(event) { + let event_files: Vec = relative_paths + .iter() + .filter(|f| crate::notifications::NotificationEvent::from_path(f) == Some(event)) + .cloned() + .collect(); + if let Err(e) = nats.publish_notification( + &project_name, + &event, + &event_files, + ).await { + warn!(error = %e, "NATS notification publish failed"); + } + } + } + } + } + } + } + } + + // Re-seed DB if ontology files changed + #[cfg(feature = "db")] + { + let ontology_changed = canonical.iter().any(|p| { + p.to_string_lossy().contains(".ontology") + }); + if ontology_changed { + if let Some(ref db) = deps.db { + info!("re-seeding ontology tables from changed files"); + crate::seed::seed_ontology( + db, + &project_root, + &cache, + deps.import_path.as_deref(), + ).await; + } + } + } + } + }, + _ = full_tick.tick() => { + // Periodic full invalidation as safety net against missed events. + let before = cache.len(); + cache.invalidate_all(); + if before > 0 { + info!(evicted = before, "periodic full cache invalidation"); + } + } + } + } +} diff --git a/crates/ontoref-daemon/templates/base.html b/crates/ontoref-daemon/templates/base.html new file mode 100644 index 0000000..8169af1 --- /dev/null +++ b/crates/ontoref-daemon/templates/base.html @@ -0,0 +1,510 @@ + + + + + + + {% block title %}Ontoref{% endblock title %} + + + + + + {% block head %}{% endblock head %} + + + + + +
+ {% block content %}{% endblock content %} +
+ + {% block scripts %}{% endblock scripts %} + + + +
+ + ontoref + + v{{ daemon_version | default(value="") }} + | + 2026 +
+ + + diff --git a/crates/ontoref-daemon/templates/macros/ui.html b/crates/ontoref-daemon/templates/macros/ui.html new file mode 100644 index 0000000..eb912fd --- /dev/null +++ b/crates/ontoref-daemon/templates/macros/ui.html @@ -0,0 +1,52 @@ +{% macro stat(title, value, desc="", accent="") %} +
+
{{ title }}
+
{{ value }}
+ {% if desc %}
{{ desc }}
{% endif %} +
+{% endmacro stat %} + +{% macro badge(text, kind="neutral") %} +{{ text }} +{% endmacro badge %} + +{% macro event_badge(event) %} +{% if event == "OntologyChanged" %} + ontology +{% elif event == "AdrChanged" %} + adr +{% elif event == "ReflectionChanged" %} + reflection +{% else %} + {{ event }} +{% endif %} +{% endmacro event_badge %} + +{% macro actor_badge(actor_type) %} +{% if actor_type == "developer" %} + developer +{% elif actor_type == "agent" %} + agent +{% elif actor_type == "ci" %} + ci +{% else %} + {{ actor_type }} +{% endif %} +{% endmacro actor_badge %} + +{% macro age(secs) %} +{% if secs < 60 %}{{ secs }}s ago +{% elif secs < 3600 %}{{ secs / 60 | round }}m ago +{% else %}{{ secs / 3600 | round }}h ago +{% endif %} +{% endmacro age %} + +{% macro empty_state(message) %} +
+ + + +

{{ message }}

+
+{% endmacro empty_state %} diff --git a/crates/ontoref-daemon/templates/pages/actions.html b/crates/ontoref-daemon/templates/pages/actions.html new file mode 100644 index 0000000..e27df6c --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/actions.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% import "macros/ui.html" as m %} + +{% block title %}Actions — Ontoref{% endblock title %} +{% block nav_actions %}active{% endblock nav_actions %} +{% block mob_nav_actions %}active{% endblock mob_nav_actions %} + +{% block content %} +
+

Quick Actions

+ Runnable tasks and workflows +
+ +{% if not actions or actions | length == 0 %} +{{ m::empty_state(message="No quick actions configured — add quick_actions to .ontoref/config.ncl") }} +{% else %} + +{% set grouped = actions | group_by(attribute="category") %} +{% for cat, cat_actions in grouped %} +
+

+ {% if cat == "docs" %}Documentation + {% elif cat == "sync" %}Synchronization + {% elif cat == "analysis" %}Analysis + {% elif cat == "test" %}Testing + {% else %}{{ cat | title }}{% endif %} +

+
+ {% for action in cat_actions %} +
+
+
+
+ + {% if action.icon == "book-open" %} + + {% elif action.icon == "refresh" %} + + {% elif action.icon == "code" %} + + {% else %} + + {% endif %} + +
+
+

{{ action.label }}

+

mode: {{ action.mode }}

+
+
+ +
+ {% for actor in action.actors %} + {{ actor }} + {% endfor %} +
+ + {% if not current_role or current_role == "admin" %} +
+
+ + +
+
+ {% endif %} +
+
+ {% endfor %} +
+
+{% endfor %} + +{% endif %} +{% endblock content %} diff --git a/crates/ontoref-daemon/templates/pages/backlog.html b/crates/ontoref-daemon/templates/pages/backlog.html new file mode 100644 index 0000000..e304f0a --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/backlog.html @@ -0,0 +1,332 @@ +{% extends "base.html" %} +{% block title %}Backlog — Ontoref{% endblock title %} +{% block nav_backlog %}active{% endblock nav_backlog %} + +{% block content %} +
+

Backlog

+ +
+ +{% if not has_backlog %} +
+

No reflection/backlog.ncl found in this project.

+
+{% else %} + + +
+
+
Open
+
{{ stats.open }}
+
+
+
In Progress
+
{{ stats.inprog }}
+
+
+
Done
+
{{ stats.done }}
+
+
+
Critical
+
{{ stats.critical }}
+
+
+ + +
+ + + + +
+ + + +
+
+ + +{% if items %} +
+ + + + + + + + + + + + + {% for it in items %} + + + + + + + + + {% endfor %} + +
IDStatusPriorityKindTitleActions
{{ it.id }} + {{ it.status }} + + {{ it.priority }} + + {{ it.kind }} + +
{{ it.title }}
+ {% if it.detail %} +
{{ it.detail }}
+ {% endif %} +
+ +
+
+{% else %} +
+ No backlog items yet. +
+{% endif %} + +{% endif %} + +{% if slug %} + +
+

Cross-project backlog

+
+ + +
+
+

Enter a project slug and click Load to view its backlog.

+
+
+{% endif %} + + + + + + + +{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/crates/ontoref-daemon/templates/pages/compose.html b/crates/ontoref-daemon/templates/pages/compose.html new file mode 100644 index 0000000..630985b --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/compose.html @@ -0,0 +1,407 @@ +{% extends "base.html" %} + +{% block title %}Compose — Ontoref{% endblock title %} + +{% block content %} +
+
+

Agent Task Composer

+

Select a form template, fill fields, send to an AI provider or export.

+
+
+ +
+ + +
+ + +
+
+

Template

+ {% if forms %} + + {% else %} +

No forms found in reflection/forms/.

+ {% endif %} +
+
+ + + + + +
+
+

System prompt (optional)

+ +
+
+
+ + +
+ + +
+
+
+

Assembled prompt

+ +
+ +
+
+ + +
+
+

Send / Export

+ +
+ + +
+ +
+ + +
+ + + + + + +
+
+
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/crates/ontoref-daemon/templates/pages/dashboard.html b/crates/ontoref-daemon/templates/pages/dashboard.html new file mode 100644 index 0000000..742a105 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/dashboard.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% import "macros/ui.html" as m %} + +{% block title %}Dashboard — Ontoref{% endblock title %} +{% block nav_dashboard %}active{% endblock nav_dashboard %} + +{% block content %} +
+

Dashboard

+

{{ project_root }}

+
+ + +
+ {{ m::stat(title="Uptime", value=uptime_secs ~ "s", desc="seconds since start") }} + {{ m::stat(title="Cache entries", value=cache_entries) }} + {{ m::stat(title="Cache hit rate", value=cache_hit_rate, desc=cache_hits ~ " hits / " ~ cache_misses ~ " misses", accent="success") }} + {{ m::stat(title="Sessions", value=active_actors, accent="primary") }} + {{ m::stat(title="Notifications", value=notification_count, accent="warning") }} +
+ + +{% if backlog %} +
+ {{ m::stat(title="Backlog total", value=backlog.total) }} + {{ m::stat(title="Open", value=backlog.open, accent="warning") }} + {{ m::stat(title="In progress", value=backlog.inprog, accent="info") }} + {{ m::stat(title="Done", value=backlog.done, accent="success") }} + {{ m::stat(title="Critical", value=backlog.critical, accent="error") }} + {{ m::stat(title="ADRs", value=adr_count) }} + {{ m::stat(title="Modes", value=mode_count) }} +
+{% endif %} + + +{% endblock content %} diff --git a/crates/ontoref-daemon/templates/pages/graph.html b/crates/ontoref-daemon/templates/pages/graph.html new file mode 100644 index 0000000..814e7c8 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/graph.html @@ -0,0 +1,458 @@ +{% extends "base.html" %} + +{% block title %}Ontology Graph — Ontoref{% endblock title %} +{% block nav_graph %}active{% endblock nav_graph %} + +{% block head %} + + +{% endblock head %} + +{% block content %} + +
+

Ontology Graph

+
+ + + + +
+ + + + +
+ +
+ + +
+ +
+
+ + +
+
+
+
+ +
+ + +
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/crates/ontoref-daemon/templates/pages/login.html b/crates/ontoref-daemon/templates/pages/login.html new file mode 100644 index 0000000..2333ce4 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/login.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Login — {{ slug }} — Ontoref{% endblock title %} +{% block content %} +
+
+
+
+

ontoref

+

{{ slug }}

+
+ {% if error %} +
+ + + + Invalid key. +
+ {% endif %} +
+
+ + +
+ +
+
+
+
+{% endblock content %} diff --git a/crates/ontoref-daemon/templates/pages/manage.html b/crates/ontoref-daemon/templates/pages/manage.html new file mode 100644 index 0000000..8293136 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/manage.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} + +{% block title %}Manage Projects — Ontoref{% endblock title %} + +{% block content %} +
+

Manage Projects

+ {% if registry_path %} + {{ registry_path }} + {% endif %} +
+ +{% if error %} +
+ + + + {{ error }} +
+{% endif %} + + +
+

Registered Projects

+ {% if projects %} +
+ + + + + + + + + + + {% for p in projects %} + + + + + + + {% endfor %} + +
SlugRootAuthActions
+ {{ p.slug }} + {{ p.root }} + {% if p.auth %} + protected + {% else %} + open + {% endif %} + +
+ + +
+
+
+ {% else %} +
+ No projects registered. Add one below. +
+ {% endif %} +
+ + +
+
+

Add Project

+
+
+ + +
+
+ + +
+
+ +
+
+
+
+{% endblock content %} diff --git a/crates/ontoref-daemon/templates/pages/modes.html b/crates/ontoref-daemon/templates/pages/modes.html new file mode 100644 index 0000000..6f0ab27 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/modes.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} +{% import "macros/ui.html" as m %} + +{% block title %}Reflection Modes — Ontoref{% endblock title %} +{% block nav_modes %}active{% endblock nav_modes %} + +{% block content %} +
+

Reflection Modes

+ {{ total }} +
+ +{% if showcase %} + +{% endif %} + +{% if generated %} +
+

Generated Artifacts

+
+ {% for g in generated %} + + + + + {{ g.label }} + + + + + {% endfor %} +
+
+{% endif %} + +{% if modes | length == 0 %} + {{ m::empty_state(message="No reflection modes found in reflection/modes/") }} +{% else %} +
+ {% for mode in modes %} +
+
+ +
+

+ {{ mode.id | default(value="unknown") }} +

+ {% if mode._error %} + error + {% else %} + {% set step_count = mode.steps | default(value=[]) | length %} + {{ step_count }} step(s) + {% endif %} +
+ + {% if mode._error %} +

{{ mode._error }}

+ {% else %} +

{{ mode.trigger | default(value="") }}

+ + {% set steps = mode.steps | default(value=[]) %} + {% if steps | length > 0 %} +
+ +
+ Steps ({{ steps | length }}) +
+
+
    + {% for step in steps %} +
  1. + {{ step.id | default(value=loop.index) }} +
    +

    {{ step.action | default(value="") }}

    + {% if step.cmd %} + {{ step.cmd }} + {% endif %} +
    +
  2. + {% endfor %} +
+
+
+ {% endif %} + + {% if mode.preconditions %} +
+ {{ mode.preconditions | length }} precondition(s) +
+ {% endif %} + {% endif %} + +
+ {{ mode._file | default(value="") }} +
+
+
+ {% endfor %} +
+{% endif %} +{% endblock content %} diff --git a/crates/ontoref-daemon/templates/pages/notifications.html b/crates/ontoref-daemon/templates/pages/notifications.html new file mode 100644 index 0000000..e26cff0 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/notifications.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} +{% import "macros/ui.html" as m %} + +{% block title %}Notifications — Ontoref{% endblock title %} +{% block nav_notifications %}active{% endblock nav_notifications %} + +{% block content %} +
+
+

Notifications

+

{{ total }} total

+
+ {% if other_projects %} + + {% endif %} +
+ +{% if notifications | length == 0 %} + {{ m::empty_state(message="No notifications in store") }} +{% else %} +
+ + + + + + + + + + + + + {% for n in notifications %} + + + + + + + + + {% endfor %} + +
#TypeProjectContentSourceAge
{{ n.id }} + {% if n.is_custom %} + {{ n.custom_kind | default(value="custom") }} + {% else %} + {{ m::event_badge(event=n.event) }} + {% endif %} + + {{ n.project }} + {% if n.source_project and n.source_project != n.project %} + ← {{ n.source_project }} + {% endif %} + + {% if n.is_custom %} +
{{ n.custom_title | default(value="") }}
+ {% if n.custom_payload %} + {% set p = n.custom_payload %} + {% if p.actions %} + +
+ {% for act in p.actions %} +
+ + +
+ {% endfor %} +
+ {% else %} +
+ payload +
{{ p }}
+
+ {% endif %} + {% endif %} + {% else %} +
+ {% for f in n.files %} + {{ f }} + {% endfor %} +
+ {% endif %} +
+ {{ n.source_actor | default(value="—") }} + {{ n.age_secs }}s ago
+
+{% endif %} + +{% if other_projects %} + + + + + +{% endif %} +{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/crates/ontoref-daemon/templates/pages/project_picker.html b/crates/ontoref-daemon/templates/pages/project_picker.html new file mode 100644 index 0000000..daabeae --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/project_picker.html @@ -0,0 +1,294 @@ +{% extends "base.html" %} +{% block title %}Projects — Ontoref{% endblock title %} + +{% block head %} + +{% endblock head %} + +{% block content %} + + + +
+
+

Projects

+

{{ projects | length }} project{% if projects | length != 1 %}s{% endif %} registered

+
+ + + + + + Manage + +
+ +{% if projects %} +
+ {% for p in projects %} +
+ +
+ + +
+
+ {{ p.slug }} + {% if p.auth %} + protected + {% else %} + open + {% endif %} + {% if p.default_mode %} + {{ p.default_mode }} + {% endif %} + {% if p.repo_kind %} + {{ p.repo_kind }} + {% endif %} +
+ + +
+ {% if p.description %} +

{{ p.description }}

+ {% endif %} +

{{ p.root }}

+ + + {% if p.repos %} +
+ {% for r in p.repos %} + + + + + {{ r.name }} + + {% endfor %} +
+ {% endif %} + + + {% if p.showcase or p.generated %} + + {% endif %} + + +
+ {% if p.session_count > 0 %} + + + {{ p.session_count }} session{% if p.session_count != 1 %}s{% endif %} + + {% else %} + no sessions + {% endif %} + + {% if p.notif_count > 0 %} + {{ p.notif_count }} notif + {% endif %} + + {% if p.backlog_open > 0 %} + {{ p.backlog_open }} open + {% endif %} + + {% if p.layers %} + {{ p.layers | length }} layers + {% endif %} + + {% if p.op_modes %} + {{ p.op_modes | length }} modes + {% endif %} +
+ + +
+ + {% if p.layers or p.op_modes %} +
+ + Features & Layers + +
+ {% if p.layers %} +

Layers

+
+ {% for l in p.layers %} +
+ {{ l.id }} + {{ l.description }} +
+ {% endfor %} +
+ {% endif %} + {% if p.op_modes %} +

Operational Modes

+
+ {% for m in p.op_modes %} +
+ {{ m.id }} + {{ m.description }} +
+ {% endfor %} +
+ {% endif %} +
+
+ {% endif %} + + {% if p.backlog_items %} +
+ + Backlog + {{ p.backlog_open }} open + +
+
+ {% for it in p.backlog_items %} +
+ + {{ it.status }} + + + {{ it.priority }} + + {{ it.title }} +
+ {% endfor %} +
+ + Manage backlog → + +
+
+ {% endif %} + + {% if p.sessions %} +
+ + Sessions + {{ p.session_count }} + +
+
+ {% for s in p.sessions %} +
+ {{ s.actor_type }} + {{ s.hostname }} + {{ s.last_seen_ago }}s ago +
+ {% endfor %} +
+
+
+ {% endif %} + + {% if p.notifications %} +
+ + Notifications + {{ p.notif_count }} + +
+
+ {% for n in p.notifications %} +
+ {{ n.event }} + + {% if n.files %} + {{ n.files | first }} + {% set fc = n.files | length %} + {% if fc > 1 %} +{{ fc - 1 }}{% endif %} + {% endif %} + + {{ n.age_secs }}s +
+ {% endfor %} +
+
+
+ {% endif %} + +
+
+
+ {% endfor %} +
+{% else %} +
+ + + +

No projects registered.

+ Add a project +
+{% endif %} +{% endblock content %} diff --git a/crates/ontoref-daemon/templates/pages/qa.html b/crates/ontoref-daemon/templates/pages/qa.html new file mode 100644 index 0000000..55816c4 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/qa.html @@ -0,0 +1,389 @@ +{% extends "base.html" %} + +{% block title %}Q&A — Ontoref{% endblock title %} +{% block nav_qa %}active{% endblock nav_qa %} +{% block mob_nav_qa %}active{% endblock mob_nav_qa %} + +{% block content %} + + + + +
+

+ Q&A Bookmarks + +

+
+ +
+ +
+ + +
+ +
+
+ + + + +
+
+ + +
    + + + +
    + + +
    + + +
    + + + + Select a Q&A or add a new one +
    + + + + + + + +
    +
    +{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/crates/ontoref-daemon/templates/pages/search.html b/crates/ontoref-daemon/templates/pages/search.html new file mode 100644 index 0000000..6b4acf2 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/search.html @@ -0,0 +1,423 @@ +{% extends "base.html" %} + +{% block title %}Search — Ontoref{% endblock title %} +{% block nav_search %}active{% endblock nav_search %} + +{% block content %} + + + + +
    + + +
    + +
    + +
    + + +
    + + +
    +
    +
    + + + + +
    +
    + +
      +
      + + + +
      + + +
      + + + + + +
      +
      + + + + Type to search, click a result to view +
      +
      + +
      +{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/crates/ontoref-daemon/templates/pages/sessions.html b/crates/ontoref-daemon/templates/pages/sessions.html new file mode 100644 index 0000000..a778ce1 --- /dev/null +++ b/crates/ontoref-daemon/templates/pages/sessions.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% import "macros/ui.html" as m %} + +{% block title %}Sessions — Ontoref{% endblock title %} +{% block nav_sessions %}active{% endblock nav_sessions %} + +{% block content %} +
      +

      Active Sessions

      + {{ total }} actor(s) +
      + +{% if sessions | length == 0 %} + {{ m::empty_state(message="No active actor sessions") }} +{% else %} +
      + + + + + + + + + + + + + + + {% for s in sessions %} + + + + + + + + + + + {% endfor %} + +
      TokenTypeRoleProjectHost / PIDRegisteredLast seenPending
      {{ s.token }}{{ m::actor_badge(actor_type=s.actor_type) }} + {{ s.role }} + {% if s.has_preferences %} + + {% endif %} + {{ s.project }}{{ s.hostname }}:{{ s.pid }}{{ s.registered_ago }}s ago{{ s.last_seen_ago }}s ago + {% if s.pending_notifications > 0 %} + {{ s.pending_notifications }} + {% else %} + + {% endif %} +
      +
      +{% endif %} +{% endblock content %} diff --git a/crates/ontoref-ontology/Cargo.toml b/crates/ontoref-ontology/Cargo.toml new file mode 100644 index 0000000..f2dbbe1 --- /dev/null +++ b/crates/ontoref-ontology/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ontoref-ontology" +version = "0.1.0" +edition = "2021" +description = "Load and query project ontology (.ontology/ NCL files) as typed Rust structs" +license = "MIT OR Apache-2.0" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +anyhow = { version = "1" } +thiserror = { version = "2" } +tracing = { version = "0.1" } + +[dev-dependencies] +tempfile = { version = "3" } diff --git a/crates/ontoref-ontology/src/error.rs b/crates/ontoref-ontology/src/error.rs new file mode 100644 index 0000000..a8373c2 --- /dev/null +++ b/crates/ontoref-ontology/src/error.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum OntologyError { + #[error("nickel export failed on '{path}': {stderr}")] + NickelExport { path: String, stderr: String }, + + #[error("failed to parse '{section}' from nickel output: {source}")] + Parse { + section: &'static str, + #[source] + source: serde_json::Error, + }, + + #[error("ontology directory '{0}' is missing expected file '{1}'")] + MissingFile(String, String), +} diff --git a/crates/ontoref-ontology/src/lib.rs b/crates/ontoref-ontology/src/lib.rs new file mode 100644 index 0000000..5968adc --- /dev/null +++ b/crates/ontoref-ontology/src/lib.rs @@ -0,0 +1,11 @@ +pub mod error; +pub mod ontology; +pub mod types; + +pub use error::OntologyError; +pub use ontology::{Core, Gate, Ontology, State}; +pub use types::{ + AbstractionLevel, CoreConfig, Coupling, Dimension, DimensionState, Duration, Edge, EdgeType, + GateConfig, Horizon, Membrane, Node, OpeningCondition, Permeability, Pole, Protocol, + SignalType, StateConfig, TensionLevel, Transition, +}; diff --git a/crates/ontoref-ontology/src/ontology.rs b/crates/ontoref-ontology/src/ontology.rs new file mode 100644 index 0000000..549c543 --- /dev/null +++ b/crates/ontoref-ontology/src/ontology.rs @@ -0,0 +1,503 @@ +use std::{collections::HashMap, path::Path}; + +use anyhow::{anyhow, Context, Result}; +use serde_json::Value; +use tracing::debug; + +use crate::{ + error::OntologyError, + types::{ + AbstractionLevel, CoreConfig, Dimension, Edge, GateConfig, Membrane, Node, Permeability, + StateConfig, TensionLevel, + }, +}; + +/// Full project ontology: core DAG + state FSM + gate membranes. +#[derive(Debug)] +pub struct Ontology { + pub core: Core, + pub state: State, + pub gate: Gate, +} + +impl Ontology { + /// Load all three sections from `ontology_dir/` (core.ncl, state.ncl, + /// gate.ncl). Each file is exported via `nickel export --format json`. + /// + /// Prefer constructing from pre-fetched JSON via [`Core::from_value`], + /// [`State::from_value`], [`Gate::from_value`] when a daemon or cache + /// is available. + #[deprecated(note = "use from_value() constructors with daemon-provided JSON instead")] + pub fn load(ontology_dir: &Path) -> Result { + #[allow(deprecated)] + { + let core = Core::load(&ontology_dir.join("core.ncl"))?; + let state = State::load(&ontology_dir.join("state.ncl"))?; + let gate = Gate::load(&ontology_dir.join("gate.ncl"))?; + Ok(Self { core, state, gate }) + } + } + + /// Construct from pre-fetched JSON values (from stratum-daemon, stratum-db, + /// or any other source that provides the NCL export output). + pub fn from_values(core_json: &Value, state_json: &Value, gate_json: &Value) -> Result { + Ok(Self { + core: Core::from_value(core_json)?, + state: State::from_value(state_json)?, + gate: Gate::from_value(gate_json)?, + }) + } + + /// Reload all sections from disk (re-runs nickel export). + #[deprecated(note = "use from_values() with daemon-provided JSON instead")] + pub fn reload(&mut self, ontology_dir: &Path) -> Result<()> { + #[allow(deprecated)] + { + self.core = Core::load(&ontology_dir.join("core.ncl"))?; + self.state = State::load(&ontology_dir.join("state.ncl"))?; + self.gate = Gate::load(&ontology_dir.join("gate.ncl"))?; + Ok(()) + } + } +} + +// ── Core ────────────────────────────────────────────────────────────────────── + +/// The core ontology DAG: nodes (axioms, tensions, practices) and edges. +#[derive(Debug)] +pub struct Core { + nodes: Vec, + edges: Vec, + by_id: HashMap, +} + +impl Core { + /// Construct from a pre-fetched JSON value (the output of `nickel export + /// core.ncl`). + pub fn from_value(value: &Value) -> Result { + let cfg: CoreConfig = + serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse { + section: "core", + source: e, + })?; + + let by_id: HashMap = cfg + .nodes + .iter() + .enumerate() + .map(|(i, n)| (n.id.clone(), i)) + .collect(); + + Ok(Self { + nodes: cfg.nodes, + edges: cfg.edges, + by_id, + }) + } + + #[deprecated(note = "use Core::from_value() with daemon-provided JSON instead")] + fn load(path: &Path) -> Result { + let raw = nickel_export(path, "core")?; + let cfg: CoreConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse { + section: "core", + source: e, + })?; + + let by_id: HashMap = cfg + .nodes + .iter() + .enumerate() + .map(|(i, n)| (n.id.clone(), i)) + .collect(); + + Ok(Self { + nodes: cfg.nodes, + edges: cfg.edges, + by_id, + }) + } + + pub fn nodes(&self) -> &[Node] { + &self.nodes + } + + pub fn edges(&self) -> &[Edge] { + &self.edges + } + + pub fn node_by_id(&self, id: &str) -> Option<&Node> { + self.by_id.get(id).map(|&i| &self.nodes[i]) + } + + pub fn axioms(&self) -> impl Iterator { + self.nodes + .iter() + .filter(|n| n.level == AbstractionLevel::Axiom) + } + + pub fn tensions(&self) -> impl Iterator { + self.nodes + .iter() + .filter(|n| n.level == AbstractionLevel::Tension) + } + + pub fn practices(&self) -> impl Iterator { + self.nodes + .iter() + .filter(|n| n.level == AbstractionLevel::Practice) + } + + /// Nodes with `invariant = true` — must never be violated. + pub fn invariants(&self) -> impl Iterator { + self.nodes.iter().filter(|n| n.invariant) + } + + /// All edges originating from `node_id`. + pub fn edges_from(&self, node_id: &str) -> impl Iterator { + let id = node_id.to_owned(); + self.edges.iter().filter(move |e| e.from == id) + } + + /// All edges pointing to `node_id`. + pub fn edges_to(&self, node_id: &str) -> impl Iterator { + let id = node_id.to_owned(); + self.edges.iter().filter(move |e| e.to == id) + } +} + +// ── State ───────────────────────────────────────────────────────────────────── + +/// The state FSM: tracked dimensions and their transition graphs. +#[derive(Debug)] +pub struct State { + dimensions: Vec, + by_id: HashMap, +} + +impl State { + /// Construct from a pre-fetched JSON value (the output of `nickel export + /// state.ncl`). + pub fn from_value(value: &Value) -> Result { + let cfg: StateConfig = + serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse { + section: "state", + source: e, + })?; + + let by_id: HashMap = cfg + .dimensions + .iter() + .enumerate() + .map(|(i, d)| (d.id.clone(), i)) + .collect(); + + Ok(Self { + dimensions: cfg.dimensions, + by_id, + }) + } + + #[deprecated(note = "use State::from_value() with daemon-provided JSON instead")] + fn load(path: &Path) -> Result { + let raw = nickel_export(path, "state")?; + let cfg: StateConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse { + section: "state", + source: e, + })?; + + let by_id: HashMap = cfg + .dimensions + .iter() + .enumerate() + .map(|(i, d)| (d.id.clone(), i)) + .collect(); + + Ok(Self { + dimensions: cfg.dimensions, + by_id, + }) + } + + pub fn dimensions(&self) -> &[Dimension] { + &self.dimensions + } + + pub fn dimension_by_id(&self, id: &str) -> Option<&Dimension> { + self.by_id.get(id).map(|&i| &self.dimensions[i]) + } + + /// Dimensions with high tension in their current state. + pub fn high_tension_dimensions(&self) -> impl Iterator { + self.dimensions.iter().filter(|d| { + d.states + .iter() + .find(|e| e.id == d.current_state) + .is_some_and(|e| e.tension == TensionLevel::High) + }) + } + + /// Check if a transition from `current` to `target` is declared for + /// dimension `dim_id`. Returns `Ok(())` if valid, `Err` with the + /// declared blocker if not. + pub fn can_transition(&self, dim_id: &str, to: &str) -> Result<(), String> { + let dim = self + .dimension_by_id(dim_id) + .ok_or_else(|| format!("dimension '{dim_id}' not found"))?; + + let transition = dim + .transitions + .iter() + .find(|t| t.from == dim.current_state && t.to == to); + + match transition { + Some(t) if t.blocker.is_empty() => Ok(()), + Some(t) => Err(format!("transition blocked: {}", t.blocker)), + None => Err(format!( + "no declared transition from '{}' to '{to}' in dimension '{dim_id}'", + dim.current_state + )), + } + } +} + +// ── Gate ────────────────────────────────────────────────────────────────────── + +/// The gate: membranes that filter incoming signals. +#[derive(Debug)] +pub struct Gate { + membranes: Vec, + by_id: HashMap, +} + +impl Gate { + /// Construct from a pre-fetched JSON value (the output of `nickel export + /// gate.ncl`). + pub fn from_value(value: &Value) -> Result { + let cfg: GateConfig = + serde_json::from_value(value.clone()).map_err(|e| OntologyError::Parse { + section: "gate", + source: e, + })?; + + let by_id: HashMap = cfg + .membranes + .iter() + .enumerate() + .map(|(i, m)| (m.id.clone(), i)) + .collect(); + + Ok(Self { + membranes: cfg.membranes, + by_id, + }) + } + + #[deprecated(note = "use Gate::from_value() with daemon-provided JSON instead")] + fn load(path: &Path) -> Result { + let raw = nickel_export(path, "gate")?; + let cfg: GateConfig = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse { + section: "gate", + source: e, + })?; + + let by_id: HashMap = cfg + .membranes + .iter() + .enumerate() + .map(|(i, m)| (m.id.clone(), i)) + .collect(); + + Ok(Self { + membranes: cfg.membranes, + by_id, + }) + } + + pub fn membranes(&self) -> &[Membrane] { + &self.membranes + } + + pub fn membrane_by_id(&self, id: &str) -> Option<&Membrane> { + self.by_id.get(id).map(|&i| &self.membranes[i]) + } + + /// Active membranes that are currently open. + pub fn active_membranes(&self) -> impl Iterator { + self.membranes.iter().filter(|m| m.active) + } + + /// Membranes with `Closed` permeability — signals cannot enter. + pub fn closed_membranes(&self) -> impl Iterator { + self.membranes + .iter() + .filter(|m| m.permeability == Permeability::Closed) + } + + /// Membranes that protect the node with the given id. + pub fn protecting(&self, node_id: &str) -> impl Iterator { + let id = node_id.to_owned(); + self.membranes + .iter() + .filter(move |m| m.protects.iter().any(|p| p == &id)) + } +} + +// ── Shared ──────────────────────────────────────────────────────────────────── + +fn nickel_export(path: &Path, section: &'static str) -> Result> { + if !path.exists() { + return Err(OntologyError::MissingFile( + path.parent().unwrap_or(path).display().to_string(), + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + ) + .into()); + } + + debug!(section, path = %path.display(), "running nickel export"); + + let output = std::process::Command::new("nickel") + .arg("export") + .arg("--format") + .arg("json") + .arg(path) + .output() + .with_context(|| format!("running nickel export on '{}'", path.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(anyhow!(OntologyError::NickelExport { + path: path.display().to_string(), + stderr, + })); + } + + Ok(output.stdout) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn core_from_value_parses_valid_json() { + let json = serde_json::json!({ + "nodes": [ + { + "id": "test-axiom", + "name": "Test Axiom", + "pole": "Yang", + "level": "Axiom", + "description": "A test axiom", + "invariant": true + } + ], + "edges": [ + { + "from": "test-axiom", + "to": "test-axiom", + "kind": "Contains", + "weight": 1.0, + "note": "" + } + ] + }); + + let core = Core::from_value(&json).unwrap(); + assert_eq!(core.nodes().len(), 1); + assert_eq!(core.edges().len(), 1); + assert!(core.node_by_id("test-axiom").is_some()); + assert_eq!(core.axioms().count(), 1); + assert_eq!(core.invariants().count(), 1); + } + + #[test] + fn state_from_value_parses_and_transitions() { + let json = serde_json::json!({ + "dimensions": [ + { + "id": "test-dim", + "name": "Test", + "description": "", + "current_state": "a", + "desired_state": "b", + "horizon": "Weeks", + "states": [], + "transitions": [ + { + "from": "a", + "to": "b", + "condition": "ready", + "catalyst": "", + "blocker": "", + "horizon": "Weeks" + } + ], + "coupled_with": [] + } + ] + }); + + let state = State::from_value(&json).unwrap(); + assert_eq!(state.dimensions().len(), 1); + assert!(state.can_transition("test-dim", "b").is_ok()); + assert!(state.can_transition("test-dim", "c").is_err()); + } + + #[test] + fn gate_from_value_parses_membranes() { + let json = serde_json::json!({ + "membranes": [ + { + "id": "test-gate", + "name": "Test Gate", + "description": "A test membrane", + "permeability": "High", + "accepts": ["HardBug"], + "protects": ["test-axiom"], + "opening_condition": { + "max_tension_dimensions": 2, + "pending_transitions": 1, + "core_stable": true, + "description": "test" + }, + "closing_condition": "done", + "protocol": "Observe", + "max_duration": "Weeks", + "active": true + } + ] + }); + + let gate = Gate::from_value(&json).unwrap(); + assert_eq!(gate.membranes().len(), 1); + assert_eq!(gate.active_membranes().count(), 1); + assert_eq!(gate.protecting("test-axiom").count(), 1); + } + + #[test] + fn ontology_from_values_composes_all_three() { + let core_json = serde_json::json!({ + "nodes": [{ + "id": "ax", "name": "Ax", "pole": "Yang", + "level": "Axiom", "description": "d", "invariant": false + }], + "edges": [] + }); + let state_json = serde_json::json!({ "dimensions": [] }); + let gate_json = serde_json::json!({ "membranes": [] }); + + let ont = Ontology::from_values(&core_json, &state_json, &gate_json).unwrap(); + assert_eq!(ont.core.nodes().len(), 1); + assert!(ont.state.dimensions().is_empty()); + assert!(ont.gate.membranes().is_empty()); + } + + #[test] + fn from_value_rejects_invalid_json() { + let bad = serde_json::json!({"nodes": "not_an_array"}); + assert!(Core::from_value(&bad).is_err()); + } +} diff --git a/crates/ontoref-ontology/src/types.rs b/crates/ontoref-ontology/src/types.rs new file mode 100644 index 0000000..4a53db5 --- /dev/null +++ b/crates/ontoref-ontology/src/types.rs @@ -0,0 +1,226 @@ +use serde::{Deserialize, Serialize}; + +// ── Core (DAG) +// ──────────────────────────────────────────────────────────────── + +/// Node polarity in the ontology DAG. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Pole { + Yang, + Yin, + Spiral, +} + +/// Abstraction level of a node. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AbstractionLevel { + Axiom, + Tension, + Practice, + Project, + Moment, +} + +/// Edge type between nodes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EdgeType { + Contains, + TensionWith, + ManifestsIn, + ValidatedBy, + FlowsTo, + CyclesIn, + SpiralsWith, + LimitedBy, + Complements, +} + +/// An ontology node (axiom, tension, practice, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Node { + pub id: String, + pub name: String, + pub pole: Pole, + pub level: AbstractionLevel, + pub description: String, + #[serde(default)] + pub invariant: bool, +} + +/// A directed edge between two nodes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Edge { + pub from: String, + pub to: String, + pub kind: EdgeType, + #[serde(default = "Edge::default_weight")] + pub weight: f64, + #[serde(default)] + pub note: String, +} + +impl Edge { + fn default_weight() -> f64 { + 1.0 + } +} + +/// Deserialization root for core.ncl exports. +#[derive(Debug, Deserialize)] +pub struct CoreConfig { + pub nodes: Vec, + pub edges: Vec, +} + +// ── State (FSM) +// ─────────────────────────────────────────────────────────────── + +/// Tension level of a state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TensionLevel { + High, + Medium, + Low, + Ignored, +} + +/// Time horizon for a dimension or transition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Horizon { + Weeks, + Months, + Years, + Continuous, +} + +/// A discrete state within a dimension. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DimensionState { + pub id: String, + pub name: String, + pub description: String, + pub tension: TensionLevel, +} + +/// A valid transition between two states. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transition { + pub from: String, + pub to: String, + pub condition: String, + pub catalyst: String, + pub blocker: String, + pub horizon: Horizon, +} + +/// Coupling relationship between dimensions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Coupling { + pub origin: String, + pub destination: String, + pub kind: String, + pub note: String, +} + +/// A tracked dimension of the project's state (FSM). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Dimension { + pub id: String, + pub name: String, + pub description: String, + pub current_state: String, + pub desired_state: String, + pub horizon: Horizon, + pub states: Vec, + pub transitions: Vec, + #[serde(default)] + pub coupled_with: Vec, +} + +/// Deserialization root for state.ncl exports. +#[derive(Debug, Deserialize)] +pub struct StateConfig { + pub dimensions: Vec, +} + +// ── Gate (membranes) +// ────────────────────────────────────────────────────────── + +/// Membrane permeability level. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Permeability { + High, + Medium, + Low, + Closed, +} + +/// Signal processing protocol. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Protocol { + Observe, + Absorb, + Challenge, + Reject, +} + +/// Maximum duration a membrane can remain open. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Duration { + Moment, + Days, + Weeks, + Indefinite, +} + +/// Known signal types that membranes can accept. +/// Uses `#[serde(other)]` so project-specific extensions deserialize as +/// `Unknown`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SignalType { + FrameBreakingQuestion, + UsageFriction, + ProductiveMisunderstanding, + HardBug, + ContextlessInsight, + SignificantSilence, + ConnectsToPractice, + EcosystemRelevance, + DepthDemonstrated, + IdentityReinforcement, + OpportunityAlignment, + #[serde(other)] + Unknown, +} + +/// Conditions under which a membrane opens. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpeningCondition { + pub max_tension_dimensions: u32, + pub pending_transitions: u32, + pub core_stable: bool, + pub description: String, +} + +/// A gate membrane — filters incoming signals to protect ontology invariants. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Membrane { + pub id: String, + pub name: String, + pub description: String, + pub permeability: Permeability, + pub accepts: Vec, + pub protects: Vec, + pub opening_condition: OpeningCondition, + pub closing_condition: String, + pub protocol: Protocol, + pub max_duration: Duration, + #[serde(default)] + pub active: bool, +} + +/// Deserialization root for gate.ncl exports. +#[derive(Debug, Deserialize)] +pub struct GateConfig { + pub membranes: Vec, +} diff --git a/crates/ontoref-reflection/Cargo.toml b/crates/ontoref-reflection/Cargo.toml new file mode 100644 index 0000000..0f6bb28 --- /dev/null +++ b/crates/ontoref-reflection/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "ontoref-reflection" +version = "0.1.0" +edition = "2021" +description = "Load, validate, and execute Reflection modes (NCL DAG contracts) against project state" +license = "MIT OR Apache-2.0" + +[features] +default = [] +nats = ["dep:platform-nats", "dep:bytes"] + +[dependencies] +stratum-graph = { path = "../../../stratumiops/crates/stratum-graph" } +stratum-state = { path = "../../../stratumiops/crates/stratum-state" } + +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +anyhow = { version = "1" } +thiserror = { version = "2" } +async-trait = { version = "0.1" } +tokio = { version = "1", features = ["full"] } +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +tracing = { version = "0.1" } +regex = { version = "1" } + +platform-nats = { path = "../../../stratumiops/crates/platform-nats", optional = true } +bytes = { version = "1", optional = true } + +[dev-dependencies] +tokio-test = { version = "0.4" } +tempfile = { version = "3" } diff --git a/crates/ontoref-reflection/src/dag.rs b/crates/ontoref-reflection/src/dag.rs new file mode 100644 index 0000000..e359b38 --- /dev/null +++ b/crates/ontoref-reflection/src/dag.rs @@ -0,0 +1,200 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use crate::{error::ReflectionError, mode::ActionStep}; + +/// Full DAG validation: uniqueness + referential integrity + cycle detection. +pub fn validate(steps: &[ActionStep]) -> Result<(), ReflectionError> { + check_uniqueness(steps)?; + check_referential_integrity(steps)?; + detect_cycles(steps)?; + Ok(()) +} + +/// Kahn's algorithm: returns steps grouped into parallel execution layers. +/// Layer 0 has no dependencies; layer N depends only on layers < N. +/// Returns `Err(CycleDetected)` if the graph is not a DAG. +pub fn topological_layers(steps: &[ActionStep]) -> Result>, ReflectionError> { + // in_degree[id] = number of steps id depends on + let mut in_degree: HashMap<&str, usize> = steps + .iter() + .map(|s| (s.id.as_str(), s.depends_on.len())) + .collect(); + + // dependents[id] = steps that depend on id (reverse edges) + let mut dependents: HashMap<&str, Vec<&str>> = + steps.iter().map(|s| (s.id.as_str(), vec![])).collect(); + + for step in steps { + for dep in &step.depends_on { + dependents + .entry(dep.step.as_str()) + .or_default() + .push(step.id.as_str()); + } + } + + let mut queue: VecDeque<&str> = in_degree + .iter() + .filter_map(|(&id, &d)| (d == 0).then_some(id)) + .collect(); + + let mut layers: Vec> = Vec::new(); + let mut visited = 0usize; + + while !queue.is_empty() { + let layer: Vec<&str> = queue.drain(..).collect(); + visited += layer.len(); + + let mut next: Vec<&str> = Vec::new(); + for &node in &layer { + for &dep in dependents.get(node).map(Vec::as_slice).unwrap_or(&[]) { + let d = in_degree + .get_mut(dep) + .expect("all step ids must be in in_degree — check uniqueness first"); + *d -= 1; + if *d == 0 { + next.push(dep); + } + } + } + + layers.push(layer.iter().map(|&s| s.to_string()).collect()); + queue.extend(next); + } + + if visited != steps.len() { + Err(ReflectionError::CycleDetected) + } else { + Ok(layers) + } +} + +fn check_uniqueness(steps: &[ActionStep]) -> Result<(), ReflectionError> { + let mut seen: HashSet<&str> = HashSet::with_capacity(steps.len()); + for step in steps { + if !seen.insert(step.id.as_str()) { + return Err(ReflectionError::DuplicateStepId(step.id.clone())); + } + } + Ok(()) +} + +fn check_referential_integrity(steps: &[ActionStep]) -> Result<(), ReflectionError> { + let ids: HashSet<&str> = steps.iter().map(|s| s.id.as_str()).collect(); + let bad: Vec = steps + .iter() + .flat_map(|step| { + step.depends_on.iter().filter_map(|dep| { + if ids.contains(dep.step.as_str()) { + None + } else { + Some(format!( + "step '{}' depends_on unknown '{}'", + step.id, dep.step + )) + } + }) + }) + .collect(); + + if bad.is_empty() { + Ok(()) + } else { + Err(ReflectionError::BadDependencyRefs(bad)) + } +} + +fn detect_cycles(steps: &[ActionStep]) -> Result<(), ReflectionError> { + topological_layers(steps).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mode::{Dependency, DependencyKind, OnError}; + + fn step(id: &str, deps: &[&str]) -> ActionStep { + ActionStep { + id: id.to_string(), + action: id.to_string(), + actor: crate::mode::Actor::Both, + cmd: None, + depends_on: deps + .iter() + .map(|d| Dependency { + step: d.to_string(), + kind: DependencyKind::Always, + condition: None, + }) + .collect(), + on_error: OnError::default(), + verify: None, + note: None, + } + } + + #[test] + fn linear_chain_produces_single_layers() { + let steps = vec![step("a", &[]), step("b", &["a"]), step("c", &["b"])]; + let layers = topological_layers(&steps).unwrap(); + assert_eq!(layers.len(), 3); + assert_eq!(layers[0], vec!["a"]); + assert_eq!(layers[1], vec!["b"]); + assert_eq!(layers[2], vec!["c"]); + } + + #[test] + fn parallel_deps_form_single_layer() { + // a → {b, c} → d + let steps = vec![ + step("a", &[]), + step("b", &["a"]), + step("c", &["a"]), + step("d", &["b", "c"]), + ]; + let layers = topological_layers(&steps).unwrap(); + assert_eq!(layers.len(), 3); + assert_eq!(layers[0], vec!["a"]); + assert!(layers[1].contains(&"b".to_string())); + assert!(layers[1].contains(&"c".to_string())); + assert_eq!(layers[2], vec!["d"]); + } + + #[test] + fn cycle_detected() { + let steps = vec![step("a", &["b"]), step("b", &["a"])]; + assert!(matches!( + topological_layers(&steps), + Err(ReflectionError::CycleDetected) + )); + } + + #[test] + fn duplicate_id_rejected() { + let steps = vec![step("a", &[]), step("a", &[])]; + assert!(matches!( + check_uniqueness(&steps), + Err(ReflectionError::DuplicateStepId(_)) + )); + } + + #[test] + fn bad_ref_rejected() { + let steps = vec![step("a", &["nonexistent"])]; + assert!(matches!( + check_referential_integrity(&steps), + Err(ReflectionError::BadDependencyRefs(_)) + )); + } + + #[test] + fn validate_passes_for_valid_dag() { + let steps = vec![ + step("init_repo", &[]), + step("copy_ontology", &["init_repo"]), + step("init_kogral", &["init_repo"]), + step("publish", &["copy_ontology", "init_kogral"]), + ]; + assert!(validate(&steps).is_ok()); + } +} diff --git a/crates/ontoref-reflection/src/error.rs b/crates/ontoref-reflection/src/error.rs new file mode 100644 index 0000000..44f194c --- /dev/null +++ b/crates/ontoref-reflection/src/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ReflectionError { + #[error("duplicate step id: '{0}'")] + DuplicateStepId(String), + + #[error("invalid depends_on references: {}", .0.join(", "))] + BadDependencyRefs(Vec), + + #[error("cycle detected in step dependency graph")] + CycleDetected, + + #[error("unknown parameter placeholders in cmd (not in RunContext.params): {}", .0.join(", "))] + UnknownParams(Vec), + + #[error("nickel export failed on '{path}': {stderr}")] + NickelExport { path: String, stderr: String }, + + #[error("failed to parse ReflectionMode from nickel output: {0}")] + ParseMode(#[from] serde_json::Error), + + #[error("step task panicked: {0}")] + TaskPanic(String), +} diff --git a/crates/ontoref-reflection/src/executor.rs b/crates/ontoref-reflection/src/executor.rs new file mode 100644 index 0000000..cbe01e2 --- /dev/null +++ b/crates/ontoref-reflection/src/executor.rs @@ -0,0 +1,441 @@ +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use anyhow::{anyhow, Context, Result}; +use regex::Regex; +use stratum_graph::types::NodeId; +use stratum_state::{PipelineRun, PipelineRunId, PipelineStatus, StateTracker, StepRecord}; +use tokio::task::JoinSet; +use tracing::{info, warn}; + +use crate::{ + dag, + error::ReflectionError, + mode::{ActionStep, Actor, ErrorStrategy, ReflectionMode}, +}; + +/// Context provided to a mode execution run. +pub struct RunContext { + /// Project identifier — used in NATS trigger subject. + pub project: String, + /// Parameter values substituted into step `cmd` fields via `{key}` + /// placeholders. + pub params: HashMap, + /// State tracker for recording pipeline run and step records. + pub state: Arc, + /// Optional NATS stream for publishing step completion events. + #[cfg(feature = "nats")] + pub nats: Option>, +} + +/// Result of executing a reflection mode. +#[derive(Debug)] +pub struct ModeRun { + pub mode_id: String, + pub run_id: PipelineRunId, + pub final_status: PipelineStatus, +} + +impl ReflectionMode { + /// Validate and execute this mode against the provided context. + /// Steps within each topological layer run concurrently via `JoinSet`. + /// Step failure behaviour is governed by `on_error.strategy`. + pub async fn execute(&self, ctx: &RunContext) -> Result { + self.validate() + .with_context(|| format!("mode '{}' failed pre-execution DAG validation", self.id))?; + + let trigger_subject = format!("ecosystem.reflection.{}.{}", self.id, ctx.project); + let trigger_payload = serde_json::to_value(&ctx.params) + .context("serializing RunContext.params as trigger payload")?; + + let run = PipelineRun::new(trigger_subject, trigger_payload); + let run_id = run.id.clone(); + ctx.state + .create_run(&run) + .await + .context("creating PipelineRun in state tracker")?; + + let layers = dag::topological_layers(&self.steps) + .with_context(|| format!("computing execution layers for mode '{}'", self.id))?; + + let step_index: HashMap<&str, &ActionStep> = + self.steps.iter().map(|s| (s.id.as_str(), s)).collect(); + + for layer in &layers { + let failure = run_layer(layer, &step_index, ctx, &run_id, &self.id).await?; + + if let Some(e) = failure { + ctx.state + .update_status(&run_id, PipelineStatus::Failed) + .await + .context("updating pipeline status to Failed")?; + return Err(e); + } + } + + ctx.state + .update_status(&run_id, PipelineStatus::Success) + .await + .context("updating pipeline status to Success")?; + + info!(mode = %self.id, run = %run_id, "mode completed successfully"); + + Ok(ModeRun { + mode_id: self.id.clone(), + run_id, + final_status: PipelineStatus::Success, + }) + } +} + +/// Execute all steps in a layer concurrently. Returns the first fatal error, if +/// any. +async fn run_layer( + layer: &[String], + step_index: &HashMap<&str, &ActionStep>, + ctx: &RunContext, + run_id: &PipelineRunId, + mode_id: &str, +) -> Result> { + let mut set: JoinSet<(String, Result<()>)> = JoinSet::new(); + + for step_id in layer { + let step = (*step_index + .get(step_id.as_str()) + .expect("layer ids are derived from the validated step list")) + .clone(); + let owned_step_id = step.id.clone(); + let params = ctx.params.clone(); + let state = Arc::clone(&ctx.state); + let owned_run_id = run_id.clone(); + let owned_mode_id = mode_id.to_owned(); + let owned_project = ctx.project.clone(); + #[cfg(feature = "nats")] + let nats = ctx.nats.clone(); + + set.spawn(async move { + #[cfg(feature = "nats")] + let result = execute_step( + step, + params, + state, + owned_run_id, + owned_mode_id, + owned_project, + nats, + ) + .await; + #[cfg(not(feature = "nats"))] + let result = execute_step( + step, + params, + state, + owned_run_id, + owned_mode_id, + owned_project, + ) + .await; + (owned_step_id, result) + }); + } + + let mut fatal: Option = None; + + while let Some(join_result) = set.join_next().await { + let (step_id, result) = + join_result.map_err(|e| anyhow!(ReflectionError::TaskPanic(e.to_string())))?; + + if let Err(e) = result { + let step = step_index[step_id.as_str()]; + match step.on_error.strategy { + ErrorStrategy::Stop | ErrorStrategy::Retry => { + // Retry is exhausted inside execute_step; treat the final failure as Stop. + set.abort_all(); + fatal = Some(e); + break; + } + ErrorStrategy::Continue | ErrorStrategy::Fallback | ErrorStrategy::Branch => { + warn!( + step = %step_id, + strategy = ?step.on_error.strategy, + "step failed, continuing per on_error strategy: {e}" + ); + } + } + } + } + + Ok(fatal) +} + +/// Execute a single step: record start → run cmd (with retry) → record outcome. +/// All parameters are owned so this future is `'static` and can be spawned. +#[cfg_attr(not(feature = "nats"), allow(unused_variables))] +async fn execute_step( + step: ActionStep, + params: HashMap, + state: Arc, + run_id: PipelineRunId, + mode_id: String, + project: String, + #[cfg(feature = "nats")] nats: Option>, +) -> Result<()> { + let start_record = StepRecord::start(NodeId(step.id.clone())); + state + .record_step(&run_id, &start_record) + .await + .with_context(|| format!("recording step '{}' start", step.id))?; + + info!(step = %step.id, action = %step.action, actor = ?step.actor, "executing step"); + + let outcome = match step.actor { + Actor::Human => { + info!( + step = %step.id, + "step requires human action — skipping automated execution: {}", + step.note.as_deref().unwrap_or(&step.action) + ); + Ok(()) + } + Actor::Agent | Actor::Both => match &step.cmd { + None => { + info!(step = %step.id, "no cmd — step is documentation-only"); + Ok(()) + } + Some(cmd) => { + let resolved = substitute_params(cmd, ¶ms) + .with_context(|| format!("substituting params in step '{}' cmd", step.id))?; + run_with_retry(&resolved, &step.on_error).await + } + }, + }; + + match outcome { + Ok(()) => { + state + .record_step(&run_id, &start_record.succeed(vec![])) + .await + .with_context(|| format!("recording step '{}' success", step.id))?; + + #[cfg(feature = "nats")] + if let Some(ref nats) = nats { + publish_step_event(nats, &run_id, &step.id, true).await; + publish_kogral_capture(nats, &run_id, &mode_id, &project, &step.id, "success") + .await; + } + + Ok(()) + } + Err(e) => { + let err_str = e.to_string(); + state + .record_step(&run_id, &start_record.fail(err_str.clone())) + .await + .with_context(|| format!("recording step '{}' failure", step.id))?; + + #[cfg(feature = "nats")] + if let Some(ref nats) = nats { + publish_step_event(nats, &run_id, &step.id, false).await; + publish_kogral_capture(nats, &run_id, &mode_id, &project, &step.id, "failed").await; + } + + Err(anyhow!("step '{}' failed: {}", step.id, err_str)) + } + } +} + +/// Replace `{key}` placeholders in `cmd` with values from `params`. +/// Returns `Err(UnknownParams)` if any placeholder key is absent from `params`. +fn substitute_params( + cmd: &str, + params: &HashMap, +) -> Result { + let re = Regex::new(r"\{([^}]+)\}").expect("static regex is valid"); + + let unknown: Vec = re + .captures_iter(cmd) + .filter_map(|cap| { + let key = cap[1].to_string(); + if params.contains_key(&key) { + None + } else { + Some(key) + } + }) + .collect(); + + if !unknown.is_empty() { + return Err(ReflectionError::UnknownParams(unknown)); + } + + let mut result = cmd.to_owned(); + for (key, value) in params { + result = result.replace(&format!("{{{key}}}"), value); + } + + Ok(result) +} + +/// Execute `cmd` via `sh -c`, retrying on failure when `on_error.strategy == +/// Retry`. +async fn run_with_retry(cmd: &str, on_error: &crate::mode::OnError) -> Result<()> { + let max_attempts = if on_error.strategy == ErrorStrategy::Retry { + on_error.max.max(1) + } else { + 1 + }; + + for attempt in 0..max_attempts { + match run_cmd(cmd).await { + Ok(()) => return Ok(()), + Err(e) => { + if attempt + 1 < max_attempts { + let delay = Duration::from_secs(on_error.backoff_s); + warn!( + attempt = attempt + 1, + max = max_attempts, + "cmd failed, retrying in {delay:?}: {e}" + ); + tokio::time::sleep(delay).await; + } else { + return Err(e); + } + } + } + } + + // Defensive: reached only when max_attempts == 0, which is prevented by .max(1) + // above. + Err(anyhow!("retry loop exited without result")) +} + +async fn run_cmd(cmd: &str) -> Result<()> { + let output = tokio::process::Command::new("sh") + .arg("-c") + .arg(cmd) + .output() + .await + .with_context(|| format!("spawning sh -c: {cmd}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(anyhow!( + "command exited {}: stderr='{}' stdout='{}'", + output.status, + stderr.trim(), + stdout.trim() + )) + } +} + +#[cfg(feature = "nats")] +async fn publish_step_event( + nats: &platform_nats::EventStream, + run_id: &PipelineRunId, + step_id: &str, + success: bool, +) { + let subject = if success { + "ecosystem.reflection.step.completed" + } else { + "ecosystem.reflection.step.failed" + }; + + let payload = serde_json::json!({ + "run_id": run_id.0.to_string(), + "step_id": step_id, + "success": success, + }); + + match serde_json::to_vec(&payload) { + Ok(bytes) => { + if let Err(e) = nats.publish(subject, bytes::Bytes::from(bytes)).await { + warn!(step = %step_id, "failed to publish step event to '{subject}': {e}"); + } + } + Err(e) => { + warn!(step = %step_id, "failed to serialize step event: {e}"); + } + } +} + +/// Publish to `ecosystem.kogral.capture` so Kogral can record this step as an +/// Execution node in the shared knowledge graph. Matches the KogralCapture +/// payload contract defined in `nats/subjects.ncl`. +#[cfg(feature = "nats")] +async fn publish_kogral_capture( + nats: &platform_nats::EventStream, + run_id: &PipelineRunId, + mode_id: &str, + project: &str, + step_id: &str, + status: &str, +) { + const SUBJECT: &str = "ecosystem.kogral.capture"; + + let payload = serde_json::json!({ + "project": project, + "mode_id": mode_id, + "step_id": step_id, + "run_id": run_id.0.to_string(), + "action": step_id, + "status": status, + "context": {}, + }); + + match serde_json::to_vec(&payload) { + Ok(bytes) => { + if let Err(e) = nats.publish(SUBJECT, bytes::Bytes::from(bytes)).await { + warn!(step = %step_id, "failed to publish kogral capture to '{SUBJECT}': {e}"); + } + } + Err(e) => { + warn!(step = %step_id, "failed to serialize kogral capture payload: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn substitute_replaces_known_params() { + let mut params = HashMap::new(); + params.insert("project_name".to_string(), "my-service".to_string()); + params.insert("project_dir".to_string(), "/tmp/my-service".to_string()); + + let result = + substitute_params("git -C {project_dir} init && echo {project_name}", ¶ms).unwrap(); + assert_eq!(result, "git -C /tmp/my-service init && echo my-service"); + } + + #[test] + fn substitute_rejects_unknown_params() { + let params = HashMap::new(); + let err = substitute_params("echo {unknown_key}", ¶ms).unwrap_err(); + assert!(matches!( + err, + ReflectionError::UnknownParams(keys) if keys.contains(&"unknown_key".to_string()) + )); + } + + #[test] + fn substitute_no_placeholders_is_identity() { + let result = substitute_params("ls -la /tmp", &HashMap::new()).unwrap(); + assert_eq!(result, "ls -la /tmp"); + } + + #[tokio::test] + async fn run_cmd_success() { + run_cmd("true").await.unwrap(); + } + + #[tokio::test] + async fn run_cmd_failure_returns_err() { + let err = run_cmd("false").await.unwrap_err(); + assert!(err.to_string().contains("command exited")); + } +} diff --git a/crates/ontoref-reflection/src/lib.rs b/crates/ontoref-reflection/src/lib.rs new file mode 100644 index 0000000..e11dee4 --- /dev/null +++ b/crates/ontoref-reflection/src/lib.rs @@ -0,0 +1,10 @@ +pub mod dag; +pub mod error; +pub mod executor; +pub mod mode; + +pub use error::ReflectionError; +pub use executor::{ModeRun, RunContext}; +pub use mode::{ + ActionStep, Actor, Dependency, DependencyKind, ErrorStrategy, OnError, ReflectionMode, +}; diff --git a/crates/ontoref-reflection/src/mode.rs b/crates/ontoref-reflection/src/mode.rs new file mode 100644 index 0000000..ecfc10c --- /dev/null +++ b/crates/ontoref-reflection/src/mode.rs @@ -0,0 +1,167 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::dag; +use crate::error::ReflectionError; + +/// A reflection mode loaded from a Nickel `.ncl` file. +/// Corresponds to the `Mode String` contract in `reflection/schema.ncl`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReflectionMode { + pub id: String, + pub trigger: String, + #[serde(default)] + pub preconditions: Vec, + pub steps: Vec, + #[serde(default)] + pub postconditions: Vec, +} + +impl ReflectionMode { + /// Load a `ReflectionMode` from a Nickel file by invoking `nickel export + /// --format json`. Mirrors the pattern in + /// `stratum-orchestrator::graph::loader::load_node_from_ncl`. + pub fn load(mode_file: &Path) -> Result { + let output = std::process::Command::new("nickel") + .arg("export") + .arg("--format") + .arg("json") + .arg(mode_file) + .output() + .with_context(|| format!("running nickel export on '{}'", mode_file.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(ReflectionError::NickelExport { + path: mode_file.display().to_string(), + stderr, + } + .into()); + } + + serde_json::from_slice::(&output.stdout) + .map_err(ReflectionError::ParseMode) + .with_context(|| { + format!( + "parsing ReflectionMode from '{}' — confirm the .ncl exports `| (s.Mode \ + String)`", + mode_file.display() + ) + }) + } + + /// Validate the step DAG in Rust (uniqueness + referential integrity + + /// cycle detection). Complements the Nickel-side structural + + /// referential checks; adds cycle detection that Nickel cannot express. + pub fn validate(&self) -> Result<(), ReflectionError> { + dag::validate(&self.steps) + } + + /// Return steps in a flat topological order (one valid sequencing, no + /// parallelism). Useful for dry-run display. For parallel execution, + /// use `dag::topological_layers`. + pub fn execution_order(&self) -> Result, ReflectionError> { + let layers = dag::topological_layers(&self.steps)?; + Ok(layers + .into_iter() + .flat_map(|layer| { + layer + .into_iter() + .filter_map(|id| self.steps.iter().find(|s| s.id == id)) + }) + .collect()) + } +} + +/// A single executable step within a mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionStep { + pub id: String, + /// Semantic action name (string identifier in cross-project modes). + pub action: String, + #[serde(default)] + pub actor: Actor, + /// Shell command with optional `{param}` placeholders substituted from + /// `RunContext.params`. + pub cmd: Option, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub on_error: OnError, + pub verify: Option, + pub note: Option, +} + +/// Who executes this step. +/// Nickel enum tags serialize with their exact case: `'Human` → `"Human"`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Actor { + Human, + Agent, + #[default] + Both, +} + +/// Dependency edge: this step waits for another step based on `kind`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Dependency { + /// ID of the step this step depends on. + pub step: String, + #[serde(default)] + pub kind: DependencyKind, + pub condition: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum DependencyKind { + #[default] + Always, + OnSuccess, + OnFailure, +} + +/// Error handling strategy for a step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OnError { + pub strategy: ErrorStrategy, + pub target: Option, + pub on_success: Option, + #[serde(default = "OnError::default_max")] + pub max: u32, + /// Backoff in seconds between retry attempts. + #[serde(default = "OnError::default_backoff_s")] + pub backoff_s: u64, +} + +impl OnError { + fn default_max() -> u32 { + 3 + } + + fn default_backoff_s() -> u64 { + 5 + } +} + +impl Default for OnError { + fn default() -> Self { + Self { + strategy: ErrorStrategy::Stop, + target: None, + on_success: None, + max: Self::default_max(), + backoff_s: Self::default_backoff_s(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ErrorStrategy { + Stop, + Continue, + Retry, + Fallback, + Branch, +}