From 1bf6a86122fe4eea348022c1c4163da880136eb4 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:44:22 -0500 Subject: [PATCH 1/2] feat(keystone): add native investigations, scoped execution and canvas --- .gitignore | 3 + Cargo.lock | 243 ++ Cargo.toml | 7 + frontend/docs/keystone-browser-validation.md | 195 ++ frontend/docs/keystone-foundations.md | 506 +++ frontend/docs/keystone-local-smoke.md | 132 + frontend/docs/keystone-providers.md | 143 + frontend/e2e/app.spec.ts | 3 +- frontend/e2e/auth.spec.ts | 10 +- frontend/e2e/classic-links.spec.ts | 34 +- frontend/e2e/native-regressions.spec.ts | 40 +- frontend/package-lock.json | 232 +- frontend/package.json | 1 + frontend/playwright.config.ts | 14 +- frontend/playwright.keystone.config.ts | 47 + frontend/playwright.storybook.config.ts | 24 +- .../src/features/keystone/BranchActions.tsx | 79 + .../src/features/keystone/BranchComposer.tsx | 452 +++ .../src/features/keystone/BranchReader.tsx | 107 + .../src/features/keystone/EvidenceCard.tsx | 128 + .../keystone/InvestigationNodeCard.tsx | 114 + .../features/keystone/Keystone.stories.tsx | 141 + .../src/features/keystone/KeystoneCanvas.tsx | 269 ++ .../features/keystone/KeystoneFullPage.tsx | 463 +++ .../keystone/KeystonePage.stories.tsx | 67 + .../src/features/keystone/KeystonePage.tsx | 46 +- .../features/keystone/KeystoneUnavailable.tsx | 62 + .../features/keystone/KeystoneWorkspace.tsx | 1502 ++++++++ .../features/keystone/NewInvestigation.tsx | 280 ++ .../src/features/keystone/NodeInspector.tsx | 160 + .../src/features/keystone/ProviderSetup.tsx | 68 + .../src/features/keystone/RunActivity.tsx | 254 ++ .../src/features/keystone/RunControls.tsx | 68 + .../src/features/keystone/StatusBadge.tsx | 34 + .../features/keystone/cardGeometry.test.ts | 135 + .../src/features/keystone/cardGeometry.ts | 110 + .../keystone/eligibleDatasets.test.ts | 118 + .../src/features/keystone/eligibleDatasets.ts | 78 + .../keystone/fixtures/pageHarness.tsx | 851 +++++ .../keystone/fixtures/serverCapabilities.ts | 95 + .../keystone/fixtures/storyHarness.tsx | 418 +++ .../features/keystone/fixtures/synthetic.ts | 462 +++ frontend/src/features/keystone/keystone.css | 1189 +++++++ frontend/src/features/keystone/page.test.ts | 78 + .../src/features/keystone/pageContract.ts | 122 + .../src/features/keystone/pageTransport.ts | 6 + frontend/src/features/keystone/readiness.ts | 86 + .../src/features/keystone/runFollower.test.ts | 145 + frontend/src/features/keystone/runFollower.ts | 138 + .../src/features/keystone/runLink.test.ts | 35 + frontend/src/features/keystone/runLink.ts | 46 + .../src/features/keystone/runProgress.test.ts | 89 + frontend/src/features/keystone/runProgress.ts | 91 + .../keystone/serverCapabilities.test.ts | 110 + frontend/src/lib/keystone/context.ts | 132 + frontend/src/lib/keystone/decode.ts | 519 +++ frontend/src/lib/keystone/graph.ts | 62 + frontend/src/lib/keystone/index.ts | 8 + frontend/src/lib/keystone/keystone.test.ts | 332 ++ frontend/src/lib/keystone/layout.test.ts | 251 ++ frontend/src/lib/keystone/layout.ts | 260 ++ .../src/lib/keystone/nativeTransport.test.ts | 269 ++ frontend/src/lib/keystone/nativeTransport.ts | 556 +++ frontend/src/lib/keystone/sse.ts | 120 + frontend/src/lib/keystone/streaming.ts | 35 + frontend/src/lib/keystone/transport.ts | 233 ++ frontend/src/lib/keystone/types.ts | 225 ++ frontend/src/lib/query.ts | 1 + frontend/src/lib/settingsCapabilities.ts | 9 +- frontend/src/lib/types.ts | 1 + .../story-tests/keystone-auto-format.spec.ts | 420 +++ .../story-tests/keystone-live-page.spec.ts | 450 +++ .../story-tests/keystone-readiness.spec.ts | 169 + .../story-tests/keystone-run-progress.spec.ts | 379 ++ frontend/story-tests/keystone.spec.ts | 689 ++++ src/catalog/manifest.rs | 520 ++- src/handlers/http/modal/ingest_server.rs | 4 + src/handlers/http/modal/query_server.rs | 1 + src/handlers/http/modal/server.rs | 1 + src/keystone/acceptance.rs | 143 + src/keystone/api.rs | 1562 +++++++++ src/keystone/api_live_tests.rs | 944 +++++ src/keystone/context.rs | 641 ++++ src/keystone/coordinator.rs | 2835 +++++++++++++++ src/keystone/domain.rs | 412 +++ src/keystone/grants.rs | 1213 +++++++ src/keystone/mod.rs | 16 + src/keystone/presentation.rs | 219 ++ src/keystone/provider_runtime.rs | 1697 +++++++++ src/keystone/providers.rs | 210 ++ src/keystone/providers/decisions.rs | 924 +++++ src/keystone/providers/destination.rs | 500 +++ src/keystone/providers/live.rs | 919 +++++ src/keystone/providers/secrets.rs | 1310 +++++++ src/keystone/providers/transport.rs | 516 +++ src/keystone/query_bridge.rs | 1405 ++++++++ src/keystone/repository.rs | 3040 +++++++++++++++++ src/keystone/runtime.rs | 524 +++ src/keystone/stream_loader.rs | 556 +++ src/keystone/tools.rs | 351 ++ src/lib.rs | 1 + src/metastore/metastore_traits.rs | 9 + .../metastores/object_store_metastore.rs | 4 + src/query/listing_table_builder.rs | 76 + src/query/stream_schema_provider.rs | 220 +- src/rbac/map.rs | 121 + src/storage/localfs.rs | 54 + src/storage/object_storage.rs | 513 +++ 108 files changed, 36836 insertions(+), 75 deletions(-) create mode 100644 frontend/docs/keystone-browser-validation.md create mode 100644 frontend/docs/keystone-foundations.md create mode 100644 frontend/docs/keystone-local-smoke.md create mode 100644 frontend/docs/keystone-providers.md create mode 100644 frontend/playwright.keystone.config.ts create mode 100644 frontend/src/features/keystone/BranchActions.tsx create mode 100644 frontend/src/features/keystone/BranchComposer.tsx create mode 100644 frontend/src/features/keystone/BranchReader.tsx create mode 100644 frontend/src/features/keystone/EvidenceCard.tsx create mode 100644 frontend/src/features/keystone/InvestigationNodeCard.tsx create mode 100644 frontend/src/features/keystone/Keystone.stories.tsx create mode 100644 frontend/src/features/keystone/KeystoneCanvas.tsx create mode 100644 frontend/src/features/keystone/KeystoneFullPage.tsx create mode 100644 frontend/src/features/keystone/KeystonePage.stories.tsx create mode 100644 frontend/src/features/keystone/KeystoneUnavailable.tsx create mode 100644 frontend/src/features/keystone/KeystoneWorkspace.tsx create mode 100644 frontend/src/features/keystone/NewInvestigation.tsx create mode 100644 frontend/src/features/keystone/NodeInspector.tsx create mode 100644 frontend/src/features/keystone/ProviderSetup.tsx create mode 100644 frontend/src/features/keystone/RunActivity.tsx create mode 100644 frontend/src/features/keystone/RunControls.tsx create mode 100644 frontend/src/features/keystone/StatusBadge.tsx create mode 100644 frontend/src/features/keystone/cardGeometry.test.ts create mode 100644 frontend/src/features/keystone/cardGeometry.ts create mode 100644 frontend/src/features/keystone/eligibleDatasets.test.ts create mode 100644 frontend/src/features/keystone/eligibleDatasets.ts create mode 100644 frontend/src/features/keystone/fixtures/pageHarness.tsx create mode 100644 frontend/src/features/keystone/fixtures/serverCapabilities.ts create mode 100644 frontend/src/features/keystone/fixtures/storyHarness.tsx create mode 100644 frontend/src/features/keystone/fixtures/synthetic.ts create mode 100644 frontend/src/features/keystone/keystone.css create mode 100644 frontend/src/features/keystone/page.test.ts create mode 100644 frontend/src/features/keystone/pageContract.ts create mode 100644 frontend/src/features/keystone/pageTransport.ts create mode 100644 frontend/src/features/keystone/readiness.ts create mode 100644 frontend/src/features/keystone/runFollower.test.ts create mode 100644 frontend/src/features/keystone/runFollower.ts create mode 100644 frontend/src/features/keystone/runLink.test.ts create mode 100644 frontend/src/features/keystone/runLink.ts create mode 100644 frontend/src/features/keystone/runProgress.test.ts create mode 100644 frontend/src/features/keystone/runProgress.ts create mode 100644 frontend/src/features/keystone/serverCapabilities.test.ts create mode 100644 frontend/src/lib/keystone/context.ts create mode 100644 frontend/src/lib/keystone/decode.ts create mode 100644 frontend/src/lib/keystone/graph.ts create mode 100644 frontend/src/lib/keystone/index.ts create mode 100644 frontend/src/lib/keystone/keystone.test.ts create mode 100644 frontend/src/lib/keystone/layout.test.ts create mode 100644 frontend/src/lib/keystone/layout.ts create mode 100644 frontend/src/lib/keystone/nativeTransport.test.ts create mode 100644 frontend/src/lib/keystone/nativeTransport.ts create mode 100644 frontend/src/lib/keystone/sse.ts create mode 100644 frontend/src/lib/keystone/streaming.ts create mode 100644 frontend/src/lib/keystone/transport.ts create mode 100644 frontend/src/lib/keystone/types.ts create mode 100644 frontend/story-tests/keystone-auto-format.spec.ts create mode 100644 frontend/story-tests/keystone-live-page.spec.ts create mode 100644 frontend/story-tests/keystone-readiness.spec.ts create mode 100644 frontend/story-tests/keystone-run-progress.spec.ts create mode 100644 frontend/story-tests/keystone.spec.ts create mode 100644 src/keystone/acceptance.rs create mode 100644 src/keystone/api.rs create mode 100644 src/keystone/api_live_tests.rs create mode 100644 src/keystone/context.rs create mode 100644 src/keystone/coordinator.rs create mode 100644 src/keystone/domain.rs create mode 100644 src/keystone/grants.rs create mode 100644 src/keystone/mod.rs create mode 100644 src/keystone/presentation.rs create mode 100644 src/keystone/provider_runtime.rs create mode 100644 src/keystone/providers.rs create mode 100644 src/keystone/providers/decisions.rs create mode 100644 src/keystone/providers/destination.rs create mode 100644 src/keystone/providers/live.rs create mode 100644 src/keystone/providers/secrets.rs create mode 100644 src/keystone/providers/transport.rs create mode 100644 src/keystone/query_bridge.rs create mode 100644 src/keystone/repository.rs create mode 100644 src/keystone/runtime.rs create mode 100644 src/keystone/stream_loader.rs create mode 100644 src/keystone/tools.rs diff --git a/.gitignore b/.gitignore index 034f0d47e..726197527 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ cache # Local credentials and review notes *.pem pr-*.md + +# Stray installs at the repository root (the frontend keeps its own) +/node_modules/ diff --git a/Cargo.lock b/Cargo.lock index 6418b05aa..e93b1600c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,6 +327,43 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -733,6 +770,45 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -1336,6 +1412,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1611,6 +1696,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1929,6 +2024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1962,6 +2058,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -2770,6 +2875,20 @@ dependencies = [ "stacker", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint 0.4.6", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.5" @@ -3256,6 +3375,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", + "zeroize", +] + [[package]] name = "gimli" version = "0.32.3" @@ -3798,6 +3928,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -4471,6 +4610,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4483,6 +4631,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openid" version = "0.18.3" @@ -4722,6 +4876,8 @@ dependencies = [ "actix-web-lab", "actix-web-prometheus", "actix-web-static-files", + "aes", + "aes-gcm", "anyhow", "argon2", "arrow", @@ -4754,6 +4910,7 @@ dependencies = [ "futures", "futures-core", "futures-util", + "ghash", "hex", "hostname", "hotpath", @@ -4785,6 +4942,7 @@ dependencies = [ "prost 0.13.5", "rand 0.8.6", "rayon", + "rcgen", "rdkafka", "regex", "relative-path 2.0.1", @@ -4808,6 +4966,7 @@ dependencies = [ "tikv-jemalloc-ctl", "tikv-jemallocator", "tokio", + "tokio-rustls", "tokio-stream", "tokio-util", "tonic", @@ -4825,6 +4984,7 @@ dependencies = [ "uuid", "vergen-gitcl", "xxhash-rust", + "zeroize", "zip", "zstd", ] @@ -4873,6 +5033,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4947,6 +5117,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5456,6 +5638,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "rdkafka" version = "0.39.0" @@ -5720,6 +5916,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -6928,6 +7133,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -7833,6 +8048,24 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + [[package]] name = "xmlparser" version = "0.13.6" @@ -7845,6 +8078,16 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 8674a5b0e..47ec67112 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -174,6 +174,10 @@ itertools = "0.15.0" once_cell = "1.20" rayon = "1.12.0" rand = "0.8.5" +aes-gcm = { version = "0.10", features = ["zeroize"] } +aes = { version = "0.8", features = ["zeroize"] } +ghash = { version = "0.5", features = ["zeroize"] } +zeroize = "1" regex = "1.12.2" reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", @@ -229,6 +233,9 @@ anyhow = "1.0" procfs = { version = "0.18.0", default-features = false } [dev-dependencies] +tokio = { version = "^1.52.3", features = ["test-util"] } +rcgen = "0.14" +tokio-rustls = "0.26" rstest = "0.26.1" arrow = "59.2.0" temp-dir = "0.1.14" diff --git a/frontend/docs/keystone-browser-validation.md b/frontend/docs/keystone-browser-validation.md new file mode 100644 index 000000000..ec1e00e52 --- /dev/null +++ b/frontend/docs/keystone-browser-validation.md @@ -0,0 +1,195 @@ +# Keystone browser validation + +Run the focused Storybook interaction suite from `frontend`: + +```sh +npm run build-storybook +npx playwright test -c playwright.keystone.config.ts +``` + +Set `TMPDIR` to a directory with free space if the default temporary directory is small. + +Build Storybook first: the config serves the existing `storybook-static` directory and does not +build it. It starts its own server on `127.0.0.1:6046` (or `KEYSTONE_STORY_PORT`) with +`--strictPort` and no server reuse, so the run fails rather than silently testing another build +already listening there. Live source edits or a simultaneous build therefore cannot reload a +measured interaction. `KEYSTONE_STORY_URL` is only for explicitly testing a Storybook server you +started yourself; when it is set, no server is started. Chromium must be installed in the normal +Playwright cache, or supplied with `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`. + +### Ports for the CI suites + +| Variable | Suite | Behavior | +| --------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `KEYSTONE_STORY_PORT` | `playwright.keystone.config.ts` | Default 6046; strict port, never reuses a server | +| `KEYSTONE_STORY_URL` | `playwright.keystone.config.ts` | Explicit opt-in to an external server; nothing is started | +| `UI_TEST_PORT` | `npm run test:ui` | When set, starts this checkout's dev server on that port with `--strictPort` and never reuses one. Unset: port 5173, reused outside CI | +| `STORYBOOK_TEST_PORT` | `npm run test:stories` | Default 6006; strict port. Outside CI an existing server on that port is reused, so make sure it serves this checkout's build or pick a free port | + +End-to-end expectations derive URLs from the configured base URL, so `test:ui` passes on a +non-default `UI_TEST_PORT`. + +## Artifacts + +Every test retains a screenshot, video, trace, and fixture request log in a run folder under +`test-results/keystone/`. Set `KEYSTONE_RUN_ID` to name that folder; otherwise it uses a +timestamp. Separate runs keep earlier artifacts. The HTML report is in `playwright-report/keystone`. +Open a trace with `npx playwright show-trace path/to/trace.zip`. + +Both folders are ignored by git and are **not part of this change**. To inspect equivalent +artifacts, rerun the suite with your own `KEYSTONE_RUN_ID`, or run a single file or test with +`--grep`. Recorded demos use labeled mock inference and are shared with reviewers separately. + +## What the suite gates + +Interaction checks gate behavior: geometry edits must not send semantic commands, streaming must +preserve the viewport, branch requests must use the pinned direct parent export, and wider scope +must require explicit approval. Reader navigation, mobile layout, keyboard actions, and reduced +motion are checked in the browser. + +The 100-node tree exercise records animation-frame intervals while panning, zooming, moving a card, +and receiving streamed updates. Measurements at normal speed and Chromium's 4× CPU throttle are +advisory: frame interval p95 and counts over 50 ms depend on the browser, hardware, and test +instrumentation. The graph, request, and viewport assertions are deterministic gates. + +## Foundation checks + +The foundation suite covers inspector request cancellation (moving from one node to another +cancels the delayed evidence load, prevents cross-node source painting, and lets the cancelled +load be retried) and narrow-screen behavior: the reader is the default, controls are at least 44 +pixels, and switching to Canvas overview disables card drag, resize, and layout buttons. Keyboard +arrows and a pointer drag leave geometry unchanged and send no layout PATCH, while pan and zoom +still work. + +The foundation performance scenario, recorded before the explicit formatting preview was added, +used the actual 100-node, 99-edge tree, retained all cards in the DOM, and delivered 200 answer +chunks plus a status and terminal event. Every event preserved the viewport, selected node, and +focused node and sent no additional fixture requests. Trace and video recording were enabled +during timing. + +| CPU throttle | Frame intervals | p95 | Intervals over 50 ms | Maximum | +| ------------ | --------------: | ------: | -------------------: | -------: | +| 1× | 1,117 | 16.7 ms | 0 | 33.3 ms | +| 4× | 2,235 | 50.0 ms | 103 | 133.2 ms | + +Each performance test writes `frame-timing.json` (every frame interval), `hundred-nodes-1x.png` or +`hundred-nodes-4x.png`, `trace.zip`, `video.webm` and `fixture-requests.json` into its test folder. + +The focused timing tests allow three minutes so that frame speed remains advisory; an earlier 4× +run against a live development server exceeded the original 60-second timeout, and a Storybook +reload during a build interrupted another. Serving a frozen build prevents both. + +## Auto format + +The Auto format cases verify all pairwise card intersections, retained width and height, compact +bounding boxes and shorter edge endpoint distances for 10 and 100 cards. They gate unchanged +camera, selection, focus, semantic revision, and answer data. Formatting twice produces identical +geometry. A preview remains local beyond the autosave debounce; Save sends one layout PATCH, and +Undo restores exact geometry. Overlapping pins remain fixed with a visible conflict notice. +Streamed answer events change neither the original sprawl nor the formatted preview. A queued move +autosave is suspended during review and resumes with only its original move on Undo. Held +transport writes verify that in-flight saves block conflicting edits; a later manual move +invalidates the saved formatting Undo. + +The instrumented action-to-settled-geometry timings below are advisory. They include the browser +action, two animation frames, DOM snapshot, and trace recording. Edge extent is the total +horizontal and vertical distance between parent source and child target endpoints, rather than a +measurement of rendered Bézier paths. + +| Cards | Action to settled geometry | Bounding-box area before → after | Edge endpoint extent before → after | +| ----- | -------------------------: | -------------------------------: | ----------------------------------: | +| 10 | 90 ms | 44,044,000 → 4,477,244 | 40,775 → 3,374 | +| 100 | 214 ms | 1,129,765,910 → 74,661,951 | 2,229,315 → 87,108.6 | + +The Save/Undo demo test writes `auto-format-before.png`, `auto-format-after.png`, `video.webm`, +`trace.zip` and `auto-format-metrics.json` (exact initial and formatted geometry and timing). The +demo pauses before and after formatting, verifies that format itself preserves the camera, then +invokes the explicit Fit View control to show the compact overview before saving. The 100-card +test writes its own `auto-format-metrics.json`. + +## Full-page integration + +The full-page cases use the actual `KeystoneFullPage`, workspace, new-investigation form, run +follower, composer, inspector, and collapse controls against an explicitly isolated story +transport. This transport stores fixture investigations, layouts, selected-source metadata, and +mock run events in per-story browser `localStorage`; a persisted router path reopens the same item +after reload. It does not contact the native API, a provider, or a query service. The banner and +every generated answer explicitly identify scripted mock inference. Native adapter and backend +integration validation are separate from these browser fixture checks. + +The full-page story IDs are: + +- `keystone-full-page--durable-mock`: list, create, reopen, and explicit mock Run/Cancel. +- `keystone-full-page--collapse-persistence`: collapsed root beside another card, making expansion + collisions visible without moving either card automatically. +- `keystone-full-page--execution-unavailable`: durable saved work and drafts remain usable while + Run is explicitly gated. +- `keystone-full-page--mobile`: the same full-page flow in a mobile touch viewport. +- `keystone-full-page--provider-setup`: the optional administrator connection form. + +The browser checks verify that listing, saving a root draft, reopening, and saving a child draft +never start inference. A 30-day selection freezes exact UTC bounds; the saved scope remains +unchanged after reload. Clicking Run starts precisely one mock run, and each scripted result +preserves the canvas viewport and focused node. Cancel retains the partial answer and completed +source on reopen without restarting the run; only the explicit Retry action starts a distinct +second run, with a new idempotency key and the same approved scope. Parent sources are unchecked +by default; a branch passes either no sources or exactly the one source explicitly selected, +retains the direct-parent version and server export hash, and inherits the same 30-day scope. + +Collapse restores the exact expanded height and persists that expanded height with the collapsed +flag, rather than saving the 46-pixel header height. Expansion reports the card it covers and +leaves other geometry and semantic graph unchanged. Auto format uses the drawn collapsed sizes, +keeps the collapsed flag through explicit Save, and Undo restores the exact previous geometry. +Mobile keyboard checks confirm the default reader, local collapse in the read-only canvas +overview, no layout write or implicit inference, and no horizontal overflow. Both the mobile index +and the mobile collapsed canvas pass WCAG 2 A/AA and WCAG 2.1 AA axe checks. + +The provider-setup story's synthetic save handler never stores or logs the entered value, +retaining only `{ "action": "credential_saved" }` as an action marker. The browser test uses +public, non-secret surrogate strings with surrounding whitespace, verifies trimming before +submission and that the password input is cleared after success and failure, checks no surrogate +value appears in local storage, fixture snapshots, request logs, or body text, and confirms that +saving a credential never runs a question. The failure message contains no entered value. The +expanded setup form also passes the axe audit. Actual native credential HTTP behavior is tested +separately in Rust; no real provider credential is entered in the browser fixture. + +## Run links, progress and follow-up questions + +`keystone-run-progress.spec.ts` covers queued-to-completed phases, run deep links and reload, +failure without implicit retry, exact direct-parent follow-up requests, terminal recovery after +disconnect, reconnect without new inference, honest stale/unknown links, and mobile accessibility. +Branch tests assert immediate focus on the question textarea; cancelling restores focus to the +quick follow-up control. Viewport/focus stability and no inference requests during +navigation/replay are deterministic gates. + +Advisory timings on the 100-node, 99-edge tree from the latest recorded performance run: + +| CPU throttle | Frames | p95 frame interval | Frames over 50 ms | Maximum | +| ------------ | -----: | -----------------: | ----------------: | -------: | +| 1x | 1,172 | 16.8 ms | 0 | 33.4 ms | +| 4x | 2,313 | 66.7 ms | 187 (8.1%) | 199.9 ms | + +These machine-specific observations are not a universal smoothness guarantee. + +## Readiness, denial and classic links + +`keystone-readiness.spec.ts` covers denied, storage-unavailable, degraded, unsupported, +session-expired (401) and forbidden (403) pages, each with exactly one capability read and no +dataset request, plus a ready investigation link where datasets are listed once, after +capabilities. In `test:ui`, `e2e/native-regressions.spec.ts` serves plain-text 403 responses the +way the authentication middleware does and checks the denial, the classic links under `/next` +and that no dataset lookups happen; `e2e/classic-links.spec.ts` checks that the root-mounted page +shows the storage explanation and no classic link. + +## Recorded results + +Under CI, `npm run test:stories` runs the Keystone specs as the `keystone` project of +`playwright.storybook.config.ts`, at the same viewport and timeout as the focused config above +but without retained artifacts. In the final frontend validation the full `npm run check` passed +inside the pinned CI Playwright container; `test:stories` passed **293/293**, including the +screenshot specs and all **60** Keystone cases. Results are in +[validation status](keystone-foundations.md#validation-status). + +Screenshot comparisons are only meaningful inside the CI container. On a developer machine the +screenshot specs may fail because of local font and rendering differences; that is not a +regression signal, and baselines must not be refreshed from such a run. diff --git a/frontend/docs/keystone-foundations.md b/frontend/docs/keystone-foundations.md new file mode 100644 index 000000000..0cf3a78ce --- /dev/null +++ b/frontend/docs/keystone-foundations.md @@ -0,0 +1,506 @@ +# Keystone foundation increment + +Branch `feat/keystone-foundations`, based on commit +`bd20b0431ab2e5a50d8e159bb01ae19f72487778`. Native Keystone is opt-in: without the deployment +inputs described below, every Keystone surface reports itself unavailable and nothing is +initialized. + +> **Status: merge-readiness fixes implemented and validated.** The frontend CI pipeline and the +> clean backend validation both passed on the final source, subject to the baseline and +> host-toolchain notes in [validation status](#validation-status). This document makes no +> production-readiness claim: none of these fixes has been deployed or exercised against a running +> server or a paid provider. + +## Implemented backend + +`src/keystone` is native Rust, independent of the classic Keystone records. + +- Versioned investigation/node/run/scope/export/context/evidence/artifact/event records. +- A child context contains only its direct parent's own prompt, completed answer, approved scope, + explicitly selected authorized evidence, and the child's prompt. It cannot load ancestor + transcripts or sibling content. Context bytes, export hash and context hash are immutable. The + provider projection excludes authorization identity metadata; the internal manifest has a + separate integrity hash. Branch creation pins the context on the node version, and execution + reuses that exact package. A root has no inherited package. +- Absolute time bounds remain unchanged unless a scope change is explicitly reviewed. +- Private tenant/owner access and current exact Query/dataset checks. Runtime tenant identity is + taken from the authenticated session, not request headers. LLM grants and internal-stream + exceptions do not confer dataset access. +- A local repository uses `.keystone_native/v1`, a process-exclusive file lock, immutable + SHA-256-addressed JSON objects and a manifest published last. It has durable idempotency, + distinct semantic/layout revisions, and restart recovery that marks unfinished runs interrupted + without rerunning them. +- An injectable coordinator allows four simultaneous executions, up to sixteen accepted runs (at + most eight per tenant across owners), one active run per node, a 120-second deadline, and one + model step. Completed node versions need a new immutable version to run again. There is no + autonomous tool loop. Cancellation is described [below](#cancellation-and-teardown). +- An owned read-only facade accepts typed schema/sample operations, never arbitrary SQL, HTTP + request objects, stored SessionKeys, mutation tools or client graph context. Current grant + checks happen before dispatch and before publishing results. +- Separate stateless model and advisory decision interfaces. Jev and OpenAI Luna fixture codecs + preserve native score/confidence/refusal semantics. Destination binding and every-address + authorization are tested independently of transport. + +## Native HTTP surface and gating + +All and query server routes register `/api/prism/v1/keystone/native`. Ingest exposes the same +explicit unavailable capability surface; it never initializes the repository. Prism and index +modes are disabled if this scope is mounted by their hosting code. + +Storage is enabled only when an operator sets `KEYSTONE_NATIVE_DIRECTORY` on **one designated +coordinator**. See [deployment inputs](#single-coordinator-deployment-inputs). Missing or locked +storage reports unavailable; a failed commit reports degraded storage and requires a +restart/reopen. + +| Route suffix | Implemented behavior | +| --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `GET /capabilities` | JSON readiness and explicit unavailable reasons | +| `GET/POST /investigations` | Authorized private summaries / idempotent draft creation | +| `GET /investigations/{id}` | Filtered domain snapshot, current dependency checks | +| `POST /investigations/{id}/nodes` | Completed direct-parent branch and pinned context; no execution | +| `PUT /investigations/{id}/layout` | Presentation-only save with expected layout revision | +| `POST /investigations/{id}/runs` | Idempotent explicit run; 503 before acceptance if execution/provider is unconfigured | +| `GET /investigations/{id}/runs/{run}/events?after=N` | Authenticated live fetch SSE and committed replay; renewed current-authority observer | +| `POST /investigations/{id}/runs/{run}/cancel` | Requests cancellation; a claimed run returns its current status until teardown ends | +| `PUT /providers/credentials` | Admin-only encrypted credential entry; fails closed without deployment configuration | +| `GET /ui/capabilities`, `GET /investigations/{id}/graph`, `/presentation`, `/nodes/{node}/export`, `/nodes/{node}/evidence` | Strict browser projections after ownership and current scope authorization | + +The native execution runtime wires current credential grants, real scoped DataFusion samples, +encrypted local credentials and OpenRouter generation. It samples each approved dataset (up to 25 +rows each), preserves immutable bounded evidence, then makes one text-generation request. This is +sample-based analysis, not exhaustive statistics or a model-generated SQL/tool loop. The provider +is unconfigured until an administrator stores a destination-bound credential. The generation +request is non-streaming: SSE carries committed progress/evidence events and the completed answer +checkpoint, not incremental tokens from OpenRouter. Provider routing and privacy requirements are +in [provider foundations](keystone-providers.md#openrouter-generation-adapter). + +Each query uses a dedicated 32 MiB tracked DataFusion memory pool and disables spilling. That is +not a process-wide memory or cumulative scanned-byte bound. Keystone manifest decoding admits at +most 8 MiB encoded per manifest and 16 MiB decoded cumulatively per read, uses two blocking +workers and an 8 MiB Zstandard window, and checks cancellation while decoding. Worker permits +remain held until the worker actually stops. Wide windows may exceed these limits and fail +closed; a 30-day scope is retained rather than silently narrowed. Unsupported stored filters fail +closed rather than being ignored. + +Ordinary (non-Keystone) queries keep the original in-memory manifest decoder. The checked, +budgeted streaming decoder runs only when a Keystone read has a decode budget in scope. + +Metadata hydration is read-only against the configured object-store metastore. It validates at +most 64 selected catalog documents, 1 MiB per document and 8 MiB per dataset batch, checks +tombstones and conflicting partition metadata, and installs only an in-memory stream cache entry. +That cache is shared with normal server query paths. No canonical `.stream.json` or schema is +synthesized or published. Unsupported metastore backends fail closed. Local file reads have a +byte cap; cloud catalog reads admit listed sizes and check returned bytes, but the existing cloud +`get_object` still buffers its response. The limits are not a process-wide or strict cloud +transport-memory guarantee. + +Native reads capture the validated catalog and preserve the default-tenant root path or the exact +named-tenant/dataset prefix. Older pre-manifest data uses the same tenant-bound prefix, with a +maximum 366-day discovery window and 4,096 prefixes/directory results. Oversized discovery fails +without narrowing the saved scope. Storage metadata, manifest paths and physical file paths still +assume trusted operators/ingestors/bucket writers; they are not a sandbox for compromised storage. + +### Request execution and concurrency + +HTTP handlers do no repository I/O on their worker threads. Every repository call from a handler +(reads, list, create, layout, branch, run start, cancel, the initial events lookup and each +live-progress poll) runs as a job on a bounded blocking pool of four slots per repository. +Callers wait for a slot before a blocking thread is used, so a waiting request that disconnects +costs nothing. Once a job starts it runs to completion even if its caller disconnects, so a +durable commit is never abandoned halfway. + +Accepted runs execute on a dedicated runtime with four `keystone-execution` worker threads, not on +HTTP workers or the query runtime. Starting a run commits the acceptance and dispatches the +execution inside the same job, so a client that disconnects after acceptance cannot leave a run +queued but never started. Retrying with the same idempotency key returns the same run without a +second execution. The readiness check reads a health flag and does not wait behind a commit. + +A queued job does not trust the access resolved before it waited. Immediately before its lookup, +projection or commit, each job rechecks the caller's current session or API key and current roles. +The request keeps only the session-derived principal and salted credential digests, never the +request, session key, password or API key, and the binding expires after 180 seconds. An ended or +expired session, a rotated, expired or removed API key, a removed user or a wait longer than 180 +seconds returns 401 `authentication_required` before anything is looked up. A role removed during +the wait narrows access, so the dataset is reported as the usual 404 `not_found`. The recheck only +ever narrows the access issued to the request: a role granted during the wait adds nothing. These +rechecks need no execution service or provider, so a storage-only server stays readable. The +recheck runs just before the protected work; revocation is not atomic with writing the response. + +Durable commits still hold the repository mutex, and coordinator checkpoints remain synchronous +whole-state commits on those threads. A job can wait on that mutex while a commit is in +progress, and live-progress polls share the four slots with start and cancel. This is a +single-process, single-coordinator design; there is no distributed coordinator. + +### Cancellation and teardown + +Cancelling a claimed run, or reaching its 120-second deadline, signals the run and returns its +current non-terminal status; the client sees "cancellation requested". The coordinator then waits +for the run's top-level query task to be dropped before it frees the execution permit and +admission slot and records exactly one terminal (Cancelled, or Failed with `deadline_exceeded`). +A queued run that has not been claimed is cancelled immediately. + +The wait is bounded by a 10-second grace period. Work blocked inside a single poll cannot be +dropped until it yields. If teardown does not finish in time, the run records +`_teardown_pending` (for example `cancelled_teardown_pending`) and the stuck task keeps +holding the run's permit and admission slot, so a new attempt on that node is refused until it +unwinds. Durable replay keeps working meanwhile. + +The guarantee covers the **top-level** query task and its resource accounting, not every unit of +work it started. DataFusion child tasks, manifest decodes on blocking threads and local file reads +on blocking threads can briefly outlive the top-level task until they reach a cooperative +cancellation point. Each is bounded by its own limits (decode budget, memory pool, file byte cap) +and its result is discarded. A terminal status therefore does not mean that every such resource +has already stopped. + +## Full page and reusable UI + +The native page lists, creates and reopens private durable investigations. Creation saves a +draft; only **Run question** starts execution. Storage readiness is independent of provider +readiness. The browser adapter maps native DTOs, preserves server export hashes, uses +authenticated fetch SSE with replay, and reports errors instead of simulating success. The demo +workspace retains its explicit capability notice. + +The page reads `GET /capabilities` first and gates everything else on it: + +- **Readiness.** Storage that is unconfigured, degraded or disabled in this server mode is shown + with its specific reason (`repository_unconfigured`, `repository_degraded` or `mode_disabled`) + instead of an empty workspace. Storage readiness is reported separately from provider readiness. +- **Denial.** 401 and 403 responses are treated as denied before the content type is checked, + including the plain-text responses from the authentication middleware. The response body is + never read or echoed. HTML or other non-JSON success responses and schema mismatches are + reported as unreadable, not as denial. +- **Dataset loading.** Datasets are listed only after capabilities are ready and the page gate is + open. Leaving the page, opening another investigation or reloading capabilities cancels the + load and discards any late result. +- **Classic fallback.** When the native page is mounted under `/next` beside the classic UI and + cannot open, it links to the classic investigation (`/keystone/investigation`) or classic + Keystone index (`/keystone`). When the native UI is served at the root there is no classic UI + beside it, so no classic link is shown. + +Administrators enter the provider key on the investigation list; the page no longer links to the +classic LLM settings. + +Cards can be collapsed without changing semantic context. Their expanded dimensions are +preserved; Auto format uses the visible collapsed height. Expanding into another card offers +manual Auto format and never moves the camera automatically. Selected parent evidence is +unchecked by default and only the sources explicitly selected are passed to a child. + +Full-page Storybook fixtures explicitly label synthetic storage and mock inference. They are not +production fallbacks. Browser and backend tests use no API key. + +See [browser validation](keystone-browser-validation.md) for interaction checks and how to +regenerate artifacts, and [provider foundations](keystone-providers.md) for the exact provider +contracts and source provenance. + +## Run navigation and follow-up questions + +Queued and active runs have direct links and an activity panel with Queued, Reading data, Writing +answer and Result stages. Links preserve node/run selection on reload and require existing +private-owner authorization. Reconnect resumes recorded events without submitting a new run. +Missing history is marked unknown; there is no invented queue position, ETA, percentage or token +streaming. + +A completed selected card exposes **Add follow-up question** near its heading. The composer +focuses the question field and previews the pinned direct parent's prompt, answer, approved scope +and exported evidence. Cancelling restores the launch button's focus. Pending, failed and +cancelled parents remain ineligible: a completed export is required. No sibling or hidden +ancestor context is added. + +If event following becomes unavailable, an authorized graph read recovers a completed result +without moving cards or starting inference. Backend graph status reflects committed +query/provider phases. Duplicate or foreign execution attempts cannot interrupt the legitimate +run; failures are logged with sanitized codes, not provider content or credentials. Historical +superseded-run browsing, queue position and provider token streaming are outside this increment. + +## Manual Auto format + +The component harness includes an explicit **Auto format** action for compact, size-aware branch +placement. It preserves resized card dimensions and pinned positions, and places movable cards +without overlap. Overlapping pinned cards are reported for the user to resolve by unpinning them. + +Formatting is a local preview. **Save layout** commits the preview using the layout revision; +**Undo layout** restores the pre-format positions. It does not fit or pan the viewport, rewrite +context, change semantic revision, query telemetry or start inference. Incoming answer events do +not trigger formatting. Ordinary drag/resize saves retain their existing behavior outside the +format preview. + +Coordinate limits are checked before changing the preview. Large unpinned layouts can be +translated into the allowed range; impossible layouts or pins that prevent this retain their +current positions and show an actionable message. A later manual edit clears post-save Undo so it +cannot accidentally erase subsequent work. + +## Durable storage limits + +Storage fault injection covers after object publication, before manifest publication, and after +manifest publication. It does not simulate physical disk loss. The store uses synchronous +whole-state snapshots capped at 64 MiB / 10,000 retained records in total. Each owner is limited +to 64 investigations, 2,000 retained records and 8 MiB of serialized records. Capacity exhaustion +returns 429. Accepted runs reserve bounded record/byte headroom for terminal state and restart +recovery. The manifest retains current and previous snapshot objects; obsolete hash objects and +strictly named pending-write crash files are collected. + +Several owners can collectively exhaust the global capacity. Writes validate synchronously. +Reading one investigation copies only that investigation's records, but list requests and commits +still operate on the bounded whole-state snapshot; scalable persistence and request-cost +optimization remain follow-up work. Domain history and durable idempotency are retained: +automatic logical retention, archive/purge and capacity expansion are not implemented. It assumes +a trusted local directory administrator; it does not defend against a malicious same-user +filesystem race. + +## Validation + +### Validation status + +Frontend and backend were validated separately, each on a clean snapshot of the final source. Both +results below are final. + +**Frontend: final.** The full CI `npm run check` passed (exit 0) inside the pinned CI Playwright +container, on a clean snapshot of the final frontend source that was confirmed byte-identical to +the working tree before and after the run. Documentation edits made afterwards were checked with +`npm run format:check` only. + +| Step | Result | +| --------------------- | -------------------------------------------------------------------- | +| `format:check`, build | Passed | +| `npm test` | 2,483 passed, 14 skipped | +| `test:ui` | 557 passed, 1 flaky, 2 skipped, 0 failed | +| `test:prod` | 24/24 passed | +| `test:stories` | 293/293 passed, including the screenshot specs and 60 Keystone cases | + +The flaky `test:ui` case is `e2e/apm.spec.ts`: its first attempt failed with +`locator.fill: Malformed value` because a date field already held a later time, and it passed on +CI's automatic retry. This change touches no APM code, and the same test passed in an earlier run +on both this branch and the base commit. It is reported as a flake, not hidden. + +Screenshot results count only inside the CI container. On other machines the two screenshot +specs can fail because of local font and rendering differences; no baseline was updated by this +change. + +**Backend: final.** Every source file of the snapshot was given a fresh modification time so +Cargo could not reuse build output from older source, and every step recompiled `parseable`. + +| Command | Result | +| ------------------------------------------------------------------------------------------------ | ----------------------------------------------- | +| `cargo fmt --all -- --check` | Passed | +| `cargo check --locked --lib --bins` | Passed | +| `cargo test --locked --lib -- keystone::` | 136 passed | +| `cargo test --locked --lib -- catalog::manifest keystone:: storage:: query:: rbac:: metastore::` | 254 passed, 1 ignored | +| `cargo test --locked --lib` | 807 passed, 1 ignored | +| `cargo clippy --locked -- -D warnings` | Passed | +| `cargo clippy --locked --all-targets --keep-going -- -D warnings` | 11 errors, the same 11 as the base commit | +| `cargo hack clippy --verbose --each-feature --no-dev-deps -- -D warnings` | All 7 feature runs passed (host settings below) | +| `cargo build --locked --bin parseable` | Passed, with the native UI embedded | + +The ignored test is a benchmark that is already marked ignored on the base commit. + +The all-target Clippy run lints test code, which CI does not. Its 11 errors are in test code +outside this change (`known_schema.rs` 6, `handlers/http/mod.rs` 1, `staging/reader.rs` 3, +`streams.rs` 1), and the base commit produces the same 11 at the same locations. This change adds +no new violation. + +The CI feature gate passed only with three host-specific environment settings. No source or +system configuration was changed for it, but it is not a pass of the unmodified host command. +GCC 16 compiles C23 by default, which breaks the vendored cyrus-sasl that the `kafka` feature +builds, so `CFLAGS=-std=gnu17` was set. CMake 3.31.8 was put on `PATH`, and +`CMAKE_POLICY_VERSION_MINIMUM=3.5` stood in for the librdkafka CMake adjustment that CI applies. +Without these settings the `kafka` feature fails in the `sasl2-sys` build script on that host. The +`cargo-hack` 0.6.45 tool was installed in an isolated directory. + +The binary was built with the real native frontend build embedded through `NEXT_ASSETS_PATH` and a +local placeholder as the classic UI through `LOCAL_ASSETS_PATH`. The pinned classic bundle was not +downloaded, so the embedded classic UI was not validated. + +An independent review of the final authorization, persistence and credential-store changes found +no defects and no merge blockers. Its accepted limitations are listed under +[known limits](#known-limits-and-open-work). + +**Retired source harness.** An earlier standalone crate, `tests/keystone-foundations`, compiled +the Keystone source files without DataFusion or UI assets. It defined no tests of its own: every +test it ran is a test in those source files, and all of them (91 in the final source) run in the +main `keystone::` suite, which also covers the grant, runtime, provider runtime, query bridge and +stream loader modules the harness left out. The final source no longer compiled in that crate, CI +never ran it, and an earlier "73 passed" result came from a stale cached binary. The harness has +been removed; the main library suite supersedes it. + +### Build-time UI assets + +`build.rs` reads two optional environment variables: + +- `LOCAL_ASSETS_PATH`: a directory used as the **classic** UI instead of downloading the pinned + classic bundle from `Cargo.toml` metadata. The directory must exist or the build panics. When + unset, the build downloads and checksums the pinned classic bundle. +- `NEXT_ASSETS_PATH`: a native frontend build directory (`frontend/dist` after + `npm run build`) whose `index.html` contains ``. When unset, no native UI is + embedded. CI sets it to `${{ github.workspace }}/frontend/dist`. + +For a **backend-only** compile or test, a placeholder classic directory avoids the download. It is +not a UI and verifies nothing about either frontend: + +```sh +placeholder="${TMPDIR:-/tmp}/keystone-classic-placeholder" +mkdir -p "$placeholder" +printf 'classic UI placeholder\n' > "$placeholder/index.html" +export LOCAL_ASSETS_PATH="$placeholder" +unset NEXT_ASSETS_PATH +``` + +For **UI verification** of the native page served by the server, build the frontend first and +embed it, using an absolute path: + +```sh +(cd frontend && npm ci && npm run build) +export NEXT_ASSETS_PATH="$PWD/frontend/dist" +``` + +Leave `LOCAL_ASSETS_PATH` unset (or point it at a real classic build) whenever the classic UI +itself must be checked; the placeholder does not exercise it. + +### Validation commands + +From the repository root, with the asset variables set as above: + +```sh +cargo fmt --check +cargo clippy --locked --lib --bins -- -D warnings +cargo test --locked --lib keystone:: +cargo test --locked --lib catalog::manifest +cargo test --locked --lib +cargo check --locked --lib --bins +``` + +From `frontend`: + +```sh +npm ci +npm run format:check +npm run build +npm test +npm run build-storybook +npx playwright test -c playwright.keystone.config.ts +``` + +### CI gates + +- `.github/workflows/frontend.yaml` runs `npm ci && npm run build`, then `npm run check`: + `format:check` (including `frontend/docs`), build, unit tests, `test:ui`, `test:prod` and + `test:stories`. +- `.github/workflows/coverage.yaml` builds the frontend, sets `NEXT_ASSETS_PATH`, then runs + `cargo hack clippy --verbose --each-feature --no-dev-deps -- -D warnings` (library and binary + targets for each feature, including `kafka`; test code is not linted), + `cargo hack llvm-cov --no-report nextest`, and the `kafka` feature tests. +- `.github/workflows/build.yaml` builds release targets with the embedded native frontend. +- No workflow runs `cargo fmt --check`; run it locally. + +`cargo clippy --all-targets -- -D warnings` also lints test code, which CI does not. On the final +source it reports the same 11 pre-existing test-code errors as the base commit and nothing new +(see [validation status](#validation-status)); the two earlier errors in `src/catalog/manifest.rs` +were fixed. Treat the CI command above as the merge gate. + +## Single-coordinator deployment inputs + +Native Keystone is disabled unless an operator supplies these inputs. No compose, Helm or default +configuration in this repository sets them. + +- `KEYSTONE_NATIVE_DIRECTORY`: persistent private local directory (mode `0700`) on **exactly one + designated all/query coordinator**. +- `KEYSTONE_SECRET_MASTER_KEY_HEX`: exactly 32 random bytes encoded as 64 hexadecimal characters, + supplied by deployment secrets. Never put it in the repository, an image, a command argument or + chat. Preserve it across restarts; losing it makes stored provider credentials unreadable. + Master-key rotation is not implemented. The value stays in the server's process environment, + readable by the same user and inherited by child processes. +- `KEYSTONE_PROVIDER_ALLOWED_CIDRS`: explicit comma-separated operator egress CIDRs. Every resolved + address must be approved; loopback, link-local and metadata addresses remain denied under any + grant, as do the reserved IPv6 range `::/8` (including IPv4-compatible and IPv4-translated + forms such as `::ffff:0:7f00:1`) and the discard prefix `100::/64`. IPv4-mapped addresses are + judged as IPv4. A private address (10/8, 172.16/12, 192.168/16, fc00::/7) is allowed only by a + grant that lies entirely inside that private block, so catch-all or wider grants never reach a + private gateway; list its narrow private range explicitly. DNS addresses are revalidated and + pinned for every request; redirects and environment proxies are disabled. Grant the narrowest + ranges that cover the provider, not `0.0.0.0/0` or `::/0`. +- `KEYSTONE_GENERATION_MODEL`: optional; defaults to `xiaomi/mimo-v2.6-flash`. Model changes + require credential rebinding and retain the same output/routing ceilings. The initial API base + is fixed to `https://openrouter.ai/api/v1`; tenant credential entry cannot change the + destination. + +**Single coordinator.** The repository's file lock only excludes other processes on the same +host and filesystem. It is not distributed fencing or an object-store lease. In a deployment with +several query-mode servers, set `KEYSTONE_NATIVE_DIRECTORY` on one designated server only, route +Keystone traffic to it, and leave the variable unset elsewhere so those servers report Keystone +unavailable. Do not share one directory between hosts over a network filesystem: separate servers +would each hold their own lock and their own view of state. + +The provider key is entered using **OpenRouter connection** on the investigation list +(administrators only). The password field is cleared immediately and the value is never persisted +in the browser. Saving a key does not start inference. The form uses an authenticated admin-only +raw-body PUT to `/api/prism/v1/keystone/native/providers/credentials` over the application's +TLS connection. Do not place key bytes in a URL, command argument, log or chat. Responses never +return the key. Credential files are AES-256-GCM encrypted, tenant/reference/destination bound, +mode 0600 under a 0700 directory. This assumes a trusted local administrator and is not a remote +KMS or rollback-proof store. The server copies the request body into framework buffers that are +not zeroized; only the parsed credential value is. That is acceptable only under the trusted-host +model above. + +Credential saves use their own bounded grant pool (2 per user, 4 per tenant, 64 in total), so +open live-progress streams cannot block a key save. A save proceeds only with an admin permit that +this server issued to the same principal; peer, cross-tenant or forged permits are refused before +anything is written. Administrator rights are checked before and after the encrypted write, saves +are serialized, and a save whose client disconnects still completes or rolls back as a whole. +Runs and live-progress observers keep their pool of 8 per user, 32 per tenant and 256 in total; +excess subscriptions return an error and can be reopened. + +**Credential file recovery.** New credential versions are written to a temporary file and renamed +into place; the credential manifest (`providers.json`) is published last. At startup, under the +store's exclusive lock, unpublished temporary files are removed. A sealed credential file that a +valid manifest does not reference is moved, unchanged and never decrypted, into the credential +directory's `quarantine/` subdirectory (mode 0700) instead of being deleted, because the manifest +may be a stale restore. A later startup whose manifest references it again moves it back, so +restoring the correct `providers.json` and restarting is the whole recovery. A missing, corrupt or +malformed manifest moves nothing. Unknown names, non-canonical names, symlinks and directories +are never touched, and an existing file is never replaced. Quarantine holds at most 1,024 entries +(further unreferenced files stay in place) and one startup inspects at most 2,048 entries. Nothing +is deleted from quarantine automatically; removal is a manual operator action. A superseded +version is retired only when no record in the newly published manifest still references it. + +Telemetry artifacts and answers are normal private investigation data, not encrypted credential +records. They currently have no automatic retention/deletion workflow; existing owner/global +limits remain explicit above. Configured query windows do not imply artifact retention periods. + +See [local smoke setup](keystone-local-smoke.md) for a single-host recipe. + +## Known limits and open work + +These limits come from the merge-readiness review and remain after its fixes. + +- **Cold catalog checks are strict.** Keystone refuses a dataset when canonical and ingestor + `stream.json` documents disagree on schema version, custom/time partition, static-schema flag + or stream type, or when the catalog exceeds 64 documents. Normal queries merge whatever + snapshots exist. Measure representative long-lived buckets before relaxing anything; tombstone + and deletion-marker checks must stay in place. +- **Decode and catalog budgets are unmeasured.** The 16 MiB decoded-manifest budget per read and + the catalog document limits have not been measured against busy production streams. Runs that + exceed them fail with an explicit budget error rather than narrowing scope. +- **Request cost and contention.** Repository work runs off the HTTP workers, but durable commits + still hold one repository mutex and are whole-state snapshots, and the four-slot blocking pool + is shared by start, cancel and live-progress polls (see + [request execution](#request-execution-and-concurrency)). Every read request checks each + referenced dataset's deletion marker in the object store. +- **Cancellation scope.** Only the top-level query task is awaited; nested DataFusion, manifest + decode and local file work can briefly outlive it (see + [cancellation](#cancellation-and-teardown)). There is no gauge for runs in + `*_teardown_pending` and no automatic retry experience; both are possible follow-ups. +- **Request authorization.** Queued jobs recheck access just before their protected work, but + revocation is not atomic with sending the response (see + [request execution](#request-execution-and-concurrency)). A user without an API key who also + sends an `x-api-key` or `Bearer` header gets 401 on every Keystone route. +- **Credential store.** Startup cleanup runs only when the store opens, never while the server is + running. Quarantined files are kept until an operator removes them, and a full quarantine leaves + unreferenced files counting toward the store's entry cap. Request-body copies of a credential + are not zeroized. +- **Provider.** No paid live call has validated the configured model against the required privacy + routing. See [provider foundations](keystone-providers.md#privacy-routing-requirements). +- **Not yet exercised on a running server.** The fixes in this change are covered by automated + tests only. They have not been deployed, no existing installation has been restarted with them, + and no paid inference has run against them. +- Distributed coordinators, broader tools/SQL, sharing, dashboard promotion, retention/archival, + encrypted-store rotation, real token streaming and advisory-provider activation remain follow-up + work, not capabilities silently enabled by this increment. diff --git a/frontend/docs/keystone-local-smoke.md b/frontend/docs/keystone-local-smoke.md new file mode 100644 index 000000000..59dd61ae2 --- /dev/null +++ b/frontend/docs/keystone-local-smoke.md @@ -0,0 +1,132 @@ +# Isolated local Keystone smoke setup + +A recipe for running native Keystone on one development machine with synthetic data, separate +from any other Parseable installation. It is a local test, not a release deployment. Replace every +`` with a value for your machine; nothing here is a working credential. + +## Runtime layout + +Create one private runtime directory and keep everything for this instance inside it: + +```sh +RUNTIME="" # e.g. a directory under your XDG data home +umask 077 +mkdir -p "$RUNTIME"/{data,staging,keystone,logs} +chmod 700 "$RUNTIME" +``` + +- `data`, `staging` and `keystone` are absolute paths under the runtime root, used for the + local-store data directory (`P_FS_DIR`), `P_STAGING_DIR` and `KEYSTONE_NATIVE_DIRECTORY`. +- Generate a local application password and a fresh master key into mode-`0600` files. Read them + locally; never paste them into chat, a wiki, a URL, a command argument or tool output: + + ```sh + openssl rand -base64 24 > "$RUNTIME/admin-password.txt" + openssl rand -hex 32 > "$RUNTIME/master-key.hex" + chmod 600 "$RUNTIME/admin-password.txt" "$RUNTIME/master-key.hex" + ``` + +- The application password is the Parseable login (`P_USERNAME` / `P_PASSWORD`), not the provider + key. The master key (`KEYSTONE_SECRET_MASTER_KEY_HEX`) must be preserved across restarts; + regenerating it makes stored provider credentials unreadable. + +## Process environment + +Start the server from a launcher that builds an explicit, clean environment instead of inheriting +your shell's. A suitable launcher: + +- sets `P_ADDR=127.0.0.1:` and distinct unused `P_GRPC_PORT`, `P_FLIGHT_PORT` and + `P_QUERY_GRPC_PORT` values, so this instance cannot collide with another one; +- sets `P_FS_DIR`, `P_STAGING_DIR`, `KEYSTONE_NATIVE_DIRECTORY`, `P_USERNAME` and `P_PASSWORD` + from the runtime directory, and `KEYSTONE_SECRET_MASTER_KEY_HEX` from `master-key.hex`, without + echoing values; +- sets `P_CHECK_UPDATE=false`, `P_SEND_ANONYMOUS_USAGE_DATA=false` and a modest log level such as + `RUST_LOG=warn,parseable=info`; +- disables core dumps (`ulimit -c 0`), because the process environment holds the master key; +- refuses to start when a port is already in use, runs in the foreground, writes its process ID to + `$RUNTIME/server.pid` and logs to `$RUNTIME/logs/server.log`. + +Run the `local-store` mode so a single process is the all-in-one coordinator. That one process is +the **only** server with `KEYSTONE_NATIVE_DIRECTORY` set; the local file lock is not distributed +fencing (see +[deployment inputs](keystone-foundations.md#single-coordinator-deployment-inputs)). + +A matching stop helper should confirm that the PID in `server.pid` belongs to this runtime +directory and executable before sending SIGTERM. Restart with the same directory and master key; do +not regenerate credentials. Install no system service or automatic startup for a smoke instance. + +An existing smoke instance keeps running the build it was started with. Rebuild and restart it to +exercise later changes; until then, results from it say nothing about those changes. + +If a restart reports quarantined credential files, do not delete them. Check that +`providers.json` is the version you expect; restoring the correct manifest and restarting moves +referenced files back automatically (see +[credential file recovery](keystone-foundations.md#single-coordinator-deployment-inputs)). + +## Embedded UI + +Build the native frontend and embed it with `NEXT_ASSETS_PATH` (see +[build-time UI assets](keystone-foundations.md#build-time-ui-assets)). A classic-UI placeholder in +`LOCAL_ASSETS_PATH` avoids downloading the classic bundle, but then the classic UI is not +available or tested on this instance. Open `http://127.0.0.1:/keystone`. + +## Provider egress + +`KEYSTONE_PROVIDER_ALLOWED_CIDRS` is required before any provider call can happen. For a smoke +test, allow only the provider's currently resolved public addresses, each as its own `/32` (IPv4) +or `/128` (IPv6) network: + +```sh +getent ahosts openrouter.ai # inspect; then list each address as /32 or /128 +``` + +When the provider's DNS rotates to an unlisted address, requests fail closed until the policy is +updated and the server restarted. Do not use `0.0.0.0/0` or `::/0`. A catch-all grant would not +reach a private gateway anyway: private addresses need their own narrow private CIDR (see +[deployment inputs](keystone-foundations.md#single-coordinator-deployment-inputs)). + +Generation uses the fixed OpenRouter API base and the model in `KEYSTONE_GENERATION_MODEL` (default +`xiaomi/mimo-v2.6-flash`). Every request requires no-data-collection and zero-data-retention +routing; see [privacy routing requirements](keystone-providers.md#privacy-routing-requirements). +The default model may have no qualifying endpoint within the price caps, in which case runs fail +rather than relaxing those requirements. Jev/Luna remain disabled. + +## Remote access + +Keep every listener on loopback and check with `ss -ltnp` that nothing else is bound. To reach the +instance from another of your own devices, put a private TLS reverse proxy in front of the HTTP +port, reachable only on your private network, for example +`https://:/keystone`. Do not publish it to the public Internet. Confirm +TLS, the login redirect and cookie-authenticated `GET /api/prism/v1/keystone/native/capabilities` +through that address, and remove only that proxy route when finished. + +## Private key entry + +Sign in, then expand **OpenRouter connection** on the investigation list. Enter the OpenRouter key +in a regular browser with recording disabled: stop browser tests, traces, screenshots and screen +recordings first. Do not enter it into Storybook, chat, a URL or a wiki. + +Choose **Save credential**. Storage readiness and key entry can be checked without a provider key; +saving a key never starts a question. Account and model compatibility require a real run, so agree +a spending ceiling before the first paid call. + +## Smoke checklist + +These checks need no provider key and make no provider calls: + +1. `GET /api/v1/liveness` and `/api/v1/readiness` return 200. +2. Basic-authenticated and browser-cookie-authenticated + `GET /api/prism/v1/keystone/native/capabilities` return 200 with storage and the secret store + ready and generation unconfigured. +3. Ingest a small synthetic dataset once, flush it to Parquet, and count it through the query API. +4. Save a 30-day investigation draft; confirm it has one node and no runs, evidence or answers. +5. Restart gracefully with the same runtime directory and master key; the investigation ID and + all rows survive, and the encrypted store reopens. +6. With a browser guard counting native mutations, open and reload the draft and a run link. Expect + **zero run requests and zero credential writes** unless you deliberately run a question. + +The new-investigation picker offers only datasets whose metadata reports `streamType: UserDefined`; +internal datasets such as `pstats` are never offered, and the backend rejects them regardless. + +Smoke artifacts (logs, screenshots, browser results) belong outside the repository, for example +under `$RUNTIME` or a private scratch directory. They are not part of this change. diff --git a/frontend/docs/keystone-providers.md b/frontend/docs/keystone-providers.md new file mode 100644 index 000000000..d97075d5e --- /dev/null +++ b/frontend/docs/keystone-providers.md @@ -0,0 +1,143 @@ +# Keystone provider foundations + +The native runtime now includes optional live generation and encrypted credential entry. +They remain unavailable until operator deployment inputs and an admin-entered credential +are present. Automated verification uses fixtures and local TLS only. No paid call has +validated the configured model against the privacy routing requirements below. + +`ModelProvider` and the separate advisory `DecisionProvider` receive a +`FrozenProviderContext` verified from the server-built `PinnedContext`. The wire +input is the same frozen direct-parent package for both decision protocols. No +provider conversation/session identifier, recursive graph access, SQL executor, +tool executor, or scope mutation capability exists on either interface. + +| Protocol | Fixed operation | Pinned model | Native handling | +| ---------------- | --------------- | ------------ | ----------------------------------------------------------------------------------------------------- | +| TypeSafe Jev | `/v1/systemone` | `jev-1.13.0` | Keyed questions/answers; noul, choice, fractional ordinal score and legend | +| OpenAI Decisions | `/v1/decisions` | `gpt-6-luna` | Ordered named questions/answers; predicate, choice, fractional ordinal score and per-question refusal | + +Native confidence is retained separately from choice probability. Refusal remains +a distinct outcome. Response validation rejects missing/duplicate/unexpected IDs, +unknown answer fields, incorrect answer kinds, invalid scales/labels, +non-normalized distributions, and scores inconsistent with their native weighted +distribution. Boolean OpenAI choices are outside this portable string-only MVP +and are rejected rather than coerced. + +Destination validation derives the fixed endpoint from an operator-approved HTTPS +root, exact origin, port, and gateway prefix. Userinfo, query/fragment, dot-segment +tricks, percent-escaped roots and duplicate API suffixes are rejected. Credential +bindings include origin, prefix, port and policy revision; a change invalidates the +binding. Every DNS address needs its own matching operator CIDR grant; loopback, +link-local and metadata destinations remain denied even under a broad grant. So do +the reserved IPv6 range `::/8`, which includes the IPv4-compatible and +IPv4-translated forms (for example `::ffff:0:7f00:1`), and the discard prefix +`100::/64`. A private address is allowed only by an explicit grant lying entirely +inside its private block; catch-all or wider grants never reach a private gateway. + +`EncryptedFileSecretStore` implements AES-256-GCM immutable credential versions with +external deployment master-key input, tenant/version/destination enforcement and exclusive +local locking. Writes are atomic, and unreferenced versions are quarantined recoverably +rather than deleted (see +[credential file recovery](keystone-foundations.md#single-coordinator-deployment-inputs)). +Debug output is redacted and keys never appear in API responses. The pinned +HTTPS transport validates every resolved address, disables redirects/proxies, verifies TLS, +rejects compressed responses, bounds response bytes and enforces deadlines. Local injected +TLS fixtures verify those boundaries without bypassing production policy. + +See [deployment inputs](keystone-foundations.md#single-coordinator-deployment-inputs). +Jev/Luna decision adapters remain separate, advisory and disabled in the application; +they do not generate Keystone answers or authorize tools, SQL or scope changes. The +OpenRouter privacy routing below applies only to generation, not to these adapters. + +Protocol schemas were checked against [TypeSafe's API reference](https://docs.typesafe.ai/api) +and the [official OpenAI create-decision reference](https://developers.openai.com/api/reference/resources/decisions/methods/create) +on 13 October 2026. The [OpenAI Decisions guide](https://developers.openai.com/api/docs/guides/decisions) +confirms zero-based ordinal levels and fractional probability-weighted scores. + +## OpenRouter generation adapter + +The user-selected OpenRouter API base is `https://openrouter.ai/api/v1`. +`GenerationConfig::openrouter` approves the exact `https://openrouter.ai/api` +origin/prefix and derives `/v1/chat/completions` once. Generic operator-root +validation continues to reject versioned operation URLs. Approved operator +gateways may use `from_operator_chat_root`; tenant settings cannot change roots, +models, routing, token limits, or price caps. + +The initial operator model is `xiaomi/mimo-v2.6-flash`; `z-ai/glm-5.3-flash` +is also a validated configurable model. On 13 October 2026 the public model +catalog reported MiMo at $0.14/M input and $0.28/M output, versus GLM at +$0.0261/M input and $0.90/M output. MiMo was chosen for lower output cost. +The current [MiMo endpoint catalog](https://openrouter.ai/api/v1/models/xiaomi/mimo-v2.6-flash/endpoints) +shows varying provider prices, so requests enforce caps of $0.20/M input and +$1.00/M output. These are ceilings, not a fixed bill or quality guarantee. + +The [official chat reference](https://openrouter.ai/docs/api/api-reference/chat/create-a-chat-completion) +defines the non-streaming chat envelope. Each request contains exactly one system +instruction and one user message with the frozen context, a single model and +`max_tokens` (initially 2048). It contains no conversation ID, tools, plugins, +fallback model list, session metadata, or prior provider output. +The [provider routing reference](https://openrouter.ai/docs/guides/routing/provider-selection) +documents `allow_fallbacks: false`, `require_parameters: true`, and +`provider.max_price` in dollars per million tokens. Unsupported requested +parameters or unavailable prices fail rather than silently changing policy. + +### Privacy routing requirements + +Requests include up to 25 sampled log rows per approved dataset, so every request +carries this `provider` block: + +```json +{ + "allow_fallbacks": false, + "require_parameters": true, + "data_collection": "deny", + "zdr": true, + "max_price": { "prompt": 0.2, "completion": 1.0 } +} +``` + +`data_collection: "deny"` and `zdr: true` are hard requirements. They are not +configurable, and the request never sets `order`, `only`, `ignore`, `sort`, +`models` or `route`. Per OpenRouter's documentation, checked on 13 October 2026: + +- [Provider selection](https://openrouter.ai/docs/guides/routing/provider-selection): + `data_collection` is `"allow"` or `"deny"` and defaults to `"allow"`; `"deny"` + uses only providers that do not collect user data. `zdr: true` routes only to + Zero Data Retention endpoints, and the request flag can only add to account-wide + ZDR settings. +- [Chat completion reference](https://openrouter.ai/docs/api/api-reference/chat/create-a-chat-completion): + both fields are in the request schema; if no provider meets the + `data_collection` requirement, the request returns an error. +- [Zero Data Retention](https://openrouter.ai/docs/guides/features/zdr): the + per-request `{"provider":{"zdr":true}}` form. + +**Model availability is not guaranteed.** The configured model may have no endpoint +that meets both requirements within the price caps. The documentation does not +state the `zdr: true` behavior when no ZDR endpoint exists; Keystone assumes the +request fails and never falls back. Such a run fails with the upstream rejection +rather than sending data to a less restricted endpoint. The default model was chosen +for price; its availability under these requirements has not been tested with a +paid call, so no call has confirmed which endpoints serve it. Before +enabling Keystone, check the model's endpoint list and run one controlled call; +choose another model with `KEYSTONE_GENERATION_MODEL` if none qualifies. + +### Credential-save capacity + +Saving a provider key takes a permit from a dedicated administrator pool (2 per user, +4 per tenant, 64 in total), separate from the run and live-progress pool (8 per user, +32 per tenant, 256 in total). Open live-progress streams therefore cannot prevent a +key save. A save is accepted only with a permit this server issued to the same +principal; permits from another administrator, tenant or server instance are +refused before any write. The administrator check runs before and after the +encrypted write, and saves are serialized. + +The [reasoning token guide](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) +documents that reasoning can consume the output budget. Requests set +`reasoning.enabled: false` and `exclude: true`; the model catalog advertises +reasoning parameter support. Any returned reasoning is discarded. Responses +must name the configured model and contain exactly one assistant text choice +with `finish_reason: stop`. Refusal, tool/function calls, multimodal output, +errors, incomplete/empty output, duplicate JSON keys and oversized answers fail +closed. Synthetic dispatch fixtures exercise context isolation, destination and +credential binding, error propagation, and these rejection cases without a live +provider call. diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts index a7dfff59f..06be0db94 100644 --- a/frontend/e2e/app.spec.ts +++ b/frontend/e2e/app.spec.ts @@ -206,6 +206,7 @@ for (const route of ['/logs', '/sql-editor', '/datasets', '/dashboards', '/team' test('native login switches from demo to live and sends backend query contracts', async ({ page, + baseURL, }) => { let authenticated = false; let queryBody: Record | undefined; @@ -219,7 +220,7 @@ test('native login switches from demo to live and sends backend query contracts' const redirect = new URL(new URL(request.url()).searchParams.get('redirect')!); // The current Logs view may carry its preserved navigation context. redirect.searchParams.delete('observe'); - expect(redirect.href).toBe('http://127.0.0.1:5173/logs'); + expect(redirect.href).toBe(new URL('/logs', baseURL!).href); authenticated = true; return route.fulfill({ status: 200, body: 'ok' }); } diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 548edce6b..a5ec7d74f 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -21,13 +21,14 @@ test('login has working password visibility and administrator recovery', async ( test('live 401 returns to the requested application path after native sign in', async ({ page, + baseURL, }) => { let signedIn = false; await page.route('**/api/v1/**', (route) => { const request = route.request(); const url = new URL(request.url()); if (url.pathname === '/api/v1/o/login') { - expect(url.searchParams.get('redirect')).toBe('http://127.0.0.1:5173/datasets?view=list'); + expect(url.searchParams.get('redirect')).toBe(new URL('/datasets?view=list', baseURL!).href); signedIn = true; return route.fulfill({ status: 200, body: 'ok' }); } @@ -44,11 +45,14 @@ test('live 401 returns to the requested application path after native sign in', await expect(page.getByText('server_logs', { exact: true })).toBeVisible(); }); -test('OAuth is a document navigation with a safe same-origin redirect', async ({ page }) => { +test('OAuth is a document navigation with a safe same-origin redirect', async ({ + page, + baseURL, +}) => { await page.route('**/api/v1/o/login?**', (route) => { expect(route.request().isNavigationRequest()).toBe(true); expect(new URL(route.request().url()).searchParams.get('redirect')).toBe( - 'http://127.0.0.1:5173/', + new URL('/', baseURL!).href, ); return route.fulfill({ contentType: 'text/html', body: '

