diff --git a/.gitignore b/.gitignore index f56caa7..8dae7d3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ make-deb/dist/ .DS_Store # Local server config (copy from config.toml.example). config.toml +!docker/config.toml +!e2e/otel/config.toml /data/ .venv/ *.sqlite diff --git a/AGENTS.md b/AGENTS.md index 06a0eac..8e98c42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ For machine spirits and maintainers: layout, commands, and doctrine. - Example config: [config.toml.example](config.toml.example) — copy to `config.toml` for a writable local path, or rely on `just run` (uses the example when `config.toml` is absent). API scrolls: [docs/json-rpc-methods.md](docs/json-rpc-methods.md), [docs/rest-api.md](docs/rest-api.md), [docs/curl-examples.md](docs/curl-examples.md). - Optional Python checks: [requirements-dev.txt](requirements-dev.txt), [tests/](tests/) (`pytest tests/`) — set `TABULARIUM_TEST_URL` when a server is running; for CLI tests, set `TABULARIUM_TB_BIN` or install `tb` on `PATH`. - **Selenium / Web UI (`@pytest.mark.webui`)**: do **not** run against `localhost` or `127.0.0.1`. Tests are skipped unless `TABULARIUM_TEST_URL` or `TABULARIUM_URL` points at host **`10.90.1.122`** (same port as the running server). Use the integration host for all Web UI browser tests; `just ci-test` binds the ephemeral server on `0.0.0.0:` and sets the base URL to `http://10.90.1.122:` automatically. -- Docs: [docs/](docs/). Meetings/tasks: tabularium MCP +- Docs: [docs/](docs/). Meetings/tasks: **`own-tb`** MCP (Cursor: `user-own-tb`). Not `batteree-tb` / `sky-tb`. ## Behaviour notes (meeting consensus) @@ -21,7 +21,7 @@ For machine spirits and maintainers: layout, commands, and doctrine. - **`accessed_at` / `touch`**: every successful logical read via `Database::get_document` calls `Storage::touch` after content is resolved (cache hit or miss). If `touch` fails, the error is propagated to the caller (no `Ok` with stale silence). - **Read cache**: `moka` async cache holds document bodies only; `cache_size == 0` keeps the cache object but never stores entries. Mutations invalidate the affected document id after a successful storage write. - **`reindex`**: `Database::reindex(None)` clears the Tantivy index and rebuilds from every `documents` row. `Database::reindex(Some(...))` re-upserts only rows in the targeted directory subtree via the internal directory row identifier; other indexed documents are left as-is. Public API uses directory/path vocabulary only. -- **Tracing**: the library uses `tracing` spans on `SqliteDatabase::init`, `Database` façade methods, `SqliteStorage` (`Storage`), and `SearchIndex` I/O. Install a subscriber in binaries/tests (e.g. `tracing_subscriber`) and set `RUST_LOG=tabularium=debug` (or `trace`) to see them; the library crate does not initialize global logging. +- **Tracing**: the library uses `tracing` spans on `SqliteDatabase::init`, `Database` façade methods, `SqliteStorage` (`Storage`), and `SearchIndex` I/O. Install a subscriber in binaries/tests (e.g. `tracing_subscriber`) and set `RUST_LOG=tabularium=debug` (or `trace`) to see them; the library crate does not initialize global logging. Optional OTLP/HTTP export (OpenObserve-compatible, no Collector) is documented in [docs/opentelemetry.md](docs/opentelemetry.md). ## Build & test diff --git a/CHANGELOG.md b/CHANGELOG.md index 04afe6a..871bb57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Adds optional `[anonymous]` path ACL: when auth is enabled and credentials are + absent, requests use a configured non-admin allow/deny principal. Present but + invalid PSK/OIDC credentials still fail closed. Web UI opens the public view + without a key and offers Login / Logout around PSK sessions. + ## 0.1.7 - 2026-05-11 Authentication, concurrency, and operator tooling release. diff --git a/Cargo.lock b/Cargo.lock index b3caf22..34183ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.8" @@ -396,12 +419,31 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -449,6 +491,16 @@ dependencies = [ "crossterm 0.29.0", ] +[[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" @@ -831,6 +883,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[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" @@ -1081,6 +1139,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -1707,6 +1771,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2168,6 +2281,83 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest 0.13.4", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest 0.13.4", + "thiserror 2.0.18", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.2", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "ownedbytes" version = "0.7.0" @@ -2364,6 +2554,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2390,6 +2603,7 @@ 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", @@ -2684,6 +2898,43 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2835,6 +3086,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -2843,6 +3095,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2853,12 +3117,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "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.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2898,6 +3190,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[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" @@ -2944,6 +3254,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "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 = "semver" version = "1.0.27" @@ -3120,6 +3453,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[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" @@ -3491,9 +3840,10 @@ dependencies = [ "dateparser", "fs-err", "futures-util", + "globset", "moka", "regex", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sqlx", @@ -3516,7 +3866,6 @@ dependencies = [ "crossterm 0.28.1", "filetime", "futures-util", - "globset", "homedir", "libc", "ratatui", @@ -3547,9 +3896,12 @@ dependencies = [ "include_dir", "jsonwebtoken", "multer", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "parking_lot", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "rmcp", "schemars", "serde", @@ -3562,6 +3914,7 @@ dependencies = [ "tokio-util", "toml", "tracing", + "tracing-opentelemetry", "tracing-subscriber", "urlencoding", "uuid", @@ -4078,6 +4431,22 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -4301,6 +4670,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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" @@ -4449,6 +4828,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -4499,6 +4887,15 @@ 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" diff --git a/config.toml.example b/config.toml.example index f59f113..c45b8a2 100644 --- a/config.toml.example +++ b/config.toml.example @@ -23,6 +23,17 @@ timeout = 3600 # timeout = 10 # group_name_prefix = "tb_" +# Optional `[anonymous]` — when authenticate is true and no credentials are supplied, apply this path ACL. +# Present-but-invalid `X-Auth-Key` / assertion still fails closed (401). `admin` must be false. +# [anonymous] +# admin = false +# [anonymous.allow] +# read = ["/wiki", "/wiki/*", "/sandbox", "/sandbox/*"] +# write = ["/sandbox", "/sandbox/*"] +# [anonymous.deny] +# read = [] +# write = [] + # Optional MCP (streamable HTTP). Build with: cargo build -p tabularium-server --features mcp [mcp] listen = "127.0.0.1:3031" diff --git a/docker/config.toml b/docker/config.toml new file mode 100644 index 0000000..d6813d5 --- /dev/null +++ b/docker/config.toml @@ -0,0 +1,11 @@ +[server] +listen = "0.0.0.0:3050" +database_path = "/var/tabularium/data/tabularium.db" +index_dir = "/var/tabularium/data/tabularium.index" +workers = 0 +timeout = 60 +authenticate = false + +[mcp] +listen = "0.0.0.0:3031" +authenticate = false diff --git a/docs/authentication.md b/docs/authentication.md index aac0c16..5c54988 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -24,11 +24,12 @@ open or closed independently from the main HTTP server. ## Supported auth modes -Tabularium supports two built-in credential paths: +Tabularium supports three built-in credential paths: 1. Pre-shared keys (PSKs) mapped to ACLs 2. Optional signed assertion tokens from a trusted upstream AAA/WAF via `[oidc]` +3. Optional path-scoped `[anonymous]` principal when credentials are **absent** Tabularium still does **not** provide: @@ -41,9 +42,39 @@ Tabularium still does **not** provide: The model is intentionally smaller: one request resolves to one effective principal, then ACL checks happen on paths. +## Anonymous path ACL + +When `[server].authenticate = true` (or `[mcp].authenticate = true`) and an +optional `[anonymous]` section is configured, requests **without** credentials +resolve to that principal instead of `401`. The body uses the same allow/deny +shape as `acl_put`. `admin` must be `false`. + +```toml +[anonymous] +admin = false + +[anonymous.allow] +read = ["/wiki", "/wiki/*", "/sandbox", "/sandbox/*"] +write = ["/sandbox", "/sandbox/*"] + +[anonymous.deny] +read = [] +write = [] +``` + +Rules: + +- credentials **absent** → `Authenticated(anonymous)` when configured; else `401` +- `X-Auth-Key` present but empty / unknown → still `401` (never falls through) +- OIDC assertion present but invalid → still `401` (unchanged fail-closed) +- anonymous never bypasses `require_mgmt_admin` (`acl_*`, `psk_*`, `reindex`) + +`[mcp].authenticate` uses the same anonymous principal as the HTTP surface. + ## PSK + ACL model -When auth is enabled and no assertion header is present, the caller must send: +When auth is enabled, no assertion header is present, and `[anonymous]` is not +configured (or credentials are supplied), the caller must send: ```http X-Auth-Key: diff --git a/docs/docker.md b/docs/docker.md index 86db93a..c06d7cb 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -34,6 +34,8 @@ Default host mappings (see `compose.yaml`): - `http://127.0.0.1:3050` — web UI + API - `http://127.0.0.1:3031/mcp` — MCP +For OpenTelemetry + OpenObserve E2E (OTLP/HTTP, no Collector), see [opentelemetry.md](opentelemetry.md) and [e2e/otel/README.md](../e2e/otel/README.md). + ## Volumes and paths The container contract is explicit: diff --git a/docs/json-rpc-methods.md b/docs/json-rpc-methods.md index 479cc19..b9cfb84 100644 --- a/docs/json-rpc-methods.md +++ b/docs/json-rpc-methods.md @@ -74,7 +74,7 @@ Path rules: | `tail` | `{ path, lines? }` — omit for default 10 last lines; `lines: 0` returns no lines; string `"+N"` = from line _N_ | `{ text }` | | `slice` | `{ path, start_line, end_line }` | `{ text }` (1-based inclusive lines) | | `grep` | `{ path, pattern, max_matches? }` | `[{ line, text }]` (`max_matches` `0` = unlimited) | -| `wc` | `{ path }` | `{ bytes, lines, words, chars }` | +| `wc` | `{ path, recursive?, lines?, words?, bytes?, chars? }` — `path` may use a final-segment glob (`/docs/*`); bare directory errors `wc: PATH: is a directory` unless `recursive: true`. With `recursive`, glob matches on directories expand to all files in each subtree; a bare directory path walks the full tree. Metric booleans select fields (omit for all four). **One resolved file** → flat counts; **two or more files** → `{ files: [{ path, …counts }], total: { …counts } }`. POSIX `bytes` = UTF-8 byte length; `chars` = Unicode scalar count. | Single: `{ bytes, lines, words, chars }` (subset when flags set). Multi: `{ files, total }`. | | `stat` | `{ path }` | `{ id, path, size_bytes, line_count, …timestamps }` | | `test` | `{}` (no keys) | `{ product_name, product_version, uptime, authenticate_api }` — diagnostics; `product_name` is `"tabularium"`, `product_version` is the **server** crate compile-time version, `uptime` is process uptime in nanoseconds (`u64`, saturates at max), `authenticate_api` tells clients whether the main HTTP surface currently requires auth | | `wait` | `{ path }` | `null` when document body changes after the call begins; `-32602` with `"wait timed out"` at server long-poll ceiling | diff --git a/docs/opentelemetry.md b/docs/opentelemetry.md new file mode 100644 index 0000000..78713e2 --- /dev/null +++ b/docs/opentelemetry.md @@ -0,0 +1,92 @@ +# OpenTelemetry (OTLP) + +Tabularium exports traces and metrics with the OpenTelemetry SDK over **OTLP/HTTP protobuf**, layered onto the existing `tracing` / `#[instrument]` spans. There is **no** OpenTelemetry Collector in the default path: + +```text +tabularium-server --OTLP/HTTP--> OpenObserve (or any OTLP/HTTP backend) +``` + +## Enable / disable + +Export is **off by default**. The process starts with the usual fmt subscriber when OTLP is not configured. + +Enable by setting a non-empty OTLP endpoint (and not disabling the SDK): + +```bash +export OTEL_SERVICE_NAME=tabularium +export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://openobserve:5080/api/default/v1/traces +export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://openobserve:5080/api/default/v1/metrics +export OTEL_EXPORTER_OTLP_HEADERS='Authorization=Basic …' +``` + +Disable explicitly: + +```bash +export OTEL_SDK_DISABLED=true +``` + +Or omit the endpoint variables. + +Supported (standard) variables include: + +| Variable | Role | +|----------|------| +| `OTEL_SERVICE_NAME` | Service name in traces/metrics (default `tabularium`) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Traces URL (takes precedence for traces) | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Metrics URL | +| `OTEL_EXPORTER_OTLP_HEADERS` | Extra headers (`k=v,k2=v2`) | +| `OTEL_EXPORTER_OTLP_PROTOCOL` / `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | Expect `http/protobuf` | +| `OTEL_SDK_DISABLED` | `true` forces fmt-only mode | + +Do not put OpenObserve URLs, tenants, or credentials in source code — only in deployment env / compose. + +## OpenObserve example + +```bash +OTEL_SERVICE_NAME=tabularium +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://openobserve:5080/api/default/v1/traces +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic +``` + +Local Docker E2E: see [e2e/otel/README.md](../e2e/otel/README.md). + +## Trace hierarchy + +Existing `#[instrument]` spans in `tabularium` (Database, SqliteStorage, SearchIndex) are reused. MCP adds a root span: + +```text +mcp.tools/call (mcp.tool / mcp.method / otel.status_code) +└── Database::search / search_hits + ├── files_display_batch (SQLite) + └── SearchIndex::search_scored + ├── search_parse + └── search_tantivy +``` + +### MCP attributes + +- `mcp.method` / `rpc.method` / `mcp.tool` — tool / JSON-RPC method name +- `otel.status_code` — `OK` or `ERROR` +- `error.type` — `auth_error` or `tool_error` when failed + +Request bodies, search queries, and document contents are **not** recorded as span attributes. + +Application-level tool failures (`Err(String)` from MCP tools) mark the span as error even when the HTTP transport returns 200. + +## Metrics + +When OTLP is enabled: + +- `mcp.client.operation.duration` (histogram, seconds) +- `mcp.tool.calls` / `mcp.tool.errors` / `mcp.tool.active` +- `mcp.wait.timeouts` — long-poll `wait` timeouts (if that counter is registered) +- Tokio gauges: `tokio.runtime.workers`, `tokio.runtime.alive_tasks`, `tokio.runtime.global_queue_depth` + +Percentiles (p50/p95/p99) are computed in the backend (OpenObserve), not in the Rust process. + +## Shutdown + +On process exit the server flushes/shuts down the tracer and meter providers with a short wall-clock timeout so shutdown cannot hang indefinitely. diff --git a/docs/pr-feat-tb-wc-flags-glob-recursive.md b/docs/pr-feat-tb-wc-flags-glob-recursive.md new file mode 100644 index 0000000..a0b38a6 --- /dev/null +++ b/docs/pr-feat-tb-wc-flags-glob-recursive.md @@ -0,0 +1,105 @@ +# feat: tb wc with POSIX flags, glob, recursive walk, and MCP support + +## Summary + +Extends `tb wc` to GNU/POSIX-like behaviour: `-l`/`-w`/`-c`/`-m` flags, final-segment glob, recursive directory walk (`-r`), a single JSON-RPC round-trip per invocation, and an updated MCP tool with catalog examples. Path resolution and metric filtering live in the library crate; CLI, shell, RPC, and MCP share the same semantics and response shapes. + +## Motivation + +- `tb wc -l /docs/*` did not behave like users expect from Linux `wc` (glob was only partially handled on the CLI read path, not in server-side `wc`). +- Need Linux-like errors (`is a directory`) and a hint to use `-r` when a glob matches directories only. +- MCP agents need structured `wc` with documented request/response examples. + +## Changes + +### Library (`tabularium`) + +| Area | Detail | +|------|--------| +| **`glob_path.rs`** (new) | Final-segment glob, `path_is_directory`, recursive file collection, `resolve_wc_file_paths` | +| **`wc.rs`** (new) | `WcMetricsFilter`, JSON helpers, `sum_wc_stats`, unit tests | +| **`rpc/client.rs`** | `Client::wc()`, types `WcResponse` / `WcMetrics` / `WcFileEntry`; legacy `document_wc` kept | +| **`Cargo.toml`** | `globset` under feature `db` | +| **`db/meta.rs`** | `WcStats::from_totals` | + +### Server (`tabularium-server`) + +| Area | Detail | +|------|--------| +| **`web.rs`** | RPC `wc`: glob, `recursive`, metric flags; `{ files, total }` only when **two or more** files resolve | +| **`mcp.rs`** | `WcArg`, expanded catalog and `#[tool(description)]` with examples | +| **`res/mcp/help.txt`** | `wc` paragraph | +| **`tests/text_ops.rs`** | Glob batch, directory guard, dirs-only hint, recursive walk, single-glob flat response | + +### CLI (`tabularium-cli`) + +| Area | Detail | +|------|--------| +| **`main.rs`** | `Wc` flags and `-r`; cleaner error output via `print_cli_message` | +| **`execute.rs`** | Single `client.wc()` call; GNU/TTY formatting; shared `print_cli_message` | +| **`shell.rs`** | `flags_for("wc")`, parse tests, errors via `print_cli_message` | +| **`Cargo.toml`** | Removed direct `globset` dependency (via library) | + +### Tests & docs + +- **`tests/test_cli_wc.py`** (new) — glob, directory error, recursive glob, single-file `-l` +- **`docs/json-rpc-methods.md`** — updated `wc` params and response + +### Refactors + +- DRY in `wc.rs` (`wc_metrics_object`, `format_optional_counts`, `column_labels`) +- Simplified `print_wc_gnu_line` in CLI +- Batch JSON only when `paths.len() > 1` (one glob match → flat object, same as a literal path) +- Unified CLI and shell error UX (strips `invalid input:` / `not found:` prefixes) + +## Behaviour + +| Input | Result | +|-------|--------| +| `tb wc -l /docs/readme.md` | single number (pipe-friendly) | +| `tb wc -l '/docs/*'` | per-file lines + `total` | +| `tb wc -l /projects/bms` | error: `is a directory` + hint to use `-r` | +| `tb wc -l -r 'projects/bms/*'` | all files under each matched child directory + `total` | +| `tb wc -l -r /projects/bms` | whole subtree + `total` | +| RPC glob → one file | flat `{ lines: … }` (not batch) | +| RPC glob → two or more files | `{ files: [...], total: {...} }` | + +**Defaults:** no flags → GNU columns `lines`, `words`, `bytes`. **`-c`** = UTF-8 byte length (POSIX); **`-m`** = Unicode scalar count. + +## API notes + +- **Backward compatible:** `document_wc()` without metric flags still returns all four fields in a flat object. +- **MCP:** `WcArg` with optional booleans; explicit `false` is not sent on the wire. +- **ACL:** glob checks `check_read(parent)` first, then per-file `check_read` (fail-fast on first denial). +- **Performance (Stage 1):** N resolved files ⇒ N full body reads via `document_wc`. Design note in Tabularium KB: `/projects/tabularium/design/wc-performance.md`. + +## Files to include in the commit + +``` +Cargo.lock +docs/json-rpc-methods.md +tabularium/Cargo.toml +tabularium/src/glob_path.rs +tabularium/src/wc.rs +tabularium/src/lib.rs +tabularium/src/db/meta.rs +tabularium/src/rpc/client.rs +tabularium/src/rpc/mod.rs +tabularium-cli/Cargo.toml +tabularium-cli/src/main.rs +tabularium-cli/src/execute.rs +tabularium-cli/src/shell.rs +tabularium-server/src/web.rs +tabularium-server/src/mcp.rs +tabularium-server/res/mcp/help.txt +tabularium-server/tests/text_ops.rs +tests/test_cli_wc.py +``` + +Do **not** include local/unrelated untracked files: `config.fuse-test.toml`, `entities.json`, `mempalace.yaml`. + +## Suggested commit message + +``` +feat: add tb wc flags, glob, recursive walk, and MCP support +``` diff --git a/docs/uml-diagrams.md b/docs/uml-diagrams.md new file mode 100644 index 0000000..e4ee0c8 --- /dev/null +++ b/docs/uml-diagrams.md @@ -0,0 +1,45 @@ +# UML / Mermaid diagrams + +Tabularium Web UI renders Mermaid diagrams (including UML class and sequence) from markdown fenced blocks. + +Supported fence languages: + +- `mermaid` +- `uml` (alias — same Mermaid syntax) + +Class diagram example: + +````md +```uml +classDiagram + class Document { + +String path + +String content + } + class Storage { + +get(path) + +put(path, content) + } + Document --> Storage +``` +```` + +Sequence diagram example: + +````md +```mermaid +sequenceDiagram + participant CLI as tb + participant API as Server + participant DB as SQLite + CLI->>API: wc path + API->>DB: resolve + stats + API-->>CLI: { files, total } +``` +```` + +Notes: + +- Diagrams render in preview and chat markdown views. +- Invalid diagrams stay visible as source text with the error (page does not crash). +- Other fenced languages stay as ordinary code blocks. diff --git a/e2e/otel/.env.example b/e2e/otel/.env.example new file mode 100644 index 0000000..65fbc43 --- /dev/null +++ b/e2e/otel/.env.example @@ -0,0 +1,3 @@ +OO_BASIC_AUTH=cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM= +ZO_ROOT_USER_EMAIL=root@example.com +ZO_ROOT_USER_PASSWORD=Complexpass#123 diff --git a/e2e/otel/README.md b/e2e/otel/README.md new file mode 100644 index 0000000..dcb7b8a --- /dev/null +++ b/e2e/otel/README.md @@ -0,0 +1,68 @@ +# OpenTelemetry E2E (Tabularium → OpenObserve via OTLP/HTTP) + +Local stack with **no Collector**: + +```text +Tabularium --OTLP/HTTP--> OpenObserve +``` + +## Prerequisites + +- Docker + Docker Compose v2 +- Ports free: `3050`, `3031`, `5080` + +## Start + +From the repository root: + +```bash +# Basic auth header for OTLP (root@example.com:Complexpass#123) +export OO_BASIC_AUTH="$(printf '%s' 'root@example.com:Complexpass#123' | base64)" + +docker compose -f e2e/otel/compose.yaml --env-file e2e/otel/.env.example up --build +``` + +Or run the verifier as a one-shot after services are healthy: + +```bash +docker compose -f e2e/otel/compose.yaml --env-file e2e/otel/.env.example up --build -d +docker compose -f e2e/otel/compose.yaml run --rm e2e +``` + +## Endpoints + +| Service | URL | +|---------|-----| +| Tabularium UI/API | http://127.0.0.1:3050 | +| Tabularium MCP | http://127.0.0.1:3031/mcp | +| OpenObserve UI | http://127.0.0.1:5080 | + +OpenObserve local credentials (e2e only): `root@example.com` / `Complexpass#123`. + +## Inspect traces + +1. Open http://127.0.0.1:5080 and sign in. +2. Go to **Traces** (org `default`). +3. Filter `service_name = tabularium`. +4. Expected hierarchy (names may include crate targets): + +```text +mcp.tools/call +└── search / Database::search / search_hits + ├── files_display_batch (SQLite) + └── SearchIndex::search_scored + ├── search_parse + └── search_tantivy +``` + +## Load test + +`scripts/run_e2e.py` runs search workloads at concurrency **10 / 50 / 100** and prints JSON lines with `success`, `errors`, `p50`, `p95`, `p99`. No SLO thresholds are asserted — values are measured only. + +After load, re-open OpenObserve Traces to see which child span grew with concurrency. + +## Cleanup + +```bash +docker compose -f e2e/otel/compose.yaml down -v +``` diff --git a/e2e/otel/compose.yaml b/e2e/otel/compose.yaml new file mode 100644 index 0000000..513f351 --- /dev/null +++ b/e2e/otel/compose.yaml @@ -0,0 +1,68 @@ +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + environment: + ZO_ROOT_USER_EMAIL: root@example.com + ZO_ROOT_USER_PASSWORD: Complexpass#123 + ZO_DATA_DIR: /data + ports: + - "5080:5080" + volumes: + - oo-data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:5080/healthz"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + + tabularium: + build: + context: ../.. + dockerfile: Dockerfile + image: tabularium:otel-e2e + depends_on: + openobserve: + condition: service_healthy + environment: + OTEL_SERVICE_NAME: tabularium + OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://openobserve:5080/api/default/v1/traces + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: http://openobserve:5080/api/default/v1/metrics + OTEL_EXPORTER_OTLP_HEADERS: Authorization=Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM= + RUST_LOG: info,tabularium=debug,tabularium_server=debug + ports: + - "3050:3050" + - "3031:3031" + volumes: + - ./config.toml:/etc/tabularium/config.toml:ro + - tb-data:/var/tabularium/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3050/api/test"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 20s + + e2e: + image: python:3.12-slim + depends_on: + tabularium: + condition: service_healthy + openobserve: + condition: service_healthy + environment: + TABULARIUM_URL: http://tabularium:3050 + TABULARIUM_MCP_URL: http://tabularium:3031/mcp + OPENOBSERVE_URL: http://openobserve:5080 + OO_USER: root@example.com + OO_PASSWORD: Complexpass#123 + OO_ORG: default + volumes: + - ./scripts:/scripts:ro + working_dir: /scripts + command: ["python", "-u", "run_e2e.py"] + +volumes: + oo-data: + tb-data: diff --git a/e2e/otel/config.toml b/e2e/otel/config.toml new file mode 100644 index 0000000..d6813d5 --- /dev/null +++ b/e2e/otel/config.toml @@ -0,0 +1,11 @@ +[server] +listen = "0.0.0.0:3050" +database_path = "/var/tabularium/data/tabularium.db" +index_dir = "/var/tabularium/data/tabularium.index" +workers = 0 +timeout = 60 +authenticate = false + +[mcp] +listen = "0.0.0.0:3031" +authenticate = false diff --git a/e2e/otel/scripts/run_e2e.py b/e2e/otel/scripts/run_e2e.py new file mode 100644 index 0000000..7f704c2 --- /dev/null +++ b/e2e/otel/scripts/run_e2e.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Seed Tabularium, exercise MCP/RPC, verify OpenObserve traces, run load steps.""" + +from __future__ import annotations + +import base64 +import json +import os +import statistics +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +TB = os.environ.get("TABULARIUM_URL", "http://127.0.0.1:3050").rstrip("/") +MCP = os.environ.get("TABULARIUM_MCP_URL", "http://127.0.0.1:3031/mcp").rstrip("/") +OO = os.environ.get("OPENOBSERVE_URL", "http://127.0.0.1:5080").rstrip("/") +OO_USER = os.environ.get("OO_USER", "root@example.com") +OO_PASSWORD = os.environ.get("OO_PASSWORD", "Complexpass#123") +OO_ORG = os.environ.get("OO_ORG", "default") + + +def http_json( + method: str, + url: str, + body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float = 60.0, +) -> tuple[int, Any]: + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Accept", "application/json") + if body is not None: + req.add_header("Content-Type", "application/json") + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode() or "null" + try: + return resp.status, json.loads(raw) + except json.JSONDecodeError: + return resp.status, raw + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + parsed: Any = json.loads(raw) if raw else None + except json.JSONDecodeError: + parsed = raw + return e.code, parsed + + +def wait_ready() -> None: + deadline = time.time() + 180 + while time.time() < deadline: + try: + code, _ = http_json("GET", f"{TB}/api/test") + if code == 200: + print("tabularium ready") + return + except Exception as e: # noqa: BLE001 + print(f"waiting for tabularium: {e}") + time.sleep(2) + raise SystemExit("tabularium did not become ready") + + +def rpc(method: str, params: dict[str, Any]) -> Any: + code, body = http_json( + "POST", + f"{TB}/rpc", + {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + ) + if code != 200: + raise RuntimeError(f"rpc HTTP {code}: {body}") + if isinstance(body, dict) and body.get("error"): + raise RuntimeError(f"rpc error: {body['error']}") + return body.get("result") if isinstance(body, dict) else body + + +def seed() -> None: + try: + rpc("create_directory", {"path": "/otel", "parents": True}) + except RuntimeError as e: + if "exists" not in str(e).lower() and "duplicate" not in str(e).lower(): + # Some deployments use create_directory without parents flag naming. + try: + rpc("create_directory", {"path": "/otel"}) + except RuntimeError: + print(f"create_directory note: {e}") + rpc( + "put_document", + { + "path": "/otel/latency.md", + "content": "OpenTelemetry search tantivy sqlite latency under load.\n", + "force": True, + }, + ) + print("seeded /otel/latency.md") + + +def mcp_tools_call(name: str, arguments: dict[str, Any]) -> tuple[bool, Any]: + code, body = http_json( + "POST", + MCP, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, + headers={"Accept": "application/json, text/event-stream"}, + ) + if code != 200: + return False, body + if isinstance(body, dict) and body.get("error"): + return False, body + if isinstance(body, dict): + result = body.get("result") + if isinstance(result, dict) and result.get("isError"): + return False, body + return True, body + + +def exercise() -> None: + hits = rpc("search", {"query": "OpenTelemetry"}) + print("search hits:", hits) + try: + rpc("get_document", {"path": "/missing/nope.md"}) + raise RuntimeError("expected missing document error") + except RuntimeError as e: + print("expected error:", e) + + ok, body = mcp_tools_call("search", {"query": "tantivy"}) + print("mcp search ok=", ok, "snippet=", str(body)[:200]) + ok_err, _ = mcp_tools_call("get_document", {"path": "/no/such.md"}) + print("mcp error-case transport_ok_but_tool_fail=", not ok_err) + + +def oo_headers() -> dict[str, str]: + token = base64.b64encode(f"{OO_USER}:{OO_PASSWORD}".encode()).decode() + return {"Authorization": f"Basic {token}", "Content-Type": "application/json"} + + +def wait_for_traces(min_spans: int = 3) -> list[dict[str, Any]]: + deadline = time.time() + 120 + sql = ( + "SELECT trace_id, span_name, duration, status_code " + f"FROM {OO_ORG}.traces WHERE service_name = 'tabularium' " + "ORDER BY start_time DESC LIMIT 200" + ) + while time.time() < deadline: + code, body = http_json( + "POST", + f"{OO}/api/{OO_ORG}/_search", + { + "query": { + "sql": sql, + "start_time": int((time.time() - 900) * 1_000_000), + "end_time": int(time.time() * 1_000_000), + "from": 0, + "size": 200, + } + }, + headers=oo_headers(), + ) + hits: list[dict[str, Any]] = [] + if code == 200 and isinstance(body, dict): + raw_hits = body.get("hits") or body.get("data") or [] + if isinstance(raw_hits, dict): + raw_hits = raw_hits.get("hits") or [] + if isinstance(raw_hits, list): + hits = raw_hits + print(f"openobserve search status={code} hits={len(hits)}") + if len(hits) >= min_spans: + return hits + time.sleep(3) + raise SystemExit("timed out waiting for traces in OpenObserve") + + +def verify_traces(hits: list[dict[str, Any]]) -> None: + names = {str(h.get("span_name") or h.get("name") or "") for h in hits} + print("span names sample:", sorted(names)[:40]) + joined = " ".join(names) + if "mcp.tools/call" not in joined and not any("mcp" in n for n in names): + # RPC-only path still exercises search spans when MCP streamable init differs. + assert any("search" in n for n in names), names + else: + assert any("search" in n for n in names), names + assert any( + h.get("duration") is not None or h.get("duration_us") is not None for h in hits + ) + print("trace verification ok") + + +def one_search(_: int) -> tuple[bool, float]: + t0 = time.perf_counter() + try: + rpc("search", {"query": "latency"}) + return True, time.perf_counter() - t0 + except Exception: + return False, time.perf_counter() - t0 + + +def percentile(sorted_vals: list[float], p: float) -> float: + if not sorted_vals: + return float("nan") + k = (len(sorted_vals) - 1) * p + f = int(k) + c = min(f + 1, len(sorted_vals) - 1) + if f == c: + return sorted_vals[f] + return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f) + + +def load_level(concurrency: int, total: int) -> None: + latencies: list[float] = [] + ok_n = 0 + err_n = 0 + t0 = time.perf_counter() + with ThreadPoolExecutor(max_workers=concurrency) as pool: + futs = [pool.submit(one_search, i) for i in range(total)] + for fut in as_completed(futs): + ok, dt = fut.result() + latencies.append(dt) + if ok: + ok_n += 1 + else: + err_n += 1 + wall = time.perf_counter() - t0 + latencies.sort() + print( + json.dumps( + { + "concurrency": concurrency, + "requests": total, + "success": ok_n, + "errors": err_n, + "wall_seconds": round(wall, 3), + "latency_mean": round(statistics.mean(latencies), 4) if latencies else None, + "p50": round(percentile(latencies, 0.50), 4), + "p95": round(percentile(latencies, 0.95), 4), + "p99": round(percentile(latencies, 0.99), 4), + } + ) + ) + + +def main() -> None: + wait_ready() + seed() + exercise() + time.sleep(5) + hits = wait_for_traces() + verify_traces(hits) + for c in (10, 50, 100): + load_level(c, total=max(c * 2, 20)) + print("e2e complete") + + +if __name__ == "__main__": + main() diff --git a/tabularium-cli/Cargo.toml b/tabularium-cli/Cargo.toml index c42f3fe..91ff010 100644 --- a/tabularium-cli/Cargo.toml +++ b/tabularium-cli/Cargo.toml @@ -14,7 +14,6 @@ clap = { version = "4", features = ["derive"] } crossterm = { version = "0.28", features = ["bracketed-paste", "event-stream"] } filetime = "0.2" futures-util = "0.3" -globset = "0.4" homedir = "0.3.6" ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } regex = "1" diff --git a/tabularium-cli/src/execute.rs b/tabularium-cli/src/execute.rs index 6e2500e..23b8aa4 100644 --- a/tabularium-cli/src/execute.rs +++ b/tabularium-cli/src/execute.rs @@ -7,17 +7,18 @@ use std::process::{Command as StdCommand, Stdio}; use std::time::UNIX_EPOCH; use filetime::{FileTime, set_file_mtime}; -use globset::GlobBuilder; use regex::Regex; use serde_json::{Value, json}; use tabularium::Error; use tabularium::TailMode; use tabularium::Timestamp; +use tabularium::glob_path::{compile_name_glob, parent_and_glob_pattern, path_has_glob_metachar}; use tabularium::parse_acl_json; use tabularium::resource_path::{normalize_user_path, parent_and_final_name}; -use tabularium::rpc::{Client, ListedEntryRow, SearchHitRow, StatRow}; +use tabularium::rpc::{Client, ListedEntryRow, SearchHitRow, StatRow, WcMetrics, WcResponse}; use tabularium::validate_chat_speaker_id; use tabularium::validate_entity_name; +use tabularium::wc::WcMetricsFilter; use tabularium::ws::RecvMessage; use tokio::io::AsyncBufReadExt; @@ -26,6 +27,14 @@ use crate::render::mad_skin; pub(crate) type BoxErr = Box; +pub(crate) fn print_cli_message(msg: &str) { + let msg = msg + .strip_prefix("invalid input: ") + .or_else(|| msg.strip_prefix("not found: ")) + .unwrap_or(msg); + eprintln!("{msg}"); +} + #[derive(Debug, PartialEq, Eq)] pub(crate) enum ChatSubmitAction { Ignore, @@ -134,27 +143,12 @@ async fn expand_final_segment_glob( client: &Client, normalized_path: &str, ) -> Result>, BoxErr> { - if normalized_path == "/" { - return Ok(None); - } - let (parent, last) = normalized_path - .rsplit_once('/') - .map(|(p, n)| { - let p = if p.is_empty() { - "/".to_string() - } else { - p.to_string() - }; - (p, n.to_string()) - }) - .ok_or_else(|| -> BoxErr { "invalid path".into() })?; - if !path_has_glob_metachar(&last) { + let Some((parent, pattern)) = + parent_and_glob_pattern(normalized_path).map_err(|e| -> BoxErr { e.to_string().into() })? + else { return Ok(None); - } - if path_has_glob_metachar(&parent) { - return Err("wildcards are only allowed in the final path segment".into()); - } - let matcher = compile_name_glob(&last)?; + }; + let matcher = compile_name_glob(&pattern).map_err(|e| -> BoxErr { e.to_string().into() })?; let rows = client.list_directory(&parent).await?; let mut out: Vec<_> = rows .into_iter() @@ -168,6 +162,77 @@ async fn expand_final_segment_glob( Ok(Some(out)) } +fn wc_metric_values(m: &WcMetrics, filter: WcMetricsFilter) -> Vec { + filter.format_optional_counts(m.lines(), m.words(), m.bytes(), m.chars()) +} + +fn print_wc_gnu_line(width: usize, values: &[String], label: &str) { + let counts = values.join("\t"); + if label.is_empty() { + println!("{counts}"); + } else { + println!("{counts:>width$} {label}", width = width.max(1)); + } +} + +fn print_wc_output(resp: &WcResponse, filter: WcMetricsFilter) -> Result<(), BoxErr> { + let tty = io::stdout().is_terminal(); + let multi = resp.is_batch(); + + if multi { + let files = resp.files().unwrap_or(&[]); + let total = resp.total().cloned().unwrap_or_default(); + let mut rows_for_width: Vec> = Vec::new(); + for f in files { + rows_for_width.push(wc_metric_values(f.metrics(), filter)); + } + rows_for_width.push(wc_metric_values(&total, filter)); + let width = rows_for_width + .iter() + .flat_map(|r| r.iter().map(String::len)) + .max() + .unwrap_or(1); + + if tty && filter.active_columns() > 1 { + let headers = filter.column_labels(); + let mut table_rows: Vec> = files + .iter() + .map(|f| { + let mut row = vec![f.path().to_string()]; + row.extend(wc_metric_values(f.metrics(), filter)); + row + }) + .collect(); + let mut total_row = vec!["total".to_string()]; + total_row.extend(wc_metric_values(&total, filter)); + table_rows.push(total_row); + let mut hdr = vec!["path"]; + hdr.extend(headers); + print_cli_table(true, &hdr, &table_rows); + return Ok(()); + } + + for f in files { + print_wc_gnu_line(width, &wc_metric_values(f.metrics(), filter), f.path()); + } + print_wc_gnu_line(width, &wc_metric_values(&total, filter), "total"); + return Ok(()); + } + + let m = resp.single_metrics(); + let values = wc_metric_values(&m, filter); + if tty && filter.active_columns() > 1 { + print_cli_table(true, &filter.column_labels(), &[values]); + return Ok(()); + } + if filter.active_columns() == 1 { + println!("{}", values.first().map(String::as_str).unwrap_or("0")); + return Ok(()); + } + print_wc_gnu_line(0, &values, ""); + Ok(()) +} + async fn resolve_read_file_paths(client: &Client, user_path: &str) -> Result, BoxErr> { let norm = normalize_user_path(user_path.trim()).map_err(|e| -> BoxErr { e.to_string().into() })?; @@ -1257,71 +1322,17 @@ pub(crate) async fn execute( validate_entity_name(name.trim()).map_err(|e| -> BoxErr { e.to_string().into() })?; client.psk_destroy(name.trim()).await?; } - Command::Wc { path } => { - let paths = resolve_read_file_paths(client, path.trim()).await?; - let multi = paths.len() > 1; - if io::stdout().is_terminal() { - let mut table_rows: Vec> = Vec::new(); - let mut sum_b = 0u64; - let mut sum_l = 0usize; - let mut sum_w = 0usize; - let mut sum_c = 0usize; - for p in &paths { - let w = client.document_wc(p).await?; - sum_b += w.bytes(); - sum_l += w.lines(); - sum_w += w.words(); - sum_c += w.chars(); - if multi { - table_rows.push(vec![ - p.clone(), - w.bytes().to_string(), - w.lines().to_string(), - w.words().to_string(), - w.chars().to_string(), - ]); - } else { - table_rows.push(vec![ - w.bytes().to_string(), - w.lines().to_string(), - w.words().to_string(), - w.chars().to_string(), - ]); - } - } - if multi { - table_rows.push(vec![ - "total".to_string(), - sum_b.to_string(), - sum_l.to_string(), - sum_w.to_string(), - sum_c.to_string(), - ]); - print_cli_table( - true, - &["path", "bytes", "lines", "words", "chars"], - &table_rows, - ); - } else { - print_cli_table(true, &["bytes", "lines", "words", "chars"], &table_rows); - } - } else { - for p in &paths { - let w = client.document_wc(p).await?; - if multi { - println!( - "{}\t{}\t{}\t{}\t{}", - p, - w.bytes(), - w.lines(), - w.words(), - w.chars() - ); - } else { - println!("{}\t{}\t{}\t{}", w.bytes(), w.lines(), w.words(), w.chars()); - } - } - } + Command::Wc { + path, + lines, + words, + bytes, + chars, + recursive, + } => { + let filter = WcMetricsFilter::from_cli_flags(lines, words, bytes, chars); + let resp = client.wc(path.trim(), filter, recursive).await?; + print_wc_output(&resp, filter)?; } Command::Edit { path } => { let path = @@ -1514,18 +1525,6 @@ struct FindRow { description: Option, } -fn path_has_glob_metachar(path: &str) -> bool { - path.chars().any(|c| matches!(c, '*' | '?' | '[')) -} - -fn compile_name_glob(pat: &str) -> Result { - Ok(GlobBuilder::new(pat) - .literal_separator(true) - .build() - .map_err(|e| -> BoxErr { format!("invalid glob pattern: {e}").into() })? - .compile_matcher()) -} - async fn cmd_rm(client: &Client, path: &str, recursive: bool) -> Result<(), BoxErr> { if !path_has_glob_metachar(path) { return cmd_rm_literal(client, path, recursive).await; @@ -2795,6 +2794,7 @@ mod mcd_ec_integration_tests { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/tabularium-cli/src/main.rs b/tabularium-cli/src/main.rs index 2b17dd2..2d7a18d 100644 --- a/tabularium-cli/src/main.rs +++ b/tabularium-cli/src/main.rs @@ -41,17 +41,24 @@ fn merged_extra_headers(cli: &Cli) -> Result { Ok(map) } +fn default_api_uri() -> String { + std::env::var("TB_API_URI") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "http://127.0.0.1:3050".into()) +} + #[derive(Parser)] #[command( name = "tb", version, about = "tabularium CLI (JSON-RPC)", - after_long_help = "Environment:\n TB_HEADERS Optional extra HTTP headers on every RPC and WebSocket request.\n One `Name: value` per line (newline-separated); `#` starts a comment line.\n TB_API_KEY Optional PSK (same as `-k` / `--key`); avoids argv and shell history leaks (`ps` still sees env on some systems — `TB_HEADERS` can hide the name).\n Precedence: TB_HEADERS < repeated --header < TB_API_KEY < -k/--key.\n" + after_long_help = "Environment:\n TB_API_URI Optional default API URI for JSON-RPC requests.\n TB_HEADERS Optional extra HTTP headers on every RPC and WebSocket request.\n One `Name: value` per line (newline-separated); `#` starts a comment line.\n TB_API_KEY Optional PSK (same as `-k` / `--key`); avoids argv and shell history leaks (`ps` still sees env on some systems — `TB_HEADERS` can hide the name).\n Precedence: TB_HEADERS < repeated --header < TB_API_KEY < -k/--key.\n TB_DANGER_ACCEPT_INVALID_CERTS\n Accept invalid HTTPS certificates for RPC requests.\n" )] pub(crate) struct Cli { #[arg(short = 't', default_value_t = 5)] timeout_sec: u64, - #[arg(short = 'u', default_value = "http://127.0.0.1:3050")] + #[arg(short = 'u', default_value_t = default_api_uri())] api_uri: String, /// Pre-shared key sent as `X-Auth-Key` on every RPC/WebSocket request when the server requires authentication (stage-1 ACL). Prefer `TB_API_KEY` to keep secrets out of argv. #[arg(short = 'k', long = "key", value_name = "PSK")] @@ -298,8 +305,21 @@ pub(crate) enum Command { Touch { path: String, time: Option }, /// Block until `DIR/FILE` changes (server long-poll timeout applies). Wait { path: String }, - /// Byte/line/word/char counts. - Wc { path: String }, + /// Line/word/byte/char counts (`-l`/`-w`/`-c`/`-m`; default GNU columns: lines, words, bytes). Final-segment glob (`/docs/*`) counts files in one directory level; `-r` walks subtrees (glob directories or a bare directory path). + Wc { + path: String, + #[arg(short = 'l')] + lines: bool, + #[arg(short = 'w')] + words: bool, + #[arg(short = 'c')] + bytes: bool, + #[arg(short = 'm')] + chars: bool, + /// Recursive file walk (required for bare directories; with globs, expands into matching directory subtrees). + #[arg(short = 'r', long = "recursive")] + recursive: bool, + }, /// Show resolved ACL for the current key (`whoami` RPC). Whoami, } @@ -337,7 +357,10 @@ async fn main() -> Result<(), Box> { match execute::execute(&client, cmd, execute::ExecuteContext::Cli, None, opts).await { Ok(execute::ExecuteOutcome::Ok) => {} Ok(execute::ExecuteOutcome::Interrupted) => std::process::exit(130), - Err(e) => return Err(e), + Err(e) => { + execute::print_cli_message(&e.to_string()); + std::process::exit(1); + } } } } diff --git a/tabularium-cli/src/shell.rs b/tabularium-cli/src/shell.rs index 7bc917b..cb75ea4 100644 --- a/tabularium-cli/src/shell.rs +++ b/tabularium-cli/src/shell.rs @@ -23,7 +23,7 @@ use tokio::runtime::Handle; use crate::execute::{ ExecuteContext, ExecuteOutcome, ShellChildRpc, cmd_mcd, execute, execute_opts_from_command, - join_abs_dir_entry, + join_abs_dir_entry, print_cli_message, }; use crate::shell_path::{ resolve_ls_directory, resolve_shell_doc_path, resolve_shell_rm_path, resolve_shell_tree_scope, @@ -168,6 +168,7 @@ fn flags_for(sub: &str) -> &'static [&'static str] { "find" | "search" => &["-d", "--directory"], "grep" => &["-m", "-v", "--invert-match"], "head" => &["-n", "--raw"], + "wc" => &["-l", "-w", "-c", "-m", "-r", "--recursive"], "import" => &["-n", "--name"], "l" | "ll" | "ls" => &["-t", "--time", "-r", "--reverse"], "lt" => &["-r", "--reverse"], @@ -1434,8 +1435,20 @@ pub(crate) fn apply_shell_cwd(cmd: Command, shell_cwd: Option<&str>) -> Result Command::Stat { path: resolve_shell_doc_path(&path, shell_cwd)?, }, - Command::Wc { path } => Command::Wc { + Command::Wc { + path, + lines, + words, + bytes, + chars, + recursive, + } => Command::Wc { path: resolve_shell_doc_path(&path, shell_cwd)?, + lines, + words, + bytes, + chars, + recursive, }, Command::Edit { path } => Command::Edit { path: resolve_shell_doc_path(&path, shell_cwd)?, @@ -1863,7 +1876,7 @@ fn run_shell_blocking( "help" | "h" | "?" => { push_history(&mut rl, &history_path, trimmed)?; if let Err(e) = print_shell_help() { - eprintln!("{e}"); + print_cli_message(&e.to_string()); } continue; } @@ -1881,7 +1894,7 @@ fn run_shell_blocking( push_history(&mut rl, &history_path, trimmed)?; } Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; } } @@ -1894,7 +1907,7 @@ fn run_shell_blocking( push_history(&mut rl, &history_path, trimmed)?; } Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; } } @@ -1913,7 +1926,7 @@ fn run_shell_blocking( push_history(&mut rl, &history_path, trimmed)?; } Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; } } @@ -1929,7 +1942,7 @@ fn run_shell_blocking( continue; } if let Err(e) = exec_shell_line(script) { - eprintln!("{e}"); + print_cli_message(&e.to_string()); } push_history(&mut rl, &history_path, trimmed)?; continue; @@ -1946,7 +1959,7 @@ fn run_shell_blocking( ) { Ok(p) => p, Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; continue; } @@ -1959,7 +1972,7 @@ fn run_shell_blocking( match apply_shell_cwd(parsed.command, g.cwd.as_deref()) { Ok(c) => c, Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; continue; } @@ -1988,7 +2001,7 @@ fn run_shell_blocking( g.invalidate_all(); } } - Err(e) => eprintln!("{e}"), + Err(e) => print_cli_message(&e.to_string()), } rl = build_editor( Arc::clone(&client), @@ -2020,7 +2033,7 @@ fn run_shell_blocking( push_history(&mut rl, &history_path, trimmed)?; } Err(e) => { - eprintln!("{e}"); + print_cli_message(&e.to_string()); push_history(&mut rl, &history_path, trimmed)?; } } @@ -2337,7 +2350,17 @@ mod shell_cwd_tests { "c/d", ), (Command::Stat { path: "d".into() }, "c/d"), - (Command::Wc { path: "d".into() }, "c/d"), + ( + Command::Wc { + path: "d".into(), + lines: false, + words: false, + bytes: false, + chars: false, + recursive: false, + }, + "c/d", + ), (Command::Ec { path: "d".into() }, "c/d"), (Command::Edit { path: "d".into() }, "c/d"), (Command::Wait { path: "d".into() }, "c/d"), @@ -2358,7 +2381,7 @@ mod shell_cwd_tests { | Command::Chat { path: p, .. } | Command::Slice { path: p, .. } | Command::Stat { path: p } - | Command::Wc { path: p } + | Command::Wc { path: p, .. } | Command::Ec { path: p } | Command::Edit { path: p } | Command::Wait { path: p } @@ -2703,6 +2726,11 @@ mod shell_cwd_tests { ( Command::Wc { path: "/c/d".into(), + lines: false, + words: false, + bytes: false, + chars: false, + recursive: false, }, "c/d", ), @@ -2738,7 +2766,7 @@ mod shell_cwd_tests { | Command::Chat { path: p, .. } | Command::Slice { path: p, .. } | Command::Stat { path: p } - | Command::Wc { path: p } + | Command::Wc { path: p, .. } | Command::Ec { path: p } | Command::Edit { path: p } | Command::Wait { path: p } @@ -2750,6 +2778,49 @@ mod shell_cwd_tests { } } + #[test] + fn wc_parses_metric_flags() { + use crate::ShellCommandOnly; + use clap::Parser; + let c = ShellCommandOnly::parse_from(["tb", "wc", "-l", "-w", "/docs/*"]); + let Command::Wc { + path, + lines, + words, + bytes, + chars, + recursive, + } = c.command + else { + panic!("expected wc"); + }; + assert_eq!(path, "/docs/*"); + assert!(lines); + assert!(words); + assert!(!bytes); + assert!(!chars); + assert!(!recursive); + } + + #[test] + fn wc_parses_recursive_flag() { + use crate::ShellCommandOnly; + use clap::Parser; + let c = ShellCommandOnly::parse_from(["tb", "wc", "-l", "-r", "projects/bms/*"]); + let Command::Wc { + path, + lines, + recursive, + .. + } = c.command + else { + panic!("expected wc"); + }; + assert_eq!(path, "projects/bms/*"); + assert!(lines); + assert!(recursive); + } + #[test] fn grep_and_mv_absolute_paths_strip() { let g = unwrap_ac( @@ -3011,6 +3082,7 @@ mod shell_initial_cwd_mkdir_parents_tests { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/tabularium-cli/tests/tail_follow_interrupt.rs b/tabularium-cli/tests/tail_follow_interrupt.rs index 09170ba..2987265 100644 --- a/tabularium-cli/tests/tail_follow_interrupt.rs +++ b/tabularium-cli/tests/tail_follow_interrupt.rs @@ -30,6 +30,7 @@ async fn spawn_test_server() -> TestServer { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/tabularium-server/Cargo.toml b/tabularium-server/Cargo.toml index c274f4f..0073c54 100644 --- a/tabularium-server/Cargo.toml +++ b/tabularium-server/Cargo.toml @@ -38,12 +38,20 @@ toml = "0.8" tracing = "0.1" uuid = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-opentelemetry = "0.33" +opentelemetry = { version = "0.32", features = ["trace", "metrics"] } +opentelemetry_sdk = { version = "0.32", features = ["trace", "metrics"] } +opentelemetry-otlp = { version = "0.32", default-features = false, features = ["http-proto", "reqwest-blocking-client", "reqwest-rustls", "trace", "metrics"] } urlencoding = "2" jsonwebtoken = { version = "10.3.0", default-features = false, features = ["rust_crypto", "use_pem"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } parking_lot = "0.12.5" [dev-dependencies] +opentelemetry = { version = "0.32", features = ["trace"] } +opentelemetry_sdk = { version = "0.32", features = ["trace", "testing"] } +tracing-opentelemetry = "0.33" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } base64 = "0.22.1" futures-util = { version = "0.3", default-features = false, features = ["sink"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } diff --git a/tabularium-server/res/mcp/help.txt b/tabularium-server/res/mcp/help.txt index 22a8059..556a755 100644 --- a/tabularium-server/res/mcp/help.txt +++ b/tabularium-server/res/mcp/help.txt @@ -10,6 +10,8 @@ When **`[mcp].authenticate = true`** and **`mcp.full = true`**, send **`X-Auth-K **Doctrine — three rites, do not confuse them:** **find** (tree walk) = locate files and directories by path or name in the tree, using repeated **`list_directory`** calls — there is no separate MCP “find” tool. **search** = indexed full-text across **all document bodies** (Tantivy). **grep** = regex line matches **inside one document** only. +**wc** — line/word/byte/char counts (like `tb wc`). Params: `path`; optional `recursive`, `lines`, `words`, `bytes`, `chars` (booleans). Glob allowed only in the **last path segment** (`/docs/*.md`, `/projects/bms/*`). Without `recursive`, only **files directly in that folder** match. With `recursive: true`, each matched **directory** is walked recursively; a bare directory path also needs `recursive`. **One resolved file** → flat counts; **two or more** → `{ files, total }`. Examples: `{ "path": "/docs/readme.md", "lines": true }`; `{ "path": "/docs/*.md", "lines": true }`; `{ "path": "/projects/bms/*", "recursive": true, "lines": true }` when `bms` contains only subfolders; `{ "path": "/projects/bms", "recursive": true, "bytes": true }` for the whole tree. `bytes` = UTF-8 length (POSIX `-c`); `chars` = Unicode scalars. + **append_if_not_contains** (`path`, `marker`, `content`) appends raw bytes only when `marker` is not already a **substring** of the UTF-8 body (same semantics as Rust `str::contains`). The file **must exist** (otherwise the call errors). Returns JSON `{ "appended": bool, "revision": string }` — use `appended` like the older boolean wire shape; `revision` is the post-call file token. **Revision (UUID v4)** tags each **file** row for optimistic concurrency. It advances only on **writes** (content/metadata mutations such as put, append, touch_document on a file, describe on a file, rename/move file). Passive reads update **`accessed_at`** only via `get_document` / `stat` / listing — they **do not** change `revision`. Directories keep `revision` null. **`only_if_revision`** on **`put_document`** or **`create_document`** (with `force=true` when replacing an existing file) performs compare-and-swap: mismatch → **`RevisionMismatch`** (-32003); missing file when `only_if_revision` is set → **`NotFound`** (-32603). When present, **`only_if_revision` overrides `force`** for stale-write detection (force cannot bypass a revision mismatch). diff --git a/tabularium-server/src/auth.rs b/tabularium-server/src/auth.rs index 7bf7114..392523b 100644 --- a/tabularium-server/src/auth.rs +++ b/tabularium-server/src/auth.rs @@ -14,7 +14,7 @@ use crate::jwt_assertion::{AssertionRuntime, normalize_groups_from_claim}; pub(crate) enum RequestAuth { /// `[server].authenticate = false` — full access without key; ACL admin APIs open. Disabled, - /// Valid `X-Auth-Key` mapped to an ACL row. + /// Valid `X-Auth-Key` mapped to an ACL row, OIDC groups, or configured `[anonymous]`. Authenticated(AuthContext), } @@ -43,18 +43,26 @@ pub(crate) fn peek_assertion_header<'a>( } } -pub(crate) fn extract_x_auth_key(headers: &HeaderMap) -> tabularium::Result<&str> { +pub(crate) enum AuthKeyHeader<'a> { + Absent, + EmptyOrBadEncoding, + Key(&'a str), +} + +/// Inspect `X-Auth-Key`. Empty / bad encoding is supplied-but-invalid (never falls through to anonymous). +pub(crate) fn peek_x_auth_key(headers: &HeaderMap) -> AuthKeyHeader<'_> { let Some(raw) = headers.get(axum::http::header::HeaderName::from_static("x-auth-key")) else { - return Err(Error::Unauthorized("missing X-Auth-Key header".into())); + return AuthKeyHeader::Absent; + }; + let Ok(s) = raw.to_str() else { + return AuthKeyHeader::EmptyOrBadEncoding; }; - let s = raw - .to_str() - .map_err(|_| Error::Unauthorized("invalid X-Auth-Key encoding".into()))?; let t = s.trim(); if t.is_empty() { - return Err(Error::Unauthorized("empty X-Auth-Key".into())); + AuthKeyHeader::EmptyOrBadEncoding + } else { + AuthKeyHeader::Key(t) } - Ok(t) } pub(crate) async fn resolve_request_auth( @@ -62,6 +70,7 @@ pub(crate) async fn resolve_request_auth( authenticate: bool, headers: &HeaderMap, oidc: Option<&AssertionRuntime>, + anonymous: Option<&AuthContext>, ) -> tabularium::Result { if !authenticate { return Ok(RequestAuth::Disabled); @@ -99,12 +108,24 @@ pub(crate) async fn resolve_request_auth( } } - let key = extract_x_auth_key(headers)?; - let ctx = db - .resolve_auth_key(key) - .await? - .ok_or_else(|| Error::Unauthorized("unknown X-Auth-Key".into()))?; - Ok(RequestAuth::Authenticated(ctx)) + match peek_x_auth_key(headers) { + AuthKeyHeader::Absent => { + if let Some(ctx) = anonymous { + return Ok(RequestAuth::Authenticated(ctx.clone())); + } + Err(Error::Unauthorized("missing X-Auth-Key header".into())) + } + AuthKeyHeader::EmptyOrBadEncoding => Err(Error::Unauthorized( + "empty or invalid X-Auth-Key encoding".into(), + )), + AuthKeyHeader::Key(key) => { + let ctx = db + .resolve_auth_key(key) + .await? + .ok_or_else(|| Error::Unauthorized("unknown X-Auth-Key".into()))?; + Ok(RequestAuth::Authenticated(ctx)) + } + } } /// [`Arc`] wrapper indirection so REST/MCP/WebSocket share the same resolver without borrow fights. @@ -113,8 +134,16 @@ pub(crate) async fn resolve_request_auth_arc( authenticate: bool, headers: &HeaderMap, oidc: Option<&Arc>, + anonymous: Option<&AuthContext>, ) -> tabularium::Result { - resolve_request_auth(db, authenticate, headers, oidc.map(|a| a.as_ref())).await + resolve_request_auth( + db, + authenticate, + headers, + oidc.map(|a| a.as_ref()), + anonymous, + ) + .await } pub(crate) fn require_mgmt_admin(auth: &RequestAuth) -> tabularium::Result<()> { diff --git a/tabularium-server/src/config.rs b/tabularium-server/src/config.rs index 9344e85..e3bf45a 100644 --- a/tabularium-server/src/config.rs +++ b/tabularium-server/src/config.rs @@ -4,7 +4,7 @@ use std::path::Path; use serde::Deserialize; -use tabularium::{Error, Result}; +use tabularium::{AclBodyJson, Error, Result}; /// Root config file shape (`config.toml`). #[derive(Debug, Deserialize)] @@ -16,6 +16,17 @@ pub struct Config { /// Signed assertion from upstream AAA/WAF — JWKS `key` URL/path, validated locally (see meeting `oidc`). #[serde(default)] pub oidc: Option, + /// Path-scoped principal when auth is on and no credentials are supplied. + #[serde(default)] + pub anonymous: Option, +} + +/// Optional `[anonymous]` — same allow/deny body as `acl_put`; `admin` must be false. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AnonymousSection { + #[serde(flatten)] + pub body: AclBodyJson, } fn default_oidc_header() -> String { @@ -102,5 +113,18 @@ pub struct ServerSection { pub fn load(path: &Path) -> Result { let raw = std::fs::read_to_string(path) .map_err(|e| Error::Io(format!("config {}: {e}", path.display())))?; - toml::from_str(&raw).map_err(|e| Error::InvalidInput(format!("config {}: {e}", path.display()))) + let cfg: Config = toml::from_str(&raw) + .map_err(|e| Error::InvalidInput(format!("config {}: {e}", path.display())))?; + if let Some(anon) = cfg.anonymous.as_ref() { + if anon.body.admin { + return Err(Error::InvalidInput( + "config [anonymous]: admin must be false".into(), + )); + } + tabularium::parse_acl_json( + &serde_json::to_string(&anon.body) + .map_err(|e| Error::InvalidInput(format!("config [anonymous]: {e}")))?, + )?; + } + Ok(cfg) } diff --git a/tabularium-server/src/lib.rs b/tabularium-server/src/lib.rs index 83a5340..e22f7cc 100644 --- a/tabularium-server/src/lib.rs +++ b/tabularium-server/src/lib.rs @@ -7,6 +7,7 @@ pub mod jwt_assertion; #[cfg(feature = "mcp")] pub mod mcp; mod multipart_body; +pub mod otel; mod rpc_preview; mod test_payload; pub mod web; diff --git a/tabularium-server/src/main.rs b/tabularium-server/src/main.rs index aa2ba4c..6ba3bfe 100644 --- a/tabularium-server/src/main.rs +++ b/tabularium-server/src/main.rs @@ -5,101 +5,10 @@ use std::sync::Arc; use std::time::Duration; use clap::Parser; +use tabularium_server::otel::{self, Telemetry}; use tabularium_server::{config, web}; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; -use tracing_subscriber::EnvFilter; -use tracing_subscriber::fmt; -use tracing_subscriber::fmt::format::Format; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; - -/// Buffers output until newline, then writes the line with leading whitespace trimmed (for systemd logs). -struct LineTrimWriter { - inner: W, - buf: Vec, -} - -impl LineTrimWriter { - fn new(inner: W) -> Self { - Self { - inner, - buf: Vec::new(), - } - } - - fn flush_line(&mut self) -> std::io::Result<()> { - if let Some(i) = self.buf.iter().position(|&b| b == b'\n') { - let line = &self.buf[..=i]; - let s = String::from_utf8_lossy(line); - let trimmed = s.trim_start(); - self.inner.write_all(trimmed.as_bytes())?; - self.buf.drain(..=i); - } - Ok(()) - } -} - -impl std::io::Write for LineTrimWriter { - fn write(&mut self, b: &[u8]) -> std::io::Result { - self.buf.extend_from_slice(b); - self.flush_line()?; - Ok(b.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - if !self.buf.is_empty() { - let s = String::from_utf8_lossy(&self.buf); - let trimmed = s.trim_start(); - self.inner.write_all(trimmed.as_bytes())?; - self.buf.clear(); - } - self.inner.flush() - } -} - -/// [`tracing_subscriber::fmt::MakeWriter`] when `INVOCATION_ID` is set; wraps stdout in [`LineTrimWriter`]. -struct SystemdWriter; - -impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SystemdWriter { - type Writer = LineTrimWriter; - - fn make_writer(&'a self) -> Self::Writer { - LineTrimWriter::new(std::io::stdout()) - } -} - -fn init_tracing() { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { - EnvFilter::new("info,tabularium=debug,tabularium_server=debug,tantivy=warn") - }); - let is_systemd = std::env::var("INVOCATION_ID").is_ok(); - - if is_systemd { - tracing_subscriber::registry() - .with(filter) - .with( - fmt::layer() - .with_writer(SystemdWriter) - .with_ansi(false) - .event_format(Format::default().without_time()) - .with_target(true) - .with_level(true), - ) - .init(); - } else { - tracing_subscriber::registry() - .with(filter) - .with( - fmt::layer() - .with_writer(std::io::stdout) - .with_ansi(true) - .with_target(true) - .with_level(true), - ) - .init(); - } -} #[derive(Parser)] #[command(name = "tabularium-server", version, about)] @@ -114,7 +23,7 @@ struct Cli { } fn main() -> Result<(), Box> { - init_tracing(); + let telemetry = otel::init_tracing(); let cli = Cli::parse(); let config_path = std::env::var_os("TABULARIUM_CONFIG") @@ -148,10 +57,16 @@ fn main() -> Result<(), Box> { .enable_all() .build()?; - rt.block_on(run(cfg)) + let result = rt.block_on(run(cfg, &telemetry)); + telemetry.shutdown(); + result } -async fn run(cfg: config::Config) -> Result<(), Box> { +async fn run( + cfg: config::Config, + telemetry: &Telemetry, +) -> Result<(), Box> { + otel::maybe_spawn_runtime_metrics(telemetry); let process_started_at = bma_ts::Monotonic::now(); #[cfg(feature = "mcp")] @@ -173,6 +88,10 @@ async fn run(cfg: config::Config) -> Result<(), Box Result<(), Box Result { - let mcp_full_only_tool = MCP_FULL_ONLY_TOOL_NAMES.contains(&method) - || MCP_FULL_ONLY_ACL_PSK_TOOL_NAMES.contains(&method); - info!( - target: "tabularium_server::rpc", - transport = "mcp", - mcp_full = self.mcp_full, - mcp_full_only_tool, - method = %method, - params = %crate::rpc_preview::format_rpc_params_preview(Some(¶ms)), - "RPC request" + let span = info_span!( + "mcp.tools/call", + otel.name = "mcp.tools/call", + mcp.method = method, + rpc.method = method, + mcp.tool = method, + otel.status_code = field::Empty, + error.type = field::Empty, ); - let auth: RequestAuth = if !self.app.authenticate_mcp { - RequestAuth::Disabled - } else if method == "test" { - RequestAuth::Disabled - } else { - match resolve_request_auth_arc( - self.app.db.as_ref(), - true, - &parts.headers, - self.app.oidc.as_ref(), - ) - .await - { - Ok(a) => a, - Err(e) => return Err(e.to_string()), - } - }; - let map = params.as_object().cloned().unwrap_or_default(); - match dispatch_app_rpc(&self.app, &auth, method, map).await { - Ok(v) => Ok(if v.is_null() { - "null".to_string() + async { + let _active = enter_active_tool(method); + let started = Instant::now(); + let mcp_full_only_tool = MCP_FULL_ONLY_TOOL_NAMES.contains(&method) + || MCP_FULL_ONLY_ACL_PSK_TOOL_NAMES.contains(&method); + info!( + target: "tabularium_server::rpc", + transport = "mcp", + mcp_full = self.mcp_full, + mcp_full_only_tool, + method = %method, + params = %crate::rpc_preview::format_rpc_params_preview(Some(¶ms)), + "RPC request" + ); + let auth: RequestAuth = if !self.app.authenticate_mcp { + RequestAuth::Disabled + } else if method == "test" { + RequestAuth::Disabled } else { - serde_json::to_string_pretty(&v).map_err(|e| e.to_string())? - }), - Err(e) => Err(match e { - RpcAppError::MethodNotFound(m) => m, - RpcAppError::Other(err) => err.to_string(), - }), + match resolve_request_auth_arc( + self.app.db.as_ref(), + true, + &parts.headers, + self.app.oidc.as_ref(), + self.app.anonymous.as_ref(), + ) + .await + { + Ok(a) => a, + Err(e) => { + let msg = e.to_string(); + tracing::Span::current().record("otel.status_code", "ERROR"); + tracing::Span::current().record("error.type", "auth_error"); + record_tool_call(method, started, false); + return Err(msg); + } + } + }; + let map = params.as_object().cloned().unwrap_or_default(); + let result = match dispatch_app_rpc(&self.app, &auth, method, map).await { + Ok(v) => Ok(if v.is_null() { + "null".to_string() + } else { + serde_json::to_string_pretty(&v).map_err(|e| e.to_string())? + }), + Err(e) => Err(match e { + RpcAppError::MethodNotFound(m) => m, + RpcAppError::Other(err) => err.to_string(), + }), + }; + match &result { + Ok(_) => { + tracing::Span::current().record("otel.status_code", "OK"); + record_tool_call(method, started, true); + } + Err(_) => { + tracing::Span::current().record("otel.status_code", "ERROR"); + tracing::Span::current().record("error.type", "tool_error"); + record_tool_call(method, started, false); + } + } + result } + .instrument(span) + .await } } @@ -358,6 +400,21 @@ struct GrepArg { invert_match: Option, } +#[derive(Deserialize, JsonSchema)] +struct WcArg { + path: String, + #[serde(default)] + recursive: Option, + #[serde(default)] + lines: Option, + #[serde(default)] + words: Option, + #[serde(default)] + bytes: Option, + #[serde(default)] + chars: Option, +} + #[derive(Deserialize, JsonSchema)] struct DeleteDirectoryArg { path: String, @@ -656,14 +713,43 @@ impl TabulariumMcp { .await } - #[tool(description = "Line/word/byte counts (JSON-RPC wc).")] + #[tool( + description = r#"Line/word/byte/char counts (JSON-RPC wc). Like `tb wc -l/-w/-c/-m` and `-r`. + +Params: `path` (required); optional booleans `recursive`, `lines`, `words`, `bytes`, `chars`. Final-segment glob only (`/docs/*.md`, `/projects/bms/*`). Without `recursive`, glob counts files in one directory level. With `recursive`, matched directories and bare directory paths walk full subtrees. Omit metric flags for all four counts. + +Response: single file → flat object; two or more files → `{ "files": [{ "path", … }], "total": { … } }`. + +Examples: +• `{ "path": "/docs/readme.md" }` — all metrics on one file +• `{ "path": "/docs/readme.md", "lines": true }` — lines only +• `{ "path": "/docs/*.md", "lines": true }` — each `.md` in `/docs` + total +• `{ "path": "/projects/bms/*", "recursive": true, "lines": true }` — lines in every file under each child of `bms` (use when the folder contains only subdirectories) +• `{ "path": "/projects/bms", "recursive": true, "bytes": true }` — UTF-8 bytes for the whole `bms` tree"# + )] async fn wc( &self, Extension(parts): Extension, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { - self.call_rpc_json(&parts, "wc", json!({ "path": p.path })) - .await + let mut m = Map::new(); + m.insert("path".into(), json!(p.path)); + if let Some(v) = p.recursive { + m.insert("recursive".into(), json!(v)); + } + if let Some(v) = p.lines { + m.insert("lines".into(), json!(v)); + } + if let Some(v) = p.words { + m.insert("words".into(), json!(v)); + } + if let Some(v) = p.bytes { + m.insert("bytes".into(), json!(v)); + } + if let Some(v) = p.chars { + m.insert("chars".into(), json!(v)); + } + self.call_rpc_json(&parts, "wc", Value::Object(m)).await } #[tool( @@ -978,6 +1064,24 @@ impl ServerHandler for TabulariumMcp { } } +impl TabulariumMcp { + /// In-process MCP→RPC invocation for integration tests (empty request parts, no auth headers). + #[doc(hidden)] + pub async fn invoke_rpc_for_test( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + let req = http::Request::builder() + .method("POST") + .uri("/mcp") + .body(()) + .expect("test request"); + let (parts, ()) = req.into_parts(); + self.call_rpc_json(&parts, method, params).await + } +} + /// Streamable HTTP MCP endpoint at `http://{listen}/mcp`. pub async fn serve( listen: &str, diff --git a/tabularium-server/src/otel.rs b/tabularium-server/src/otel.rs new file mode 100644 index 0000000..316e19d --- /dev/null +++ b/tabularium-server/src/otel.rs @@ -0,0 +1,495 @@ +//! OpenTelemetry (OTLP/HTTP protobuf) layered onto the existing `tracing` subscriber. +//! +//! Export is **opt-in** via environment variables. When no OTLP endpoint is set (or +//! `OTEL_SDK_DISABLED=true`), only the fmt subscriber is installed and the process +//! behaves as before. + +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use opentelemetry::KeyValue; +use opentelemetry::global; +use opentelemetry::metrics::{Counter, Histogram, MeterProvider as _, UpDownCounter}; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_otlp::{MetricExporter, Protocol, SpanExporter, WithExportConfig}; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::metrics::SdkMeterProvider; +use opentelemetry_sdk::propagation::TraceContextPropagator; +use opentelemetry_sdk::trace::SdkTracerProvider; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt; +use tracing_subscriber::fmt::format::Format; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +/// Buffers output until newline, then writes the line with leading whitespace trimmed (for systemd logs). +struct LineTrimWriter { + inner: W, + buf: Vec, +} + +impl LineTrimWriter { + fn new(inner: W) -> Self { + Self { + inner, + buf: Vec::new(), + } + } + + fn flush_line(&mut self) -> std::io::Result<()> { + if let Some(i) = self.buf.iter().position(|&b| b == b'\n') { + let line = &self.buf[..=i]; + let s = String::from_utf8_lossy(line); + let trimmed = s.trim_start(); + self.inner.write_all(trimmed.as_bytes())?; + self.buf.drain(..=i); + } + Ok(()) + } +} + +impl std::io::Write for LineTrimWriter { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.buf.extend_from_slice(b); + self.flush_line()?; + Ok(b.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + if !self.buf.is_empty() { + let s = String::from_utf8_lossy(&self.buf); + let trimmed = s.trim_start(); + self.inner.write_all(trimmed.as_bytes())?; + self.buf.clear(); + } + self.inner.flush() + } +} + +/// [`tracing_subscriber::fmt::MakeWriter`] when `INVOCATION_ID` is set; wraps stdout in [`LineTrimWriter`]. +struct SystemdWriter; + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SystemdWriter { + type Writer = LineTrimWriter; + + fn make_writer(&'a self) -> Self::Writer { + LineTrimWriter::new(std::io::stdout()) + } +} + +static MCP_METRICS: OnceLock = OnceLock::new(); + +/// MCP tool-call instruments (set when OTLP export is enabled). +#[derive(Clone)] +pub struct McpMetrics { + /// Tool call duration in seconds. + pub duration: Histogram, + /// Tool invocations (success and failure). + pub calls: Counter, + /// Application-level tool errors (`Err` from tool handlers). + pub errors: Counter, + /// In-flight tool calls. + pub active: UpDownCounter, + /// Explicit wait / long-poll timeouts. + pub timeouts: Counter, +} + +/// OpenTelemetry providers to flush on process shutdown. +pub struct Telemetry { + tracer_provider: Option, + meter_provider: Option, +} + +impl Telemetry { + /// Disabled telemetry handle (fmt logging only, no OTLP exporters). + #[must_use] + pub fn disabled() -> Self { + Self { + tracer_provider: None, + meter_provider: None, + } + } + + /// Indicates whether OTLP export is active. + #[must_use] + pub fn enabled(&self) -> bool { + self.tracer_provider.is_some() + } + + /// Shuts down exporters on a helper thread so the Tokio runtime is not blocked indefinitely. + pub fn shutdown(self) { + const LIMIT: Duration = Duration::from_secs(5); + if let Some(provider) = self.tracer_provider { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = provider.shutdown(); + let _ = tx.send(r); + }); + match rx.recv_timeout(LIMIT) { + Ok(Ok(())) => {} + Ok(Err(e)) => eprintln!("OpenTelemetry tracer shutdown failed: {e}"), + Err(_) => eprintln!("OpenTelemetry tracer shutdown timed out after {LIMIT:?}"), + } + } + if let Some(provider) = self.meter_provider { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = provider.shutdown(); + let _ = tx.send(r); + }); + match rx.recv_timeout(LIMIT) { + Ok(Ok(())) => {} + Ok(Err(e)) => eprintln!("OpenTelemetry meter shutdown failed: {e}"), + Err(_) => eprintln!("OpenTelemetry meter shutdown timed out after {LIMIT:?}"), + } + } + } +} + +/// Returns MCP metrics when OTLP was initialized. +#[must_use] +pub fn mcp_metrics() -> Option<&'static McpMetrics> { + MCP_METRICS.get() +} + +/// Decrements the active-tool gauge on drop. +pub struct ActiveToolGuard { + tool: String, +} + +impl Drop for ActiveToolGuard { + fn drop(&mut self) { + if let Some(m) = mcp_metrics() { + m.active + .add(-1, &[KeyValue::new("mcp.tool", self.tool.clone())]); + } + } +} + +/// Increments active tool count until the returned guard is dropped. +#[must_use] +pub fn enter_active_tool(tool: &str) -> Option { + let m = mcp_metrics()?; + m.active + .add(1, &[KeyValue::new("mcp.tool", tool.to_owned())]); + Some(ActiveToolGuard { + tool: tool.to_owned(), + }) +} + +/// Records duration / call / error counters for a finished MCP tool invocation. +pub fn record_tool_call(tool: &str, started: Instant, ok: bool) { + let Some(m) = mcp_metrics() else { + return; + }; + let attrs = [KeyValue::new("mcp.tool", tool.to_owned())]; + m.duration.record(started.elapsed().as_secs_f64(), &attrs); + m.calls.add(1, &attrs); + if !ok { + m.errors.add(1, &attrs); + } +} + +/// Records an explicit wait / long-poll timeout. +pub fn record_wait_timeout() { + if let Some(m) = mcp_metrics() { + m.timeouts.add(1, &[]); + } +} + +fn env_nonempty(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) +} + +fn sdk_disabled() -> bool { + matches!( + env_nonempty("OTEL_SDK_DISABLED").as_deref(), + Some("true" | "1" | "TRUE" | "True") + ) +} + +/// Indicates whether OTLP should be enabled from the process environment. +#[must_use] +pub fn otlp_configured() -> bool { + if sdk_disabled() { + return false; + } + env_nonempty("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT").is_some() + || env_nonempty("OTEL_EXPORTER_OTLP_ENDPOINT").is_some() +} + +fn traces_protocol() -> Protocol { + let raw = env_nonempty("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") + .or_else(|| env_nonempty("OTEL_EXPORTER_OTLP_PROTOCOL")) + .unwrap_or_else(|| "http/protobuf".into()); + match raw.as_str() { + "http/protobuf" | "http/proto" => Protocol::HttpBinary, + other => { + eprintln!( + "Unsupported OTEL protocol `{other}`; using http/protobuf (this build exports OTLP/HTTP protobuf only)" + ); + Protocol::HttpBinary + } + } +} + +fn default_env_filter() -> EnvFilter { + EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new("info,tabularium=debug,tabularium_server=debug,tantivy=warn") + }) +} + +fn install_fmt_only(filter: EnvFilter) { + let is_systemd = std::env::var("INVOCATION_ID").is_ok(); + if is_systemd { + tracing_subscriber::registry() + .with(filter) + .with( + fmt::layer() + .with_writer(SystemdWriter) + .with_ansi(false) + .event_format(Format::default().without_time()) + .with_target(true) + .with_level(true), + ) + .init(); + } else { + tracing_subscriber::registry() + .with(filter) + .with( + fmt::layer() + .with_writer(std::io::stdout) + .with_ansi(true) + .with_target(true) + .with_level(true), + ) + .init(); + } +} + +fn build_resource() -> Resource { + let service = env_nonempty("OTEL_SERVICE_NAME").unwrap_or_else(|| "tabularium".into()); + Resource::builder().with_service_name(service).build() +} + +fn init_otlp_providers() -> Result<(SdkTracerProvider, SdkMeterProvider), String> { + global::set_text_map_propagator(TraceContextPropagator::new()); + let resource = build_resource(); + let protocol = traces_protocol(); + + let span_exporter = SpanExporter::builder() + .with_http() + .with_protocol(protocol) + .build() + .map_err(|e| format!("OTLP span exporter: {e}"))?; + + let tracer_provider = SdkTracerProvider::builder() + .with_batch_exporter(span_exporter) + .with_resource(resource.clone()) + .build(); + + let metric_exporter = MetricExporter::builder() + .with_http() + .with_protocol(protocol) + .build() + .map_err(|e| format!("OTLP metric exporter: {e}"))?; + + let meter_provider = SdkMeterProvider::builder() + .with_periodic_exporter(metric_exporter) + .with_resource(resource) + .build(); + + Ok((tracer_provider, meter_provider)) +} + +fn install_mcp_instruments(meter_provider: &SdkMeterProvider) { + let meter = meter_provider.meter("tabularium-server"); + let metrics = McpMetrics { + duration: meter + .f64_histogram("mcp.client.operation.duration") + .with_description("MCP tool call duration in seconds") + .with_unit("s") + .build(), + calls: meter + .u64_counter("mcp.tool.calls") + .with_description("MCP tool invocations") + .build(), + errors: meter + .u64_counter("mcp.tool.errors") + .with_description("MCP tool application-level errors") + .build(), + active: meter + .i64_up_down_counter("mcp.tool.active") + .with_description("In-flight MCP tool calls") + .build(), + timeouts: meter + .u64_counter("mcp.wait.timeouts") + .with_description("Document wait / long-poll timeouts") + .build(), + }; + let _ = MCP_METRICS.set(metrics); +} + +/// Publishes stable Tokio runtime gauges every few seconds while OTLP metrics are enabled. +pub fn spawn_tokio_runtime_metrics(meter_provider: &SdkMeterProvider) { + let meter = meter_provider.meter("tabularium-server"); + let workers = meter + .u64_gauge("tokio.runtime.workers") + .with_description("Tokio runtime worker thread count") + .build(); + let alive = meter + .u64_gauge("tokio.runtime.alive_tasks") + .with_description("Tokio alive tasks") + .build(); + let queue = meter + .u64_gauge("tokio.runtime.global_queue_depth") + .with_description("Tokio global injection queue depth") + .build(); + + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(5)); + loop { + ticker.tick().await; + let metrics = tokio::runtime::Handle::current().metrics(); + workers.record(metrics.num_workers() as u64, &[]); + alive.record(metrics.num_alive_tasks() as u64, &[]); + queue.record(metrics.global_queue_depth() as u64, &[]); + } + }); +} + +/// Installs the tracing subscriber; enables OTLP when configured via env. +pub fn init_tracing() -> Telemetry { + let filter = default_env_filter(); + + if !otlp_configured() { + install_fmt_only(filter); + return Telemetry::disabled(); + } + + let (tracer_provider, meter_provider) = match init_otlp_providers() { + Ok(p) => p, + Err(e) => { + eprintln!("OpenTelemetry init failed ({e}); continuing with fmt logging only"); + install_fmt_only(filter); + return Telemetry::disabled(); + } + }; + + global::set_tracer_provider(tracer_provider.clone()); + global::set_meter_provider(meter_provider.clone()); + install_mcp_instruments(&meter_provider); + + let is_systemd = std::env::var("INVOCATION_ID").is_ok(); + // Build the OTel layer inside each branch so its `Layer` impl matches the fmt writer type. + if is_systemd { + let otel_layer = + tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("tabularium-server")); + tracing_subscriber::registry() + .with(filter) + .with( + fmt::layer() + .with_writer(SystemdWriter) + .with_ansi(false) + .event_format(Format::default().without_time()) + .with_target(true) + .with_level(true), + ) + .with(otel_layer) + .init(); + } else { + let otel_layer = + tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("tabularium-server")); + tracing_subscriber::registry() + .with(filter) + .with( + fmt::layer() + .with_writer(std::io::stdout) + .with_ansi(true) + .with_target(true) + .with_level(true), + ) + .with(otel_layer) + .init(); + } + + Telemetry { + tracer_provider: Some(tracer_provider), + meter_provider: Some(meter_provider), + } +} + +/// Starts Tokio runtime metric export when telemetry is enabled. +pub fn maybe_spawn_runtime_metrics(telemetry: &Telemetry) { + if let Some(mp) = telemetry.meter_provider.as_ref() { + spawn_tokio_runtime_metrics(mp); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + prev: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, val: &str) -> Self { + let prev = std::env::var_os(key); + // Safety: guarded by ENV_LOCK in each test. + unsafe { std::env::set_var(key, val) }; + Self { key, prev } + } + + fn unset(key: &'static str) -> Self { + let prev = std::env::var_os(key); + unsafe { std::env::remove_var(key) }; + Self { key, prev } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.prev { + Some(v) => unsafe { std::env::set_var(self.key, v) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } + } + + #[test] + fn otlp_configured_false_without_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _a = EnvVarGuard::unset("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"); + let _b = EnvVarGuard::unset("OTEL_EXPORTER_OTLP_ENDPOINT"); + let _c = EnvVarGuard::unset("OTEL_SDK_DISABLED"); + assert!(!otlp_configured()); + } + + #[test] + fn otlp_configured_respects_sdk_disabled() { + let _lock = ENV_LOCK.lock().unwrap(); + let _ep = EnvVarGuard::set("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318"); + let _dis = EnvVarGuard::set("OTEL_SDK_DISABLED", "true"); + assert!(!otlp_configured()); + } + + #[test] + fn otlp_configured_true_with_traces_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _dis = EnvVarGuard::unset("OTEL_SDK_DISABLED"); + let _ep = EnvVarGuard::set( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "http://127.0.0.1:5080/api/default/v1/traces", + ); + assert!(otlp_configured()); + } +} diff --git a/tabularium-server/src/web.rs b/tabularium-server/src/web.rs index 0d19ae9..369e7cf 100644 --- a/tabularium-server/src/web.rs +++ b/tabularium-server/src/web.rs @@ -19,6 +19,7 @@ use crate::auth::{ resolve_request_auth_arc, whoami_json, }; use crate::jwt_assertion::AssertionRuntime; +use crate::otel; use crate::test_payload::test_payload; use crate::ws_doc::ws_upgrade; use tabularium::jsonrpc_codes::{FORBIDDEN, UNAUTHORIZED}; @@ -95,6 +96,8 @@ pub struct AppState { pub authenticate_mcp: bool, /// Optional `[oidc]` upstream JWT assertion verifier (JWKS). pub oidc: Option>, + /// Optional `[anonymous]` path-scoped principal when credentials are absent. + pub anonymous: Option, } pub fn router(state: AppState) -> Router { @@ -135,9 +138,15 @@ async fn api_resolve_auth(st: &AppState, headers: &HeaderMap) -> Result Ok(StatusCode::NO_CONTENT.into_response()), - DocumentWaitStatus::TimedOut => Ok(StatusCode::GATEWAY_TIMEOUT.into_response()), + DocumentWaitStatus::TimedOut => { + otel::record_wait_timeout(); + Ok(StatusCode::GATEWAY_TIMEOUT.into_response()) + } }; } let (meta, body) = st.db.cat_document_bundle(fid).await?; @@ -668,7 +680,14 @@ async fn rpc_dispatch(State(st): State, headers: HeaderMap, body: Byte // Uptime rite stays callable so the machine-spirits can probe the Throne without a key. Ok(RequestAuth::Disabled) } else { - resolve_request_auth_arc(st.db.as_ref(), true, &headers, st.oidc.as_ref()).await + resolve_request_auth_arc( + st.db.as_ref(), + true, + &headers, + st.oidc.as_ref(), + st.anonymous.as_ref(), + ) + .await }; let auth = match auth_result { Ok(a) => a, @@ -1181,9 +1200,12 @@ pub(crate) async fn dispatch_app_rpc( .await? { DocumentWaitStatus::Changed => Ok(Value::Null), - DocumentWaitStatus::TimedOut => Err(RpcAppError::Other(Error::InvalidInput( - "wait timed out".into(), - ))), + DocumentWaitStatus::TimedOut => { + otel::record_wait_timeout(); + Err(RpcAppError::Other(Error::InvalidInput( + "wait timed out".into(), + ))) + } } } "get_document_ref" => { @@ -1309,15 +1331,37 @@ pub(crate) async fn dispatch_app_rpc( } "wc" => { let path = rpc_path(&m, "path")?; - check_read(auth, &path).map_err(RpcAppError::Other)?; - let fid = st.db.resolve_existing_file_path(&path).await?; - let w = st.db.document_wc(fid).await?; - Ok(json!({ - "bytes": w.bytes(), - "lines": w.lines(), - "words": w.words(), - "chars": w.chars(), - })) + let filter = tabularium::wc::WcMetricsFilter::from_rpc_params(&m); + let glob_parent = tabularium::glob_path::parent_and_glob_pattern(&path) + .map_err(RpcAppError::Other)?; + if let Some((parent, _)) = glob_parent { + check_read(auth, &parent).map_err(RpcAppError::Other)?; + } else { + check_read(auth, &path).map_err(RpcAppError::Other)?; + } + let recursive = m.get("recursive").and_then(Value::as_bool).unwrap_or(false); + let paths = tabularium::glob_path::resolve_wc_file_paths(&st.db, &path, recursive) + .await + .map_err(RpcAppError::Other)?; + let mut stats: Vec = Vec::with_capacity(paths.len()); + for p in &paths { + check_read(auth, p).map_err(RpcAppError::Other)?; + let fid = st.db.resolve_existing_file_path(p).await?; + stats.push(st.db.document_wc(fid).await?); + } + if paths.len() > 1 { + let files: Vec = paths + .iter() + .zip(&stats) + .map(|(p, w)| tabularium::wc::wc_file_to_value(p, w, &filter)) + .collect(); + let total = tabularium::wc::sum_wc_stats(&stats); + return Ok(json!({ + "files": files, + "total": tabularium::wc::wc_stats_to_value(&total, &filter), + })); + } + Ok(tabularium::wc::wc_stats_to_value(&stats[0], &filter)) } "stat" => { let path = rpc_path(&m, "path")?; diff --git a/tabularium-server/src/ws_doc.rs b/tabularium-server/src/ws_doc.rs index 3060be9..05fd4ad 100644 --- a/tabularium-server/src/ws_doc.rs +++ b/tabularium-server/src/ws_doc.rs @@ -74,7 +74,15 @@ pub async fn ws_upgrade( let auth: RequestAuth = if !st.authenticate_api { RequestAuth::Disabled } else { - match resolve_request_auth_arc(st.db.as_ref(), true, &headers, st.oidc.as_ref()).await { + match resolve_request_auth_arc( + st.db.as_ref(), + true, + &headers, + st.oidc.as_ref(), + st.anonymous.as_ref(), + ) + .await + { Ok(a) => a, Err(e) => { return (StatusCode::UNAUTHORIZED, e.to_string()).into_response(); diff --git a/tabularium-server/tests/acl_http.rs b/tabularium-server/tests/acl_http.rs index 0ddb280..813f609 100644 --- a/tabularium-server/tests/acl_http.rs +++ b/tabularium-server/tests/acl_http.rs @@ -107,6 +107,7 @@ async fn list_root_filtered_when_acl_allows_only_child_path() { authenticate_api: true, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -157,6 +158,7 @@ async fn list_root_shows_dir_when_acl_allows_only_subtree_glob() { authenticate_api: true, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -228,6 +230,7 @@ async fn rpc_exists_allows_directory_path_when_acl_is_subtree_glob_only() { authenticate_api: true, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/tabularium-server/tests/acl_ops_http.rs b/tabularium-server/tests/acl_ops_http.rs index edc1dda..ffeb0ad 100644 --- a/tabularium-server/tests/acl_ops_http.rs +++ b/tabularium-server/tests/acl_ops_http.rs @@ -144,6 +144,7 @@ async fn spawn_acl_ops_fixture() -> AclOpsFixture { authenticate_api: true, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/tabularium-server/tests/anonymous_acl_http.rs b/tabularium-server/tests/anonymous_acl_http.rs new file mode 100644 index 0000000..0aec6d0 --- /dev/null +++ b/tabularium-server/tests/anonymous_acl_http.rs @@ -0,0 +1,214 @@ +//! `[anonymous]` path ACL — credentials absent use the configured principal; invalid keys stay 401. + +use std::sync::Arc; +use std::time::Duration; + +use bma_ts::Monotonic; +use serde_json::json; +use tabularium::jsonrpc_codes::{FORBIDDEN, UNAUTHORIZED}; +use tabularium::{AclBodyJson, AclSideJson, AuthContext, SqliteDatabase}; +use tabularium_server::web::{AppState, router}; +use tokio::net::TcpListener; + +async fn spawn_anonymous_server() -> (String, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("anon.db"); + let idx_path = dir.path().join("anon.idx"); + let uri = format!("sqlite://{}", db_path.display()); + let db = Arc::new(SqliteDatabase::init(&uri, &idx_path, 8).await.unwrap()); + db.create_directory("/wiki", None, false).await.unwrap(); + db.create_directory("/sandbox", None, false).await.unwrap(); + db.create_directory("/private", None, false).await.unwrap(); + db.create_document_at_path("/wiki/home.md", "# wiki\n", false, None) + .await + .unwrap(); + db.create_document_at_path("/sandbox/note.md", "# sandbox\n", false, None) + .await + .unwrap(); + db.create_document_at_path("/private/secret.md", "# secret\n", false, None) + .await + .unwrap(); + + let admin_acl = + r#"{"admin":true,"allow":{"read":[],"write":[]},"deny":{"read":[],"write":[]}}"#; + db.acl_upsert_validated("admin", admin_acl).await.unwrap(); + db.psk_insert("admin-local", "admin", "admin-psk-abcdefghijklmnopqrstuv") + .await + .unwrap(); + + let anonymous = AuthContext::new( + "anonymous".into(), + AclBodyJson { + admin: false, + allow: AclSideJson { + read: vec![ + "/wiki".into(), + "/wiki/*".into(), + "/sandbox".into(), + "/sandbox/*".into(), + ], + write: vec!["/sandbox".into(), "/sandbox/*".into()], + }, + deny: AclSideJson { + read: vec![], + write: vec![], + }, + }, + ); + + let app = router(AppState { + db, + wait_timeout: Duration::from_secs(3), + process_started_at: Monotonic::now(), + authenticate_api: true, + authenticate_mcp: false, + oidc: None, + anonymous: Some(anonymous), + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + tokio::time::sleep(Duration::from_millis(120)).await; + (format!("http://{}", addr), dir) +} + +#[tokio::test] +async fn health_stays_open_with_anonymous() { + let (base, _dir) = spawn_anonymous_server().await; + let c = reqwest::Client::new(); + let r = c.get(format!("{base}/api/test")).send().await.unwrap(); + assert_eq!(r.status(), reqwest::StatusCode::OK); +} + +#[tokio::test] +async fn anonymous_whoami_and_wiki_read() { + let (base, _dir) = spawn_anonymous_server().await; + let c = reqwest::Client::new(); + let w = c.get(format!("{base}/api/whoami")).send().await.unwrap(); + assert_eq!(w.status(), reqwest::StatusCode::OK); + let who: serde_json::Value = w.json().await.unwrap(); + assert_eq!(who["acl"], "anonymous"); + assert_eq!(who["admin"], false); + + let r = c + .get(format!("{base}/api/doc/wiki/home.md")) + .send() + .await + .unwrap(); + assert_eq!(r.status(), reqwest::StatusCode::OK); + + let list = c.get(format!("{base}/api/doc")).send().await.unwrap(); + assert_eq!(list.status(), reqwest::StatusCode::OK); + let rows: Vec = list.json().await.unwrap(); + let names: Vec<&str> = rows.iter().filter_map(|r| r["name"].as_str()).collect(); + assert!(names.contains(&"wiki")); + assert!(names.contains(&"sandbox")); + assert!(!names.contains(&"private")); +} + +#[tokio::test] +async fn anonymous_cannot_write_wiki_or_read_private() { + let (base, _dir) = spawn_anonymous_server().await; + let c = reqwest::Client::new(); + let put = c + .put(format!("{base}/api/doc/wiki/home.md")) + .header("content-type", "application/json") + .json(&json!({"content": "# hacked\n"})) + .send() + .await + .unwrap(); + assert_eq!(put.status(), reqwest::StatusCode::FORBIDDEN); + + let secret = c + .get(format!("{base}/api/doc/private/secret.md")) + .send() + .await + .unwrap(); + assert_eq!(secret.status(), reqwest::StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn anonymous_can_write_sandbox() { + let (base, _dir) = spawn_anonymous_server().await; + let c = reqwest::Client::new(); + let put = c + .put(format!("{base}/api/doc/sandbox/note.md")) + .header("content-type", "application/json") + .json(&json!({"content": "# updated\n"})) + .send() + .await + .unwrap(); + assert_eq!(put.status(), reqwest::StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn unknown_psk_is_unauthorized_not_anonymous() { + let (base, _dir) = spawn_anonymous_server().await; + let c = reqwest::Client::new(); + let r = c + .get(format!("{base}/api/whoami")) + .header("X-Auth-Key", "not-a-real-key") + .send() + .await + .unwrap(); + assert_eq!(r.status(), reqwest::StatusCode::UNAUTHORIZED); + + let empty = c + .get(format!("{base}/api/whoami")) + .header("X-Auth-Key", " ") + .send() + .await + .unwrap(); + assert_eq!(empty.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn anonymous_rpc_admin_forbidden_admin_psk_ok() { + let (base, _dir) = spawn_anonymous_server().await; + let c = reqwest::Client::new(); + let body = json!({ + "jsonrpc": "2.0", + "method": "acl_list", + "params": {}, + "id": 1_i64, + }); + let denied = c + .post(format!("{base}/rpc")) + .json(&body) + .send() + .await + .unwrap(); + let v: serde_json::Value = denied.json().await.unwrap(); + assert_eq!(v["error"]["code"], FORBIDDEN as i64); + + let ok = c + .post(format!("{base}/rpc")) + .header("X-Auth-Key", "admin-psk-abcdefghijklmnopqrstuv") + .json(&body) + .send() + .await + .unwrap(); + let v: serde_json::Value = ok.json().await.unwrap(); + assert!(v.get("error").is_none(), "{v:?}"); + assert!(v["result"].is_array()); + + let miss = c + .post(format!("{base}/rpc")) + .json(&json!({ + "jsonrpc": "2.0", + "method": "get_document", + "params": {"path": "/private/secret.md"}, + "id": 2_i64, + })) + .send() + .await + .unwrap(); + let v: serde_json::Value = miss.json().await.unwrap(); + // Forbidden for anonymous path deny (may surface as -32004). + assert!( + v["error"]["code"] == FORBIDDEN as i64 || v["error"]["code"] == UNAUTHORIZED as i64, + "{v:?}" + ); +} diff --git a/tabularium-server/tests/common/mod.rs b/tabularium-server/tests/common/mod.rs index a794e16..f3b36bc 100644 --- a/tabularium-server/tests/common/mod.rs +++ b/tabularium-server/tests/common/mod.rs @@ -35,6 +35,7 @@ pub async fn spawn_test_server_with_wait_timeout( authenticate_api, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/tabularium-server/tests/http_smoke.rs b/tabularium-server/tests/http_smoke.rs index ab61866..ab99033 100644 --- a/tabularium-server/tests/http_smoke.rs +++ b/tabularium-server/tests/http_smoke.rs @@ -20,6 +20,7 @@ async fn rest_root_listing_and_rpc_list_directory() { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/tabularium-server/tests/mcp_full_gate.rs b/tabularium-server/tests/mcp_full_gate.rs index 51dea3e..96699f1 100644 --- a/tabularium-server/tests/mcp_full_gate.rs +++ b/tabularium-server/tests/mcp_full_gate.rs @@ -27,6 +27,7 @@ async fn test_app() -> (AppState, tempfile::TempDir) { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }; (app, dir) } diff --git a/tabularium-server/tests/oidc_assertion_http.rs b/tabularium-server/tests/oidc_assertion_http.rs index a41eced..3e131aa 100644 --- a/tabularium-server/tests/oidc_assertion_http.rs +++ b/tabularium-server/tests/oidc_assertion_http.rs @@ -75,6 +75,7 @@ async fn spawn_oidc_authenticated_server() -> (String, Vec, &'static str, Te authenticate_api: true, authenticate_mcp: false, oidc: Some(oidc), + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/tabularium-server/tests/otel_spans.rs b/tabularium-server/tests/otel_spans.rs new file mode 100644 index 0000000..ebca06f --- /dev/null +++ b/tabularium-server/tests/otel_spans.rs @@ -0,0 +1,142 @@ +//! OpenTelemetry span hierarchy around MCP→search (in-memory exporter, no OpenObserve). + +#![cfg(feature = "mcp")] + +use std::sync::Arc; +use std::time::Duration; + +use bma_ts::Monotonic; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; +use serde_json::json; +use tabularium::SqliteDatabase; +use tabularium_server::mcp::TabulariumMcp; +use tabularium_server::otel::Telemetry; +use tabularium_server::web::AppState; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +async fn seeded_app() -> (AppState, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("otel.db"); + let idx_path = dir.path().join("otel.idx"); + let uri = format!("sqlite://{}", db_path.display()); + let db = Arc::new(SqliteDatabase::init(&uri, &idx_path, 8).await.unwrap()); + db.create_directory("/notes", None, true).await.unwrap(); + db.put_document_by_path( + "/notes/alpha.md", + "observability latency search tantivy", + false, + None, + ) + .await + .unwrap(); + let app = AppState { + db, + wait_timeout: Duration::from_secs(2), + process_started_at: Monotonic::now(), + authenticate_api: false, + authenticate_mcp: false, + oidc: None, + anonymous: None, + }; + (app, dir) +} + +#[test] +fn telemetry_disabled_shutdown_is_noop() { + Telemetry::disabled().shutdown(); +} + +#[tokio::test] +async fn mcp_search_emits_nested_spans() { + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let tracer = provider.tracer("tabularium-test"); + let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer); + let _guard = tracing_subscriber::registry() + .with(otel_layer) + .set_default(); + + let (app, _dir) = seeded_app().await; + let mcp = TabulariumMcp::new(app, Arc::from(""), false); + let body = mcp + .invoke_rpc_for_test("search", json!({ "query": "observability" })) + .await + .expect("search ok"); + assert!(body.contains("alpha.md") || body.contains("observability") || body != "null"); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + let names: Vec<&str> = spans.iter().map(|s| s.name.as_ref()).collect(); + assert!( + names + .iter() + .any(|n| n.contains("mcp.tools/call") || *n == "mcp.tools/call"), + "missing mcp root span in {names:?}" + ); + assert!( + names + .iter() + .any(|n| *n == "search_parse" || n.contains("search_parse")), + "missing search_parse in {names:?}" + ); + assert!( + names + .iter() + .any(|n| *n == "search_tantivy" || n.contains("search_tantivy")), + "missing search_tantivy in {names:?}" + ); +} + +#[tokio::test] +async fn mcp_tool_error_marks_span_error() { + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let tracer = provider.tracer("tabularium-test"); + let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer); + let _guard = tracing_subscriber::registry() + .with(otel_layer) + .set_default(); + + let (app, _dir) = seeded_app().await; + let mcp = TabulariumMcp::new(app, Arc::from(""), false); + let err = mcp + .invoke_rpc_for_test("get_document", json!({ "path": "/does/not/exist.md" })) + .await + .expect_err("missing doc must fail"); + assert!(!err.is_empty()); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + let mcp_spans: Vec<_> = spans + .iter() + .filter(|s| s.name.as_ref() == "mcp.tools/call") + .collect(); + assert!(!mcp_spans.is_empty(), "expected mcp.tools/call span"); + let status_err = mcp_spans.iter().any(|s| { + s.attributes + .iter() + .any(|kv| kv.key.as_str() == "otel.status_code" && kv.value.as_str() == "ERROR") + || s.status == opentelemetry::trace::Status::error("") + || format!("{:?}", s.status).contains("Error") + }); + // Attribute recording via tracing fields may land as OTel status or attributes. + assert!( + status_err + || mcp_spans.iter().any(|s| { + s.attributes + .iter() + .any(|kv| kv.key.as_str() == "error.type") + }), + "tool error not reflected on span: {:?}", + mcp_spans + .iter() + .map(|s| (&s.name, &s.status, &s.attributes)) + .collect::>() + ); +} diff --git a/tabularium-server/tests/rpc_codes.rs b/tabularium-server/tests/rpc_codes.rs index 6675530..25d7655 100644 --- a/tabularium-server/tests/rpc_codes.rs +++ b/tabularium-server/tests/rpc_codes.rs @@ -24,6 +24,7 @@ async fn unknown_rpc_method_returns_minus_32601() { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -72,6 +73,7 @@ async fn invalid_rpc_params_return_minus_32602() { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -115,6 +117,7 @@ async fn rpc_duplicate_create_document_returns_duplicate_resource_code() { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/tabularium-server/tests/rpc_test.rs b/tabularium-server/tests/rpc_test.rs index 1795d51..b81387f 100644 --- a/tabularium-server/tests/rpc_test.rs +++ b/tabularium-server/tests/rpc_test.rs @@ -24,6 +24,7 @@ async fn rpc_test_returns_identity_and_uptime() { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -78,6 +79,7 @@ async fn rpc_test_rejects_extra_params() { authenticate_api: false, authenticate_mcp: false, oidc: None, + anonymous: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/tabularium-server/tests/text_ops.rs b/tabularium-server/tests/text_ops.rs index 308953e..5e954e4 100644 --- a/tabularium-server/tests/text_ops.rs +++ b/tabularium-server/tests/text_ops.rs @@ -199,3 +199,176 @@ async fn rpc_tail_plus_n_form() { assert_eq!(v["error"]["code"], -32602, "bad lines: {bad}"); } } + +#[tokio::test] +async fn rpc_wc_glob_batch_and_directory_guard() { + let s = spawn_test_server().await; + let base = &s.base_url; + let client = reqwest::Client::new(); + + let cat = "text_wc_glob_cat"; + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}"), "description": null })) + .send() + .await + .unwrap(); + for (name, body) in [("a.md", "one two\n"), ("b.md", "three\nfour")] { + client + .put(format!("{base}/api/doc/{cat}/{name}")) + .json(&json!({ "content": body })) + .send() + .await + .unwrap(); + } + + let v = rpc( + &client, + base, + "wc", + json!({ "path": format!("/{cat}/*"), "lines": true }), + ) + .await; + let files = v["result"]["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(v["result"]["total"]["lines"], 3); + + let v = rpc( + &client, + base, + "wc", + json!({ "path": format!("/{cat}/a.md"), "lines": true }), + ) + .await; + assert_eq!(v["result"]["lines"], 1); + assert!(v["result"]["files"].is_null()); + + let v = rpc(&client, base, "wc", json!({ "path": format!("/{cat}") })).await; + let msg = v["error"]["message"].as_str().unwrap_or(""); + assert!(msg.contains("is a directory"), "{msg}"); + assert!(msg.contains("recursive"), "{msg}"); +} + +#[tokio::test] +async fn rpc_wc_glob_single_match_returns_flat() { + let s = spawn_test_server().await; + let base = &s.base_url; + let client = reqwest::Client::new(); + + let cat = "text_wc_glob_one"; + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}"), "description": null })) + .send() + .await + .unwrap(); + client + .put(format!("{base}/api/doc/{cat}/solo.md")) + .json(&json!({ "content": "only file\n" })) + .send() + .await + .unwrap(); + + let v = rpc( + &client, + base, + "wc", + json!({ "path": format!("/{cat}/*.md"), "lines": true }), + ) + .await; + assert_eq!(v["result"]["lines"], 1); + assert!(v["result"]["files"].is_null()); +} + +#[tokio::test] +async fn rpc_wc_glob_dirs_only_suggests_recursive() { + let s = spawn_test_server().await; + let base = &s.base_url; + let client = reqwest::Client::new(); + + let cat = "text_wc_dirs_only"; + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}"), "description": null })) + .send() + .await + .unwrap(); + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}/inner"), "description": null })) + .send() + .await + .unwrap(); + + let v = rpc( + &client, + base, + "wc", + json!({ "path": format!("/{cat}/*"), "lines": true }), + ) + .await; + let msg = v["error"]["message"].as_str().unwrap_or(""); + assert!(msg.contains("recursive"), "{msg}"); + assert!(msg.contains("director"), "{msg}"); +} + +#[tokio::test] +async fn rpc_wc_recursive_glob_and_directory() { + let s = spawn_test_server().await; + let base = &s.base_url; + let client = reqwest::Client::new(); + + let cat = "text_wc_rec_cat"; + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}"), "description": null })) + .send() + .await + .unwrap(); + client + .post(format!("{base}/api/doc")) + .json(&json!({ "path": format!("/{cat}/inner"), "description": null })) + .send() + .await + .unwrap(); + client + .put(format!("{base}/api/doc/{cat}/inner/a.md")) + .json(&json!({ "content": "one\n" })) + .send() + .await + .unwrap(); + client + .put(format!("{base}/api/doc/{cat}/inner/b.md")) + .json(&json!({ "content": "two\nthree\n" })) + .send() + .await + .unwrap(); + + let v = rpc( + &client, + base, + "wc", + json!({ + "path": format!("/{cat}/*"), + "recursive": true, + "lines": true, + }), + ) + .await; + let files = v["result"]["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(v["result"]["total"]["lines"], 3); + + let v = rpc( + &client, + base, + "wc", + json!({ + "path": format!("/{cat}"), + "recursive": true, + "lines": true, + }), + ) + .await; + assert_eq!(v["result"]["total"]["lines"], 3); +} diff --git a/tabularium/Cargo.toml b/tabularium/Cargo.toml index 73183c8..534245a 100644 --- a/tabularium/Cargo.toml +++ b/tabularium/Cargo.toml @@ -21,6 +21,7 @@ db = [ "dep:dashmap", "dep:dateparser", "dep:fs-err", + "dep:globset", "dep:moka", "dep:regex", "dep:serde_json", @@ -47,6 +48,7 @@ dashmap = { version = "6", optional = true } dateparser = { version = "0.2", optional = true } futures-util = { version = "0.3", default-features = false, features = ["sink"], optional = true } fs-err = { version = "3", optional = true } +globset = { version = "0.4", optional = true } moka = { version = "0.12", features = ["future"], optional = true } regex = { version = "1", optional = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } diff --git a/tabularium/src/db/meta.rs b/tabularium/src/db/meta.rs index 050ba29..a8f1134 100644 --- a/tabularium/src/db/meta.rs +++ b/tabularium/src/db/meta.rs @@ -45,6 +45,15 @@ impl WcStats { } } + pub(crate) fn from_totals(bytes: u64, lines: usize, words: usize, chars: usize) -> Self { + Self { + bytes, + lines, + words, + chars, + } + } + pub fn bytes(&self) -> u64 { self.bytes } diff --git a/tabularium/src/db/search_index.rs b/tabularium/src/db/search_index.rs index 1a9291c..08d149c 100644 --- a/tabularium/src/db/search_index.rs +++ b/tabularium/src/db/search_index.rs @@ -266,13 +266,16 @@ impl SearchIndex { ) -> Result> { let reader = self.reader.lock().await; let searcher = reader.searcher(); - let parser = QueryParser::for_index( - &self.index, - vec![self.f_content, self.f_file_name, self.f_description], - ); - let text_q = parser - .parse_query(keywords.as_ref()) - .map_err(|e| Error::Search(e.to_string()))?; + let text_q = { + let _parse = tracing::info_span!("search_parse").entered(); + let parser = QueryParser::for_index( + &self.index, + vec![self.f_content, self.f_file_name, self.f_description], + ); + parser + .parse_query(keywords.as_ref()) + .map_err(|e| Error::Search(e.to_string()))? + }; let mut q: Box = match directory_prefix { None | Some("") => text_q, Some(prefix) => { @@ -301,6 +304,7 @@ impl SearchIndex { (Occur::Must, doc_q), ])); } + let _tantivy = tracing::info_span!("search_tantivy").entered(); let top = searcher .search(&q, &TopDocs::with_limit(SEARCH_LIMIT)) .map_err(|e| Error::Search(e.to_string()))?; diff --git a/tabularium/src/glob_path.rs b/tabularium/src/glob_path.rs new file mode 100644 index 0000000..c6636ad --- /dev/null +++ b/tabularium/src/glob_path.rs @@ -0,0 +1,192 @@ +//! Final-segment glob expansion and directory detection for path-oriented RPC/CLI. + +use globset::{GlobBuilder, GlobMatcher}; + +use crate::EntryKind; +use crate::db::{Database, Storage}; +use crate::resource_path::{join_under_directory, parent_and_final_name}; +use crate::{Error, Result}; + +/// Indicates whether `segment` contains glob metacharacters. +pub fn path_has_glob_metachar(segment: &str) -> bool { + segment.chars().any(|c| matches!(c, '*' | '?' | '[')) +} + +/// Compiles a glob that matches a single path segment name (no `/` in pattern). +/// +/// # Errors +/// +/// See the [`Error`] for details. +pub fn compile_name_glob(pat: &str) -> Result { + let matcher = GlobBuilder::new(pat) + .literal_separator(true) + .build() + .map_err(|e| Error::InvalidInput(format!("invalid glob pattern: {e}")))? + .compile_matcher(); + Ok(matcher) +} + +/// Splits a normalized non-root path into parent directory and final segment when the final +/// segment has glob metacharacters. +/// +/// # Errors +/// +/// See the [`Error`] for details. +pub fn parent_and_glob_pattern(normalized_path: &str) -> Result> { + if normalized_path == "/" { + return Ok(None); + } + let (parent, last) = parent_and_final_name(normalized_path)?; + if !path_has_glob_metachar(&last) { + return Ok(None); + } + if path_has_glob_metachar(&parent) { + return Err(Error::InvalidInput( + "wildcards are only allowed in the final path segment".into(), + )); + } + Ok(Some((parent, last))) +} + +/// Collects every file path under `dir_path` (depth-first, sorted siblings). +/// +/// # Errors +/// +/// See the [`Error`] for details. +pub async fn collect_files_recursive( + db: &Database, + dir_path: &str, + out: &mut Vec, +) -> Result<()> { + let entries = db.list_directory(dir_path).await?; + for e in entries { + let full = join_under_directory(dir_path, e.name()); + if e.kind() == EntryKind::File { + out.push(full); + } else { + Box::pin(collect_files_recursive(db, &full, out)).await?; + } + } + Ok(()) +} + +/// Expands final-segment glob; with `recursive`, matching directories contribute all files in +/// their subtrees. +/// +/// # Errors +/// +/// See the [`Error`] for details. +pub async fn expand_final_segment_glob_files_opts( + db: &Database, + normalized_path: &str, + recursive: bool, +) -> Result>> { + let Some((parent, pattern)) = parent_and_glob_pattern(normalized_path)? else { + return Ok(None); + }; + let entries = db.list_directory(&parent).await?; + let matcher = compile_name_glob(&pattern)?; + let mut paths: Vec = Vec::new(); + let mut matched_dirs = 0usize; + for e in entries { + if !matcher.is_match(e.name()) { + continue; + } + let full = join_under_directory(&parent, e.name()); + match e.kind() { + EntryKind::File => paths.push(full), + EntryKind::Dir if recursive => { + collect_files_recursive(db, &full, &mut paths).await?; + } + EntryKind::Dir => { + matched_dirs += 1; + } + } + } + paths.sort(); + if paths.is_empty() { + if matched_dirs > 0 && !recursive { + return Err(Error::InvalidInput(format!( + "wc: no files match {normalized_path} ({matched_dirs} director{} matched; use -r/--recursive to count files under them)", + if matched_dirs == 1 { "y" } else { "ies" } + ))); + } + return Err(Error::InvalidInput(format!( + "wc: no files match {normalized_path}" + ))); + } + Ok(Some(paths)) +} + +/// Indicates whether `path` resolves to an existing directory. +/// +/// # Errors +/// +/// See the [`Error`] for details. +pub async fn path_is_directory(db: &Database, path: &str) -> Result { + Ok(db.resolve_directory_path(path).await.is_ok()) +} + +/// Resolves `wc` targets: glob expansion, optional recursive directory walk, or a single file +/// path. +/// +/// # Errors +/// +/// See the [`Error`] for details. +pub async fn resolve_wc_file_paths( + db: &Database, + normalized_path: &str, + recursive: bool, +) -> Result> { + if let Some(paths) = + expand_final_segment_glob_files_opts(db, normalized_path, recursive).await? + { + return Ok(paths); + } + if path_is_directory(db, normalized_path).await? { + if recursive { + let mut paths = Vec::new(); + collect_files_recursive(db, normalized_path, &mut paths).await?; + paths.sort(); + if paths.is_empty() { + return Err(Error::InvalidInput(format!( + "no files under {normalized_path}" + ))); + } + return Ok(paths); + } + return Err(Error::InvalidInput(format!( + "wc: {normalized_path}: is a directory (use -r/--recursive to count files under it)" + ))); + } + Ok(vec![normalized_path.to_string()]) +} + +#[cfg(test)] +mod tests { + use super::{compile_name_glob, parent_and_glob_pattern, path_has_glob_metachar}; + + #[test] + fn detects_glob_metachar() { + assert!(path_has_glob_metachar("*.md")); + assert!(!path_has_glob_metachar("readme.md")); + } + + #[test] + fn parent_and_glob_pattern_splits() { + let g = parent_and_glob_pattern("/docs/*.md").unwrap(); + assert_eq!(g, Some(("/docs".into(), "*.md".into()))); + assert!( + parent_and_glob_pattern("/docs/readme.md") + .unwrap() + .is_none() + ); + } + + #[test] + fn compile_name_glob_matches() { + let m = compile_name_glob("*.md").unwrap(); + assert!(m.is_match("a.md")); + assert!(!m.is_match("a.txt")); + } +} diff --git a/tabularium/src/lib.rs b/tabularium/src/lib.rs index 0acd852..fab2a1d 100644 --- a/tabularium/src/lib.rs +++ b/tabularium/src/lib.rs @@ -19,16 +19,20 @@ pub mod acl; pub mod client_headers; #[cfg(feature = "db")] pub mod db; +#[cfg(feature = "db")] +pub mod glob_path; pub mod resource_path; #[cfg(feature = "client")] pub mod rpc; #[cfg(feature = "db")] pub mod text_lines; +#[cfg(feature = "db")] +pub mod wc; #[cfg(feature = "client")] pub mod ws; #[cfg(feature = "db")] -pub use acl::{AclBodyJson, AuthContext, parse_acl_json}; +pub use acl::{AclBodyJson, AclSideJson, AuthContext, parse_acl_json}; #[cfg(feature = "db")] pub use bma_ts::Timestamp; #[cfg(feature = "client")] @@ -49,3 +53,5 @@ pub use reqwest::header::HeaderMap; #[cfg(feature = "db")] pub use text_lines::TailMode; pub use validation::{validate_chat_speaker_id, validate_entity_name}; +#[cfg(feature = "db")] +pub use wc::WcMetricsFilter; diff --git a/tabularium/src/rpc/client.rs b/tabularium/src/rpc/client.rs index 53ae7ac..0b03f2f 100644 --- a/tabularium/src/rpc/client.rs +++ b/tabularium/src/rpc/client.rs @@ -55,9 +55,13 @@ impl Client { extra_headers: HeaderMap, ) -> Result { let base = api_uri.into().trim_end_matches('/').to_string(); - let http = reqwest::Client::builder() + let mut builder = reqwest::Client::builder() .timeout(timeout) - .default_headers(extra_headers.clone()) + .default_headers(extra_headers.clone()); + if std::env::var_os("TB_DANGER_ACCEPT_INVALID_CERTS").is_some() { + builder = builder.danger_accept_invalid_certs(true); + } + let http = builder .build() .map_err(|e| Error::InvalidInput(e.to_string()))?; Ok(Self { @@ -535,7 +539,7 @@ impl Client { serde_json::from_value(r).map_err(|e| Error::InvalidInput(e.to_string())) } - /// `wc` RPC. + /// `wc` RPC (single-file legacy shape only). pub async fn document_wc(&self, path: impl AsRef) -> Result { let path = normalize_path_for_rpc(path)?; let params = json!({ "path": path }); @@ -543,6 +547,36 @@ impl Client { serde_json::from_value(r).map_err(|e| Error::InvalidInput(e.to_string())) } + /// `wc` RPC with optional metric flags, glob paths, and recursive directory walk. + pub async fn wc( + &self, + path: impl AsRef, + filter: crate::wc::WcMetricsFilter, + recursive: bool, + ) -> Result { + let path = normalize_path_for_rpc(path)?; + let mut params = json!({ "path": path }); + if let Some(obj) = params.as_object_mut() { + if recursive { + obj.insert("recursive".into(), json!(true)); + } + if filter.lines { + obj.insert("lines".into(), json!(true)); + } + if filter.words { + obj.insert("words".into(), json!(true)); + } + if filter.bytes { + obj.insert("bytes".into(), json!(true)); + } + if filter.chars { + obj.insert("chars".into(), json!(true)); + } + } + let r = self.call("wc", params).await?; + serde_json::from_value(r).map_err(|e| Error::InvalidInput(e.to_string())) + } + /// `rename_directory` RPC; both paths are absolute; same parent (rename last segment). pub async fn rename_directory( &self, @@ -787,7 +821,7 @@ impl StatRow { } } -/// `wc` RPC payload. +/// `wc` RPC payload (single file, all metrics). #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct WcRow { bytes: u64, @@ -814,6 +848,105 @@ impl WcRow { } } +/// Partial metric row (`wc` with selective flags or batch `total`). +#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] +pub struct WcMetrics { + #[serde(default)] + bytes: Option, + #[serde(default)] + lines: Option, + #[serde(default)] + words: Option, + #[serde(default)] + chars: Option, +} + +impl WcMetrics { + /// Returns the byte count when present. + pub fn bytes(&self) -> Option { + self.bytes + } + + /// Returns the line count when present. + pub fn lines(&self) -> Option { + self.lines + } + + /// Returns the word count when present. + pub fn words(&self) -> Option { + self.words + } + + /// Returns the character count when present. + pub fn chars(&self) -> Option { + self.chars + } +} + +/// One file row in a batch `wc` response. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct WcFileEntry { + path: String, + #[serde(flatten)] + metrics: WcMetrics, +} + +impl WcFileEntry { + /// Returns the absolute document path for this row. + pub fn path(&self) -> &str { + &self.path + } + + /// Returns the filtered metrics for this file. + pub fn metrics(&self) -> &WcMetrics { + &self.metrics + } +} + +/// `wc` RPC response (single-file object or batch `{ files, total }`). +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct WcResponse { + #[serde(default)] + files: Option>, + #[serde(default)] + total: Option, + #[serde(default)] + bytes: Option, + #[serde(default)] + lines: Option, + #[serde(default)] + words: Option, + #[serde(default)] + chars: Option, +} + +impl WcResponse { + /// Indicates whether this response carries a multi-file `files`/`total` shape. + pub fn is_batch(&self) -> bool { + self.files.is_some() + } + + /// Returns per-file rows when this is a batch response. + pub fn files(&self) -> Option<&[WcFileEntry]> { + self.files.as_deref() + } + + /// Returns the batch total when present. + pub fn total(&self) -> Option<&WcMetrics> { + self.total.as_ref() + } + + /// Returns top-level metrics for a single-file response shape. + pub fn single_metrics(&self) -> WcMetrics { + WcMetrics { + bytes: self.bytes, + lines: self.lines, + words: self.words, + chars: self.chars, + } + } +} + /// One row from `list_directory`. #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct ListedEntryRow { diff --git a/tabularium/src/rpc/mod.rs b/tabularium/src/rpc/mod.rs index 74a0c61..283931c 100644 --- a/tabularium/src/rpc/mod.rs +++ b/tabularium/src/rpc/mod.rs @@ -10,5 +10,5 @@ mod client; pub use client::{ Client, CreateDocumentReply, DocumentBody, DocumentMetaRow, GrepLineRow, ListedEntryRow, - SearchHitRow, ServerTest, StatRow, WcRow, + SearchHitRow, ServerTest, StatRow, WcFileEntry, WcMetrics, WcResponse, WcRow, }; diff --git a/tabularium/src/wc.rs b/tabularium/src/wc.rs new file mode 100644 index 0000000..66b8bc9 --- /dev/null +++ b/tabularium/src/wc.rs @@ -0,0 +1,215 @@ +//! Word-count metrics filter and JSON shapes for `wc` RPC/CLI. + +use serde_json::{Map, Value, json}; + +use crate::db::WcStats; + +/// Which `wc` metrics to include in RPC/CLI output. +#[derive(Debug, Clone, Copy, Default)] +pub struct WcMetricsFilter { + /// Indicator whether line counts are included. + pub lines: bool, + + /// Indicator whether word counts are included. + pub words: bool, + + /// Indicator whether byte counts are included. + pub bytes: bool, + + /// Indicator whether character counts are included. + pub chars: bool, +} + +impl WcMetricsFilter { + /// Returns a filter that includes every metric. + pub const fn all() -> Self { + Self { + lines: true, + words: true, + bytes: true, + chars: true, + } + } + + /// Returns the GNU/`wc` default columns: lines, words, bytes. + pub const fn posix_default() -> Self { + Self { + lines: true, + words: true, + bytes: true, + chars: false, + } + } + + /// Builds a filter from JSON-RPC params; with no `true` flags, includes all metrics. + pub fn from_rpc_params(m: &Map) -> Self { + let lines = m.get("lines").and_then(Value::as_bool); + let words = m.get("words").and_then(Value::as_bool); + let bytes = m.get("bytes").and_then(Value::as_bool); + let chars = m.get("chars").and_then(Value::as_bool); + let any = [lines, words, bytes, chars] + .into_iter() + .any(|o| o == Some(true)); + if !any { + return Self::all(); + } + Self { + lines: lines == Some(true), + words: words == Some(true), + bytes: bytes == Some(true), + chars: chars == Some(true), + } + } + + /// Builds a filter from CLI `-l`/`-w`/`-c`/`-m`; with no flags, uses [`Self::posix_default`]. + pub fn from_cli_flags(lines: bool, words: bool, bytes: bool, chars: bool) -> Self { + if !lines && !words && !bytes && !chars { + return Self::posix_default(); + } + Self { + lines, + words, + bytes, + chars, + } + } + + /// Returns how many metric columns are active. + pub fn active_columns(&self) -> usize { + usize::from(self.lines) + + usize::from(self.words) + + usize::from(self.bytes) + + usize::from(self.chars) + } + + /// Returns active column labels in display order. + pub fn column_labels(&self) -> Vec<&'static str> { + let mut cols = Vec::with_capacity(self.active_columns()); + if self.lines { + cols.push("lines"); + } + if self.words { + cols.push("words"); + } + if self.bytes { + cols.push("bytes"); + } + if self.chars { + cols.push("chars"); + } + cols + } + + /// Formats counts from [`WcStats`] for the active columns. + pub fn format_counts(&self, stats: &WcStats) -> Vec { + self.format_optional_counts( + Some(stats.lines()), + Some(stats.words()), + Some(stats.bytes()), + Some(stats.chars()), + ) + } + + /// Formats optional counts for the active columns (`None` → `"0"`). + pub fn format_optional_counts( + &self, + lines: Option, + words: Option, + bytes: Option, + chars: Option, + ) -> Vec { + let mut cols = Vec::with_capacity(self.active_columns()); + if self.lines { + cols.push(lines.unwrap_or(0).to_string()); + } + if self.words { + cols.push(words.unwrap_or(0).to_string()); + } + if self.bytes { + cols.push(bytes.unwrap_or(0).to_string()); + } + if self.chars { + cols.push(chars.unwrap_or(0).to_string()); + } + cols + } +} + +fn wc_metrics_object(stats: &WcStats, filter: &WcMetricsFilter) -> Map { + let mut m = Map::new(); + if filter.lines { + m.insert("lines".into(), json!(stats.lines())); + } + if filter.words { + m.insert("words".into(), json!(stats.words())); + } + if filter.bytes { + m.insert("bytes".into(), json!(stats.bytes())); + } + if filter.chars { + m.insert("chars".into(), json!(stats.chars())); + } + m +} + +/// Builds a JSON object of filtered metrics from [`WcStats`]. +pub fn wc_stats_to_value(stats: &WcStats, filter: &WcMetricsFilter) -> Value { + Value::Object(wc_metrics_object(stats, filter)) +} + +/// Builds a JSON file row (`path` plus filtered metrics). +pub fn wc_file_to_value(path: &str, stats: &WcStats, filter: &WcMetricsFilter) -> Value { + let mut m = wc_metrics_object(stats, filter); + m.insert("path".into(), json!(path)); + Value::Object(m) +} + +/// Sums metric totals across rows into one [`WcStats`]. +pub fn sum_wc_stats(rows: &[WcStats]) -> WcStats { + let mut bytes = 0u64; + let mut lines = 0usize; + let mut words = 0usize; + let mut chars = 0usize; + for w in rows { + bytes += w.bytes(); + lines += w.lines(); + words += w.words(); + chars += w.chars(); + } + WcStats::from_totals(bytes, lines, words, chars) +} + +#[cfg(test)] +mod tests { + use super::{WcMetricsFilter, wc_stats_to_value}; + use crate::db::WcStats; + use serde_json::json; + + #[test] + fn from_rpc_params_defaults_to_all_when_no_flags() { + let f = WcMetricsFilter::from_rpc_params(&serde_json::Map::new()); + assert!(f.lines && f.words && f.bytes && f.chars); + } + + #[test] + fn from_rpc_params_honours_true_flags_only() { + let mut m = serde_json::Map::new(); + m.insert("lines".into(), json!(true)); + m.insert("words".into(), json!(false)); + let f = WcMetricsFilter::from_rpc_params(&m); + assert!(f.lines); + assert!(!f.words); + } + + #[test] + fn wc_stats_to_value_respects_filter() { + let stats = WcStats::from_totals(10, 2, 3, 8); + let f = WcMetricsFilter { + lines: true, + words: false, + bytes: false, + chars: false, + }; + assert_eq!(wc_stats_to_value(&stats, &f), json!({ "lines": 2 })); + } +} diff --git a/tests/test_cli_wc.py b/tests/test_cli_wc.py new file mode 100644 index 0000000..cbdb802 --- /dev/null +++ b/tests/test_cli_wc.py @@ -0,0 +1,85 @@ +import subprocess + +import requests + +from tests.helpers import _base, _rpc, _slug, _tb_bin, mkdir, put_doc + + +def test_cli_wc_glob_lines_words_bytes(): + tb = _tb_bin() + base = _base() + cat = f"py_wc_glob_{_slug()}" + mkdir(base, cat) + put_doc(base, cat, "a.md", "one two\nthree") + put_doc(base, cat, "b.md", "four\nfive six") + for flag, expect_total in (("-l", "4"), ("-w", "6"), ("-c", "26")): + r = subprocess.run( + [tb, "-u", base, "wc", flag, f"/{cat}/*"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert r.returncode == 0, r.stderr + assert "total" in r.stdout + assert expect_total in r.stdout.split("total")[-1] + _rpc("delete_directory", {"path": cat, "recursive": True}) + + +def test_cli_wc_directory_errors(): + tb = _tb_bin() + base = _base() + cat = f"py_wc_dir_{_slug()}" + mkdir(base, cat) + r = subprocess.run( + [tb, "-u", base, "wc", "-l", f"/{cat}"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert r.returncode != 0 + assert "is a directory" in (r.stderr + r.stdout) + assert "recursive" in (r.stderr + r.stdout) + _rpc("delete_directory", {"path": cat, "recursive": True}) + + +def test_cli_wc_recursive_glob(): + tb = _tb_bin() + base = _base() + cat = f"py_wc_rec_{_slug()}" + mkdir(base, cat) + requests.post( + f"{base}/api/doc", + json={"path": f"/{cat}/inner", "description": None}, + timeout=10, + ).raise_for_status() + put_doc(base, f"{cat}/inner", "a.md", "x\ny") + r = subprocess.run( + [tb, "-u", base, "wc", "-l", "-r", f"/{cat}/*"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert r.returncode == 0, r.stderr + assert "total" in r.stdout + _rpc("delete_directory", {"path": cat, "recursive": True}) + + +def test_cli_wc_single_file_lines_only(): + tb = _tb_bin() + base = _base() + cat = f"py_wc_one_{_slug()}" + mkdir(base, cat) + put_doc(base, cat, "solo.md", "a\nb\nc") + r = subprocess.run( + [tb, "-u", base, "wc", "-l", f"/{cat}/solo.md"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert r.returncode == 0, r.stderr + assert r.stdout.strip() == "3" + _rpc("delete_directory", {"path": cat, "recursive": True}) diff --git a/ui/package-lock.json b/ui/package-lock.json index 9657956..819985f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,6 +12,7 @@ "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", "katex": "^0.16.45", + "mermaid": "^11.16.0", "react": "^19.2.4", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.4", @@ -39,6 +40,19 @@ "vite": "^8.0.1" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -279,6 +293,18 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -525,6 +551,23 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -581,6 +624,15 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", @@ -1188,6 +1240,259 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1212,6 +1517,12 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -1278,6 +1589,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1585,6 +1903,16 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -1986,6 +2314,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2007,6 +2344,520 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2044,6 +2895,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2076,6 +2936,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -2095,6 +2964,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2476,6 +3355,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2696,6 +3581,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2729,6 +3626,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2745,6 +3652,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -2924,6 +3840,17 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3215,6 +4142,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3252,6 +4185,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -3553,6 +4498,35 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mermaid": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -4244,6 +5218,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz", + "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4294,6 +5274,12 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4334,6 +5320,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -4627,6 +5629,12 @@ "node": ">=4" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", @@ -4668,6 +5676,30 @@ "dev": true, "license": "MIT" }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sass": { "version": "1.99.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", @@ -4797,6 +5829,12 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4810,6 +5848,15 @@ "node": ">=8" } }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4860,6 +5907,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5082,6 +6138,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/ui/package.json b/ui/package.json index 1643f8e..42a1f59 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", "katex": "^0.16.45", + "mermaid": "^11.16.0", "react": "^19.2.4", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.4", diff --git a/ui/src/app/AppShell.tsx b/ui/src/app/AppShell.tsx index 181daff..29da5ae 100644 --- a/ui/src/app/AppShell.tsx +++ b/ui/src/app/AppShell.tsx @@ -51,7 +51,21 @@ export function AppShell() { > Stats - {trustedAuth?.authenticateApi && !trustedAuth.oidcAssertionAuth ? ( + {trustedAuth?.authenticateApi && + !trustedAuth.oidcAssertionAuth && + trustedAuth.anonymousAuth ? ( + + ) : null} + {trustedAuth?.authenticateApi && + !trustedAuth.oidcAssertionAuth && + !trustedAuth.anonymousAuth ? ( + {anonymousAvailable ? ( + + ) : null} diff --git a/ui/src/app/trustedAuthContext.tsx b/ui/src/app/trustedAuthContext.tsx index a8afa8d..dea8fa1 100644 --- a/ui/src/app/trustedAuthContext.tsx +++ b/ui/src/app/trustedAuthContext.tsx @@ -6,8 +6,12 @@ export interface TabulariumTrustedAuthValue { authenticateApi: boolean; /** `[oidc]` on server — Web UI skips PSK gate (proxy supplies JWT assertion). */ oidcAssertionAuth: boolean; - /** Clears cookie + session PSK and returns to the gate — the Emperor revokes this seal. */ + /** No PSK stored; caller uses configured `[anonymous]` path ACL. */ + anonymousAuth: boolean; + /** Clears cookie + session PSK and returns to anonymous (or the gate). */ logoutPskSession: () => void; + /** Open the PSK gate from an anonymous public session. */ + startPskLogin: () => void; } export const TabulariumTrustedAuthContext = diff --git a/ui/src/features/chat/DocumentChatBody.tsx b/ui/src/features/chat/DocumentChatBody.tsx index 37ba378..d59217b 100644 --- a/ui/src/features/chat/DocumentChatBody.tsx +++ b/ui/src/features/chat/DocumentChatBody.tsx @@ -19,6 +19,7 @@ import { } from "./chatNicknameCookie"; import { type DocumentChatStatus, useDocumentChat } from "./useDocumentChat"; import { gfmTableKatexSanitizeSchema } from "../../markdown/gfmSanitize"; +import { markdownDiagramComponents } from "../../markdown/markdownComponents"; import { markdownRemarkPlugins, rehypeKatex, @@ -26,6 +27,7 @@ import { import styles from "../entries/PreviewPane.module.scss"; const NICK_CMD = /^\/nick\s+(.+)$/; +const diagramComponents = markdownDiagramComponents(); interface DocumentChatBodyProps { path: string; @@ -197,6 +199,7 @@ export const DocumentChatBody = forwardRef< rehypeKatex, [rehypeSanitize, gfmTableKatexSanitizeSchema], ]} + components={diagramComponents} > {transcript} diff --git a/ui/src/features/entries/PreviewPane.tsx b/ui/src/features/entries/PreviewPane.tsx index ff8d971..61967b4 100644 --- a/ui/src/features/entries/PreviewPane.tsx +++ b/ui/src/features/entries/PreviewPane.tsx @@ -22,6 +22,7 @@ import { splitPlainTextForPreview, } from "./highlightSearchTerms"; import { gfmTableKatexSanitizeSchema } from "../../markdown/gfmSanitize"; +import { markdownDiagramComponents } from "../../markdown/markdownComponents"; import { markdownRemarkPlugins, rehypeKatex, @@ -31,6 +32,8 @@ import { parentPath } from "./entryModel"; import { withOpenDocQuery } from "./entriesPath"; import styles from "./PreviewPane.module.scss"; +const diagramComponents = markdownDiagramComponents(); + const sanitizeWithMark: Schema = { ...gfmTableKatexSanitizeSchema, tagNames: [...(gfmTableKatexSanitizeSchema.tagNames ?? []), "mark"], @@ -202,6 +205,7 @@ export const PreviewPane = forwardRef( const markdownComponents = useMemo(() => { return { + ...diagramComponents, a: ({ href, children, diff --git a/ui/src/markdown/MermaidDiagram.module.scss b/ui/src/markdown/MermaidDiagram.module.scss new file mode 100644 index 0000000..de9c254 --- /dev/null +++ b/ui/src/markdown/MermaidDiagram.module.scss @@ -0,0 +1,39 @@ +@use "../styles/tokens" as *; + +.diagram { + margin: 0.85em 0; + padding: 0.75rem; + background: $bg-deep; + border: 1px solid $border; + border-radius: 4px; + overflow: auto; +} + +.pending { + margin: 0 0 0.5rem; + color: $text-muted; + font-size: 0.85rem; +} + +.svgHost { + display: flex; + justify-content: center; + + :global(svg) { + max-width: 100%; + height: auto; + } +} + +.error { + margin: 0.85em 0; + padding: 0.75rem; + background: $bg-deep; + border: 1px solid $risk; + border-radius: 4px; + white-space: pre-wrap; + word-break: break-word; + color: $text-muted; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.85rem; +} diff --git a/ui/src/markdown/MermaidDiagram.tsx b/ui/src/markdown/MermaidDiagram.tsx new file mode 100644 index 0000000..f600f5e --- /dev/null +++ b/ui/src/markdown/MermaidDiagram.tsx @@ -0,0 +1,96 @@ +import { useEffect, useId, useRef, useState } from "react"; + +import styles from "./MermaidDiagram.module.scss"; + +type MermaidApi = typeof import("mermaid").default; + +let mermaidLoader: Promise | null = null; + +function loadMermaid(): Promise { + if (mermaidLoader == null) { + mermaidLoader = import("mermaid").then((mod) => { + const mermaid = mod.default; + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: "dark", + darkMode: true, + }); + return mermaid; + }); + } + return mermaidLoader; +} + +interface MermaidDiagramProps { + chart: string; +} + +/** Renders a Mermaid / UML fenced block to SVG (lazy-loaded). */ +export function MermaidDiagram({ chart }: MermaidDiagramProps) { + const reactId = useId().replace(/:/g, ""); + const hostRef = useRef(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(true); + + useEffect(() => { + let cancelled = false; + const definition = chart.trim(); + setPending(true); + setError(null); + if (hostRef.current != null) { + hostRef.current.innerHTML = ""; + } + + void (async () => { + try { + const mermaid = await loadMermaid(); + const { svg, bindFunctions } = await mermaid.render( + `tb-mermaid-${reactId}`, + definition, + ); + if (cancelled || hostRef.current == null) { + return; + } + hostRef.current.innerHTML = svg; + bindFunctions?.(hostRef.current); + setPending(false); + } catch (e) { + if (cancelled) { + return; + } + setError(e instanceof Error ? e.message : String(e)); + setPending(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [chart, reactId]); + + if (error != null) { + return ( +
+        {chart}
+        {"\n\n"}
+        {error}
+      
+ ); + } + + return ( +
+ {pending ? ( +

+ Rendering diagram… +

+ ) : null} +
+
+ ); +} diff --git a/ui/src/markdown/markdownComponents.tsx b/ui/src/markdown/markdownComponents.tsx new file mode 100644 index 0000000..809e8cd --- /dev/null +++ b/ui/src/markdown/markdownComponents.tsx @@ -0,0 +1,62 @@ +import { + Children, + isValidElement, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; + +import { MermaidDiagram } from "./MermaidDiagram"; + +const DIAGRAM_LANGS = new Set(["mermaid", "uml"]); + +function codeText(children: ReactNode): string { + return String(children).replace(/\n$/, ""); +} + +function languageFromClassName(className: string | undefined): string | null { + const match = /language-([a-z0-9_+-]+)/i.exec(className ?? ""); + return match?.[1]?.toLowerCase() ?? null; +} + +function isDiagramFence(className: string | undefined): boolean { + const lang = languageFromClassName(className); + return lang != null && DIAGRAM_LANGS.has(lang); +} + +type CodeProps = ComponentPropsWithoutRef<"code"> & { + children?: ReactNode; +}; + +type PreProps = ComponentPropsWithoutRef<"pre"> & { + children?: ReactNode; +}; + +/** Shared `code` / `pre` handlers: Mermaid UML fences → SVG; else default HTML. */ +export function markdownDiagramComponents(): { + code: (props: CodeProps) => ReactNode; + pre: (props: PreProps) => ReactNode; +} { + return { + code({ className, children, ...props }: CodeProps) { + if (isDiagramFence(className)) { + return ; + } + return ( + + {children} + + ); + }, + pre({ children, ...props }: PreProps) { + const items = Children.toArray(children); + if ( + items.length === 1 && + isValidElement(items[0]) && + items[0].type === MermaidDiagram + ) { + return items[0]; + } + return
{children}
; + }, + }; +}