Identity provider handoff

' }); }); diff --git a/frontend/e2e/classic-links.spec.ts b/frontend/e2e/classic-links.spec.ts index 048c5a4f6..2ab7c0f7c 100644 --- a/frontend/e2e/classic-links.spec.ts +++ b/frontend/e2e/classic-links.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; +import { serverCapabilityBodies } from '../src/features/keystone/fixtures/serverCapabilities'; /** Only the preserved navigation context may remain after consuming a Logs link. */ async function expectLogsURL(page: Page, target: string) { @@ -172,11 +173,38 @@ test('classic paths that moved open their page here', async ({ page }) => { await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); } + // A classic Keystone link opens the native page, which first asks the server what it + // supports. A server without the native directory gets the readiness explanation, + // and the page reads nothing else: no investigation, dataset lookup or write. + const native: string[] = []; + await page.route('**/api/prism/v1/keystone/native/**', (route) => { + const path = new URL(route.request().url()).pathname; + native.push(`${route.request().method()} ${path}`); + const bodies = serverCapabilityBodies('default'); + if (path.endsWith('/ui/capabilities')) return route.fulfill({ json: bodies.ui }); + if (path.endsWith('/capabilities')) return route.fulfill({ json: bodies.page }); + return route.fulfill({ status: 500, json: { error: { code: 'unexpected_request' } } }); + }); + const lookups: string[] = []; + page.on('request', (request) => { + if (/^\/api\/v1\/logstream\/[^/]+\/info$/.test(new URL(request.url()).pathname)) + lookups.push(request.url()); + }); await page.goto('/keystone/example'); - await expect( - page.getByRole('heading', { name: 'Keystone analysis is unavailable in this native UI' }), - ).toBeVisible(); + await expect(page).toHaveURL('/keystone/example'); + await expect(page.getByTestId('ks-page')).toHaveAttribute('data-view', 'investigation'); + await expect(page.getByTestId('ks-readiness-storage')).toContainText('not enabled'); + await expect(page.getByTestId('ks-page-denied')).toHaveCount(0); + await expect(page.getByTestId('ks-workspace')).toHaveCount(0); + // Served at the root, no classic UI sits beside this page, so none is linked. + await expect(page.getByTestId('ks-classic-link')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); + // Development StrictMode may repeat a read; only which reads were made matters here. + expect([...new Set(native)]).toEqual([ + 'GET /api/prism/v1/keystone/native/capabilities', + 'GET /api/prism/v1/keystone/native/ui/capabilities', + ]); + expect(lookups).toEqual([]); }); test('a classic metric drilldown opens its metric and names the filter it drops', async ({ diff --git a/frontend/e2e/native-regressions.spec.ts b/frontend/e2e/native-regressions.spec.ts index f29733ced..0e10de62c 100644 --- a/frontend/e2e/native-regressions.spec.ts +++ b/frontend/e2e/native-regressions.spec.ts @@ -123,12 +123,42 @@ test('native setup and settings links remain inside a /next mount', async ({ pag 'href', '/next/add-data', ); + // Keystone reads its own capabilities. The auth middleware in front of the native + // routes rejects this session in plain text, which the page reports as an access + // problem, not an unreadable response. + const native: string[] = []; + await page.route('**/api/prism/v1/keystone/native/**', (route) => { + native.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`); + return route.fulfill({ status: 403, contentType: 'text/plain', body: 'Forbidden' }); + }); + const lookups: string[] = []; + page.on('request', (request) => { + if (/^\/api\/v1\/logstream\/[^/]+\/info$/.test(new URL(request.url()).pathname)) + lookups.push(request.url()); + }); await page.goto('/next/keystone/investigation'); - await expect(page.getByRole('link', { name: 'LLM settings', exact: true })).toHaveAttribute( - 'href', - '/next/settings/llm-key', - ); + const error = page.getByTestId('ks-page-error'); + await expect(error).toHaveAttribute('data-kind', 'denied'); + await expect(error).toContainText('You do not have permission for this'); + // Provider keys are entered on the native investigations list, so the page no longer + // sends anyone to LLM settings. await expect( - page.getByRole('link', { name: 'Open investigation in classic UI', exact: true }), + page.getByTestId('ks-page').getByRole('link', { name: 'LLM settings', exact: true }), + ).toHaveCount(0); + // The classic UI owns the root, so its link leaves the mount; native links stay inside. + await expect( + page.getByRole('link', { name: 'Open this investigation in the classic UI', exact: true }), ).toHaveAttribute('href', '/keystone/investigation'); + await page.getByRole('link', { name: 'All investigations', exact: true }).click(); + await expect(page).toHaveURL(/\/next\/keystone$/); + await expect(page.getByTestId('ks-page-error')).toHaveAttribute('data-kind', 'denied'); + await expect( + page.getByRole('link', { name: 'Open Keystone in the classic UI', exact: true }), + ).toHaveAttribute('href', '/keystone'); + // Only the capability read: the denial stops before anything else. Both routes render + // the same mounted page, so the index reuses its result instead of reading again. + // Development StrictMode may add an aborted duplicate when it reaches the route. + expect([1, 2]).toContain(native.length); + expect([...new Set(native)]).toEqual(['GET /api/prism/v1/keystone/native/capabilities']); + expect(lookups).toEqual([]); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b2b7c8ffd..70810b229 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@prometheus-io/codemirror-promql": "^0.313.4", "@radix-ui/react-dialog": "^1.2.0", "@radix-ui/react-tabs": "^1.1.22", + "@xyflow/react": "^12.12.0", "codemirror": "^6.0.2", "html-to-image": "1.11.13", "lucide-react": "^1.52.0", @@ -2820,6 +2821,55 @@ "assertion-error": "^2.0.1" } }, + "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-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-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-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "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.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3368,6 +3418,48 @@ "dev": true, "license": "MIT" }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -3593,6 +3685,12 @@ "node": ">= 16" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -3648,6 +3746,111 @@ "devOptional": true, "license": "MIT" }, + "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-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-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-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-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-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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5439,7 +5642,6 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5686,6 +5888,34 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index e95c269b6..689c9c55b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "@prometheus-io/codemirror-promql": "^0.313.4", "@radix-ui/react-dialog": "^1.2.0", "@radix-ui/react-tabs": "^1.1.22", + "@xyflow/react": "^12.12.0", "codemirror": "^6.0.2", "html-to-image": "1.11.13", "lucide-react": "^1.52.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index bceb8d16b..bf17bab4b 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,4 +1,10 @@ import { defineConfig, devices } from '@playwright/test'; + +// UI_TEST_PORT starts a dev server of this checkout on that port and fails if the port +// is taken, instead of reusing whatever already listens on the default 5173. +const ownPort = process.env.UI_TEST_PORT; +const port = Number(ownPort ?? 5173); +const url = `http://127.0.0.1:${port}`; export default defineConfig({ testDir: './e2e', fullyParallel: true, @@ -8,7 +14,7 @@ export default defineConfig({ retries: process.env.CI ? 1 : 0, reporter: [['list'], ['html', { open: 'never' }]], use: { - baseURL: 'http://127.0.0.1:5173', + baseURL: url, trace: 'retain-on-failure', ...devices['Desktop Chrome'], launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH @@ -16,8 +22,8 @@ export default defineConfig({ : {}, }, webServer: { - command: 'VITE_ENABLE_DEMO=true npm run dev', - url: 'http://127.0.0.1:5173', - reuseExistingServer: !process.env.CI, + command: `VITE_ENABLE_DEMO=true npm run dev${ownPort ? ` -- --port ${port} --strictPort` : ''}`, + url, + reuseExistingServer: !process.env.CI && !ownPort, }, }); diff --git a/frontend/playwright.keystone.config.ts b/frontend/playwright.keystone.config.ts new file mode 100644 index 000000000..e4dab2848 --- /dev/null +++ b/frontend/playwright.keystone.config.ts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Workers inherit this value, keeping reports and artifacts in the same run folder. +const runId = (process.env.KEYSTONE_RUN_ID ??= new Date().toISOString().replaceAll(':', '-')); +const artifacts = `test-results/keystone/${runId}`; + +// By default the run serves this checkout's `storybook-static` on its own port and +// fails if anything already listens there, so it never tests another build. +// KEYSTONE_STORY_URL explicitly opts into a server someone else started. +const external = process.env.KEYSTONE_STORY_URL; +const port = Number(process.env.KEYSTONE_STORY_PORT ?? 6046); +const owned = `http://127.0.0.1:${port}`; + +// A focused, reproducible harness run keeps successful traces and videos too. +export default defineConfig({ + testDir: './story-tests', + testMatch: 'keystone*.spec.ts', + workers: 1, + fullyParallel: false, + retries: 0, + timeout: 60_000, + outputDir: artifacts, + reporter: [ + ['list'], + ['json', { outputFile: `${artifacts}/results.json` }], + ['html', { open: 'never', outputFolder: 'playwright-report/keystone' }], + ], + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1000 }, + baseURL: external ?? owned, + trace: 'on', + video: 'on', + screenshot: 'on', + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } + : {}, + }, + webServer: external + ? undefined + : { + command: `npx vite preview --outDir storybook-static --port ${port} --strictPort --host 127.0.0.1`, + url: owned, + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/frontend/playwright.storybook.config.ts b/frontend/playwright.storybook.config.ts index 735676c50..e6683091c 100644 --- a/frontend/playwright.storybook.config.ts +++ b/frontend/playwright.storybook.config.ts @@ -1,25 +1,43 @@ import { defineConfig, devices } from '@playwright/test'; +const port = Number(process.env.STORYBOOK_TEST_PORT ?? 6006); +const baseURL = `http://127.0.0.1:${port}`; + export default defineConfig({ testDir: './story-tests', fullyParallel: true, workers: process.env.CI ? 2 : 4, // Missing baselines must fail, including in CI; updates are an explicit review step. updateSnapshots: 'none', + // Playwright's default with the project name left out, so the checked-in + // baselines keep their names under the projects below. + snapshotPathTemplate: + '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-snapshotSuffix}{ext}', retries: process.env.CI ? 1 : 0, outputDir: 'test-results/storybook', reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report/storybook' }]], use: { - baseURL: 'http://127.0.0.1:6006', + baseURL, trace: 'retain-on-failure', ...devices['Desktop Chrome'], launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } : {}, }, + // The Keystone specs stay in this CI run, at the viewport and timeout of their + // focused config (`playwright.keystone.config.ts`), which also records artifacts. + projects: [ + { name: 'stories', testIgnore: 'keystone*.spec.ts' }, + { + name: 'keystone', + testMatch: 'keystone*.spec.ts', + timeout: 60_000, + use: { viewport: { width: 1440, height: 1000 } }, + }, + ], webServer: { - command: 'vite preview --outDir storybook-static --port 6006 --strictPort --host 127.0.0.1', - url: 'http://127.0.0.1:6006', + command: `vite preview --outDir storybook-static --port ${port} --strictPort --host 127.0.0.1`, + url: baseURL, reuseExistingServer: !process.env.CI, }, }); diff --git a/frontend/src/features/keystone/BranchActions.tsx b/frontend/src/features/keystone/BranchActions.tsx new file mode 100644 index 000000000..dc0444018 --- /dev/null +++ b/frontend/src/features/keystone/BranchActions.tsx @@ -0,0 +1,79 @@ +import { Button } from '../../components/ui'; + +/** + * Why branching is unavailable. `read_only` cannot change by waiting (the role lacks + * the action); `waiting` clears once the question has a completed answer. + */ +export type BranchGateKind = 'read_only' | 'waiting'; + +/** + * The two branch actions. Both create a child of the selected node and inherit only + * that node's exported context; they differ in intent, not in what the child sees. + */ +export function BranchActions({ + blocked, + kind = 'waiting', + next = null, + onBranch, +}: { + blocked: string | null; + kind?: BranchGateKind; + /** What the user can do to make branching available, when anything. */ + next?: string | null; + onBranch: (intent: 'follow_up' | 'explore') => void; +}) { + const state = blocked === null ? 'ready' : kind === 'read_only' ? 'read-only' : 'waiting'; + const describedBy = blocked + ? next + ? 'ks-branch-blocked ks-branch-next' + : 'ks-branch-blocked' + : 'ks-branch-help'; + return ( +
+

Add a question from this answer

+

+ Follow up asks about this answer. Explore asks a different + question from the same point. Either way the new question sees only this question, its + answer, its scope and the sources you choose to pass on. +

+
+ + + {blocked ? ( +

+ {blocked} +

+ ) : null} + {blocked && next ? ( +

+ {next} +

+ ) : null} +
+
+ ); +} diff --git a/frontend/src/features/keystone/BranchComposer.tsx b/frontend/src/features/keystone/BranchComposer.tsx new file mode 100644 index 000000000..1991756ad --- /dev/null +++ b/frontend/src/features/keystone/BranchComposer.tsx @@ -0,0 +1,452 @@ +import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from 'react'; +import { Button, Spinner } from '../../components/ui'; +import { + branchEligibility, + formatDuration, + formatInstant, + scopeDiff, + type ScopeDiff, +} from '../../lib/keystone/context'; +import { + CONTEXT_POLICY, + type CreateNodeRequest, + type GraphNode, + type PinnedParentExport, + type ScopeVersion, +} from '../../lib/keystone/types'; + +export type ExportState = + | { state: 'loading' } + | { state: 'ready'; pinned: PinnedParentExport } + | { state: 'error'; message: string }; + +/** + * `exportedEvidenceIds` names the parent sources the user explicitly chose to pass + * on. The server re-authorizes each one; unselected sources never reach the child. + */ +export type BranchDraft = Omit & { + exportedEvidenceIds: string[]; +}; + +export interface BranchComposerProps { + intent: 'follow_up' | 'explore'; + /** The parent as it is now in the graph; may have moved past the pinned version. */ + parent: GraphNode; + exported: ExportState; + /** Datasets the caller may query; the server re-checks on submit. */ + availableDatasets: string[]; + /** Explicit reason runs cannot start, or null. Never replaced by a simulated result. */ + gated: string | null; + submitting: boolean; + submitError: string | null; + onSubmit: (draft: BranchDraft) => void; + onCancel: () => void; + /** Saves the question without running it; it runs only when the user asks. */ + saveAsDraft?: boolean; +} + +const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +const ALWAYS_OMITTED = [ + 'Questions and answers above the parent', + 'Other branches, including the parent’s other follow-ups', + 'The parent run’s working history and tool transcript', +]; + +function ContextPreview({ + pinned, + parent, + selected, + onToggle, +}: { + pinned: PinnedParentExport; + parent: GraphNode; + selected: readonly string[]; + onToggle: (id: string, include: boolean) => void; +}) { + const value = pinned.value; + const included = value.evidence.filter((item) => selected.includes(item.id)); + const left = value.evidence.filter((item) => !selected.includes(item.id)); + const omitted = [ + ...ALWAYS_OMITTED, + ...value.omitted.filter((item) => !ALWAYS_OMITTED.includes(item)), + ...left.map((item) => `Source not selected: ${item.title}`), + ]; + const duration = Date.parse(value.scope.end) - Date.parse(value.scope.start); + return ( +
+

Context for this question

+

+ The new question sees only what its direct parent exported, fixed at version{' '} + {value.parentVersion} when you opened this form. +

+ {parent.version !== value.parentVersion ? ( +

+ The parent changed to version {parent.version} after you opened this form. This question + still uses version {value.parentVersion}. +

+ ) : null} +
+
+

Included

+
    +
  • + Parent question: {value.prompt} +
  • +
  • + Parent answer: {value.answer} +
  • +
  • + Approved scope: {value.scope.datasets.join(', ')}, {formatInstant(value.scope.start)}{' '} + to {formatInstant(value.scope.end)} ({formatDuration(duration)}) +
  • + {included.length ? ( + included.map((item) =>
  • Source: {item.title}
  • ) + ) : ( +
  • No sources selected
  • + )} +
+
+
+

Not included

+
    + {omitted.map((item) => ( +
  • {item}
  • + ))} +
+
+
+ {value.evidence.length ? ( +
+ Sources to pass on +

Only the sources you select are shared with the new question.

+ {value.evidence.map((item) => ( + + ))} +
+ ) : null} + {value.truncated ? ( +

+ The parent export was shortened to fit the context limit. +

+ ) : null} +
+ + Exact parent export ( + + {(value.exportHash ?? pinned.sha256).slice(0, 12)} + + ) + +
+          {pinned.bytes}
+        
+
+
+ ); +} + +function ScopeDiffReview({ diff }: { diff: ScopeDiff }) { + const lines: string[] = []; + if (diff.startChanged || diff.endChanged) { + const comparison = + diff.childDurationMs > diff.parentDurationMs + ? 'wider' + : diff.childDurationMs < diff.parentDurationMs + ? 'narrower' + : 'same length, moved'; + lines.push( + `Time window: ${formatDuration(diff.parentDurationMs)} to ${formatDuration(diff.childDurationMs)} (${comparison})`, + ); + } + if (diff.datasetsAdded.length) lines.push(`Datasets added: ${diff.datasetsAdded.join(', ')}`); + if (diff.datasetsRemoved.length) + lines.push(`Datasets removed: ${diff.datasetsRemoved.join(', ')}`); + if (diff.filtersAdded.length) lines.push(`Filters added: ${diff.filtersAdded.join('; ')}`); + if (diff.filtersRemoved.length) lines.push(`Filters removed: ${diff.filtersRemoved.join('; ')}`); + if (diff.timezoneChanged) lines.push('Display time zone changed'); + return ( +
+

Scope change

+
    + {lines.map((line) => ( +
  • {line}
  • + ))} +
+ {diff.widens ? ( +

+ This reads data the parent did not. The server checks your access again before running. +

+ ) : null} +
+ ); +} + +export function BranchComposer({ + intent, + parent, + exported, + availableDatasets, + gated, + submitting, + submitError, + onSubmit, + onCancel, + saveAsDraft = false, +}: BranchComposerProps) { + const promptRef = useRef(null); + const promptId = useId(); + const [prompt, setPrompt] = useState(''); + const [changing, setChanging] = useState(false); + const [approved, setApproved] = useState(false); + const [sources, setSources] = useState([]); + const pinned = exported.state === 'ready' ? exported.pinned : null; + const parentScope = pinned?.value.scope; + const [draftScope, setDraftScope] = useState | null>(null); + + useEffect(() => promptRef.current?.focus(), []); + useEffect(() => { + if (parentScope && !draftScope) { + const { id: _id, ...rest } = parentScope; + setDraftScope(rest); + } + }, [parentScope, draftScope]); + + const validTimes = + !!draftScope && + INSTANT.test(draftScope.start) && + INSTANT.test(draftScope.end) && + Date.parse(draftScope.end) > Date.parse(draftScope.start); + const diff = useMemo( + () => + parentScope && draftScope && validTimes && changing + ? scopeDiff(parentScope, draftScope) + : null, + [parentScope, draftScope, validTimes, changing], + ); + const eligibility = pinned ? branchEligibility(pinned.value) : null; + const blocked = + gated ?? + (eligibility && eligibility.kind !== 'ok' + ? eligibility.kind === 'parent_active' + ? 'The parent is still running. Branch once it has a completed answer.' + : eligibility.kind === 'no_answer' + ? 'The parent has no answer to follow up on.' + : `The parent ended as ${eligibility.status}. Branch from a completed answer.` + : null); + const scopeReady = + !changing || + (validTimes && (draftScope?.datasets.length ?? 0) > 0 && (!diff?.changed || approved)); + const canSubmit = !!pinned && !blocked && prompt.trim().length > 0 && scopeReady && !submitting; + const datasets = Array.from(new Set([...(parentScope?.datasets ?? []), ...availableDatasets])); + + function submit(event: FormEvent) { + event.preventDefault(); + if (!canSubmit || !pinned) return; + onSubmit({ + parentNodeId: pinned.value.parentNodeId, + parentVersion: pinned.value.parentVersion, + // The server's own export hash when it provides one; the browser hash of the + // preview JSON is used only by servers without a native export hash. + parentExportSha256: pinned.value.exportHash ?? pinned.sha256, + contextPolicy: CONTEXT_POLICY, + intent, + prompt: prompt.trim(), + scope: changing && diff?.changed && draftScope ? draftScope : null, + // Sources are listed in export order, and only ones this export contains. + exportedEvidenceIds: pinned.value.evidence + .map((item) => item.id) + .filter((id) => sources.includes(id)), + }); + } + + return ( +
+

+ {intent === 'follow_up' ? 'Follow up on answer' : 'Explore another angle'} +

+

+ From {parent.title} +

+ {exported.state === 'loading' ? ( + + ) : exported.state === 'error' ? ( +

+ {exported.message} +

+ ) : ( + + setSources((current) => + include ? [...current, id] : current.filter((item) => item !== id), + ) + } + /> + )} +