Compare commits
370 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7328be6997 | |||
| d32299ea18 | |||
| cd5c79c253 | |||
| 5f19786ce9 | |||
| b1b0e91616 | |||
| 582a286baa | |||
| b266019ea8 | |||
| 463c82bfcf | |||
| 6d820bf0d7 | |||
| ad24cb36fa | |||
| 6ee4e3bdf4 | |||
| 1573273d27 | |||
| d56de4ace9 | |||
| 888ce3f35f | |||
| 1c28f3155f | |||
| 31ae38853d | |||
| 2f587e22be | |||
| a248be04ca | |||
| e919945d1e | |||
| 9fd51c5a41 | |||
| 8ae4d0effd | |||
| d7c2931c10 | |||
| 11105fac28 | |||
| d3eea1aa12 | |||
| 11e5247fbf | |||
| ee658eb862 | |||
| fe512637d4 | |||
| c71fb2d8fe | |||
| 35bd1bb226 | |||
| 320699e61a | |||
| 0d9ec5c490 | |||
| 487bcbf0c1 | |||
| f12e67bdc7 | |||
| f3ed86e54c | |||
| 0a8ce6e5f9 | |||
| 9ee3cd4a53 | |||
| b8d46fbb2b | |||
| b5554a9f9c | |||
| 8adf1bda3f | |||
| dc8863a46d | |||
| bd376dc479 | |||
| dc126497db | |||
| 8ea261d3a4 | |||
| 84ef8bb1f1 | |||
| 4ec80889c2 | |||
| 002d08ba5f | |||
| a7ac30f39b | |||
| cd7cf2c4c6 | |||
| 8d8b6b9bcb | |||
| 02012f105a | |||
| 09c91172ae | |||
| 8a0717c839 | |||
| f3c4169752 | |||
| eedad4b81e | |||
| 4e39ddd5d0 | |||
| c99cb048bf | |||
| 8c4b458724 | |||
| 792560191c | |||
| c884ce88ba | |||
| 4bcd942928 | |||
| bf394e0e4b | |||
| ec94001ab8 | |||
| 505cc8c3f6 | |||
| 9fc30d3ab9 | |||
| 887664f3c0 | |||
| ae712eae64 | |||
| a3df8f6ef5 | |||
| 1006079654 | |||
| 6721a4142a | |||
| 7bf938c19f | |||
| 5932250314 | |||
| b2068e9ef2 | |||
| 2ca56dbaf4 | |||
| 9785cda39d | |||
| 0cea99cd5e | |||
| 71bc754feb | |||
| cabbfed730 | |||
| 8b6ef7778a | |||
| 06319655b0 | |||
| f636169783 | |||
| 0f8f38ff52 | |||
| 142998afb3 | |||
| 665d7d2bb6 | |||
| 1746d47412 | |||
| 9bb2f7bcae | |||
| 1f2720463b | |||
| 8bb530e9b4 | |||
| 72df965a9c | |||
| f688325170 | |||
| c711213dcc | |||
| 11ecdccd65 | |||
| 27ab84d198 | |||
| 28e4da3879 | |||
| 31c0a83e51 | |||
| 34fddc3bf5 | |||
| 13c3dac554 | |||
| f7ed053e8e | |||
| 2e8feb56dd | |||
| 793178b345 | |||
| ee0359efd5 | |||
| 1dc57a85c2 | |||
| e7e8960fec | |||
| 79e278064d | |||
| 474b894d1c | |||
| 0c76b74afe | |||
| b608ed2c50 | |||
| f9d4e6bc74 | |||
| 223f11b989 | |||
| 5095be0b0d | |||
| 2aa64cf0a4 | |||
| 90c266d68a | |||
| 8410b2f4ef | |||
| ba69a5ad20 | |||
| 07b9b20eb5 | |||
| 901a0515b7 | |||
| 50c01a19c9 | |||
| 3a8fa87acb | |||
| df1dbbb8fb | |||
| 531ca851c4 | |||
| b3fd74529d | |||
| 00b376d234 | |||
| fa720d0054 | |||
| 91438e8214 | |||
| 08e9a34183 | |||
| 85937b8cbb | |||
| 107986bf85 | |||
| a8cabbebd5 | |||
| 636a0c5f43 | |||
| bf46782db3 | |||
| 02919e959e | |||
| d231eba8be | |||
| 637382f364 | |||
| bf6ef5b426 | |||
| 822e8fcd86 | |||
| 63e1a866a3 | |||
| 22c88c449d | |||
| ed6c0ac10c | |||
| 355c5d1e9a | |||
| 61d8b1dee2 | |||
| 0db32a865c | |||
| a4061ee1e1 | |||
| ec64be340e | |||
| f1c383c392 | |||
| 984c327162 | |||
| e2689d1b04 | |||
| b5c84f3df8 | |||
| 36c683df9e | |||
| 510adcb49b | |||
| ab87fd23cd | |||
| 1a352aa8d0 | |||
| 919b4ed46d | |||
| f7c57f21c5 | |||
| d4f4237723 | |||
| ba574f3052 | |||
| 0683b70abe | |||
| eaf6fae94c | |||
| e19a202565 | |||
| 8309d829cc | |||
| aabe8dedb2 | |||
| d0da907c00 | |||
| 6b434750c5 | |||
| c9047af626 | |||
| 52d692e8ec | |||
| 5c3c8b3fef | |||
| b0079fc3ee | |||
| ecfa123a79 | |||
| 9da372e4a2 | |||
| 9a998e7b52 | |||
| c3c9a80310 | |||
| 116eabba7d | |||
| e6c2649410 | |||
| e85d6f6963 | |||
| eb39aaa904 | |||
| bb7ee3cc3a | |||
| 84647c12ea | |||
| 156d237460 | |||
| 7d0710a1e6 | |||
| 5c797827f7 | |||
| b87b7f4505 | |||
| 92f08ca5a8 | |||
| 85f292a36d | |||
| 877411c6e5 | |||
| 081a5352c0 | |||
| c0b3190256 | |||
| eaefec2259 | |||
| cfbf849e2a | |||
| e01fe88594 | |||
| 80fe2fd9e2 | |||
| 516c52ece9 | |||
| 2b79818848 | |||
| e2e603ca6d | |||
| 01502678bf | |||
| 32c4b67541 | |||
| 8c23625fee | |||
| 489d686023 | |||
| 10515d1562 | |||
| c9e52d1926 | |||
| 32577ea48d | |||
| d5b4aef194 | |||
| 8130e01b70 | |||
| 3a38914be7 | |||
| 62d6cf9dac | |||
| 4e2647cce1 | |||
| 9b92900dd7 | |||
| bf73481180 | |||
| 3bb73e81e4 | |||
| c4198ac656 | |||
| 009b957523 | |||
| 9300dccbe2 | |||
| 74890a6268 | |||
| 6a1892792c | |||
| 3df8fc74c1 | |||
| 68678a22c6 | |||
| 4506c2d12d | |||
| d8f7c9bd8b | |||
| a266a4d7b6 | |||
| 76f671b8c5 | |||
| 0847edda4c | |||
| fa04bdbcf6 | |||
| 7697f407db | |||
| 4c16607e22 | |||
| 618f940310 | |||
| 313d2a1ea6 | |||
| 97f84e5c58 | |||
| db78fea161 | |||
| ec8875e864 | |||
| ed72aaba67 | |||
| d49d7af0a4 | |||
| d7db8d69e0 | |||
| 48d26b43f9 | |||
| 6e406a6b84 | |||
| 9c4469248a | |||
| 6fc5b5b607 | |||
| 3c4ae51ae2 | |||
| 57ff11b774 | |||
| 500b87ef57 | |||
| 46a4ad7487 | |||
| 03a94f4fb6 | |||
| 862f40ad26 | |||
| bc7ccd97a5 | |||
| dd85ac5e77 | |||
| dd48ed9140 | |||
| 5a65c28898 | |||
| 42374115c7 | |||
| 9506df9487 | |||
| 7f8ce987a5 | |||
| 8dbd86c297 | |||
| 75b2d7e7f1 | |||
| b861a1e3b8 | |||
| d89aee9910 | |||
| 75cbf9df09 | |||
| 138395ac35 | |||
| be0032e76b | |||
| 8096e6f07b | |||
| 032919fa07 | |||
| ea5f756d17 | |||
| 4fdeab4d67 | |||
| 478548b7f4 | |||
| 20cf700f22 | |||
| f92c22d55a | |||
| cb670952e8 | |||
| a25d8dd55a | |||
| ecfc30a94b | |||
| 6f2d0a092a | |||
| 3de0488cab | |||
| 2460162243 | |||
| 1b9104d64b | |||
| 80be36bfdd | |||
| e8d2382ac3 | |||
| 749e53edc2 | |||
| 7377a6f6cd | |||
| 45601ce386 | |||
| 7d490eb5d8 | |||
| ca018095bf | |||
| 0dd722ffca | |||
| 75e8030b07 | |||
| c24e4c48f8 | |||
| c7cfb9557d | |||
| c27428d6c0 | |||
| dfa01b1381 | |||
| 5fc7f65b2c | |||
| bbf804b7fc | |||
| 13687ec2fe | |||
| b756da5149 | |||
| 04234926ce | |||
| bfc69f5631 | |||
| 902b5fba99 | |||
| fccb7d876c | |||
| 50d4142797 | |||
| eb6dbc31e8 | |||
| fb8b585aa2 | |||
| dad18c16d5 | |||
| a704a5722e | |||
| dfa3723a7f | |||
| 6bd593f467 | |||
| 03ad0ba063 | |||
| d95b1095e5 | |||
| bcce89f076 | |||
| 66d5b03495 | |||
| 7af751c888 | |||
| 9f2d5ea704 | |||
| 8428b75b96 | |||
| 282b616a0e | |||
| 12eaf7a3de | |||
| f28e4d87f0 | |||
| ba2e52f841 | |||
| 5f1938c7c3 | |||
| 5e2f959c01 | |||
| 8da04717b6 | |||
| c094828704 | |||
| ce658e58f7 | |||
| 7f0a642972 | |||
| 84ab2b3651 | |||
| 1f0b202135 | |||
| aa56a12846 | |||
| 10996f11f7 | |||
| 1352a61d70 | |||
| 66a3926adb | |||
| 917e602a87 | |||
| 56fadb364a | |||
| 8fa1c8ce8d | |||
| c17e78c6a8 | |||
| 61bf7ba468 | |||
| 9712c7e1f7 | |||
| a560df80ce | |||
| bb56574824 | |||
| 8363c8bdc9 | |||
| b375f263dc | |||
| e1fe419028 | |||
| 9ec4f26874 | |||
| 80eea82398 | |||
| e39cf656b2 | |||
| c4f56d9e85 | |||
| c1dbaad88d | |||
| d93184d41d | |||
| de3cec6a23 | |||
| 0a4cddafc4 | |||
| 6d2b1401aa | |||
| 0a8fb5dd1b | |||
| a8c90107b5 | |||
| ced653bfc0 | |||
| 153014be9d | |||
| 8d4b3b67fb | |||
| 6cc94ebb46 | |||
| 8e8f1f0b4a | |||
| 78e015413b | |||
| 2f0eb316f5 | |||
| 55325ffd32 | |||
| ad02b6f13c | |||
| 47b12e14e6 | |||
| c43d4d7c38 | |||
| b76baf6835 | |||
| c83e3261d0 | |||
| 28ca5bdac5 | |||
| afd4c770f9 | |||
| ecc5607fd4 | |||
| 11e4cde77f | |||
| 62ed72f2b5 | |||
| 1a07574bae | |||
| 29a888d1dc | |||
| 3d307be645 | |||
| 7dfe892467 | |||
| 2cf00e9426 | |||
| 4eba9be48c | |||
| bebfc63b75 | |||
| 0319794e59 | |||
| bcf2f16c05 | |||
| 27d4cb1f36 | |||
| c1f61a380f | |||
| 5e5ed6d1ab |
19
.env.example
19
.env.example
@@ -1,10 +1,19 @@
|
|||||||
# file: .env.example
|
# file: .env.example
|
||||||
# version: 10
|
# version: 14
|
||||||
|
|
||||||
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||||
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||||
KSP_LOGS_DIRECTORY=logs
|
KSP_LOGS_DIRECTORY=logs
|
||||||
|
|
||||||
|
# PostgreSQL URI for the Devnet Store target.
|
||||||
|
KSP_SECRET_STORE_DEVNET_POSTGRES_URI=postgresql://...
|
||||||
|
|
||||||
|
# PostgreSQL URI for the Mainnet Store target.
|
||||||
|
KSP_SECRET_STORE_MAINNET_POSTGRES_URI=postgresql://...
|
||||||
|
|
||||||
|
# PostgreSQL URI for the Testnet Store target.
|
||||||
|
KSP_SECRET_STORE_TESTNET_POSTGRES_URI=postgresql://...
|
||||||
|
|
||||||
# KSP Wallet root directory. Used by config/std.wallet.json before an optional profile subdirectory is appended.
|
# KSP Wallet root directory. Used by config/std.wallet.json before an optional profile subdirectory is appended.
|
||||||
# The committed Wallet document falls back to "wallets" when neither the process environment nor .env defines this variable.
|
# The committed Wallet document falls back to "wallets" when neither the process environment nor .env defines this variable.
|
||||||
KSP_WALLETS_DIRECTORY=wallets
|
KSP_WALLETS_DIRECTORY=wallets
|
||||||
@@ -19,16 +28,16 @@ KSP_WALLETS_DIRECTORY=wallets
|
|||||||
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
||||||
|
|
||||||
# Optional public Solana Mainnet HTTP endpoint override used by config/std.transport.json and its example.
|
# Optional public Solana Mainnet HTTP endpoint override used by config/std.transport.json and its example.
|
||||||
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
|
# The committed Transport document falls back to https://api.mainnet.solana.com when this variable is absent.
|
||||||
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
|
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet.solana.com
|
||||||
|
|
||||||
# Optional public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
# Optional public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
||||||
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
||||||
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
||||||
|
|
||||||
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
||||||
# The committed Transport document falls back to wss://api.mainnet-beta.solana.com when this variable is absent.
|
# The committed Transport document falls back to wss://api.mainnet.solana.com when this variable is absent.
|
||||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
|
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet.solana.com
|
||||||
|
|
||||||
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
||||||
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||||
|
|||||||
204
CHANGELOG.md
204
CHANGELOG.md
@@ -1,8 +1,210 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 17 -->
|
<!-- version: 35 -->
|
||||||
|
|
||||||
# Changelog KSP
|
# Changelog KSP
|
||||||
|
|
||||||
|
## 0.3.15 — Raw Transaction Ingest Desk multi-route et fermeture live Mainnet — 2026-09-19
|
||||||
|
|
||||||
|
`0.3.15` livre `ksp-app-raw-transaction-ingest-desk` comme Desk Tauri KSP de composition et supervision des routes live `RawTransaction`. La Desk raisonne en routes complètes plutôt qu’en endpoints : une route n’est composable que lorsque toutes ses capabilities Config/Transport requises existent sur le même réseau, puis le Start reconstruit les ressources backend et lance une instance `ksp-worker-raw-transaction-ingest-lib` indépendante par route sélectionnée. Plusieurs routes peuvent partager le même Store sans partager implicitement lifecycle, gaps, `TargetCoverage` ou réparation. L’application expose inventaire, Start/Stop ciblé, lifecycle, health, activité, backpressure, reconnect/replay, gaps/repair et compteurs sûrs sans déplacer Transport, Store ou logique d’ingestion dans le frontend.
|
||||||
|
|
||||||
|
Les cinq familles live du Worker sont composables selon les capacités réellement déclarées : Yellowstone hydraté, Standard Logs hydraté, Standard Block direct lorsqu’un `blockSubscribe` compatible existe, Helius Transaction hydraté et HTTP Block Polling. Sur Mainnet, Yellowstone/PublicNode est traité comme capability du réseau canonique `mainnet`, pas comme réseau séparé. Le chemin Yellowstone Block final utilise le stream gRPC comme déclencheur de bloc puis un HTTP `getBlock` `Full/Base64` avec `maxSupportedTransactionVersion = 1`, au lieu d’un fan-out `getTransaction` par transaction. La queue d’updates Yellowstone reste bornée mais applique désormais une backpressure asynchrone vers le stream au lieu de transformer une saturation locale prolongée en `grpc_backpressure_overflow`; le Stop explicite accepte aussi comme fermeture coopérative un `Status` provider reçu après le half-close local, sans masquer un `grpc_status` sur session active.
|
||||||
|
|
||||||
|
Le live multi-provider a également révélé un cas de convergence RAW où deux acquisitions de la même transaction, du même slot et du même bloc différaient uniquement par `meta.logMessages`, PublicNode renvoyant un marqueur explicite `Log truncated`. Store journalise désormais des diagnostics bornés et sans matériau sensible, puis accepte le cas strictement prouvé **canonique complet déjà stocké + observation entrante tronquée compatible** comme observation supplémentaire sans remplacer le canonique ni arrêter le Worker. Cette exception reste volontairement asymétrique en `0.3.15` : un canonique tronqué n’est pas encore promu automatiquement vers une version complète et toute autre divergence non prouvée conserve la sémantique `content_conflict`.
|
||||||
|
|
||||||
|
Le gate technique final `pre.016` passe format, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, les suites Store PostgreSQL, Transport, Worker, Raw Transaction Ingest Desk et le rejeu `cargo test --workspace --all-targets --all-features`, ainsi que les graphes Cargo et `cargo tree --duplicates`. `cargo tauri build` produit les trois bundles Linux `.deb`, `.rpm` et `.AppImage`. Le live de référence immédiatement antérieur fait tourner Yellowstone Mainnet et HTTP Block Polling environ dix-neuf minutes en parallèle sans `grpc_backpressure_overflow`, sans terminal `content_conflict` et sans route `Faulted`; les deux routes terminent ensuite `Stopped/Healthy`. La réconciliation documentaire `pre.017` repasse ensuite format, audits Rust/Markdown et `cargo check --workspace` sans réouvrir le runtime.
|
||||||
|
|
||||||
|
`prompts/035-V0_3_16_START_PROMPT.md` ouvre `0.3.16` exclusivement depuis le futur tag stable `v0.3.15`. Cette release est dédiée à la résilience RAW et à la gestion durable des conflits : variantes conservées, comparaison `Exact / CompatibleLessComplete / CompatibleMoreComplete / Conflict`, promotions réversibles, quarantaine non terminale des conflits, historique de résolution, Store retry/backpressure, reconnexion Transport configurable et nouvelles vues de `ksp-app-store-desk`. `0.3.17` reste réservé au Backfill multi-route/multi-stratégie et `0.3.18` à l’adaptation de `ksp-app-backfill-desk`.
|
||||||
|
|
||||||
|
## 0.3.14 — gap repair et hardening multi-source du Worker RawTransaction — 2026-09-12
|
||||||
|
|
||||||
|
`0.3.14` ferme `ksp-worker-raw-transaction-ingest-lib` par une continuité run-local explicite au-dessus des cinq familles live de `0.3.13`, sans transformer le Worker en moteur historique. Le Worker distingue désormais reconnect, tentative de replay, delivery de replay et preuve de coverage ; il maintient des gaps bornés avec plage inclusive, `TargetCoverage` conservative et frontier de continuité distincte de la processing frontier. Une reprise live ou l'acceptation d'un `from_slot` ne vaut jamais à elle seule preuve de réparation.
|
||||||
|
|
||||||
|
La réconciliation peut s'appuyer sur une coverage réellement prouvée d'une source redondante, un scan HTTP borné de blocs/slots ou l'hydration `getTransaction observed` des références connues. Les bornes finales restent 64 gaps ouverts, 4096 slots par plage, fenêtres de discovery de 512 slots, 4 `getBlock` logiques en vol et un seul gap activement réparé. Les matériaux de repair repassent par la même canonicalisation Common RAW, la même admission, la même hydration globale et le même Store ; aucun second pipeline, retry réseau Worker, Job Backfill automatique ou backend Store direct n'est introduit.
|
||||||
|
|
||||||
|
La politique multi-source ne fault plus mécaniquement sur toute perte de source : la continuation n'est autorisée que lorsque les gaps concernés sont réconciliés et que la coverage présente/future requise est réellement démontrée. Les snapshots publics ajoutent une observabilité source-neutral et bornée des gaps avec états/raisons/méthodes et compteurs checked, sans source key, endpoint, token, signature, payload ou erreur provider arbitraire. La health policy reste conservative, la fairness alterne trafic nominal/repair sur les capacités existantes et le shutdown borne également le drain interne des sources avec abort+join des siblings non coopératifs.
|
||||||
|
|
||||||
|
La fermeture cross-layer confirme le pipeline unique `Transport -> Worker -> ksp-raw-transaction-lib -> Store`, la non-régression Legacy/V0/V1, les dependency firewalls et l'indépendance Worker/Backfill. Le gate technique final passe fmt, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features` et les graphes Cargo : `1 931` tests passent, `0` échoue et `15` restent ignored opt-in/operator-only. Les smokes keyless Solana HTTP Devnet et WebSocket Devnet passent `1/1` chacun ; Yellowstone/provider-gated, Helius live, PostgreSQL live et Worker->Store end-to-end restent explicitement non exécutés lorsqu'aucune ressource correspondante n'a été provisionnée.
|
||||||
|
|
||||||
|
`prompts/034-V0_3_15_START_PROMPT.md` ouvre `0.3.15` exclusivement depuis le futur tag stable `v0.3.14`. Cette release introduit `ksp-app-raw-transaction-ingest-desk` comme Desk Tauri de composition/supervision : une **route** est une stratégie complète pouvant nécessiter plusieurs capacités Config/Transport, elle n'est sélectionnable que si toutes ses requirements sont composables, puis une instance Worker réelle est démarrée par route sélectionnée et valide l'opérabilité runtime au Start. Le frontend reste une surface sûre ; Config, Transport, Store et Worker conservent leurs responsabilités.
|
||||||
|
|
||||||
|
## 0.3.13 — convergence live multi-source du Worker RawTransaction — 2026-09-10
|
||||||
|
|
||||||
|
`0.3.13` généralise `ksp-worker-raw-transaction-ingest-lib` de la verticale Yellowstone de `0.3.12` vers une composition caller-owned de `1..32` sources logiques appartenant au même réseau. Cinq familles live convergent désormais vers le même pipeline Common RAW / admission / Store : Yellowstone, Standard Logs avec hydration `getTransaction observed`, Standard Block RAW-direct, Helius `transactionSubscribe` avec hydration et HTTP Block Polling run-local RAW-direct. Le Worker reste sans Config, Job Backfill, backend Store physique, client réseau direct ou SDK provider parallèle.
|
||||||
|
|
||||||
|
La convergence partage un registre global d'hydration entre Yellowstone, Standard Logs et Helius afin de coalescer `(network, signature, commitment)` avant fan-out HTTP. Après canonicalisation, les acquisitions de même `(network, signature)` sont sérialisées de manière bornée : un contenu identique conserve les observations/provenances distinctes, tandis qu'une divergence devient un `content_conflict` terminal explicite. Aucune majorité provider, préférence silencieuse ou stratégie `first-provider-wins` n'est introduite. Standard Block et HTTP Block Polling admettent directement les transactions Legacy/V0/V1 qualifiées avec `maxSupportedTransactionVersion = 1`.
|
||||||
|
|
||||||
|
Le supervisor multi-source possède toutes les tâches et applique des bornes source-neutral sur admission, pending signals, hydrations et persistence avec une fairness minimale entre sources reference-bearing. Les snapshots agrègent activity/health/backpressure/reconnect/replay/gap sans exposer le matériau provider. La politique reste conservative : une source configurée qui échoue est terminale faute d'équivalence de coverage prouvée ; le gap repair, les rôles degraded/failover et la preuve de couverture restent donc `0.3.14`. Le shutdown stop/fault/drain/abort+join est durci contre les races, leaders d'hydration abandonnés, Store lent et publications tardives.
|
||||||
|
|
||||||
|
Le gate déterministe final passe `cargo fmt`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features` et les graphes Cargo : `1 850` tests passent, `0` échoue et `15` restent ignored opt-in/operator-only. Les smokes keyless Solana HTTP Devnet et WebSocket Devnet passent `1/1` chacun. Yellowstone provider-gated, Helius `transactionSubscribe` live, `blockSubscribe` provider dédié, HTTP polling Worker end-to-end et Worker multi-source vers Store restent explicitement non exécutés faute de gate provisionné ; aucune fixture ne les requalifie en PASS.
|
||||||
|
|
||||||
|
`prompts/033-V0_3_14_START_PROMPT.md` ouvre `0.3.14` exclusivement depuis le futur tag stable `v0.3.13`. Cette release doit fermer le gap repair/hardening multi-source limité au run actif — replay adressable, coverage redondante, scan HTTP borné, hydration de réparation, unresolved gaps et health policy — sans transformer le Worker en moteur historique ni créer de dépendance vers `ksp-job-backfill-lib`.
|
||||||
|
|
||||||
|
## 0.3.12 — Yellowstone + hydration HTTP + continuité de run du Worker RawTransaction — 2026-09-09
|
||||||
|
|
||||||
|
`0.3.12` ouvre la première source réseau productive de `ksp-worker-raw-transaction-ingest-lib` sans remettre en cause la fondation source-neutral de `0.3.11`. Le Worker dépend désormais de la façade `ksp-onchain-transport-lib` mais reste sans Config, Job Backfill, backend Store physique, `reqwest`, `tonic` ou proto provider direct. `RawTransactionIngestRuntimeResources` reçoit une source Yellowstone caller-composed et un pool HTTP cohérent ; `start` conserve la fondation sans source, tandis que `start_with_runtime_resources` lance la verticale live supervisée.
|
||||||
|
|
||||||
|
Les updates Yellowstone `Transaction`, `TransactionStatus` et les transactions incluses dans `Block` sont projetées vers des signaux source-neutral, coalescées de manière bornée par `(network, signature, commitment)`, hydratées via `getTransaction` observed puis converties par `ksp-raw-transaction-lib` avant admission/persistence Store. `BlockMeta` et `Slot` restent continuity-only. La provenance composite conserve les identités logiques sûres de la source et de la route d’hydration, sans URL, secret ni payload distant. Les gardes réseau/signature/slot/index/commitment empêchent les croisements de matériau avant admission.
|
||||||
|
|
||||||
|
La release matérialise une processing frontier strictement run-local : le Worker distingue travail pending/settled, n’avance jamais au travers d’un pending plus ancien et compacte ses états sans revendiquer une preuve de complétude durable. Le reconnect/replay reste Transport-owned : `from_slot` et `SubscribeReplayInfo` ne sont pas pilotés par le Worker. Le Worker projette un état source-neutral `Active/Reconnecting/Closing/Closed/Failed`, compte reconnect/replay-attempt/continuity-gap et fault avec `source_failed` lorsqu’une hausse du gap Transport prouve que la rétention ne permet plus la reprise demandée. Aucun Backfill ou repair automatique n’est déclenché.
|
||||||
|
|
||||||
|
Le hardening final ferme les duplicate storms, saturation/coalescence, Store lent/fautif, stop/fault pendant hydration/reconnect, overflow de compteurs, abandon des pending sans faux `settled`, abort/join des tâches privées et absence de retry HTTP possédé par le Worker. `pre.010` ajoute les canaris cross-layer Transport -> Worker -> Common RAW -> Store et verrouille les dependency firewalls, fixtures Legacy/V0, redaction et inventaires publics.
|
||||||
|
|
||||||
|
Le gate déterministe `pre.011` passe rustfmt check, audits Rust/Markdown, `cargo check --workspace`, Clippy strict, `cargo test --workspace --all-targets --all-features`, les suites ciblées et les trois inspections Cargo demandées. Les smokes live keyless HTTP Devnet et WebSocket Devnet sont exécutés séparément et passent ; les smokes Yellowstone token-gated et le Worker end-to-end Yellowstone + HTTP + Store restent explicitement non exécutés faute de ressources fournies, sans faux PASS. `pre.012` réconcilie ensuite README/USAGE et les architectures concernées avec cette verticale réellement matérialisée.
|
||||||
|
|
||||||
|
La suite reste découpée : `0.3.13` ajoute WS standard, Helius `transactionSubscribe`, HTTP live polling et la convergence multi-source ; `0.3.14` ferme le gap repair/hardening multi-source. `prompts/032-V0_3_13_START_PROMPT.md` ouvre `0.3.13` uniquement depuis le tag stable `v0.3.12` et impose un `pre.001` d’audit/sizing avant toute extension productive.
|
||||||
|
|
||||||
|
## 0.3.11 — Fondation runtime source-neutral du Worker RawTransaction ingest — 2026-09-08
|
||||||
|
|
||||||
|
`0.3.11` introduit `ksp-worker-raw-transaction-ingest-lib` comme premier Worker concret KSP pour l’acquisition continue de `RawTransaction`, tout en fermant volontairement la release **sans source réseau productive**. La crate consomme `ksp-worker-api`, `ksp-raw-transaction-lib` et la seule façade `ksp-store-lib`; elle ne dépend ni de Config, ni d’un Job, ni d’un backend Store physique, ni encore de `ksp-onchain-transport-lib`. Son identité stable est `raw_transaction_ingest`, ses settings bornent la capacité d’admission, la concurrence de persistence et la deadline de drain, et son `start` utilise exclusivement le runtime Tokio actif fourni par le caller.
|
||||||
|
|
||||||
|
Le runtime possède un supervisor privé, des tâches source/persistence détenues, une admission centrale `mpsc` bornée avec backpressure, la canonicalisation/assembly Common RAW, une observation key déterministe et la persistence atomique en mode `Normal`. Les outcomes distinguent insert, idempotence et tombstone purgée ; un contenu divergent devient `worker_raw_transaction_ingest.content_conflict`, les erreurs Store restent classifiées sans texte distant, et aucun provider gagnant n’est choisi implicitement. Les snapshots concrets latest-value se projettent directement sur `WorkerSnapshotSource`, utilisent séquences/compteurs checked et conservent le terminal pour les lecteurs tardifs.
|
||||||
|
|
||||||
|
Le hardening final ferme les races stop/fault, les source failures, la saturation observable, le Store lent/fautif et le shutdown borné. À l’expiration de `shutdown_drain_timeout`, le Worker ferme les admissions, abort les tâches restantes, les rejoint toutes puis publie `drain_timeout`; aucune tâche privée ne survit au terminal. Les tests externes de hardening/release verrouillent les sept codes d’erreur publics, les huit modules de production, les vingt-quatre exports crate-root, la redaction, le dependency firewall et l’absence de surface historique/Backfill/Transport/Config/backend direct.
|
||||||
|
|
||||||
|
Le gate technique final `pre.011` passe rustfmt check, audits Rust/Markdown, `cargo check --workspace`, Clippy strict, 56 tests Worker, `cargo test --workspace --all-targets --all-features`, les arbres Cargo Worker normal/features et `cargo tree --duplicates`. La réconciliation `pre.012` ajoute ensuite les README/USAGE durables du Worker et réaligne l’architecture sur l’état matériel : la fondation `0.3.11` n’a aucun adapter HTTP/WS/Yellowstone productif, aucune API publique d’enqueue/source registration et aucun replay/gap-repair live.
|
||||||
|
|
||||||
|
La trajectoire live reste découpée : `0.3.12` ouvre Yellowstone transactions/blocks/status + hydration HTTP + continuité de run, `0.3.13` ajoute WS standard/Helius/HTTP live et la convergence multi-source, puis `0.3.14` ferme le gap repair/hardening multi-source. `prompts/031-V0_3_12_START_PROMPT.md` ouvre `0.3.12` uniquement depuis le tag stable `v0.3.11`; son `pre.001` doit réauditer les surfaces Yellowstone/Solana/provider actuelles, l’injection Transport, l’hydration, le frontier, `from_slot`/ReplayInfo, les smokes accessibles et le sizing avant toute implémentation lourde.
|
||||||
|
|
||||||
|
## 0.3.10 — Lower-layer RAW Transaction commune + qualification cross-source — 2026-09-08
|
||||||
|
|
||||||
|
`0.3.10` introduit `ksp-raw-transaction-lib` comme lower-layer source-neutral commune aux producteurs `RawTransaction`. La canonicalisation RAW v1 auparavant locale à `ksp-job-backfill-lib` est extraite sans modifier les golden bytes/hash existants ; le Backfill consomme désormais cette common crate sans créer de dépendance vers un Worker. La bibliothèque construit les modèles `RawTransaction`/`RawTransactionObservation` de `ksp-store-api`, conserve l'identité canonique `(network, signature)`, borne et redacted les entrées hostiles, et reste volontairement sans Transport, Config, runtime async, Job, Worker ou backend Store physique.
|
||||||
|
|
||||||
|
La common matérialise également un wire Solana source-neutral Legacy/V0/V1 avec sérialisation Base64 et extraction de signature depuis le matériau transactionnel complet. La représentation V1 couvre header, compiled instructions, configuration V1 et signatures terminales ; les gardes structurelles rejettent les états non supportés plutôt que d'inventer une compatibilité. `ksp-onchain-transport-lib` ajoute `get_block_observed`, qui conserve l'identité sûre de la route réellement gagnante tout en réutilisant la validation `getBlock` existante.
|
||||||
|
|
||||||
|
Les preuves cross-source ferment explicitement ce qui est qualifié et ce qui exige encore hydration. Un `blockSubscribe` standard `full/base64` peut produire le même RAW v1 qu'un `getBlock` HTTP sur le sous-ensemble déterministe prouvé. Helius `transactionSubscribe` reste un signal nécessitant hydration HTTP lorsque `blockTime`/version manquent. Yellowstone qualifie le wire Transaction V1 et la conservation du `block_time` dans Block, mais `Transaction` nécessite hydration pour le temps de bloc et le chemin Block complet reste conservateur tant que l'identité byte-for-byte entre meta protobuf et meta JSON HTTP n'est pas prouvée. Aucun RAW-direct Yellowstone complet ni feed EARLY n'est revendiqué sans preuve.
|
||||||
|
|
||||||
|
Le gate technique final `pre.007` passe les audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features`, les suites ciblées common/Transport/Backfill et les graphes Cargo demandés ; les smokes live opt-in restent ignorés conformément à leur contrat. La réconciliation `pre.008` aligne ensuite la terminologie durable des couches Store sur `D1 RAW -> D2 STRUCTURAL -> D3 DECODED -> D4 DOMAIN`, réserve `N1–N4` aux niveaux architecturaux et complète `README.md`/`USAGE.md` de `ksp-raw-transaction-lib`. Son gate opérateur repasse format, audits, `cargo check --workspace` et Clippy strict sur `0.3.10-pre.8`.
|
||||||
|
|
||||||
|
Le runtime concret a été volontairement retiré de `0.3.10` après recalibrage du sizing. La trajectoire devient `0.3.11` fondation runtime/persistence déterministe, `0.3.12` Yellowstone + hydration/continuité, `0.3.13` WS standard/Helius/HTTP live + convergence multi-source, `0.3.14` gap repair/hardening/smokes, `0.3.15` Desk d'ingestion et `0.3.16` extension Backfill multi-source. Le Job historique paramétré et le Worker continu restent des producteurs indépendants du même Store.
|
||||||
|
|
||||||
|
`prompts/030-V0_3_11_START_PROMPT.md` ouvre `0.3.11` uniquement depuis le tag stable `v0.3.10`. Son `pre.001` doit auditer la base réelle, le graphe de dépendances, le runtime/supervision, les bornes de channels, la persistence/déduplication déterministe, les snapshots et le dimensionnement avant toute implémentation lourde ; aucune source live complexe n'est requise pour fermer cette release.
|
||||||
|
|
||||||
|
## 0.3.9 — Worker API générique + audit exhaustif d'acquisition RawTransaction — 2026-09-05
|
||||||
|
|
||||||
|
`0.3.9` introduit `ksp-worker-api` comme façade générique, courte et runtime-neutral pour services continus. La surface stable reste Core-only et expose `WorkerId`, `WorkerKindCode`, `WorkerState`, `WorkerHealth`, `WorkerActivity`, `WorkerLifecycle`, `WorkerStopToken`, `WorkerSnapshotSequence`, `WorkerSnapshot`, `WorkerSnapshotFuture` et `WorkerSnapshotSource`. Les identités sont bornées et redacted, le lifecycle possède des transitions explicites avec terminaux immuables, le stop token est partagé/idempotent, la séquence ne wrappe pas et le snapshot source latest-value reste object-safe, `Send + Sync` et implémentable depuis l'extérieur. L'API ne possède aucun runtime `start/stop` universel, aucune sémantique Job/checkpoint, aucun domaine Solana et aucune dépendance Transport/Store/Config/Tauri.
|
||||||
|
|
||||||
|
Après freeze de cette API, la seconde moitié de la release réalise puis consolide l'audit exhaustif des voies d'acquisition `RawTransaction`. Le document durable `docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md` est réécrit comme une synthèse unique Store-centrique : `RawTransaction` est le cœur RAW durable, tandis que `RawTransactionObservation` conserve les provenances multiples. L'audit distingue systématiquement possibilité protocolaire/provider, support architectural KSP, support implémenté et preuve live ; il couvre HTTP, WebSocket standard, Helius `transactionSubscribe`, Yellowstone gRPC, acquisition par blocs/slots, discovery + hydration, replay/gap repair, archives et sources EARLY/pre-execution. Les matrices recensent aussi les principaux providers/réseaux/tier/coûts connus sans réduire l'architecture aux seuls comptes gratuits actuellement disponibles.
|
||||||
|
|
||||||
|
La séparation des producteurs RAW est figée. `ksp-job-backfill-lib` reste un producteur historique **paramétré, borné et terminable** ; le futur `ksp-worker-raw-transaction-ingest-lib` sera un producteur live **continu start/stop sans requête métier historique**. Ils ne s'appellent pas, ne se supervisent pas et ne collaborent pas : chacun alimente indépendamment le même Store. HTTP, WS, gRPC, replay et archive sont des capabilities orthogonales à ces rôles ; un Worker peut utiliser HTTP pour hydration ou réparation de sa propre continuité live, tandis qu'un Job pourra utiliser gRPC/replay si une campagne historique bornée le justifie. Le handoff `0.3.10` retient `ksp-raw-transaction-lib` comme lower-layer source-neutral commune afin d'extraire la canonicalisation RAW v1 aujourd'hui locale au Backfill sans duplication ni edge Job ↔ Worker ; les golden bytes/hash RAW v1 doivent rester inchangés.
|
||||||
|
|
||||||
|
La release normalise aussi l'identité réseau KSP : `mainnet` devient l'identité canonique dans Config/Store/Transport/tests et `mainnet-beta` reste seulement un alias legacy/externe lorsque la frontière provider l'exige. Les données Mainnet N1 encore utilisées comme données de test ne dictent aucune compatibilité durable et aucune migration SQL n'est introduite pour préserver l'ancien libellé. Pendant le gate final, les baselines workspace sont également relevées à `jsonschema ^0.53` et `yellowstone-grpc-proto ^12.7`; Yellowstone 12.7 ajoute le RPC serveur `SubscribeGossip`, ce qui nécessite uniquement l'adaptation des fixtures `Geyser` avec une réponse `UNIMPLEMENTED` et n'ouvre aucune capability Gossip de production.
|
||||||
|
|
||||||
|
Le gate technique final passe les audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, 385/385 tests unitaires `ksp-onchain-transport-lib`, 43/43 canaries `release_completeness`, `cargo test --workspace --all-targets --all-features`, le bundle complet `ksp-worker-api` et les graphes Cargo jusqu'à `cargo tree --duplicates`; seuls les smokes/probes explicitement opt-in restent ignorés. La réconciliation documentaire `pre.008` réaligne ensuite README/USAGE/indexes/architecture/plan/validation et son gate opérateur repasse audits Rust/Markdown plus `cargo check --workspace` en `0.3.9-pre.8`.
|
||||||
|
|
||||||
|
`prompts/029-V0_3_10_START_PROMPT.md` ouvre `0.3.10` exclusivement depuis le tag stable `v0.3.9`. La prochaine release doit d'abord matérialiser `ksp-raw-transaction-lib`, migrer le Backfill vers cette canonicalisation commune sans changement fonctionnel, puis construire `ksp-worker-raw-transaction-ingest-lib` multi-source dès V1. Son `pre.001` est obligatoirement un gate d'audit/sizing : inventaire exact des gaps Transport/Config, runtime Worker, capabilities live, continuité, preuves provider et dépendances avant toute implémentation lourde.
|
||||||
|
|
||||||
|
## 0.3.8 — Store Desk V1 RAW, inspection backend-neutral et trajectoire Worker multi-source — 2026-09-04
|
||||||
|
|
||||||
|
`0.3.8` introduit `ksp-app-store-desk`, application Tauri KSP read-only dédiée à l'inspection du Store. Le package reprend le gabarit Desk commun (splash/shell/assets/styles/tracing), compose `ksp-config-lib`, `ksp-logging-lib` et la seule façade `ksp-store-lib`, et interdit à l'application tout accès direct à `ksp-store-api`, `ksp-store-postgres-lib`, SQL, driver/pool PostgreSQL, Transport on-chain ou ressource physique. Le bootstrap ouvre un Store lié au profil/réseau composite, expose health/runtime backend-neutral et effectue un shutdown borné à la fermeture de la fenêtre principale.
|
||||||
|
|
||||||
|
Pour éviter de détourner la pagination machine existante, Store API ajoute une primitive d'inspection backend-neutral distincte : `RawInspectionPageRequest`/`RawInspectionPage<T>`, summaries `RawTransaction`/`RawAccountState` sans gros bytes et quatre capabilities d'inspection couvrant entités et observations. Le backend PostgreSQL implémente les vues d'inspection par `COUNT` exact + `OFFSET/LIMIT` contrôlé, tandis que les queries historiques `RawPage` restent keyset/cursor, sans `OFFSET`, pour workers, backfills et replays. La façade `ksp-store-lib` conserve ainsi le même ensemble de 14 capabilities RAW que le backend sans exposer de détail physique.
|
||||||
|
|
||||||
|
La Desk projette `RawTransaction` et `RawAccountState` via DataTables `serverSide` avec **un seul pager visible**, filtres Store explicites, tailles de page bornées et counts vérifiés avant conversion JavaScript. Les détails sont chargés à la demande avec preview RAW bornée à 512 octets ; les rows ne transportent pas le payload transactionnel ni les bytes account. Les observations Transaction/Account sont elles aussi paginées côté serveur dans les détails et n'exposent que la provenance sûre déjà normalisée par Store API : provider/protocol/method/origin, timestamps, endpoint/session/filter/commitment optionnels, hash/taille de payload source et metadata Yellowstone account lorsqu'elle existe, jamais les bytes source. Les identifiants longs utilisent affichage tronqué, tooltip et copie explicite ; le tracing frontend reste metadata-only et ne journalise aucune valeur métier copiée.
|
||||||
|
|
||||||
|
Le hardening final fige les inventaires Cargo/npm/modules/commands/capabilities Tauri, la composition Config, l'absence de réseau/browser-storage/SQL/backend physique dans le frontend, les DTO IPC sans curseur machine et la coexistence exacte entre navigation keyset et inspection random-access. Le correctif `pre.012-fix.001` réaligne aussi `tsconfig.json` Store Desk byte-for-byte sur le gabarit TypeScript commun des autres Desk, ce qui rétablit le build `tsc && vite build` des imports CSS DataTables et ajoute un canari de non-divergence.
|
||||||
|
|
||||||
|
Le gate technique autoritaire passe `cargo fmt --all -- --check`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features`, `ksp-store-lib --no-default-features`, graphes Cargo et build Tauri Store Desk ; il comptabilise 1 592 tests passés, 0 échec et 15 tests opt-in/operator-only ignorés. Après la réconciliation documentaire, un rerun propre depuis `cargo clean` confirme encore les audits/check/Clippy/tests et reconstruit avec succès les cinq Desk KSP Config, SOL Prices, Wallet, Backfill et Store en bundles Linux `.deb` + `.rpm`, sans rouvrir le runtime.
|
||||||
|
|
||||||
|
La trajectoire suivante est explicitement redécoupée pour éviter de répéter l'hypothèse HTTP du premier backfill. `0.3.9` introduira d'abord `ksp-worker-api` comme abstraction générique de service continu, puis seulement **après fermeture fonctionnelle de cette API** produira un audit exhaustif des sources/méthodes `RawTransaction`. Cet audit couvrira HTTP, WS standard, extensions provider, Yellowstone gRPC, block/slot, discovery + hydration, replay/gap-repair et multi-provider, et classera les sources comme alternatives, complémentaires, redondantes ou spécialisées. Il servira d'entrée architecturale à `0.3.10`, qui introduira `ksp-worker-raw-transaction-ingest-lib` multi-source dès V1 et ne modifiera Transport/Config que pour les adaptations réellement justifiées par l'audit. Les futures sources Helius HTTP/WS Mainnet/Devnet devront réutiliser `KSP_SECRET_HELIUS_API_KEY` via Config ; aucune URL Helius n'est ajoutée par `0.3.8`. La terminologie `mainnet`/`mainnet-beta` sera auditée avant tout changement afin de préserver une identité KSP unique pour le même cluster.
|
||||||
|
|
||||||
|
`0.3.11` ouvrira ensuite `ksp-app-raw-transaction-ingest-desk`, capable de choisir/superviser une ou plusieurs sources/méthodes sans dupliquer la logique du worker. `0.3.12` reviendra sur `ksp-job-backfill-lib` et `ksp-app-backfill-desk` pour ajouter les autres stratégies historiques/catch-up pertinentes issues du même audit ; la verticale `getSignaturesForAddress + getTransaction` de `0.3.6`/`0.3.7` reste une première stratégie HTTP valide, pas la définition générale du backfill KSP. kbot3 reste uniquement une référence fonctionnelle pour ces audits, jamais une source de code, DTO, Config, URL ou dépendance.
|
||||||
|
|
||||||
|
`prompts/028-V0_3_9_START_PROMPT.md` ouvre `0.3.9` sur cette base : Worker API doit rester Core-only/générique et être stabilisée avant l'audit RAW Transaction, lequel prépare `0.3.10` sans ajouter de worker concret ni d'endpoints provider pendant `0.3.9`.
|
||||||
|
|
||||||
|
## 0.3.7 — Backfill Desk : contrôle, monitoring, Cancel/Resume et autocomplete — 2026-09-03
|
||||||
|
|
||||||
|
`0.3.7` introduit `ksp-app-backfill-desk`, première application Tauri KSP spécialisée dans le contrôle d'un `ksp-job-backfill-lib` historique `RawTransaction`. L'application reste une couche de composition : `ksp-config-lib` possède les profils/composites/secrets, `ksp-onchain-transport-lib` le pool HTTP et les politiques provider/retry/rate-limit, `ksp-store-lib` la persistence backend-neutral, `ksp-job-backfill-lib` la découverte/hydratation/frontier/checkpoint/runtime et `ksp-job-api` le lifecycle commun. La Desk ne dépend ni d'un backend Store physique, ni de SQL, ni d'un SDK provider.
|
||||||
|
|
||||||
|
Le package reprend le gabarit Tauri KSP courant avec cible lib + bin, launcher mince, splash commun, bridge `tracing`, frontend sous `frontend/`, bindings TS-RS applicatifs, ports stricts `1436/1437` et packaging Config/Schemas distribué. Le composite `cfg.composite.ksp-app-backfill-desk` couvre `mainnet`, `devnet` et `testnet`; Mainnet est le profil par défaut et la cohérence réseau Transport/Store est vérifiée avant ouverture d'une campagne. Les profils Store committed ont été réconciliés sur TLS PostgreSQL `disabled` pour l'environnement local de validation, sans retirer le support `verify_full` du Store/schema.
|
||||||
|
|
||||||
|
Le formulaire Backfill couvre `LatestAddress`, `BeforeAddress`, `AfterAddress` et `ExplicitSignatures`, les commitments `finalized`/`confirmed`, la sélection d'un rôle HTTP logique, `min_context_slot` lorsque le scope l'autorise, ainsi que les bornes publiques de page/candidats/concurrence. Le frontend ne choisit ni réseau physique, ni endpoint/provider, ni JobId; le réseau dérive de la composition Store et les validations finales restent déléguées à `BackfillRequest`.
|
||||||
|
|
||||||
|
Le Start réel conserve un seul run actif, génère le JobId côté backend et installe le handle de contrôle avant le spawn asynchrone. Le monitoring est latest-value : `BackfillSnapshotSource` est projeté via l'événement `ksp-backfill-status`, avec commande de resynchronisation explicite et rétention du dernier terminal. La projection expose lifecycle, phase, scope, boundary, compteurs de candidats/entités/observations, missing/conflicts/holes, concurrence maximale, frontier contiguë, présence de checkpoint et codes d'échec sûrs, sans adresse/signatures de campagne, payload RAW, URL, credential, contexte d'erreur arbitraire ni cursor/checkpoint concret.
|
||||||
|
|
||||||
|
Le Cancel est coopératif, ciblé par le JobId backend et idempotent : un Cancel IPC retardé ne peut pas annuler le run suivant, un terminal gagné reste autoritaire et la fermeture de l'application bloque d'abord les nouveaux Starts puis demande best-effort l'annulation avant le shutdown Store. Le Resume est limité à la session courante : la Desk conserve en mémoire Rust la requête terminale et le checkpoint opaque, `ksp-job-backfill-lib` valide puis réémet le checkpoint pour un nouveau JobId sans changer le scope fingerprint/frontier, et aucun checkpoint durable n'est promis après redémarrage.
|
||||||
|
|
||||||
|
Le polish final ajoute une saisie d'adresse libre avec autocomplete HTML `datalist` dérivé exclusivement de `ksp-core-lib::entries()`/`ProgramIdEntry`; aucune liste de Program IDs n'est hardcodée côté frontend et la sélection n'est jamais une allow-list. Les canaris de hardening figent ensuite l'inventaire des dépendances, modules, commandes Tauri, contrôles frontend, profils Config, frontières Store/provider, sécurité IPC et discipline crate-root des DTO partagés.
|
||||||
|
|
||||||
|
Le gate final passe `cargo fmt --all -- --check`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features`, arbres Cargo ciblés, `cargo tree --duplicates` et `cargo tauri build`. Le run workspace enregistre 1 494 tests passés, 0 échec et 15 tests ignorés explicitement opt-in/operator-only; le build produit les bundles Linux `.deb`, `.rpm` et `.AppImage`.
|
||||||
|
|
||||||
|
`prompts/027-V0_3_8_START_PROMPT.md` ouvre `0.3.8` sur `ksp-app-store-desk` V1. Le gabarit, le visuel, la structure Tauri et les dépendances npm de base viennent exclusivement des Desk KSP actuelles; DataTables reprend le pattern déjà utilisé par Config/Wallet Desk. L'archive kbot3 reste obligatoire uniquement comme référence fonctionnelle/UX des anciens diagnostics et tableaux RAW : aucune source, DTO, commande, SQL, gabarit ou version npm kbot3 n'est reprise. La première tranche doit auditer les capabilities Store existantes et les gaps éventuels de listing/diagnostic avant d'ouvrir des tableaux `RawTransaction`, `RawAccountState`, observations et rétention/tombstones via la seule façade `ksp-store-lib`.
|
||||||
|
|
||||||
|
## 0.3.6 — Job API + backfill RAW historique observable — 2026-09-01
|
||||||
|
|
||||||
|
`0.3.6` introduit `ksp-job-api` comme contrat passif et runtime-neutral pour les traitements bornés/terminables : identité `JobId`/`JobKindCode`, lifecycle explicite, annulation coopérative partagée, snapshots typés et notifications latest-value consommables par plusieurs listeners sans transformer l'API en scheduler, runtime Tokio ou bus d'événements. La surface reste Core-only et sert immédiatement au premier consumer concret `ksp-job-backfill-lib`.
|
||||||
|
|
||||||
|
`ksp-job-backfill-lib` ferme une première verticale historique `RawTransaction` sur les abstractions KSP existantes. Les scopes `LatestAddress`, `BeforeAddress`, `AfterAddress` et `ExplicitSignatures` sont bornés par pages/candidats/concurrence, dédupliqués de manière stable et liés à un fingerprint sémantique réseau/scope qui exclut volontairement provider, endpoint, rôle Transport et `JobId`. La découverte utilise les wrappers typés de `ksp-onchain-transport-lib`; l'hydratation passe exclusivement par `get_transaction_observed`, conserve la provenance du provider/endpoint gagnant et transforme la réponse en RAW v1 canonique avec hash SHA-256 exact, sans SDK provider ni modèle wire dupliqué.
|
||||||
|
|
||||||
|
La persistance passe uniquement par `ksp-store-lib` et son écriture atomique `RawTransaction + RawTransactionObservation` en mode normal. Les résultats distinguent insertion, présence idempotente, tombstone purgé, transaction manquante et conflit de contenu ; aucun read-before-write, `ForceRehydrate`, SQL, backend PostgreSQL direct ou décision de rétention n'est déplacé dans Job. Le réseau Store est vérifié avant écriture et l'identité logique de transaction reste `(RawNetworkId, Signature)` indépendamment de la source d'acquisition.
|
||||||
|
|
||||||
|
Le runtime Backfill ajoute concurrence bornée, réconciliation des terminaisons hors ordre, frontier strictement contiguë et checkpoint caller-owned. Les reprises `Latest`, `Before`, `After` et `ExplicitSignatures` ne sautent que le préfixe durable réellement prouvé. `BackfillJobRuntime` expose un `BackfillJobHandle` clonable avec annulation coopérative et `BackfillSnapshotSource` latest-value ; les opérations pré-Store peuvent être abandonnées, tandis qu'une persistance déjà soumise est toujours drainée avant la décision terminale. Les snapshots conservent phase, compteurs, holes/conflicts/missing, maximum in-flight, frontier/checkpoint et code d'échec sans projeter payload RAW, secret, URL ou état runtime interne.
|
||||||
|
|
||||||
|
Le hardening externe verrouille les codes d'erreur, bornes adversariales, redaction `Debug`, indépendance du fingerprint, absence de fuite Tokio/Futures/backend dans la façade, dépendances backend-neutral et discipline d'import/visibilité crate-root. La réconciliation documentaire finale ajoute les README/USAGE durables de Job API et Backfill et aligne les architectures Jobs/Workers/Apps sans transformer les guides d'utilisation en journal de release.
|
||||||
|
|
||||||
|
Les gates de clôture passent `cargo fmt --all -- --check`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace `--all-targets --all-features -- -D warnings`, `cargo test --workspace --all-targets --all-features`, tests ciblés Job/Backfill et graphes Cargo. Les smokes réseau/Tauri déjà explicitement opt-in restent ignorés par défaut ; aucune preuve non exécutée n'est déclarée PASS.
|
||||||
|
|
||||||
|
## 0.3.5 — Interface acquisition events partagés — 2026-08-31
|
||||||
|
|
||||||
|
`0.3.5` étend `ksp-interface-lib` avec deux familles passives d'acquisition réellement partagées, sans transformer Interface en runtime, en event bus ou en seconde couche RAW. `SlotLifecycleEvent` expose un `slot` et un `SlotLifecycleStage` non exhaustif limité à `Processed`, `FirstShredReceived`, `Completed`, `CreatedBank`, `Dead`, `OptimisticallyConfirmed` et `Rooted`. La normalisation conserve la sémantique commune : les notifications Solana `optimisticConfirmation` et Yellowstone `Confirmed` convergent vers `OptimisticallyConfirmed`, tandis que Solana `root` et Yellowstone `Finalized` convergent vers `Rooted`; les différences d'ordre, de complétude, de replay et de transport restent la responsabilité du producteur/Transport.
|
||||||
|
|
||||||
|
La seconde famille matérialise le fait minimal d'exécution transactionnelle partagé par plusieurs sources. `TransactionSignature` possède exactement 64 octets et un `Debug` redacted, `TransactionExecutionOutcome` distingue seulement `Succeeded` et `Failed`, et `TransactionExecutionEvent` transporte uniquement `slot + signature + outcome`. Les logs, erreurs provider détaillées, commitments, indexes, timestamps, filtres et payloads complets restent Transport-owned. Les snapshots HTTP `getSignatureStatuses`, les transitions one-shot `signatureSubscribe`, un éventuel `TransactionLogEvent`, les votes et les entrées Yellowstone ne sont pas fusionnés artificiellement dans ce contrat.
|
||||||
|
|
||||||
|
La frontière d'ownership reste stricte : les DTOs wire/provider demeurent dans `ksp-onchain-transport-lib`, les événements passifs provider-neutral sont Interface-owned, et les modèles persistants/replayables `RawTransaction` / `RawAccountState` restent `ksp-store-api`. `ksp-interface-lib` conserve exactement `ksp-core-lib` comme seule dépendance normale, sans feature propre, dev/build dependency, serde, codec, logging ou runtime. Les canaris publics, external consumer, inventaires exacts et hardening vérifient également qu'aucun second RAW, metadata source ou payload hostile n'entre dans la surface Interface.
|
||||||
|
|
||||||
|
Les gates de clôture passent audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, tests ciblés Interface/Program API, `cargo test --workspace` et graphes Cargo. `cargo tree -p ksp-interface-lib --edges normal` confirme le chemin `ksp-interface-lib -> ksp-core-lib -> solana-pubkey -> solana-address`; le graphe features ne montre aucune feature propre Interface et les doublons éventuels restent ceux du workspace global. La documentation durable a été réconciliée et `TransactionLogEvent` est conservé comme idée différée soumise à un nouveau gate consumer/bornes.
|
||||||
|
|
||||||
|
`prompts/025-V0_3_6_START_PROMPT.md` ouvre `0.3.6` sur le développement parallèle de `ksp-job-api` et `ksp-job-backfill-lib`. La release doit reprendre fonctionnellement le backfill historique kbot3 sans en copier le code : audit obligatoire de l'archive historique, `RawTransaction` par adresse via `getSignaturesForAddress` + `getTransaction`, directions/anchors et déduplication, hydratation/persistence via les façades KSP, frontier/checkpoint/reprise, cancellation/concurrency bornées, idempotence et distinctions missing/conflit. `ksp-job-api` doit en parallèle stabiliser lifecycle/progress/outcome et un contrat de notifications/listeners borné pour qu'une couche supérieure puisse visualiser l'état sans parser les logs. Le ROADMAP enchaîne ensuite l'app de backfill/inspection `0.3.7`, `ksp-worker-api` `0.3.8`, `ksp-worker-live-transactions-retriever-lib` `0.3.9` puis une application de monitoring/visualisation Jobs + Workers `0.3.10`, avec reprise du même pattern d'observabilité côté Worker sans fusionner les sémantiques Job/Worker/Store.
|
||||||
|
|
||||||
|
## 0.3.4 — Store/PostgreSQL RawAccountState + complétude RAW — 2026-08-31
|
||||||
|
|
||||||
|
`0.3.4` complète la seconde vertical slice RAW physique sur le couple `ksp-store-lib` / `ksp-store-postgres-lib` et ferme la conformance PostgreSQL des **10 capabilities** backend-agnostic de `ksp-store-api` : les six capabilities `RawTransaction*` acquises en `0.3.3` restent intactes et les quatre capabilities `RawAccountStateRead`, `RawAccountStateWrite`, `RawAccountObservationRead` et `RawAccountObservationWrite` sont désormais implémentées par `PostgresBackend` puis dispatchées par la façade `Store`. La séparation reste stricte : les consommateurs ordinaires passent par `ksp-store-lib`, le backend PostgreSQL conserve SQL/driver/pool/TLS/migrations privés, et la façade reste compilable/testable sans backend via `--no-default-features`.
|
||||||
|
|
||||||
|
La migration additive V002 introduit `ksp_raw_account_states` et `ksp_raw_account_observations` au-dessus de V000/V001 sans modifier leurs bytes. L'identité canonique account est `(pubkey, slot, state_hash)` : plusieurs états d'un même compte dans un même slot restent représentables lorsque `state_hash` diffère, tandis qu'une collision sur la référence complète déclenche une comparaison exacte de `lamports`, `owner`, `executable`, `rent_epoch` et `data` avant de conclure à l'idempotence ou à `store_api.raw_conflict`. Les `u64` physiques utilisent `NUMERIC(20,0)`, les clés/hashes/signatures utilisent des `BYTEA` de largeur contrainte, et les bytes account restent complets jusqu'à la borne KSP de 16 MiB. V002 contient exactement 32 ressources gérées et son checksum final est `ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e`; V000 et V001 restent respectivement `d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450` et `31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51`.
|
||||||
|
|
||||||
|
L'acquisition `RawAccountState + RawAccountObservation` est transactionnelle : `INSERT ... ON CONFLICT DO NOTHING`, lecture/verrouillage du canonical en collision, comparaison exacte et rollback complet lorsque l'observation diverge. Une observation supplémentaire vérifie la référence existante sous transaction et ne crée jamais implicitement son state. Les métadonnées Yellowstone `is_startup`, `transaction_signature` et `write_version` restent optionnelles et observation-only ; aucune FK transaction n'est inventée. La navigation account utilise une keyset `(slot, pubkey, state_hash)` ASC/DESC, avec filtre pubkey optionnel et cursor KSPA V1 opaque de 109 octets lié au réseau, au filtre, au range, à la direction et à la famille afin d'empêcher les replays cross-query/cross-family. Aucun `OFFSET`, plafond métier de batch, index owner/provider/time ou lifecycle destructif account n'est introduit.
|
||||||
|
|
||||||
|
Les gates de clôture valident audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, tests ciblés Store/API/PostgreSQL/Config, `cargo test --workspace`, façade sans feature PostgreSQL et graphes Cargo. Le live `postgres_raw_account_live` passe sur **PostgreSQL 17** avec bootstrap V000/V001/V002, round-trip des bytes et du domaine `u64`, idempotence/conflit, états distincts même pubkey+slot, observations/metadata optionnelles, pagination/cursors, concurrence, annulation/rollback et coexistence `RawTransaction`. La réconciliation documentaire finale est ensuite validée sans réouvrir code ni migrations.
|
||||||
|
|
||||||
|
`prompts/024-V0_3_5_START_PROMPT.md` ouvre `0.3.5` sur `ksp-interface-lib` uniquement. Cette release doit réauditer les surfaces d'acquisition actuelles et matérialiser seulement les modèles passifs/event-only réellement partagés, sans recopier `RawTransaction`/`RawAccountState`, sans créer un event bus et sans déplacer les DTOs provider/transport. Les candidats logs, slot/root/slotsUpdates, transaction status et vote sont traités par matrice sémantique ; une famille reste reportée si la convergence ou le consumer réel n'est pas démontré. L'archive historique kbot3 n'est pas requise pour ce gate : les sources de vérité sont la base KSP stable et les contrats officiels actuels des transports concernés.
|
||||||
|
|
||||||
|
## 0.3.3 — Store/PostgreSQL RawTransaction vertical slice — 2026-08-30
|
||||||
|
|
||||||
|
`0.3.3` complète la première vertical slice RAW physique sur le couple `ksp-store-lib` / `ksp-store-postgres-lib` sans modifier les contrats backend-agnostic acquis dans `ksp-store-api`. `PostgresBackend` et la façade `Store` implémentent désormais les six capabilities `RawTransactionRead`, `RawTransactionWrite`, `RawTransactionObservationRead`, `RawTransactionObservationWrite`, `RawTransactionRetentionRead` et `RawTransactionRetentionWrite`. Une base PostgreSQL reste liée à un unique `RawNetworkId` par `ksp_store_identity`; le mauvais réseau est refusé avant I/O, les slots `u64` sont conservés exactement en `NUMERIC(20,0)`, et la migration logique V001 reste découpée en ressources tables/contraintes/indexes avec vérification de compatibilité du schéma effectif.
|
||||||
|
|
||||||
|
La persistence canonique utilise une transaction PostgreSQL unique pour `RawTransaction + observation`, tente l'insert sous contrainte d'unicité puis compare le contenu réel sous verrou en cas de collision : contenu identique -> idempotence, divergence -> `store_api.raw_conflict`. Les observations supplémentaires conservent leur provenance complète et ne créent jamais implicitement un canonical absent. La lecture reconstruit `Full` depuis le payload chaud et `Archived` depuis la relation archive ; `Purged` reste un tombstone minimal. La navigation est une keyset stricte `(slot, signature)` ASC/DESC sans `OFFSET`, avec cursor V1 opaque de 109 octets lié au réseau, à la direction, aux bornes et à la dernière clé. Aucun plafond métier de batch n'est introduit : seule la borne physique PostgreSQL de `LIMIT requested + 1` est appliquée.
|
||||||
|
|
||||||
|
La rétention physique supportée est `Full -> Archived -> Purged`, sérialisée par `FOR UPDATE`, avec outcomes `Applied`, `AlreadyAtTarget` et `ExpectedStateMismatch`. Le mode normal respecte le tombstone après purge ; `ForceRehydrate` reste explicite et atomique. `Compacted` demeure volontairement non supporté physiquement et retourne `store.postgres_retention_compaction_unsupported` plutôt que de présenter TOAST ou une compression implicite comme contrat KSP. Les erreurs backend sont réduites à des classes/phases statiques puis projetées vers des codes Store/API stables, sans URI, SQL, SQLSTATE, bind ni texte serveur.
|
||||||
|
|
||||||
|
La preuve PostgreSQL réelle a conduit à durcir l'introspection de schéma PostgreSQL 17 : canonicalisation ciblée des CHECK numériques reconstruits par le catalogue, conservation des littéraux texte, restauration des helpers de classification de schéma et distinction d'un drift d'une migration déjà enregistrée lorsque `schema_autoupdate=false`. Les ressources SQL V000/V001 et leurs checksums sont restés inchangés pendant ces corrections (`V000 d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450`, `V001 31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51`).
|
||||||
|
|
||||||
|
Le gate technique final `pre.011` passe audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, les tests ciblés Store/API/PostgreSQL/Config, les tests et checks façade avec `--no-default-features`, `cargo test --workspace` et les graphes Cargo. Le live `postgres_raw_transaction_live` est ensuite rejoué avec succès sur **PostgreSQL 17**, couvrant bootstrap/drift-repair, atomicité, concurrence identique/divergente, rollback sur collision et annulation, pagination/cursor, rétention/races, ForceRehydrate et réouverture durable. Aucun build Tauri supplémentaire n'est requis : `0.3.3` ne change ni resources applicatives ni packaging desktop. `RawAccountState` PostgreSQL et la complétude RAW restent réservés à `0.3.4`. `prompts/023-V0_3_4_START_PROMPT.md` ouvre cette slice suivante sur les quatre capabilities `RawAccount*`, une migration additive au-dessus de V000/V001, puis la conformance finale des dix capabilities RAW ; l'archive historique kbot3 y reste une source de comparaison ciblée account/observation, jamais une architecture à recopier.
|
||||||
|
|
||||||
|
## 0.3.2 — Store/PostgreSQL runtime foundation — 2026-08-30
|
||||||
|
|
||||||
|
`0.3.2` introduit ensemble `ksp-store-lib` et `ksp-store-postgres-lib` comme fondation runtime/backend PostgreSQL au-dessus des contrats backend-agnostic de `ksp-store-api`, sans encore implémenter de capability RAW métier. La façade `Store` conserve un backend connu `Postgres` activé par défaut via Cargo feature, reste compilable avec `--no-default-features`, lie chaque instance à exactement un `RawNetworkId` et n'expose aucun `Pool`, `Client`, `Row`, `Statement`, driver ou SQL physique. `ksp-config-lib` possède désormais `std.store` avec trois targets nommés `devnet`, `mainnet` et `testnet`, chacun associé à un réseau explicite et à une URI PostgreSQL Secret indépendante ; Store/backend ne lisent directement ni `.env`, ni `KSP_*`, ni `PG*`, ni `.pgpass`.
|
||||||
|
|
||||||
|
Le backend de référence utilise `tokio-postgres 0.7.18`, `deadpool-postgres 0.14.2`, `tokio-postgres-rustls 0.14.0`, Rustls 0.23 avec roots système/AWS-LC et des modes TLS KSP limités à `Disabled` et `VerifyFull`. Les URI sont parsées puis normalisées sous la policy typée KSP, le pool et les deadlines connect/wait/create/recycle/shutdown sont bornés, et une ouverture réussie exige une connexion physique puis un bootstrap valide. Le moteur de migrations privé commence par `V000__bootstrap.sql`, enregistre version/nom/SHA-256 dans `ksp_store_schema_migrations`, sérialise les runners par advisory transaction lock borné et refuse checksum mismatch ou schema plus récent sans down automatique. La façade fournit également `runtime_snapshot()` et `health().await` avec une projection portable/redacted de readiness et des compteurs pool sûrs.
|
||||||
|
|
||||||
|
Les canaris de hardening verrouillent 84 exports crate-root côté façade, la frontière backend/Config, l'absence de bypass environnement, la redaction des URI/erreurs, la matrice de features et l'absence de persistence `RawTransaction`/`RawAccountState`. Le gate technique final a été rejoué après `cargo clean` : audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés de toutes les crates, `cargo test --workspace`, graphes Cargo et les trois builds Tauri Linux passent. Le smoke PostgreSQL réel passe sur **PostgreSQL 17** et prouve bootstrap initial/idempotent/concurrent, mismatch/recovery checksum, rollback transactionnel, health `Ready` et fermeture bornée ; la policy de fondation reste PostgreSQL >= 15 sans maximum KSP artificiel.
|
||||||
|
|
||||||
|
`prompts/022-V0_3_3_START_PROMPT.md` ouvre ensuite uniquement la vertical slice PostgreSQL `RawTransaction` complète sur les mêmes crates et la même fondation : six capabilities transaction/observation/rétention, atomicité, idempotence/conflit, get/list cursorisé, tombstone et `ForceRehydrate`. `RawAccountState` PostgreSQL et la complétude RAW restent réservés à `0.3.4`. L'archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` reste obligatoire au `pre.001` de `0.3.3`, mais seulement pour réauditer l'ancien schéma/repository RAW transaction comme source d'héritage, jamais comme autorité KSP.
|
||||||
|
|
||||||
|
## 0.3.1 — Store API RAW foundation — 2026-08-29
|
||||||
|
|
||||||
|
`0.3.1` introduit `ksp-store-api` comme contrat backend-agnostic de persistence N1 RAW, sans runtime Store ni backend physique. La release stabilise deux familles réellement convergentes : `RawTransaction` avec payload canonique opaque/versionné, identité réseau+signature et observations d’acquisition séparées, puis `RawAccountState` avec bytes complets, identité réseau+pubkey+slot+hash et observations pouvant conserver les enrichissements Yellowstone sans les confondre avec l’état canonique. `TransactionStatusObservation` reste reporté faute de convergence sémantique suffisante entre snapshot HTTP, transition WebSocket et update Yellowstone ; `logsSubscribe`, slot/root/slotsUpdates et vote restent event-only candidats, `RawBlock` reste une idée conditionnelle et Yellowstone `Entry` reste rejeté de la taxonomie active.
|
||||||
|
|
||||||
|
La façade publique conserve un modèle objet sans SQL ni rows backend, des primitives de provenance/hash/timestamps bornées, des queries cursorisées sans plafond métier KSP arbitraire, des outcomes d’idempotence/conflit, dix capabilities fines object-safe et un lifecycle logique de rétention transactionnelle `Full -> Compacted -> Archived -> Purged`. Le tombstone minimal empêche le rebackfill normal après purge tandis que `ForceRehydrate` reste une intention distincte ; une course de compare-and-transition est représentée par `ExpectedStateMismatch` plutôt que par un overwrite silencieux. Le backlog, le batch-size, la priorité, la policy de processing, la compression/archive physique, les notifications runtime et les couches STRUCTURAL/DECODED/DOMAIN restent hors Store API. Le graphe normal final de `ksp-store-api` reste strictement limité à `ksp-core-lib`; aucun PostgreSQL, Tokio, serde, Config, Transport, Program, Logging ou backend concret n’entre dans la crate.
|
||||||
|
|
||||||
|
Les canaris de clôture verrouillent 60 exports crate-root, 10 capabilities, l’inventaire exact des modules RAW, l’implémentabilité par un backend externe, les bornes adversariales, la redaction des `Debug`, la frontière Interface/Store et l’absence de surface N2/N3/N4. Le gate technique de référence a été exécuté après `cargo clean` et passe audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés des crates, `cargo test --workspace`, builds Tauri des trois Desk et graphes Cargo ; les gates documentaires suivants restent également verts. Le redécoupage final prépare trois releases Store/PostgreSQL où `ksp-store-lib` et `ksp-store-postgres-lib` avancent toujours ensemble : `0.3.2` pour la fondation runtime/backend, `0.3.3` pour la vertical slice `RawTransaction`, puis `0.3.4` pour `RawAccountState` et la complétude RAW. `prompts/021-V0_3_2_START_PROMPT.md` ouvre donc uniquement la fondation conjointe Store/PostgreSQL, avec `tokio-postgres` comme driver retenu mais pooling, TLS, migrations et Config à réauditer avant implémentation lourde.
|
||||||
|
|
||||||
|
## 0.2.14 — Program API foundation — 2026-08-28
|
||||||
|
|
||||||
|
`0.2.14` introduit `ksp-program-api` comme première API publique extensible du domaine Program, volontairement limitée au décodage d’instructions et indépendante des runtimes supérieurs. La façade réexporte les contrats Core/Interface nécessaires puis possède `ProgramInstructionRecognition` (`NoMatch`, `ProgramMatch`, `ExactMatch`), `ProgramInstructionDecodeOutcome<Decoded>` (`Decoded`, `Unsupported`) et le trait `ProgramInstructionDecoder: Send + Sync`. L’output `Decoded` reste possédé par l’implémentation et ne reçoit aucun bound implicite `Debug`, `Clone`, `Send` ou `Sync`; les erreurs réelles restent dans le `Result` Core. Les Program IDs sont des `Pubkey` opaques : une implémentation externe peut prendre en charge un programme absent du registry Core sans enum centrale fermée, `Any`, JSON, descriptor global ni registry runtime.
|
||||||
|
|
||||||
|
Le graphe normal final reste strictement `ksp-program-api -> ksp-core-lib + ksp-interface-lib`, Interface dépendant elle-même de Core. Aucun `ksp-program-lib`, codec, serde, logging, réseau, filesystem, environnement, Store, Materializer, Wallet, Config ou Tauri n’entre dans cette foundation. Les canaris public API, implémentation externe, dependency firewall, release completeness et hardening couvrent notamment l’inventaire exact de dix exports crate-root, les trois modules de production, un Program Pubkey non enregistré, l’input Interface maximal de 255 accounts / 10 240 bytes, l’absence d’echo automatique d’un payload hostile et l’absence de claim `dyn` hétérogène. `pre.005-fix.001` corrige uniquement un faux positif cross-crate du scanner Logging provoqué par le motif de test recherché, sans changement fonctionnel. Les gates `pre.006` et `pre.007` passent ensuite audits Rust/Markdown, `cargo check`, Clippy, les 18 tests Program API, ownership Logging et le workspace complet avant la réconciliation documentaire finale.
|
||||||
|
|
||||||
|
`prompts/020-V0_3_1_START_PROMPT.md` ouvre `0.3.1 — Store RAW foundation` exclusivement depuis le tag stable `v0.2.14`. Le gate `pre.001` exige également l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` afin d’auditer l’ancien `ks-store`, ses migrations PostgreSQL et ses contrats N1/N2/N3 sous une matrice `REPRENDRE / REDESSINER / REPORTER / REJETER`. L’archive reste une source historique uniquement : `0.3.1` doit créer `ksp-store-api` et `ksp-store-lib` avec PostgreSQL de référence et persistence **RAW seulement**, sans aspirer les contrats CORE/DECODE/SPECIALIZED, les jobs/workers, l’Interface `0.3.2` ni la configuration produit.
|
||||||
|
|
||||||
## 0.2.13 — Interface / wire foundation — 2026-08-28
|
## 0.2.13 — Interface / wire foundation — 2026-08-28
|
||||||
|
|
||||||
`0.2.13` introduit `ksp-interface-lib` comme première façade wire officielle KSP, volontairement passive et Program-facing. La surface stable réexporte le `Pubkey` canonique de Core, ajoute `ProgramAccountMeta` et `ProgramInstruction` à champs privés avec accessors explicites, conserve l’ordre et les doublons des account metas, accepte les Program Pubkeys opaques et borne l’admission à **255 account metas** et **10 240 octets** de data. Les deux bornes sont des limites d’admission Interface et ne prétendent pas garantir à elles seules le fit d’une transaction Solana top-level. Les erreurs réutilisent le contrat Core `Error/Result` avec uniquement `field`, `actual_len` et `maximum_len`, tandis que le `Debug` de l’instruction n’expose que `program_id`, `account_count` et `data_len`.
|
`0.2.13` introduit `ksp-interface-lib` comme première façade wire officielle KSP, volontairement passive et Program-facing. La surface stable réexporte le `Pubkey` canonique de Core, ajoute `ProgramAccountMeta` et `ProgramInstruction` à champs privés avec accessors explicites, conserve l’ordre et les doublons des account metas, accepte les Program Pubkeys opaques et borne l’admission à **255 account metas** et **10 240 octets** de data. Les deux bornes sont des limites d’admission Interface et ne prétendent pas garantir à elles seules le fit d’une transaction Solana top-level. Les erreurs réutilisent le contrat Core `Error/Result` avec uniquement `field`, `actual_len` et `maximum_len`, tandis que le `Debug` de l’instruction n’expose que `program_id`, `account_count` et `data_len`.
|
||||||
|
|||||||
18
Cargo.toml
18
Cargo.toml
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 312
|
# version: 637
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-raw-transaction-ingest-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-raw-transaction-lib", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib", "crates/ksp-worker-api", "crates/ksp-worker-raw-transaction-ingest-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.13"
|
version = "0.3.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
@@ -14,20 +14,24 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
argon2 = { version = "^0.5", default-features = false }
|
argon2 = { version = "^0.6", default-features = false }
|
||||||
base64 = { version = "^0.23" }
|
base64 = { version = "^0.23" }
|
||||||
chacha20poly1305 = { version = "^0.11", default-features = false }
|
chacha20poly1305 = { version = "^0.11", default-features = false }
|
||||||
chrono = { version = "^0.4", default-features = false }
|
chrono = { version = "^0.4", default-features = false }
|
||||||
|
deadpool-postgres = { version = "^0.14", default-features = false }
|
||||||
directories = { version = "^6.0" }
|
directories = { version = "^6.0" }
|
||||||
ed25519-dalek = { version = "^3.0", default-features = false }
|
ed25519-dalek = { version = "^3.0", default-features = false }
|
||||||
fs2 = { version = "^0.4" }
|
fs2 = { version = "^0.4" }
|
||||||
futures-util = { version = "^0.3", default-features = false }
|
futures-util = { version = "^0.3", default-features = false }
|
||||||
getrandom = { version = "^0.4", default-features = false }
|
getrandom = { version = "^0.4", default-features = false }
|
||||||
http = { version = "^1.5", default-features = false }
|
http = { version = "^1.5", default-features = false }
|
||||||
jsonschema = { version = "^0.51", default-features = false }
|
jsonschema = { version = "^0.53", default-features = false }
|
||||||
reqwest = { version = "^0.13", default-features = false }
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
|
rustls = { version = "^0.23", default-features = false }
|
||||||
|
rustls-native-certs = { version = "^0.8", default-features = false }
|
||||||
serde = { version = "^1.0" }
|
serde = { version = "^1.0" }
|
||||||
serde_json = { version = "^1.0" }
|
serde_json = { version = "^1.0" }
|
||||||
|
sha2 = { version = "^0.11", default-features = false }
|
||||||
solana-keypair = { version = "^3.1", default-features = false }
|
solana-keypair = { version = "^3.1", default-features = false }
|
||||||
solana-pubkey = { version = "^4.3", default-features = false }
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
tauri = { version = "^2.11" }
|
tauri = { version = "^2.11" }
|
||||||
@@ -39,11 +43,13 @@ tracing = { version = "^0.1", default-features = false }
|
|||||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||||
tracing-appender = { version = "^0.2", default-features = false }
|
tracing-appender = { version = "^0.2", default-features = false }
|
||||||
tokio = { version = "^1.53", default-features = false }
|
tokio = { version = "^1.53", default-features = false }
|
||||||
|
tokio-postgres = { version = "^0.7", default-features = false }
|
||||||
|
tokio-postgres-rustls = { version = "^0.14", default-features = false }
|
||||||
tokio-tungstenite = { version = "^0.30", default-features = false }
|
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||||
tonic = { version = "^0.14", default-features = false }
|
tonic = { version = "^0.14", default-features = false }
|
||||||
tonic-prost = { version = "^0.14", default-features = false }
|
tonic-prost = { version = "^0.14", default-features = false }
|
||||||
ts-rs = { version = "^12.0" }
|
ts-rs = { version = "^12.0" }
|
||||||
yellowstone-grpc-proto = { version = "^12.6", default-features = false }
|
yellowstone-grpc-proto = { version = "^12.7", default-features = false }
|
||||||
zeroize = { version = "^1.9" }
|
zeroize = { version = "^1.9" }
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: README.md -->
|
<!-- file: README.md -->
|
||||||
<!-- version: 7 -->
|
<!-- version: 15 -->
|
||||||
|
|
||||||
# Khadhroony Solana Project
|
# Khadhroony Solana Project
|
||||||
|
|
||||||
@@ -47,6 +47,20 @@ Les besoins du trading constituent une priorité produit à court terme mais ne
|
|||||||
- Aucun `rust-toolchain.toml` n'est utilisé.
|
- Aucun `rust-toolchain.toml` n'est utilisé.
|
||||||
- Les environnements et contraintes des démonstrations doivent être visibles dans leur nomenclature lorsqu'ils ne sont pas sélectionnables.
|
- Les environnements et contraintes des démonstrations doivent être visibles dans leur nomenclature lorsqu'ils ne sont pas sélectionnables.
|
||||||
|
|
||||||
|
## Fondations opérationnelles actuelles
|
||||||
|
|
||||||
|
La couche RAW dispose d'une façade Store backend-neutral et d'un premier job historique concret. `ksp-job-api` porte les contrats passifs communs des jobs bornés ; `ksp-job-backfill-lib` compose Transport observé et Store pour découvrir, hydrater et persister des transactions historiques RAW avec concurrence bornée, checkpoint contigu caller-owned, annulation coopérative et snapshots latest-value.
|
||||||
|
|
||||||
|
`ksp-app-backfill-desk` fournit la surface desktop spécialisée de composition de ce runtime : sélection d'un scope historique et d'un rôle HTTP logique, validation backend des bornes, Start single-run, monitoring latest-value, Cancel ciblé et Resume in-session sur checkpoint Rust-only. L'application ouvre Transport et Store via leurs façades KSP, ne dépend d'aucun backend Store concret et n'expose ni URL/credential, ni payload RAW, ni checkpoint opaque au frontend.
|
||||||
|
|
||||||
|
`ksp-app-store-desk` fournit l'inspection desktop read-only du Store RAW via `ksp-store-lib` : health/runtime backend-neutral, tables server-side `RawTransaction` et `RawAccountState`, observations associées, détails avec previews bornées et lecture des états de rétention/tombstones. DataTables possède l'unique pagination visible de l'inspection random-access ; la pagination cursor/keyset des consumers machine reste distincte et intacte.
|
||||||
|
|
||||||
|
`ksp-worker-api` fournit désormais la fondation générique des services continus : identité Worker, lifecycle borné, health/activity, intention de stop coopératif et observation latest-value. Cette API reste Core-only, runtime-neutral et sans connaissance Solana, Transport, Store ou Job. Elle ne démarre ni n'arrête elle-même un runtime concret.
|
||||||
|
|
||||||
|
`ksp-worker-raw-transaction-ingest-lib` est le premier Worker concret distinct du Job Backfill. Il conserve sa fondation Start/Stop, admission bornée, Common RAW, Store backend-neutral, shutdown borné et snapshots latest-value. Ses cinq familles productives couvrent Yellowstone, Standard WS `logsSubscribe`, Standard WS `blockSubscribe`, Helius `transactionSubscribe` et Solana HTTP live block polling. Les signaux transactionnels Yellowstone/Standard Logs/Helius utilisent l'hydration HTTP `getTransaction`, tandis que le mode Yellowstone Block utilisé par la route Mainnet du Desk emploie le bloc gRPC comme trigger puis réconcilie le slot par HTTP `getBlock` Full/Base64 avec concurrence bornée. Standard Block et HTTP Block Polling qualifient directement leurs transactions Legacy/V0/V1 vers Common RAW. Les queues restent bornées ; une saturation durable de la livraison Yellowstone applique désormais une backpressure asynchrone au stream gRPC au lieu de produire un overflow local. Le Store accepte aussi l'exception étroite déjà prouvée où un canonique complet reçoit ensuite une observation identique hors `logMessages` explicitement tronqués ; le canonique complet est conservé et les autres divergences restent fail-closed en `0.3.15`. Le reconnect/replay reste propriétaire de Transport, la frontier de traitement reste run-local et un gap de rétention prouvé provoque un fault au lieu de lancer une campagne historique. `ksp-job-backfill-lib` conserve son rôle historique paramétré et borné ; les deux producteurs restent indépendants et convergent uniquement vers les mêmes contrats RAW/Store.
|
||||||
|
|
||||||
|
`ksp-app-raw-transaction-ingest-desk` compose et supervise ces routes live sans déplacer leurs responsabilités dans l'UI. Les profils applicatifs sont réseau-centriques, plusieurs routes d'un même réseau peuvent partager un Store tout en gardant des Workers indépendants, et le monitoring latest-value expose lifecycle, health, activité, persistence, reconnect/replay, continuité, gaps et repair sans URL, secret, payload RAW ou handle physique. Le gate `0.3.15` valide notamment le fonctionnement parallèle Mainnet de Yellowstone Block + HTTP `getBlock` et HTTP Block Polling, la fermeture coopérative de Yellowstone après half-close et le build Tauri Linux de la Desk.
|
||||||
|
|
||||||
## Points d'entrée
|
## Points d'entrée
|
||||||
|
|
||||||
- [`RULES.md`](RULES.md) — index des règles normatives ;
|
- [`RULES.md`](RULES.md) — index des règles normatives ;
|
||||||
|
|||||||
84
ROADMAP.md
84
ROADMAP.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 91 -->
|
<!-- version: 114 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
|
|
||||||
### Cadrage
|
### Cadrage
|
||||||
|
|
||||||
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/CORE/DECODE/SPECIALIZED stabilisés.
|
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/STRUCTURAL/DECODED/DOMAIN stabilisés.
|
||||||
- [X] `0.2.1` — HTTP foundation stable : matrice 52+14, runtime/routing/résilience/exécution HTTP, 4 canaris typés, `std.transport`, adapter Config -> Transport, canaries de clôture, smoke Devnet opt-in et documentation durable validés ; les 48 wrappers typés restants sont reportés à `0.2.2`–`0.2.4`.
|
- [X] `0.2.1` — HTTP foundation stable : matrice 52+14, runtime/routing/résilience/exécution HTTP, 4 canaris typés, `std.transport`, adapter Config -> Transport, canaries de clôture, smoke Devnet opt-in et documentation durable validés ; les 48 wrappers typés restants sont reportés à `0.2.2`–`0.2.4`.
|
||||||
|
|
||||||
### Releases fonctionnelles décidées/pressenties
|
### Releases fonctionnelles décidées/pressenties
|
||||||
@@ -58,7 +58,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
- [X] `0.2.11` — Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique n’est introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
|
- [X] `0.2.11` — Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique n’est introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
|
||||||
- [X] `0.2.12` — SOL Prices Desk + projection prix Wallet stables : HID provider-neutral avec refresh row/selected/all et batch `1..=64`, observations/timestamps exacts sans polling/consensus, puis refresh balance Wallet enrichi d’une moyenne SOL/USD consumer-owned et d’un équivalent USD exact best-effort ; smoke live de composition, workspace complet et bundles Tauri Linux validés.
|
- [X] `0.2.12` — SOL Prices Desk + projection prix Wallet stables : HID provider-neutral avec refresh row/selected/all et batch `1..=64`, observations/timestamps exacts sans polling/consensus, puis refresh balance Wallet enrichi d’une moyenne SOL/USD consumer-owned et d’un équivalent USD exact best-effort ; smoke live de composition, workspace complet et bundles Tauri Linux validés.
|
||||||
- [X] `0.2.13` — Interface / wire foundation stable : `ksp-interface-lib` expose `Pubkey`, `ProgramAccountMeta` et `ProgramInstruction` passifs, admission bornée à 255 account metas / 10 240 bytes, erreurs et `Debug` sans payload hostile, façade crate-root et consumer externe canaris ; graphe strict `Interface -> Core`, sans serde/codec générique, `solana-instruction`, réseau ni logging runtime.
|
- [X] `0.2.13` — Interface / wire foundation stable : `ksp-interface-lib` expose `Pubkey`, `ProgramAccountMeta` et `ProgramInstruction` passifs, admission bornée à 255 account metas / 10 240 bytes, erreurs et `Debug` sans payload hostile, façade crate-root et consumer externe canaris ; graphe strict `Interface -> Core`, sans serde/codec générique, `solana-instruction`, réseau ni logging runtime.
|
||||||
- [ ] `0.2.14` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet ; `pre.001` doit auditer la base stable `v0.2.13` et l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip`, puis classer reconnaissance/decode/outcomes/proofs/préparation sous `REPRENDRE / REDESSINER / REPORTER / REJETER` avant de figer la surface.
|
- [X] `0.2.14` — Program API foundation stable : `ksp-program-api` expose une surface instruction-only ouverte avec `ProgramInstructionRecognition`, `ProgramInstructionDecodeOutcome<Decoded>` et `ProgramInstructionDecoder`; implémentation externe avec Program Pubkey opaque validée, 10 exports crate-root / 3 modules de production verrouillés, graphe strict Core + Interface, sans registry/runtime/serde/codec/Store/Materializer/Execution. Le prompt `0.3.1` prépare Store RAW-only.
|
||||||
|
|
||||||
### TODO/IDEAS — providers Yellowstone non planifiés
|
### TODO/IDEAS — providers Yellowstone non planifiés
|
||||||
|
|
||||||
@@ -80,33 +80,73 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
|
|
||||||
## Architecture de données — progression canonique
|
## Architecture de données — progression canonique
|
||||||
|
|
||||||
La chaîne durable cible est :
|
La progression durable cible est :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
RAW -> CORE -> DECODE -> SPECIALIZED
|
RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
||||||
```
|
```
|
||||||
|
|
||||||
- **RAW** et **CORE** ne nécessitent aucun décodage Program.
|
- **RAW** et **STRUCTURAL** ne nécessitent aucun décodage Program.
|
||||||
- À la fin de chaque couche horizontale RAW/CORE, ajouter les jobs/workers/apps nécessaires pour la rendre réellement exploitable avant d'ouvrir la couche suivante.
|
- La progression n'est pas une chaîne obligatoire pour chaque famille : une donnée event-only peut s'arrêter en D1, et un état de compte pourra aller directement vers un futur decoder si aucune décomposition STRUCTURAL utile n'existe.
|
||||||
- À partir de **DECODE**, avancer verticalement groupe par groupe : wire -> decode -> matérialisation -> projection spécialisée si utile -> préparation d'exécution -> policy -> execution -> scénarios Devnet.
|
- À la fin des couches horizontales RAW/STRUCTURAL réellement persistées, ajouter les jobs/workers/apps nécessaires avant d'ouvrir la couche suivante.
|
||||||
|
- À partir de **DECODED**, avancer verticalement groupe par groupe ; **DOMAIN** désigne les projections métier et la matérialisation est le processus qui les produit.
|
||||||
|
|
||||||
## 0.3.x — RAW / acquisition persistée
|
## 0.3.x — RAW / acquisition persistée
|
||||||
|
|
||||||
- [ ] `0.3.1` — Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
|
- [X] `0.3.1` — `ksp-store-api` stable : modèles D1 RAW backend-agnostic `RawTransaction` et `RawAccountState` avec observations, provenance, payload/hash/timestamps bornés, 10 capabilities object-safe, queries cursorisées sans plafond métier arbitraire, outcomes idempotence/conflit et lifecycle logique rétention/tombstone/force-rehydrate ; aucun backend physique, Config, runtime Store, notification dédiée ni surface STRUCTURAL/DECODED/DOMAIN.
|
||||||
- [ ] `0.3.2` — Étendre `ksp-interface-lib` avec les wires génériques nécessaires aux acquisitions et à la future normalisation CORE.
|
- [X] `0.3.2` — `ksp-store-lib` + `ksp-store-postgres-lib` stables comme fondation runtime/backend PostgreSQL : feature `postgres` par défaut, `Store` lié à un unique `RawNetworkId`, Config `std.store` avec targets/bases `devnet`/`mainnet`/`testnet`, pool Deadpool borné, `tokio-postgres`, TLS Rustls `Disabled`/`VerifyFull`, moteur de migrations privé `V000` + SHA-256/advisory lock, health/readiness portable et close borné. Gate complet + PostgreSQL réel major 17 verts ; aucune table/capability `RawTransaction`/`RawAccountState` métier n'est encore ajoutée.
|
||||||
- [ ] `0.3.3` — Introduire `ksp-job-api` et un job de backfill historique concret.
|
- [X] `0.3.3` — Vertical slice PostgreSQL `RawTransaction` complète sur `ksp-store-lib` + `ksp-store-postgres-lib` : six capabilities transaction/observation/rétention, V001 physique liée à un réseau, acquisition canonical+observation atomique, idempotence/conflit, get/list keyset cursorisé, archive/purge/tombstone/ForceRehydrate, hardening des erreurs et du schéma, concurrence et rollback validés sur PostgreSQL 17.
|
||||||
- [ ] `0.3.4` — Introduire une application spécialisée de backfill/inspection RAW.
|
- [X] `0.3.4` — Vertical slice PostgreSQL `RawAccountState` complète sur `ksp-store-lib` + `ksp-store-postgres-lib` : quatre capabilities account ajoutées aux six transaction pour une conformance RAW 10/10, V002 additive de 32 ressources au-dessus de V000/V001 immuables, state+observation atomiques, idempotence/conflit exacts, metadata Yellowstone observation-only, get/list keyset `(slot,pubkey,state_hash)` avec cursor KSPA anti-replay, hardening cross-family et live validé sur PostgreSQL 17 sans rétention destructive account.
|
||||||
- [ ] Compléter ensuite la couche RAW avec le worker/service live, son contrôle et les outils d'exploitation réellement nécessaires avant de passer à CORE.
|
- [X] `0.3.5` — `ksp-interface-lib` étendu avec deux familles passives réellement partagées : `SlotLifecycleEvent` (`Processed`, `FirstShredReceived`, `Completed`, `CreatedBank`, `Dead`, `OptimisticallyConfirmed`, `Rooted`) et `TransactionExecutionEvent` (`slot + TransactionSignature[64] + Succeeded/Failed`). Interface reste Core-only, provider-neutral, sans serde/codec/runtime/event bus et sans duplication de `RawTransaction`/`RawAccountState`; les DTOs riches restent Transport-owned et les candidats non convergents restent différés.
|
||||||
|
- [X] `0.3.6` — `ksp-job-api` + `ksp-job-backfill-lib` stables pour la première verticale historique `RawTransaction` : lifecycle/cancellation/latest-value runtime-neutral, scopes `Latest`/`Before`/`After`/signatures explicites, découverte/hydratation Transport observée, RAW v1 canonique, persistance Store atomique/idempotente, concurrence bornée, frontier contiguë, checkpoint/reprise caller-owned, snapshots sûrs et hardening externe sans dépendance backend/provider directe.
|
||||||
|
- [X] `0.3.7` — `ksp-app-backfill-desk` livrée comme Desk Tauri KSP spécialisée : composition Config Mainnet/Devnet/Testnet, quatre scopes HTTP, validation/start single-run, monitoring latest-value `ksp-backfill-status`, Cancel ciblé/idempotent, Resume in-session par checkpoint Rust-only réémis pour un nouveau JobId, autocomplete libre depuis `ksp-core-lib`, hardening IPC/dependency boundaries et build Linux `.deb`/`.rpm`/`.AppImage`. Aucun SQL/backend/provider physique ni checkpoint durable n’est exposé.
|
||||||
|
- [X] `0.3.8` — `ksp-app-store-desk` V1 RAW livrée sur le gabarit KSP courant comme application Tauri read-only backend-neutral. La Desk compose Config + Logging + `ksp-store-lib`, expose health/runtime sûrs, DataTables `serverSide` pour `RawTransaction`, `RawAccountState` et leurs observations, détails bornés, provenance sûre et états de rétention/tombstone, sans SQL/backend physique/Transport dans l'application. La nouvelle inspection random-access `offset + limit + counts exacts` reste distincte de la pagination machine cursor/keyset conservée pour workers/backfills/replays. Le gate final et les builds Linux `.deb`/`.rpm` sont verts.
|
||||||
|
- [X] `0.3.9` — `ksp-worker-api` stable comme API générique et volontairement courte pour services continus : identité/kind bornés, lifecycle explicite, health/activity sûrs, stop token partagé, séquence/snapshot latest-value et `WorkerSnapshotSource` object-safe, avec dépendance Core-only et sans runtime/start-stop universel, Solana, Transport, Store, Config ou Tauri. Après freeze Worker API, l'audit `RawTransaction` a été consolidé dans une synthèse Store-centrique exhaustive séparant possibilités/support/preuve et classant HTTP, WS standard/provider, Yellowstone gRPC, blocs/slots, discovery + hydration, replay de continuité, archives et sources EARLY. Le Job Backfill historique paramétré et le Worker Ingest live start/stop sont deux producteurs indépendants du même Store ; `mainnet` est désormais l'identité KSP canonique et `mainnet-beta` un alias legacy/externe. Le handoff retient `ksp-raw-transaction-lib` comme lower-layer commune de canonicalisation RAW v1 pour `0.3.10`, sans edge Job ↔ Worker.
|
||||||
|
- [X] `0.3.10` — Lower-layer RAW Transaction commune stabilisée : `ksp-raw-transaction-lib` possède la canonicalisation RAW v1 source-neutral partagée, le Backfill est migré sans changement des golden bytes/hash, Transport expose `get_block_observed`, le wire Solana Legacy/V0/V1 est matérialisé et les canaris HTTP/WS standard/Helius/Yellowstone qualifient précisément les voies directes ou nécessitant hydration. La release se ferme sans runtime Worker concret ; celui-ci commence en `0.3.11` selon le redécoupage `0.3.11`–`0.3.14`.
|
||||||
|
- [X] `0.3.11` — Fondation runtime de `ksp-worker-raw-transaction-ingest-lib` livrée : crate/dependency firewall, settings source-neutral, handle/start-stop, lifecycle, snapshots, supervisor, admission bornée, canonicalisation Common RAW, persistence/déduplication Store déterministe, backpressure et shutdown/fault hardening. La release se ferme volontairement sans source réseau productive ; Yellowstone + hydration HTTP commencent en `0.3.12`.
|
||||||
|
- [X] `0.3.12` — Première source live productive du Worker livrée : Yellowstone `Transaction`/`TransactionStatus`/`Block` vers signaux source-neutral, coalescence bornée puis hydration HTTP `getTransaction` observed avant Common RAW/Store ; `BlockMeta`/`Slot` restent continuity-only. Processing frontier run-local, reconnect/from_slot/ReplayInfo Transport-owned, compteurs source-neutral et fault sur gap de rétention prouvé sans Backfill automatique. Hardening duplicate/backpressure/stop/fault et canaris cross-layer fermés ; gate workspace complet vert, smokes live HTTP Devnet + WebSocket Devnet verts, Yellowstone token-gated/Worker end-to-end laissés explicitement non exécutés.
|
||||||
|
- [X] `0.3.13` — Convergence live multi-source du Worker RAW livrée : composition caller-owned `1..32` sources d’un même réseau, cinq familles Yellowstone / Standard Logs / Standard Block / Helius Transaction / HTTP Block Polling, hydration globale des trois voies reference-bearing, RAW-direct Legacy/V0/V1 pour les deux voies blocs, observations multiples, content conflict explicite, coalescence cross-source, fairness/backpressure/health source-neutral et shutdown hardening sans second actor Transport. Gate workspace complet vert (`1 850` PASS / `0` échec / `15` ignored) et smokes keyless HTTP + WebSocket Devnet verts ; les preuves provider/resource-gated non exécutées restent explicitement non revendiquées.
|
||||||
|
- [X] `0.3.14` — Gap repair/hardening multi-source du Worker RAW livré : gaps run-local bornés, distinction reconnect/replay/delivery/coverage, TargetCoverage conservative, coverage redondante prouvée, discovery HTTP bornée, known-reference hydration, réconciliation source-loss, health gap-aware, fairness nominal/repair, snapshots publics source-neutral et shutdown/drain renforcé. Le pipeline Common RAW/Store reste unique, Worker/Backfill restent indépendants, EARLY n’est pas ajouté. Gate workspace complet vert (`1 931` PASS / `0` échec / `15` ignored) et smokes keyless HTTP + WebSocket Devnet verts ; preuves provider/resource-gated non provisionnées explicitement non revendiquées.
|
||||||
|
- [X] `0.3.15` — `ksp-app-raw-transaction-ingest-desk` livrée comme Desk Tauri KSP multi-route : composition capability-driven par réseau, une instance Worker indépendante par route sélectionnée, Store partagé, Start/Stop ciblé et supervision lifecycle/health/backpressure/reconnect/replay/gaps/repair sans réimplémenter Transport ni persistence. Les cinq familles live sont projetées selon les capacités réellement configurées ; Yellowstone Mainnet utilise le trigger gRPC Block + HTTP `getBlock` et applique une backpressure bornée sans overflow local prolongé. Le Stop Yellowstone post-half-close est coopératif, et Store accepte le cas strict `canonique complet + incoming logMessages explicitement tronqué compatible` sans remplacer le canonique ni arrêter le Worker. Gate workspace/Clippy/tests/graphes/bundles Linux vert et live Mainnet Yellowstone + HTTP polling d’environ 19 minutes fermé `Stopped/Healthy`. La généralisation variantes/conflits/promotions/retry/reconnect reste explicitement `0.3.16`.
|
||||||
|
- [ ] `0.3.16` — **RAW resilience / conflict management** : faire converger Store API/façade/PostgreSQL, Worker live et Store Desk autour de variantes RAW durables, résolution automatique `Exact / CompatibleLessComplete / CompatibleMoreComplete / Conflict`, quarantaine des conflits non résolus sans arrêt du Worker, historique réversible des promotions/résolutions, retry Store sans perte silencieuse et reconnexion Transport configurable/bornée. Une provenance provider n'est jamais une priorité canonique en soi ; la représentation prouvée la plus complète gagne.
|
||||||
|
- [ ] `0.3.17` — Étendre `ksp-job-backfill-lib` au **backfill multi-route/multi-stratégie** à partir de la matrice d'acquisition auditée en `0.3.9` et de la convergence Store `0.3.16`. Conserver `getSignaturesForAddress + getTransaction` comme première voie valide puis ajouter les stratégies historiques/catch-up réellement pertinentes sans edge Job ↔ Worker.
|
||||||
|
- [ ] `0.3.18` — Adapter `ksp-app-backfill-desk` au Job multi-route `0.3.17` : inventaire/composition de routes, sélection/supervision et progression sur le modèle structurel de `ksp-app-raw-transaction-ingest-desk`, sans réimplémenter la logique du Job.
|
||||||
|
|
||||||
## Série CORE suivante
|
### TODO/IDEAS — applications spécialisées et control plane
|
||||||
|
|
||||||
- [ ] Définir la persistence CORE canonique Solana générique.
|
- [ ] **TODO** — faire évoluer `ksp-app-store-desk` avec chaque nouvelle couche réellement persistée : RAW d'abord, puis STRUCTURAL, DECODED, journal de processing/materialization et projections DOMAIN selon les contrats effectivement disponibles. Les vues avancées restent backend-agnostiques et passent uniquement par `ksp-store-lib`.
|
||||||
- [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
|
- [ ] **TODO** — différer une future `ksp-app-control-desk` jusqu'à ce que KSP dispose au minimum d'une couche D3 DECODED de processing/materialization exploitable et de plusieurs decoders réels. Cette application sera une surface **end-user simplifiée** : démarrer/arrêter les flux autorisés, lancer une recherche courante, voir l'état global et les erreurs importantes. Elle ne recopiera pas le diagnostic détaillé, les réglages avancés, les tables complètes ni toutes les fonctions des `ksp-app-*-desk` spécialisées, qui resteront les outils d'administration, développement et investigation approfondie.
|
||||||
- [ ] Ajouter replay/backfill RAW -> CORE.
|
|
||||||
- [ ] Ajouter worker/service CORE.
|
|
||||||
- [ ] Ajouter l'application de contrôle/inspection CORE utile.
|
|
||||||
|
|
||||||
## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
|
### TODO/IDEAS — taxonomie D1, processing et rétention
|
||||||
|
|
||||||
|
- [ ] **TODO** — maintenir la matrice d’admission HTTP/WS/gRPC/provider lors de toute nouvelle famille D1 : plusieurs sources ne convergent vers un même struct que si elles satisfont la même sémantique sans perte.
|
||||||
|
- [X] **TODO `0.3.9`** — matrice RAW Transaction unique produite et consolidée dans `docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md`, avec séparation possibilités/support/preuve, capabilities orthogonales aux producteurs, provenance/déduplication/continuité et handoffs désormais scindés `0.3.11`–`0.3.14` Worker live / `0.3.16` résilience RAW / `0.3.17`–`0.3.18` Backfill multi-route.
|
||||||
|
- [X] **TODO Helius `0.3.13`** — la voie Worker Helius `transactionSubscribe` réutilise la surface Transport et les profils Config Helius déjà existants avec `KSP_SECRET_HELIUS_API_KEY`; aucun nouveau profil, secret, SDK provider, tier codé ou second client parallèle n’a été nécessaire. Le payload riche reste Transport-owned et la voie Worker hydrate via `getTransaction observed` tant que le RAW complet n’est pas prouvé directement.
|
||||||
|
- [X] **TODO réseau** — identité KSP canonique fixée à `mainnet` dans Config/Store/Transport/tests ; `mainnet-beta` reste uniquement un alias legacy/externe lorsque la frontière provider l'exige. Les données D1 RAW antérieures restent non autoritaires pendant cette phase et aucune compatibilité de base de test n'impose l'ancien libellé.
|
||||||
|
- [X] `RawAccountState` + observation — contrat commun stabilisé en `0.3.1` avec bytes complets + slot, provenance séparée et enrichissements source-specific optionnels ; la persistence PostgreSQL physique est complétée en `0.3.4` avec les quatre capabilities account et la conformance RAW 10/10.
|
||||||
|
- [ ] **TODO** — statut/commitment transactionnel restant : `0.3.5` couvre uniquement le fait passif d’exécution `slot + signature + outcome`; réauditer séparément `signatureSubscribe` et `getSignatureStatuses` lorsqu’un consumer de commitment/snapshot réel apparaît, sans fusionner snapshot, transition et execution update dans un modèle Option-soup.
|
||||||
|
- [ ] **IDEA** — logs realtime enrichis : `logsSubscribe` alimente déjà la projection minimale `TransactionExecutionEvent`, mais un éventuel `TransactionLogEvent` portant les lignes de log reste différé dans `docs/IDEAS.md` jusqu’à démonstration d’un consumer et de bornes explicites. `logMessages` reste dans `RawTransaction` jusqu’à STRUCTURAL ; le wake-up post-commit reste distinct et Store API-owned conformément à `KSP-NOTIFY-*`.
|
||||||
|
- [X] slot/root/slotsUpdates — `0.3.5` stabilise `SlotLifecycleEvent` pour l’intersection réellement partagée, sans persistence Store par défaut et sans promettre l’ordre/complétude du flux.
|
||||||
|
- [ ] **TODO** — vote realtime : reste hors Interface tant qu’aucun consumer transversal et aucune sémantique provider-neutral suffisamment précise ne justifient un contrat partagé.
|
||||||
|
- [ ] **IDEA** — `RawBlock` : ne rouvrir que si une information block-level non reconstructible devient nécessaire ; `getBlock` doit d’abord être traité comme source de `RawTransaction`, pas comme invitation à recopier le ledger en blocs.
|
||||||
|
- [ ] **REJET ACTUEL** — Yellowstone `Entry` : trop bas niveau et aucune destination replay/decomposition/event métier justifiant un modèle KSP n’est identifiée.
|
||||||
|
- [ ] **TODO** — processing ledger : reprendre l’idée kbot2/kbot3 `stage + processor identity/version + input identity/hash + terminal status`, sans faire d’un `processed: bool` la preuve durable unique ; prévoir force replay/version upgrades lorsque les processors seront ouverts.
|
||||||
|
- [X] lifecycle RAW logique — `RawRetentionState`, tombstone minimal, normal-skip et force-rehydrate sont stabilisés en `0.3.1` pour `RawTransaction`.
|
||||||
|
- [X] rétention physique `RawTransaction` PostgreSQL — `0.3.3` matérialise `Full -> Archived -> Purged`, tombstone et ForceRehydrate atomiques ; `Compacted` reste explicitement unsupported tant qu’aucune représentation compactée réelle n’existe.
|
||||||
|
- [ ] **TODO** — policy de rétention/compaction : définir les critères d’éligibilité fondés sur les preuves de processing et la maintenance worker/job ; Store applique une transition demandée mais ne décide pas seul qu’un RAW peut être archivé/purgé, et la compaction physique ne sera ajoutée qu’avec un besoin réel.
|
||||||
|
- [X] frontière `ksp-interface-lib` / `ksp-store-api` — ownership documenté et canaris de non-duplication stabilisés en `0.3.1`; les events passifs non persistés restent Interface, les modèles persistants/replayables restent Store API.
|
||||||
|
- [ ] **IDEA** — réauditer la structure de processing/decode/materialization historique kbot2/kbot3 lors de l’ouverture de D2/D3 ; conserver l’isolation instruction/CPI et les statuts terminal/versionnés, sans reprendre automatiquement le schéma SQL historique.
|
||||||
|
|
||||||
|
## Série STRUCTURAL suivante
|
||||||
|
|
||||||
|
- [ ] Définir la persistence STRUCTURAL canonique Solana générique pour les familles réellement décomposables.
|
||||||
|
- [ ] Implémenter en priorité `RawTransaction -> STRUCTURAL` sans decoder Program : transaction/message, comptes/références, instructions top-level, CPI/inner instructions, logs/meta/balances/return data et relations structurelles.
|
||||||
|
- [ ] Vérifier avant extension si d'autres familles D1 possèdent une vraie décomposition STRUCTURAL utile ; ne pas créer de niveau vide par convention.
|
||||||
|
- [ ] Ajouter replay/backfill RAW -> STRUCTURAL avec processing versionné.
|
||||||
|
- [ ] Ajouter le worker/service STRUCTURAL utile sans le coupler à un worker RAW concret.
|
||||||
|
- [ ] Faire évoluer `ksp-app-store-desk` avec des vues/requêtes STRUCTURAL lorsque cette couche est réellement persistée ; ne pas créer une seconde application de browsing des mêmes données.
|
||||||
|
|
||||||
|
## Séries DECODED/DOMAIN/EXECUTION — progression verticale
|
||||||
|
|
||||||
### Priorité 1 — Solana Core Programs
|
### Priorité 1 — Solana Core Programs
|
||||||
|
|
||||||
@@ -119,7 +159,7 @@ RAW -> CORE -> DECODE -> SPECIALIZED
|
|||||||
- [ ] SPL Token.
|
- [ ] SPL Token.
|
||||||
- [ ] Associated Token Account.
|
- [ ] Associated Token Account.
|
||||||
- [ ] Token-2022 et extensions pertinentes.
|
- [ ] Token-2022 et extensions pertinentes.
|
||||||
- [ ] Pour chaque famille : decode -> materialize -> specialized -> prepare -> policy -> execute -> scenarios.
|
- [ ] Pour chaque famille : decode -> materialize -> domain projection -> prepare -> policy -> execute -> scenarios.
|
||||||
|
|
||||||
### Priorité 3 — Metadata token
|
### Priorité 3 — Metadata token
|
||||||
|
|
||||||
@@ -143,7 +183,7 @@ RAW -> CORE -> DECODE -> SPECIALIZED
|
|||||||
|
|
||||||
- [ ] Après les premiers groupes Meteora/Raydium/Pump/Orca, introduire une petite `ksp-app-market-desk` spécialisée.
|
- [ ] Après les premiers groupes Meteora/Raydium/Pump/Orca, introduire une petite `ksp-app-market-desk` spécialisée.
|
||||||
- [ ] Visualiser tokens, pools/markets, liquidité, swaps/trades, prix, volumes, OHLC/candles et activité live/récente lorsque disponible.
|
- [ ] Visualiser tokens, pools/markets, liquidité, swaps/trades, prix, volumes, OHLC/candles et activité live/récente lorsque disponible.
|
||||||
- [ ] Lire les projections SPECIALIZED KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
|
- [ ] Lire les projections DOMAIN KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
|
||||||
|
|
||||||
### Routing
|
### Routing
|
||||||
|
|
||||||
|
|||||||
66
config/composite.ksp-app-backfill-desk.json
Normal file
66
config/composite.ksp-app-backfill-desk.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "mainnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "devnet_public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "devnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "mainnet_backfill_pool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "mainnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "console_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "publicnode_testnet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "testnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
91
config/composite.ksp-app-raw-transaction-ingest-desk.json
Normal file
91
config/composite.ksp-app-raw-transaction-ingest-desk.json
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "devnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "devnet_public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport.helius",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "helius_devnet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport.yellowstone",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "orbitflare_devnet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "devnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "mainnet_public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport.helius",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "helius_mainnet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport.yellowstone",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "publicnode_mainnet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "mainnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "testnet_public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "transport.yellowstone",
|
||||||
|
"file_id": "cfg.std.transport",
|
||||||
|
"profile_id": "publicnode_testnet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "testnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
51
config/composite.ksp-app-store-desk.json
Normal file
51
config/composite.ksp-app-store-desk.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "mainnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "devnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "mainnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "testnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
81
config/examples/std.store.example.json
Normal file
81
config/examples/std.store.example.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"default_profile": "devnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"network": "devnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"network": "mainnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"network": "testnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
"name": "mainnet_public",
|
"name": "mainnet_public",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
"connect_timeout_ms": 5000,
|
"connect_timeout_ms": 5000,
|
||||||
"request_timeout_ms": 15000,
|
"request_timeout_ms": 15000,
|
||||||
"max_idle_connections_per_host": 8,
|
"max_idle_connections_per_host": 8,
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"name": "mainnet_private",
|
"name": "mainnet_private",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "private-provider",
|
"provider": "private-provider",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
|
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
|
||||||
"connect_timeout_ms": 5000,
|
"connect_timeout_ms": 5000,
|
||||||
"request_timeout_ms": 10000,
|
"request_timeout_ms": 10000,
|
||||||
@@ -85,21 +85,39 @@
|
|||||||
"name": "mainnet_public_ws",
|
"name": "mainnet_public_ws",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mainnet_helius_ws",
|
"name": "mainnet_helius_ws",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "helius",
|
"provider": "helius",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"kind": "helius_laserstream",
|
"kind": "helius_laserstream",
|
||||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
||||||
"session": {
|
"session": {
|
||||||
"notification_queue_capacity": 512,
|
"notification_queue_capacity": 512,
|
||||||
"max_active_subscriptions": 2048
|
"max_active_subscriptions": 2048
|
||||||
}
|
},
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot",
|
||||||
|
"slots_updates",
|
||||||
|
"helius_transaction"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -140,7 +158,17 @@
|
|||||||
"provider": "helius",
|
"provider": "helius",
|
||||||
"cluster": "devnet",
|
"cluster": "devnet",
|
||||||
"kind": "helius_laserstream",
|
"kind": "helius_laserstream",
|
||||||
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}"
|
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot",
|
||||||
|
"slots_updates",
|
||||||
|
"helius_transaction"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
277
config/schemas/std.store.schema.json
Normal file
277
config/schemas/std.store.schema.json
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:ksp:schema:std.store:v2",
|
||||||
|
"title": "KSP standard Store configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"enum": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profileV1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 2
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profileV2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"$defs": {
|
||||||
|
"profileId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"networkId": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128,
|
||||||
|
"pattern": "^[A-Za-z0-9_.:-]+$"
|
||||||
|
},
|
||||||
|
"duration100To60000": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 60000
|
||||||
|
},
|
||||||
|
"profileV1": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"network",
|
||||||
|
"backend",
|
||||||
|
"postgres"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"$ref": "#/$defs/networkId"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"const": "postgres"
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"$ref": "#/$defs/postgresV1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profileV2": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"network",
|
||||||
|
"backend",
|
||||||
|
"postgres"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"$ref": "#/$defs/networkId"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"const": "postgres"
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"$ref": "#/$defs/postgresV2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgresV1": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"connection_uri",
|
||||||
|
"pool",
|
||||||
|
"tls",
|
||||||
|
"bootstrap",
|
||||||
|
"shutdown_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"connection_uri": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"$ref": "#/$defs/pool"
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"$ref": "#/$defs/tls"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"$ref": "#/$defs/bootstrapV1"
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 30000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgresV2": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"connection_uri",
|
||||||
|
"pool",
|
||||||
|
"tls",
|
||||||
|
"bootstrap",
|
||||||
|
"shutdown_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"connection_uri": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"$ref": "#/$defs/pool"
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"$ref": "#/$defs/tls"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"$ref": "#/$defs/bootstrapV2"
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 30000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"max_connections",
|
||||||
|
"connect_timeout_ms",
|
||||||
|
"wait_timeout_ms",
|
||||||
|
"create_timeout_ms",
|
||||||
|
"recycle_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"max_connections": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 64
|
||||||
|
},
|
||||||
|
"connect_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/duration100To60000"
|
||||||
|
},
|
||||||
|
"wait_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/duration100To60000"
|
||||||
|
},
|
||||||
|
"create_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/duration100To60000"
|
||||||
|
},
|
||||||
|
"recycle_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/duration100To60000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"enum": [
|
||||||
|
"disabled",
|
||||||
|
"verify_full"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bootstrapV1": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"auto_migrate",
|
||||||
|
"migration_timeout_ms",
|
||||||
|
"migration_lock_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"auto_migrate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"migration_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 300000
|
||||||
|
},
|
||||||
|
"migration_lock_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bootstrapV2": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_autocreate",
|
||||||
|
"schema_autoupdate",
|
||||||
|
"migration_timeout_ms",
|
||||||
|
"migration_lock_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_autocreate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"schema_autoupdate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"migration_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 300000
|
||||||
|
},
|
||||||
|
"migration_lock_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -348,6 +348,26 @@
|
|||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"$ref": "#/$defs/wsSessionOverride"
|
"$ref": "#/$defs/wsSessionOverride"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 10,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"enum": [
|
||||||
|
"account",
|
||||||
|
"block",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot",
|
||||||
|
"slots_updates",
|
||||||
|
"vote",
|
||||||
|
"helius_transaction"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
81
config/std.store.json
Normal file
81
config/std.store.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"default_profile": "devnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"network": "devnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "disabled"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"network": "mainnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "disabled"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"network": "testnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "disabled"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -75,7 +75,67 @@
|
|||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "devnet",
|
"cluster": "devnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
|
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"block",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "helius_devnet",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_devnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "helius_devnet_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "helius",
|
||||||
|
"cluster": "devnet",
|
||||||
|
"kind": "helius_laserstream",
|
||||||
|
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot",
|
||||||
|
"slots_updates",
|
||||||
|
"helius_transaction"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -116,7 +176,16 @@
|
|||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "devnet",
|
"cluster": "devnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
|
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"block",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grpc_endpoints": [
|
"grpc_endpoints": [
|
||||||
@@ -144,8 +213,8 @@
|
|||||||
"name": "solana_mainnet_public",
|
"name": "solana_mainnet_public",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
"connect_timeout_ms": 5000,
|
"connect_timeout_ms": 5000,
|
||||||
"request_timeout_ms": 15000,
|
"request_timeout_ms": 15000,
|
||||||
"max_idle_connections_per_host": 8,
|
"max_idle_connections_per_host": 8,
|
||||||
@@ -172,9 +241,173 @@
|
|||||||
"name": "solana_mainnet_public_ws",
|
"name": "solana_mainnet_public_ws",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"block",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "helius_mainnet",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "helius_mainnet_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "helius",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"kind": "helius_laserstream",
|
||||||
|
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot",
|
||||||
|
"slots_updates",
|
||||||
|
"helius_transaction"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet_backfill_pool",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "backfill_pool",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "backfill_solana_public",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "publicnode_solana_mainnet_http",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "publicnode",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"url": "https://solana-rpc.publicnode.com",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "backfill_pool",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "backfill_publicnode",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"block",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -182,11 +415,35 @@
|
|||||||
"profile_id": "publicnode_mainnet",
|
"profile_id": "publicnode_mainnet",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
{
|
{
|
||||||
"name": "solana_mainnet_public",
|
"name": "publicnode_solana_mainnet_rpc",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "publicnode",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"url": "https://solana-rpc.publicnode.com",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public_repair",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
"connect_timeout_ms": 5000,
|
"connect_timeout_ms": 5000,
|
||||||
"request_timeout_ms": 15000,
|
"request_timeout_ms": 15000,
|
||||||
"max_idle_connections_per_host": 8,
|
"max_idle_connections_per_host": 8,
|
||||||
@@ -213,9 +470,18 @@
|
|||||||
"name": "solana_mainnet_public_ws",
|
"name": "solana_mainnet_public_ws",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"block",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grpc_endpoints": [
|
"grpc_endpoints": [
|
||||||
@@ -223,7 +489,7 @@
|
|||||||
"name": "publicnode_solana_mainnet_yellowstone",
|
"name": "publicnode_solana_mainnet_yellowstone",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "publicnode",
|
"provider": "publicnode",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"protocol": "solana_yellowstone",
|
"protocol": "solana_yellowstone",
|
||||||
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
||||||
"secret_metadata": [
|
"secret_metadata": [
|
||||||
@@ -235,6 +501,56 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet_public",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_testnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "testnet",
|
||||||
|
"url": "https://api.testnet.solana.com",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "default",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_testnet_public_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "testnet",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "wss://api.testnet.solana.com",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"block",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"profile_id": "publicnode_testnet",
|
"profile_id": "publicnode_testnet",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
@@ -272,7 +588,16 @@
|
|||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "testnet",
|
"cluster": "testnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "wss://api.testnet.solana.com"
|
"url": "wss://api.testnet.solana.com",
|
||||||
|
"capabilities": [
|
||||||
|
"account",
|
||||||
|
"block",
|
||||||
|
"logs",
|
||||||
|
"program",
|
||||||
|
"root",
|
||||||
|
"signature",
|
||||||
|
"slot"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grpc_endpoints": [
|
"grpc_endpoints": [
|
||||||
|
|||||||
44
crates/ksp-app-backfill-desk/Cargo.toml
Normal file
44
crates/ksp-app-backfill-desk/Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# file: crates/ksp-app-backfill-desk/Cargo.toml
|
||||||
|
# version: 4
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-app-backfill-desk"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ksp_app_backfill_desk_lib"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ksp-app-backfill-desk"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { workspace = true, features = ["std", "now"] }
|
||||||
|
fs2.workspace = true
|
||||||
|
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
ksp-job-api = { path = "../ksp-job-api" }
|
||||||
|
ksp-job-backfill-lib = { path = "../ksp-job-backfill-lib" }
|
||||||
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||||
|
ksp-store-lib = { path = "../ksp-store-lib" }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
tauri.workspace = true
|
||||||
|
tauri-plugin-tracing.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["time"] }
|
||||||
|
ts-rs.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
105
crates/ksp-app-backfill-desk/README.md
Normal file
105
crates/ksp-app-backfill-desk/README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<!-- file: crates/ksp-app-backfill-desk/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# `ksp-app-backfill-desk`
|
||||||
|
|
||||||
|
`ksp-app-backfill-desk` est l'application desktop spécialisée de contrôle d'un backfill historique `RawTransaction` KSP.
|
||||||
|
|
||||||
|
Elle reste une couche de composition Tauri : Config sélectionne les profils, Transport possède les endpoints/rôles/retry/rate-limit, Store possède la persistence et le backend, et `ksp-job-backfill-lib` possède découverte, hydratation, frontier/checkpoint et lifecycle du job.
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
```text
|
||||||
|
package : ksp-app-backfill-desk
|
||||||
|
lib : ksp_app_backfill_desk_lib
|
||||||
|
bin : ksp-app-backfill-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
Le binaire est un launcher mince. La bibliothèque applicative possède le bootstrap, l'état Rust single-run, les DTOs Tauri, les commands et le bridge latest-value vers le frontend.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> composite, profils, secrets et runtime packagé
|
||||||
|
ksp-core-lib -> erreurs, Pubkey et registre Program IDs
|
||||||
|
ksp-onchain-transport-lib -> HTTP pool, rôles, admission, retry/rate-limit
|
||||||
|
ksp-store-lib -> façade Store backend-neutral
|
||||||
|
ksp-job-api -> lifecycle Job commun
|
||||||
|
ksp-job-backfill-lib -> request/runtime/checkpoint/snapshots Backfill
|
||||||
|
ksp-logging-lib -> logging/tracing applicatif
|
||||||
|
ksp-app-backfill-desk -> orchestration Tauri + projections sûres
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application ne dépend pas directement de `ksp-store-api`, `ksp-store-postgres-lib`, `tokio-postgres`, `reqwest`, `tonic` ou `yellowstone-grpc-proto`.
|
||||||
|
|
||||||
|
## Capacités
|
||||||
|
|
||||||
|
La surface utilisateur comprend :
|
||||||
|
|
||||||
|
- quatre scopes : latest address, before address, after address et explicit signatures ;
|
||||||
|
- engagements `finalized` et `confirmed` ;
|
||||||
|
- sélection d'un rôle HTTP logique compatible avec `getSignaturesForAddress` et `getTransaction` ;
|
||||||
|
- bornes page/candidats/concurrence validées côté Rust par le contrat Backfill ;
|
||||||
|
- validation de la requête avant Start ;
|
||||||
|
- un seul run actif ;
|
||||||
|
- monitoring latest-value par événement `ksp-backfill-status` avec resynchronisation explicite ;
|
||||||
|
- Cancel ciblé par JobId backend et idempotent ;
|
||||||
|
- Resume in-session lorsqu'un checkpoint terminal est disponible ;
|
||||||
|
- autocomplete libre des Program IDs provenant du registre canonique `ksp-core-lib`.
|
||||||
|
|
||||||
|
L'autocomplete n'est jamais une allow-list : une adresse Solana valide non enregistrée reste saisissable.
|
||||||
|
|
||||||
|
## Checkpoint et Resume
|
||||||
|
|
||||||
|
Le checkpoint concret ne traverse jamais IPC. Le backend conserve uniquement en mémoire Rust le dernier terminal compatible et la requête nécessaire à une reprise dans la même session.
|
||||||
|
|
||||||
|
Un Resume crée un nouveau JobId backend. `ksp-job-backfill-lib` valide le checkpoint contre la requête d'origine puis le réémet pour ce nouveau Job sans modifier le scope fingerprint ni la frontier. Il n'existe pas de checkpoint durable de Backfill Desk après redémarrage de l'application.
|
||||||
|
|
||||||
|
## Config et réseaux
|
||||||
|
|
||||||
|
Le composite dédié est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.composite.ksp-app-backfill-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
Les profils committed couvrent `mainnet`, `devnet` et `testnet`. Mainnet est le profil par défaut. Transport et Store doivent sélectionner exactement le même réseau avant que la composition soit déclarée ready.
|
||||||
|
|
||||||
|
Les profils Store committed utilisent actuellement TLS PostgreSQL `disabled`; cette politique de profil n'enlève pas le support `verify_full` du Store/schema.
|
||||||
|
|
||||||
|
Le frontend ne reçoit jamais URI Store, URL RPC, credential provider, secret Config ou backend physique.
|
||||||
|
|
||||||
|
## Monitoring et sécurité
|
||||||
|
|
||||||
|
Le monitoring expose des compteurs et codes sûrs : lifecycle, phase, boundary, candidats, entités/observations, missing/conflicts/holes, concurrence maximale, frontier contiguë, présence de checkpoint et éventuel domain/code d'échec.
|
||||||
|
|
||||||
|
Il n'expose pas :
|
||||||
|
|
||||||
|
- payload RAW ;
|
||||||
|
- adresse ou signatures de campagne ;
|
||||||
|
- checkpoint/cursor concret ;
|
||||||
|
- endpoint URL ou credential ;
|
||||||
|
- contexte d'erreur arbitraire.
|
||||||
|
|
||||||
|
Le frontend ne possède ni accès direct filesystem/réseau, ni persistence navigateur de données Backfill. Les capabilities Tauri guest restent `core:default` et `tracing:default`.
|
||||||
|
|
||||||
|
## Ports et développement
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vite HTTP : 1436
|
||||||
|
Vite WS : 1437
|
||||||
|
```
|
||||||
|
|
||||||
|
Lancement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-backfill-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Build production :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-backfill-desk && cargo tauri build)
|
||||||
|
```
|
||||||
|
|
||||||
|
Voir également [`USAGE.md`](USAGE.md), [`../ksp-job-backfill-lib/README.md`](../ksp-job-backfill-lib/README.md), [`../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md`](../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md) et [`../../docs/validation/024-V0_3_7_BACKFILL_DESK.md`](../../docs/validation/024-V0_3_7_BACKFILL_DESK.md).
|
||||||
145
crates/ksp-app-backfill-desk/USAGE.md
Normal file
145
crates/ksp-app-backfill-desk/USAGE.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<!-- file: crates/ksp-app-backfill-desk/USAGE.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Utilisation de `ksp-app-backfill-desk`
|
||||||
|
|
||||||
|
## 1. Lancement
|
||||||
|
|
||||||
|
Depuis la racine du workspace :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-backfill-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le runtime charge le composite Backfill Desk via `ksp-config-lib`, construit le pool HTTP, ouvre Store puis vérifie que les deux surfaces utilisent le même réseau avant d'autoriser une campagne.
|
||||||
|
|
||||||
|
## 2. Choix du profil et readiness
|
||||||
|
|
||||||
|
Le composite `cfg.composite.ksp-app-backfill-desk` utilise Mainnet par défaut. Devnet et Testnet restent disponibles via la sélection Config du profil composite.
|
||||||
|
|
||||||
|
Avant Start, l'écran indique séparément :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Transport ready
|
||||||
|
Store ready
|
||||||
|
Network coherent
|
||||||
|
Composition ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Une composition non ready doit être corrigée côté Config/runtime ; le frontend ne peut pas fournir une URL RPC, une URI Store ou un secret pour contourner ces contrôles.
|
||||||
|
|
||||||
|
## 3. Adresse et autocomplete Program IDs
|
||||||
|
|
||||||
|
Le champ adresse accepte toute Pubkey Solana valide. Le `datalist` propose en complément les Program IDs du registre canonique `ksp-core-lib` avec leurs metadata publiques.
|
||||||
|
|
||||||
|
Choisir une proposition remplit simplement le champ ; une adresse absente du dataset reste autorisée. Le dataset n'est pas une allow-list.
|
||||||
|
|
||||||
|
## 4. Scopes
|
||||||
|
|
||||||
|
Les scopes disponibles sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
latest_address
|
||||||
|
before_address
|
||||||
|
after_address
|
||||||
|
explicit_signatures
|
||||||
|
```
|
||||||
|
|
||||||
|
`latest_address` recherche l'historique récent d'une adresse. `before_address` et `after_address` ajoutent une signature d'ancrage selon la sémantique Backfill. `explicit_signatures` traite une liste explicite de signatures et n'accepte pas `min_context_slot`.
|
||||||
|
|
||||||
|
Les signatures et adresses sont revalidées côté Rust ; leur présence dans le formulaire ne les rend pas persistantes dans le frontend.
|
||||||
|
|
||||||
|
## 5. Engagement, route HTTP et bornes
|
||||||
|
|
||||||
|
Les engagements disponibles sont `finalized` et `confirmed`.
|
||||||
|
|
||||||
|
Le sélecteur HTTP expose uniquement des rôles logiques que le backend a vérifiés compatibles avec les deux méthodes nécessaires au Backfill. Le rôle pool permet au Transport de choisir/rerouter entre endpoints selon ses propres règles ; les rôles ciblés permettent de sélectionner une route logique spécifique sans exposer l'URL physique.
|
||||||
|
|
||||||
|
Les bornes de campagne comprennent notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
page size
|
||||||
|
max pages
|
||||||
|
max candidates
|
||||||
|
hydration concurrency
|
||||||
|
min context slot
|
||||||
|
```
|
||||||
|
|
||||||
|
Les limites maximales proviennent de `ksp-job-backfill-lib` et sont revalidées lors de la conversion vers `BackfillRequest`.
|
||||||
|
|
||||||
|
## 6. Valider puis démarrer
|
||||||
|
|
||||||
|
**Valider la requête** projette un résumé sûr de la campagne sans démarrer le job.
|
||||||
|
|
||||||
|
**Démarrer** crée ensuite un JobId backend et installe le handle de contrôle avant le spawn asynchrone. Un second Start est refusé tant qu'un run est actif, y compris pendant `cancelling`.
|
||||||
|
|
||||||
|
## 7. Monitoring latest-value
|
||||||
|
|
||||||
|
La carte de monitoring est alimentée par les snapshots latest-value du runtime. Le backend émet `ksp-backfill-status`; **Resynchroniser** appelle également `backfill_status` afin de récupérer explicitement le dernier état complet.
|
||||||
|
|
||||||
|
Les informations utiles incluent :
|
||||||
|
|
||||||
|
- lifecycle et phase ;
|
||||||
|
- scope et discovery boundary ;
|
||||||
|
- candidats selected/admitted/finished ;
|
||||||
|
- entités et observations inserted/existing ;
|
||||||
|
- purged/missing/conflicts/holes/cancelled candidates ;
|
||||||
|
- maximum in flight ;
|
||||||
|
- contiguous completed ;
|
||||||
|
- présence d'un checkpoint ;
|
||||||
|
- domain/code stable d'échec éventuel.
|
||||||
|
|
||||||
|
Le monitoring ne doit pas être interprété depuis les logs texte.
|
||||||
|
|
||||||
|
## 8. Annuler
|
||||||
|
|
||||||
|
**Annuler** envoie le JobId actuellement affiché. Cette cible protège un nouveau run contre une requête Cancel retardée destinée au run précédent.
|
||||||
|
|
||||||
|
Le premier Cancel pré-terminal demande une annulation coopérative. Une répétition est idempotente. Le lifecycle peut passer par `cancelling`; les opérations déjà admises vers la persistence ne sont pas présentées comme brutalement interrompues.
|
||||||
|
|
||||||
|
## 9. Reprendre
|
||||||
|
|
||||||
|
**Reprendre** est disponible uniquement lorsqu'un snapshot terminal retenu contient un checkpoint. La commande n'accepte aucun checkpoint ou payload de campagne depuis le frontend.
|
||||||
|
|
||||||
|
Le backend :
|
||||||
|
|
||||||
|
1. exige qu'aucun run ne soit actif ;
|
||||||
|
2. récupère la requête terminale Rust-only ;
|
||||||
|
3. revalide le réseau Store et le rôle HTTP courant ;
|
||||||
|
4. génère un nouveau JobId ;
|
||||||
|
5. demande à `ksp-job-backfill-lib` de réémettre le checkpoint pour ce JobId ;
|
||||||
|
6. relance le même runtime/monitoring.
|
||||||
|
|
||||||
|
La reprise est limitée à la session courante. Fermer l'application supprime ce checkpoint applicatif en mémoire.
|
||||||
|
|
||||||
|
## 10. Fermeture de l'application
|
||||||
|
|
||||||
|
La fermeture de la fenêtre principale bloque d'abord tout nouveau Start, tente une annulation coopérative du run actif puis ferme Store avant l'exit.
|
||||||
|
|
||||||
|
Le shutdown applicatif n'est pas un mécanisme de checkpoint durable ou de reprise après crash.
|
||||||
|
|
||||||
|
## 11. Données qui ne traversent pas le frontend
|
||||||
|
|
||||||
|
Backfill Desk ne projette jamais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
URI Store
|
||||||
|
URL RPC
|
||||||
|
credentials/secrets
|
||||||
|
payload RAW
|
||||||
|
checkpoint/cursor concret
|
||||||
|
handles Store/Transport/Job
|
||||||
|
contexte d'erreur arbitraire
|
||||||
|
```
|
||||||
|
|
||||||
|
Les actions utilisateur et transitions sont instrumentées via Logging KSP sans journaliser adresse, signature ou payload métier.
|
||||||
|
|
||||||
|
## 12. Runtime packagé
|
||||||
|
|
||||||
|
Le build Tauri embarque les documents Config/schemas enregistrés. `ksp-config-lib` prépare ensuite la racine KSP user-writable et conserve les Config utilisateur existantes ; `.env` n'est jamais embarqué.
|
||||||
|
|
||||||
|
Le build production est :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-backfill-desk && cargo tauri build)
|
||||||
|
```
|
||||||
12
crates/ksp-app-backfill-desk/build.rs
Normal file
12
crates/ksp-app-backfill-desk/build.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/build.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Build script for the KSP Backfill desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
13
crates/ksp-app-backfill-desk/capabilities/default.json
Normal file
13
crates/ksp-app-backfill-desk/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for KSP Backfill Desk",
|
||||||
|
"windows": [
|
||||||
|
"splash",
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"tracing:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
crates/ksp-app-backfill-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
BIN
crates/ksp-app-backfill-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
Binary file not shown.
25
crates/ksp-app-backfill-desk/frontend/fonts/README.md
Normal file
25
crates/ksp-app-backfill-desk/frontend/fonts/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!-- file: crates/ksp-app-backfill-desk/frontend/fonts/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Fonts du splash Backfill Desk
|
||||||
|
|
||||||
|
Backfill Desk réutilise le gabarit de splash KSP déjà stabilisé dans Config Desk et Wallet Desk et conserve la police locale :
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOS_Amazigh.ttf
|
||||||
|
font-family: Dos Amazigh
|
||||||
|
```
|
||||||
|
|
||||||
|
Destination :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-app-backfill-desk/frontend/fonts/DOS_Amazigh.ttf
|
||||||
|
```
|
||||||
|
|
||||||
|
Empreinte SHA-256 de l'asset repris du gabarit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02
|
||||||
|
```
|
||||||
|
|
||||||
|
`splash.scss` déclare `Dos Amazigh` avec `@font-face` et l'applique au titre `#app-name`.
|
||||||
BIN
crates/ksp-app-backfill-desk/frontend/imgs/logo.png
Normal file
BIN
crates/ksp-app-backfill-desk/frontend/imgs/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/ksp-app-backfill-desk/frontend/imgs/splash.png
Normal file
BIN
crates/ksp-app-backfill-desk/frontend/imgs/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
231
crates/ksp-app-backfill-desk/frontend/main.html
Normal file
231
crates/ksp-app-backfill-desk/frontend/main.html
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<!-- file: crates/ksp-app-backfill-desk/frontend/main.html -->
|
||||||
|
<!-- version: 8 -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<link rel="icon" href="imgs/logo.png">
|
||||||
|
<link rel="stylesheet" href="sass/main.scss">
|
||||||
|
<title>Backfill Desk — Backfill</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<nav class="navbar h-100 py-0 bg-light text-dark">
|
||||||
|
<div class="container-fluid px-4 flex-nowrap">
|
||||||
|
<div class="navbar-brand d-flex align-items-center me-4 flex-nowrap">
|
||||||
|
<img alt="KSP" src="imgs/logo.png" class="app-logo">
|
||||||
|
<span class="ps-2 fs-4 fw-semibold text-primary text-nowrap">Backfill Desk</span>
|
||||||
|
<span class="mx-2 fs-5 text-body-secondary" aria-hidden="true">—</span>
|
||||||
|
<span id="headerViewTitle" class="fs-5 text-body text-nowrap">Backfill</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main container-fluid p-0">
|
||||||
|
<div class="row g-0 h-100 flex-nowrap">
|
||||||
|
<aside class="col-auto app-sidebar border-end bg-body-tertiary">
|
||||||
|
<div class="app-sidebar-scroll h-100" data-simplebar>
|
||||||
|
<nav class="nav nav-pills flex-column gap-1 p-3" aria-label="Navigation Backfill Desk">
|
||||||
|
<button class="nav-link text-start active" type="button" data-view="backfill">
|
||||||
|
<i class="fa-solid fa-clock-rotate-left me-2" aria-hidden="true"></i>Backfill
|
||||||
|
</button>
|
||||||
|
<button class="nav-link text-start" type="button" data-view="diagnostics">
|
||||||
|
<i class="fa-solid fa-stethoscope me-2" aria-hidden="true"></i>Diagnostics
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section class="col app-content app-scrollable h-100" data-simplebar>
|
||||||
|
<div class="container-fluid px-4 py-4">
|
||||||
|
<div class="card app-shell-card mx-auto shadow-sm border-0">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<section data-view-panel="backfill">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Backfill</h1>
|
||||||
|
<p class="text-body-secondary mb-0">Mainnet par défaut, transport HTTP sélectionnable et mapping de campagne validé côté Rust avant tout démarrage de job.</p>
|
||||||
|
</div>
|
||||||
|
<span id="appVersionBadge" class="badge text-bg-secondary">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-xl-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header fw-semibold">Transport HTTP</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<label for="backfillHttpRoute" class="form-label">Route de lecture</label>
|
||||||
|
<select id="backfillHttpRoute" class="form-select" disabled>
|
||||||
|
<option value="">Chargement des routes…</option>
|
||||||
|
</select>
|
||||||
|
<div id="backfillHttpRouteHelp" class="form-text">Le choix reste logique : aucune URL ni clé provider n'est exposée au frontend.</div>
|
||||||
|
<dl class="row mt-4 mb-0 app-runtime-list">
|
||||||
|
<dt class="col-sm-5">Réseau</dt><dd id="backfillNetwork" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Transport</dt><dd id="backfillTransportReady" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Store</dt><dd id="backfillStoreReady" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Composition</dt><dd id="backfillCompositionReady" class="col-sm-7">—</dd>
|
||||||
|
</dl>
|
||||||
|
<div id="backfillOptionsDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-8">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header fw-semibold">Campagne Backfill</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="backfillCampaignForm" novalidate>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<label for="backfillScopeKind" class="form-label">Scope</label>
|
||||||
|
<select id="backfillScopeKind" class="form-select" disabled></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<label for="backfillCommitment" class="form-label">Commitment</label>
|
||||||
|
<select id="backfillCommitment" class="form-select" disabled></select>
|
||||||
|
</div>
|
||||||
|
<div id="backfillAddressGroup" class="col-12">
|
||||||
|
<label for="backfillAddress" class="form-label">Adresse Solana</label>
|
||||||
|
<input id="backfillAddress" class="form-control font-monospace" type="text" list="backfillProgramIds" autocomplete="off" spellcheck="false" aria-describedby="backfillProgramIdAutocompleteHelp">
|
||||||
|
<datalist id="backfillProgramIds"></datalist>
|
||||||
|
<div id="backfillProgramIdAutocompleteHelp" class="form-text">Suggestions issues du registre canonique KSP ; toute adresse Solana valide reste acceptée.</div>
|
||||||
|
</div>
|
||||||
|
<div id="backfillAnchorGroup" class="col-12" hidden>
|
||||||
|
<label for="backfillAnchorSignature" class="form-label">Signature d'ancrage exclusive</label>
|
||||||
|
<input id="backfillAnchorSignature" class="form-control font-monospace" type="text" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div id="backfillExplicitSignaturesGroup" class="col-12" hidden>
|
||||||
|
<label for="backfillExplicitSignatures" class="form-label">Signatures explicites</label>
|
||||||
|
<textarea id="backfillExplicitSignatures" class="form-control font-monospace" rows="5" autocomplete="off" spellcheck="false"></textarea>
|
||||||
|
<div id="backfillExplicitSignaturesHelp" class="form-text">Une signature par ligne.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xxl-3">
|
||||||
|
<label for="backfillPageSize" class="form-label">Page size</label>
|
||||||
|
<input id="backfillPageSize" class="form-control" type="number" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xxl-3">
|
||||||
|
<label for="backfillMaxPages" class="form-label">Max pages</label>
|
||||||
|
<input id="backfillMaxPages" class="form-control" type="number" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xxl-3">
|
||||||
|
<label for="backfillMaxCandidates" class="form-label">Max candidates</label>
|
||||||
|
<input id="backfillMaxCandidates" class="form-control" type="number" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-xxl-3">
|
||||||
|
<label for="backfillHydrationConcurrency" class="form-label">Concurrence</label>
|
||||||
|
<input id="backfillHydrationConcurrency" class="form-control" type="number" step="1">
|
||||||
|
</div>
|
||||||
|
<div id="backfillMinContextSlotGroup" class="col-lg-6">
|
||||||
|
<label for="backfillMinContextSlot" class="form-label">Min context slot</label>
|
||||||
|
<input id="backfillMinContextSlot" class="form-control font-monospace" type="text" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="optionnel">
|
||||||
|
<div class="form-text">Chaîne décimale validée côté Rust pour préserver la précision u64.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3 flex-wrap mt-4">
|
||||||
|
<button id="validateBackfillRequest" class="btn btn-outline-primary" type="submit" disabled>
|
||||||
|
<i class="fa-solid fa-shield-halved me-2" aria-hidden="true"></i>Valider la requête
|
||||||
|
</button>
|
||||||
|
<button id="startBackfillRequest" class="btn btn-primary" type="button" disabled>
|
||||||
|
<i class="fa-solid fa-play me-2" aria-hidden="true"></i>Démarrer le backfill
|
||||||
|
</button>
|
||||||
|
<span class="text-body-secondary small">Un seul run peut être actif ; le monitoring latest-value est coalescé côté backend.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="backfillRequestValidation" class="alert alert-secondary mt-4 mb-0" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div id="backfillRunStatusCard" class="card shadow-sm mt-4" hidden>
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between gap-3 flex-wrap">
|
||||||
|
<span class="fw-semibold">Monitoring latest-value</span>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<button id="cancelBackfillRun" class="btn btn-outline-danger btn-sm" type="button" disabled>
|
||||||
|
<i class="fa-solid fa-ban me-2" aria-hidden="true"></i>Annuler
|
||||||
|
</button>
|
||||||
|
<button id="resumeBackfillRun" class="btn btn-outline-success btn-sm" type="button" disabled>
|
||||||
|
<i class="fa-solid fa-forward-step me-2" aria-hidden="true"></i>Reprendre
|
||||||
|
</button>
|
||||||
|
<button id="refreshBackfillStatus" class="btn btn-outline-primary btn-sm" type="button">
|
||||||
|
<i class="fa-solid fa-rotate me-2" aria-hidden="true"></i>Resynchroniser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="backfillCancelFeedback" class="alert alert-secondary py-2" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div id="backfillResumeFeedback" class="alert alert-secondary py-2" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div id="backfillTerminalSummary" class="alert alert-secondary py-2" role="status" aria-live="polite" hidden></div>
|
||||||
|
<dl class="row mb-0 app-runtime-list">
|
||||||
|
<dt class="col-sm-5">Job</dt><dd id="runStatusJobId" class="col-sm-7 font-monospace">—</dd>
|
||||||
|
<dt class="col-sm-5">Lifecycle</dt><dd id="runStatusLifecycle" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Phase</dt><dd id="runStatusPhase" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Séquence</dt><dd id="runStatusSequence" class="col-sm-7 font-monospace">—</dd>
|
||||||
|
<dt class="col-sm-5">Scope</dt><dd id="runStatusScope" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Boundary</dt><dd id="runStatusBoundary" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Candidates sel./adm./fin.</dt><dd id="runStatusCandidates" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Frontier / holes</dt><dd id="runStatusFrontier" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">RAW ins./exist./purged</dt><dd id="runStatusEntities" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Observations ins./exist.</dt><dd id="runStatusObservations" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Anomalies</dt><dd id="runStatusAnomalies" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Max in-flight</dt><dd id="runStatusConcurrency" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Checkpoint</dt><dd id="runStatusCheckpoint" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Failure</dt><dd id="runStatusFailure" class="col-sm-7">—</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="backfillRequestPreview" class="card bg-body-tertiary border-0 mt-3" hidden>
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 mb-3">Projection backend sûre</h2>
|
||||||
|
<dl class="row mb-0 app-runtime-list">
|
||||||
|
<dt class="col-sm-5">Réseau</dt><dd id="validatedNetwork" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Route HTTP</dt><dd id="validatedHttpRole" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Commitment</dt><dd id="validatedCommitment" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Scope</dt><dd id="validatedScopeKind" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Signatures explicites</dt><dd id="validatedExplicitSignatureCount" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">page/pages/candidates/concurrence</dt><dd id="validatedBounds" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Min context slot</dt><dd id="validatedMinContextSlot" class="col-sm-7">—</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section data-view-panel="diagnostics" hidden>
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Diagnostics</h1>
|
||||||
|
<p class="text-body-secondary mb-0">État sûr du scaffold Config, Logging et desktop.</p>
|
||||||
|
</div>
|
||||||
|
<button id="refreshRuntimeStatus" class="btn btn-outline-primary btn-sm" type="button">
|
||||||
|
<i class="fa-solid fa-rotate me-2" aria-hidden="true"></i>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Runtime</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 app-runtime-list">
|
||||||
|
<dt class="col-sm-5">Version</dt><dd id="runtimeVersion" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Phase</dt><dd id="runtimeShellPhase" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Documents Config</dt><dd id="runtimeConfigDocuments" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Profil Logging</dt><dd id="runtimeLoggingProfile" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Fallback Logging</dt><dd id="runtimeLoggingFallback" class="col-sm-7">—</dd>
|
||||||
|
</dl>
|
||||||
|
<div id="runtimeDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer bg-dark text-light">
|
||||||
|
<div class="container h-100 d-flex align-items-center justify-content-center">
|
||||||
|
<small>© 2026 SASEDEV</small>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="module" src="ts/main.ts" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
113
crates/ksp-app-backfill-desk/frontend/sass/_app.scss
Normal file
113
crates/ksp-app-backfill-desk/frontend/sass/_app.scss
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/sass/_app.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
$app-header-height: 72px;
|
||||||
|
$app-footer-height: 42px;
|
||||||
|
$app-sidebar-width: 250px;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: $app-header-height;
|
||||||
|
z-index: 1020;
|
||||||
|
border-bottom: 2px solid rgba($primary, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto 0 0;
|
||||||
|
height: $app-footer-height;
|
||||||
|
z-index: 1020;
|
||||||
|
border-top: 2px solid rgba($primary, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100vh - $app-header-height - $app-footer-height);
|
||||||
|
margin-top: $app-header-height;
|
||||||
|
margin-bottom: $app-footer-height;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main > .row {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-scrollable {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-card {
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-runtime-list dd {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
width: $app-sidebar-width;
|
||||||
|
min-width: $app-sidebar-width;
|
||||||
|
max-width: $app-sidebar-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-scroll,
|
||||||
|
.app-content {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar .nav-link {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-placeholder {
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px dashed var(--bs-border-color);
|
||||||
|
border-radius: var(--bs-border-radius-lg);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-placeholder i {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.app-sidebar {
|
||||||
|
width: 190px;
|
||||||
|
min-width: 190px;
|
||||||
|
max-width: 190px;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
crates/ksp-app-backfill-desk/frontend/sass/_bootswatch.scss
Normal file
160
crates/ksp-app-backfill-desk/frontend/sass/_bootswatch.scss
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/sass/_bootswatch.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
// Pulse 5.3.8
|
||||||
|
// Bootswatch
|
||||||
|
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:active,
|
||||||
|
&:active:focus,
|
||||||
|
&.active:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-secondary {
|
||||||
|
color: $gray-900;
|
||||||
|
background-color: $white;
|
||||||
|
border-color: #ccc;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gray-900;
|
||||||
|
background-color: $gray-300;
|
||||||
|
border-color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: tint-color($gray-900, 5%);
|
||||||
|
background-color: $white;
|
||||||
|
border-color: tint-color(#ccc, 5%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-warning {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-primary:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($primary, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-secondary:focus {
|
||||||
|
box-shadow: 0 0 5px $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-success:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($success, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-info:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($info, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-warning:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($warning, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-danger:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($danger, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
|
||||||
|
.table .thead-dark th {
|
||||||
|
background-color: $secondary;
|
||||||
|
border-color: $table-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-primary,
|
||||||
|
.table-secondary,
|
||||||
|
.table-success,
|
||||||
|
.table-warning,
|
||||||
|
.table-danger,
|
||||||
|
.table-info,
|
||||||
|
.table-light {
|
||||||
|
--#{$prefix}table-color: #{$body-color};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
box-shadow: 0 0 5px rgba(100, 65, 164, .4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navs
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
|
||||||
|
.nav-link,
|
||||||
|
.nav-link.active {
|
||||||
|
border-width: 0 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.active,
|
||||||
|
.nav-link.active:hover,
|
||||||
|
.nav-link.active:focus {
|
||||||
|
border-bottom: 1px solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item+.nav-item {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
&-item.active {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indicators
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
&.bg-light {
|
||||||
|
color: $dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bars
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containers
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
&-item {
|
||||||
|
color: rgba(255, 255, 255, .8);
|
||||||
|
|
||||||
|
&.active,
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $list-group-hover-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled:hover {
|
||||||
|
color: $list-group-disabled-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/ksp-app-backfill-desk/frontend/sass/_fontawesome.scss
Normal file
19
crates/ksp-app-backfill-desk/frontend/sass/_fontawesome.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/sass/_fontawesome.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||||
|
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
|
||||||
|
// $font-path: '../webfonts',
|
||||||
|
//);
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||||
|
// use fonts from @fortawesome/fontawesome-free
|
||||||
|
$font-path: '@fortawesome/fontawesome-free/webfonts',
|
||||||
|
);
|
||||||
|
// load Font Awesome core
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/fontawesome';
|
||||||
|
|
||||||
|
// load and make available Font Awesome helpers (mixins, functions, and variables)
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||||
248
crates/ksp-app-backfill-desk/frontend/sass/_simplebar.scss
Normal file
248
crates/ksp-app-backfill-desk/frontend/sass/_simplebar.scss
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/sass/_simplebar.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/* Rtl support */
|
||||||
|
[data-simplebar] {
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-wrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
max-width: inherit;
|
||||||
|
max-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-mask {
|
||||||
|
direction: inherit;
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
// z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-offset {
|
||||||
|
direction: inherit !important;
|
||||||
|
box-sizing: inherit !important;
|
||||||
|
resize: none !important;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-content-wrapper {
|
||||||
|
direction: inherit;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-hide-scrollbar {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
&:before {
|
||||||
|
content: ' ';
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: ' ';
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-placeholder {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-height-auto-observer-wrapper {
|
||||||
|
box-sizing: inherit !important;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1px;
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
max-height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
// z-index: -1;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
flex-grow: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-height-auto-observer {
|
||||||
|
box-sizing: inherit;
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 1000%;
|
||||||
|
width: 1000%;
|
||||||
|
min-height: 1px;
|
||||||
|
min-width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
// z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track {
|
||||||
|
// z-index: 1;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-simplebar].simplebar-dragging {
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
min-height: 10px;
|
||||||
|
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
background: black;
|
||||||
|
border-radius: 7px;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s 0.5s linear;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar.simplebar-visible {
|
||||||
|
&:before {
|
||||||
|
opacity: 0.5;
|
||||||
|
transition-delay: 0s;
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
top: 0;
|
||||||
|
width: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-horizontal {
|
||||||
|
left: 0;
|
||||||
|
height: 11px;
|
||||||
|
|
||||||
|
.simplebar-scrollbar {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 10px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-simplebar-direction='rtl'] {
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-dummy-scrollbar-size {
|
||||||
|
direction: rtl;
|
||||||
|
position: fixed;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
height: 500px;
|
||||||
|
width: 500px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overflow-x: scroll;
|
||||||
|
-ms-overflow-style: scrollbar !important;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
95
crates/ksp-app-backfill-desk/frontend/sass/_variables.scss
Normal file
95
crates/ksp-app-backfill-desk/frontend/sass/_variables.scss
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/sass/_variables.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
// Pulse 5.3.8
|
||||||
|
// Bootswatch
|
||||||
|
|
||||||
|
$theme: "pulse" !default;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Color system
|
||||||
|
//
|
||||||
|
|
||||||
|
$white: #fff !default;
|
||||||
|
$gray-100: #fafafa !default;
|
||||||
|
$gray-200: #f9f8fc !default;
|
||||||
|
$gray-300: #ededed !default;
|
||||||
|
$gray-400: #cbc8d0 !default;
|
||||||
|
$gray-500: #adb5bd !default;
|
||||||
|
$gray-600: #868e96 !default;
|
||||||
|
$gray-700: #444 !default;
|
||||||
|
$gray-800: #343a40 !default;
|
||||||
|
$gray-900: #17141f !default;
|
||||||
|
$black: #000 !default;
|
||||||
|
|
||||||
|
$blue: #007bff !default;
|
||||||
|
$indigo: #6610f2 !default;
|
||||||
|
$purple: #593196 !default;
|
||||||
|
$pink: #e83e8c !default;
|
||||||
|
$red: #fc3939 !default;
|
||||||
|
$orange: #fd7e14 !default;
|
||||||
|
$yellow: #efa31d !default;
|
||||||
|
$green: #13b955 !default;
|
||||||
|
$teal: #20c997 !default;
|
||||||
|
$cyan: #009cdc !default;
|
||||||
|
|
||||||
|
$primary: $purple !default;
|
||||||
|
$secondary: #a991d4 !default;
|
||||||
|
$success: $green !default;
|
||||||
|
$info: $cyan !default;
|
||||||
|
$warning: $yellow !default;
|
||||||
|
$danger: $red !default;
|
||||||
|
$light: $gray-200 !default;
|
||||||
|
$dark: $gray-900 !default;
|
||||||
|
|
||||||
|
$min-contrast-ratio: 2.1 !default;
|
||||||
|
|
||||||
|
// Options
|
||||||
|
|
||||||
|
$enable-rounded: false !default;
|
||||||
|
|
||||||
|
// Body
|
||||||
|
|
||||||
|
$body-color: $gray-700 !default;
|
||||||
|
|
||||||
|
// Links
|
||||||
|
|
||||||
|
$link-hover-color: $primary !default;
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
|
||||||
|
$table-color: initial !default;
|
||||||
|
|
||||||
|
$table-border-color: rgba(0, 0, 0, .05) !default;
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
|
||||||
|
$input-focus-border-color: $primary !default;
|
||||||
|
|
||||||
|
// Dropdowns
|
||||||
|
|
||||||
|
$dropdown-link-hover-color: $white !default;
|
||||||
|
$dropdown-link-hover-bg: $primary !default;
|
||||||
|
|
||||||
|
// Navs
|
||||||
|
|
||||||
|
$nav-tabs-border-color: $gray-300 !default;
|
||||||
|
$nav-tabs-link-hover-border-color: $primary !default;
|
||||||
|
|
||||||
|
// Navbar
|
||||||
|
|
||||||
|
$navbar-padding-y: 1.2rem !default;
|
||||||
|
|
||||||
|
// Progress bars
|
||||||
|
|
||||||
|
$progress-bg: $gray-300 !default;
|
||||||
|
$progress-bar-bg: $primary !default;
|
||||||
|
|
||||||
|
// List group
|
||||||
|
|
||||||
|
$list-group-bg: $gray-900 !default;
|
||||||
|
$list-group-border-color: transparent !default;
|
||||||
|
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
||||||
|
$list-group-active-color: $white !default;
|
||||||
|
$list-group-active-bg: $list-group-bg !default;
|
||||||
|
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||||
10
crates/ksp-app-backfill-desk/frontend/sass/main.scss
Normal file
10
crates/ksp-app-backfill-desk/frontend/sass/main.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/sass/main.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
@import "bootstrap/scss/functions";
|
||||||
|
@import "variables";
|
||||||
|
@import "fontawesome";
|
||||||
|
@import "simplebar";
|
||||||
|
@import "bootstrap/scss/bootstrap";
|
||||||
|
@import "bootswatch";
|
||||||
|
@import "app";
|
||||||
137
crates/ksp-app-backfill-desk/frontend/sass/splash.scss
Normal file
137
crates/ksp-app-backfill-desk/frontend/sass/splash.scss
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/sass/splash.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Dos Amazigh";
|
||||||
|
src: url("../fonts/DOS_Amazigh.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splash-container {
|
||||||
|
position: relative;
|
||||||
|
width: 960px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 637px;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
will-change: opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splash-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-name {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #fff;
|
||||||
|
font-family: "Dos Amazigh", sans-serif;
|
||||||
|
font-size: 76px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#debug-info {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 30%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messages-container {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
max-height: 30%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 10px 36px;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#debug-info::-webkit-scrollbar,
|
||||||
|
#messages-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-message {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
animation: splashMessageFadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-message.info { background: rgba(33, 150, 243, 0.22); }
|
||||||
|
.splash-message.warning { background: rgba(255, 152, 0, 0.22); }
|
||||||
|
.splash-message.error { background: rgba(244, 67, 54, 0.22); }
|
||||||
|
.splash-message.success { background: rgba(76, 175, 80, 0.22); }
|
||||||
|
|
||||||
|
@keyframes splashMessageFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
crates/ksp-app-backfill-desk/frontend/splash.html
Normal file
24
crates/ksp-app-backfill-desk/frontend/splash.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- file: crates/ksp-app-backfill-desk/frontend/splash.html -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<link rel="icon" href="imgs/logo.png">
|
||||||
|
<link rel="stylesheet" href="sass/splash.scss">
|
||||||
|
<title>KSP Backfill Desk — Chargement</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="splash-container" style="opacity: 0;">
|
||||||
|
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Backfill Desk">
|
||||||
|
<div id="app-name">Backfill</div>
|
||||||
|
<div id="debug-info" aria-live="off" hidden></div>
|
||||||
|
<div id="messages-container" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="ts/splash.ts" defer></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
110
crates/ksp-app-backfill-desk/frontend/ts/frontend_log.ts
Normal file
110
crates/ksp-app-backfill-desk/frontend/ts/frontend_log.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/ts/frontend_log.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { FrontendLogPayloadDto } from "./bindings/ksp_app_backfill_desk/frontend_logging/FrontendLogPayloadDto.ts";
|
||||||
|
|
||||||
|
export type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error";
|
||||||
|
export type FrontendLogTargetId = "frontend" | "main" | "splash";
|
||||||
|
|
||||||
|
type ConsoleMethod = (...items: unknown[]) => void;
|
||||||
|
|
||||||
|
const originalConsole = {
|
||||||
|
trace: console.trace.bind(console),
|
||||||
|
debug: console.debug.bind(console),
|
||||||
|
log: console.log.bind(console),
|
||||||
|
info: console.info.bind(console),
|
||||||
|
warn: console.warn.bind(console),
|
||||||
|
error: console.error.bind(console),
|
||||||
|
};
|
||||||
|
|
||||||
|
function stringifyItem(item: unknown): string {
|
||||||
|
if (item instanceof Error) {
|
||||||
|
return item.stack ?? item.message;
|
||||||
|
}
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(item);
|
||||||
|
return serialized ?? String(item);
|
||||||
|
} catch {
|
||||||
|
return String(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(items: unknown[]): string {
|
||||||
|
return items.map(item => stringifyItem(item)).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
|
||||||
|
const payload: FrontendLogPayloadDto = {
|
||||||
|
level,
|
||||||
|
targetId,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
await invoke("emit_frontend_log", { payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeOriginalConsole(level: FrontendLogLevel, message: string): void {
|
||||||
|
return level === "trace"
|
||||||
|
? originalConsole.trace(message)
|
||||||
|
: level === "debug"
|
||||||
|
? originalConsole.debug(message)
|
||||||
|
: level === "info"
|
||||||
|
? originalConsole.info(message)
|
||||||
|
: level === "warn"
|
||||||
|
? originalConsole.warn(message)
|
||||||
|
: originalConsole.error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
|
||||||
|
writeOriginalConsole(level, message);
|
||||||
|
await sendFrontendLog(level, targetId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendTrace(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.trace(message);
|
||||||
|
void sendFrontendLog("trace", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendDebug(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.debug(message);
|
||||||
|
void sendFrontendLog("debug", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendInfo(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.info(message);
|
||||||
|
void sendFrontendLog("info", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendWarn(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.warn(message);
|
||||||
|
void sendFrontendLog("warn", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendError(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.error(message);
|
||||||
|
void sendFrontendLog("error", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConsoleBridge(level: FrontendLogLevel, targetId: FrontendLogTargetId, original: ConsoleMethod): ConsoleMethod {
|
||||||
|
return (...items: unknown[]) => {
|
||||||
|
original(...items);
|
||||||
|
void sendFrontendLog(level, targetId, formatMessage(items)).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installFrontendConsoleBridge(targetId: FrontendLogTargetId): void {
|
||||||
|
console.trace = buildConsoleBridge("trace", targetId, originalConsole.trace);
|
||||||
|
console.debug = buildConsoleBridge("debug", targetId, originalConsole.debug);
|
||||||
|
console.log = buildConsoleBridge("info", targetId, originalConsole.log);
|
||||||
|
console.info = buildConsoleBridge("info", targetId, originalConsole.info);
|
||||||
|
console.warn = buildConsoleBridge("warn", targetId, originalConsole.warn);
|
||||||
|
console.error = buildConsoleBridge("error", targetId, originalConsole.error);
|
||||||
|
}
|
||||||
17
crates/ksp-app-backfill-desk/frontend/ts/invoke.ts
Normal file
17
crates/ksp-app-backfill-desk/frontend/ts/invoke.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/ts/invoke.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { frontendDebug, frontendError, frontendTrace, type FrontendLogTargetId } from "./frontend_log";
|
||||||
|
|
||||||
|
export async function invokeKsp<T>(targetId: FrontendLogTargetId, command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
frontendDebug(targetId, "Frontend IPC command requested", { command });
|
||||||
|
try {
|
||||||
|
const result = await invoke<T>(command, args);
|
||||||
|
frontendTrace(targetId, "Frontend IPC command completed", { command });
|
||||||
|
return result;
|
||||||
|
} catch (caughtError) {
|
||||||
|
frontendError(targetId, "Frontend IPC command failed", { command });
|
||||||
|
throw caughtError;
|
||||||
|
}
|
||||||
|
}
|
||||||
778
crates/ksp-app-backfill-desk/frontend/ts/main.ts
Normal file
778
crates/ksp-app-backfill-desk/frontend/ts/main.ts
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/ts/main.ts
|
||||||
|
// version: 8
|
||||||
|
|
||||||
|
import "bootstrap";
|
||||||
|
import ResizeObserver from "resize-observer-polyfill";
|
||||||
|
import "simplebar";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import type { BackfillRunStatusDto } from "./bindings/ksp_app_backfill_desk/backfill_status/BackfillRunStatusDto.ts";
|
||||||
|
import type { BackfillCancelResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillCancelResponseDto.ts";
|
||||||
|
import type { BackfillRequestPreviewDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestPreviewDto.ts";
|
||||||
|
import type { BackfillResumeResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillResumeResponseDto.ts";
|
||||||
|
import type { BackfillStartRequestDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartRequestDto.ts";
|
||||||
|
import type { BackfillStartResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartResponseDto.ts";
|
||||||
|
import type { BackfillDeskOptionsDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillDeskOptionsDto.ts";
|
||||||
|
import type { BackfillHttpRouteOptionDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillHttpRouteOptionDto.ts";
|
||||||
|
import type { ProgramIdAutocompleteOptionDto } from "./bindings/ksp_app_backfill_desk/dto_common/ProgramIdAutocompleteOptionDto.ts";
|
||||||
|
import type { ShellStatusDto } from "./bindings/ksp_app_backfill_desk/dto_common/ShellStatusDto.ts";
|
||||||
|
import { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
|
||||||
|
import { invokeKsp } from "./invoke";
|
||||||
|
|
||||||
|
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||||
|
installFrontendConsoleBridge("main");
|
||||||
|
|
||||||
|
type ViewId = "backfill" | "diagnostics";
|
||||||
|
|
||||||
|
const viewTitles: Record<ViewId, string> = {
|
||||||
|
backfill: "Backfill",
|
||||||
|
diagnostics: "Diagnostics",
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitmentLabels: Record<string, string> = {
|
||||||
|
confirmed: "confirmed",
|
||||||
|
finalized: "finalized",
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopeLabels: Record<string, string> = {
|
||||||
|
latest_address: "Adresse — historique le plus récent",
|
||||||
|
before_address: "Adresse — avant une signature",
|
||||||
|
after_address: "Adresse — après une signature",
|
||||||
|
explicit_signatures: "Signatures explicites",
|
||||||
|
};
|
||||||
|
|
||||||
|
let compositionReadyForStart = false;
|
||||||
|
let currentRunStatus: BackfillRunStatusDto | null = null;
|
||||||
|
let runStartAccepted = false;
|
||||||
|
let resumeInFlight = false;
|
||||||
|
|
||||||
|
function isViewId(value: string | undefined): value is ViewId {
|
||||||
|
return value === "backfill" || value === "diagnostics";
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateView(viewId: ViewId, source: "startup" | "user"): void {
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-view-panel]").forEach(panel => {
|
||||||
|
panel.hidden = panel.dataset.viewPanel !== viewId;
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
|
||||||
|
const active = button.dataset.view === viewId;
|
||||||
|
button.classList.toggle("active", active);
|
||||||
|
button.setAttribute("aria-current", active ? "page" : "false");
|
||||||
|
});
|
||||||
|
const header = document.querySelector<HTMLElement>("#headerViewTitle");
|
||||||
|
if (header) {
|
||||||
|
header.textContent = viewTitles[viewId];
|
||||||
|
}
|
||||||
|
document.title = `Backfill Desk — ${viewTitles[viewId]}`;
|
||||||
|
frontendTrace("main", "Backfill Desk view DOM updated", { viewId, source });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindFrontendInteractions(): void {
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
event => {
|
||||||
|
const source = event.target;
|
||||||
|
if (!(source instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const control = source.closest<HTMLElement>('button, a, input, select, textarea, [role="button"], [role="tab"], [data-view]');
|
||||||
|
if (!control) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk frontend control clicked", {
|
||||||
|
controlId: control.id || null,
|
||||||
|
role: control.getAttribute("role"),
|
||||||
|
tagName: control.tagName.toLowerCase(),
|
||||||
|
viewId: control.dataset.view ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
frontendTrace("main", "Backfill Desk frontend control interaction logger installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindNavigation(): void {
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const viewId = button.dataset.view;
|
||||||
|
frontendDebug("main", "Backfill Desk navigation tab clicked", { viewId: viewId ?? "unknown" });
|
||||||
|
if (isViewId(viewId)) {
|
||||||
|
activateView(viewId, "user");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
frontendTrace("main", "Backfill Desk navigation handlers installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateCodeSelect(selectId: string, values: string[], labels: Record<string, string>): void {
|
||||||
|
const select = document.querySelector<HTMLSelectElement>(`#${selectId}`);
|
||||||
|
if (!select) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = select.value;
|
||||||
|
select.replaceChildren();
|
||||||
|
for (const value of values) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = value;
|
||||||
|
option.textContent = labels[value] ?? value;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
select.disabled = values.length === 0;
|
||||||
|
if (values.includes(previous)) {
|
||||||
|
select.value = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNumberInputContract(inputId: string, maximum: number, defaultValue: number): void {
|
||||||
|
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.min = "1";
|
||||||
|
input.max = maximum.toString();
|
||||||
|
if (input.value === "") {
|
||||||
|
input.value = defaultValue.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStartButton(): void {
|
||||||
|
const startButton = document.querySelector<HTMLButtonElement>("#startBackfillRequest");
|
||||||
|
if (startButton) {
|
||||||
|
startButton.disabled = !compositionReadyForStart || runStartAccepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderProgramIdAutocomplete(options: ProgramIdAutocompleteOptionDto[]): void {
|
||||||
|
const datalist = document.querySelector<HTMLDataListElement>("#backfillProgramIds");
|
||||||
|
if (!datalist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
datalist.replaceChildren();
|
||||||
|
for (const program of options) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = program.programId;
|
||||||
|
option.label = `${program.name} — ${program.code}`;
|
||||||
|
option.dataset.code = program.code;
|
||||||
|
option.dataset.domain = program.domain;
|
||||||
|
option.dataset.family = program.family;
|
||||||
|
option.dataset.name = program.name;
|
||||||
|
option.dataset.protocol = program.protocol;
|
||||||
|
datalist.append(option);
|
||||||
|
}
|
||||||
|
const help = document.querySelector<HTMLElement>("#backfillProgramIdAutocompleteHelp");
|
||||||
|
if (help) {
|
||||||
|
help.textContent = `${options.length} Program ID KSP disponibles comme suggestions ; la saisie reste libre.`;
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk Program ID autocomplete dataset rendered", { optionCount: options.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedProgramAutocompleteEntry(address: string): HTMLOptionElement | null {
|
||||||
|
const datalist = document.querySelector<HTMLDataListElement>("#backfillProgramIds");
|
||||||
|
if (!datalist) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const option of Array.from(datalist.options)) {
|
||||||
|
if (option.value === address) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCommandErrorText(caughtError: unknown, fallback: string): string {
|
||||||
|
if (typeof caughtError === "object" && caughtError !== null) {
|
||||||
|
const candidate = caughtError as { code?: unknown; domain?: unknown; message?: unknown };
|
||||||
|
if (typeof candidate.domain === "string" && typeof candidate.code === "string" && typeof candidate.message === "string") {
|
||||||
|
return `${candidate.domain}/${candidate.code}: ${candidate.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCampaignContract(options: BackfillDeskOptionsDto): void {
|
||||||
|
renderProgramIdAutocomplete(options.programIdOptions);
|
||||||
|
populateCodeSelect("backfillScopeKind", options.scopeKinds, scopeLabels);
|
||||||
|
populateCodeSelect("backfillCommitment", options.commitments, commitmentLabels);
|
||||||
|
applyNumberInputContract("backfillPageSize", options.limits.maxPageSize, options.limits.defaultPageSize);
|
||||||
|
applyNumberInputContract("backfillMaxPages", options.limits.maxPages, options.limits.defaultMaxPages);
|
||||||
|
applyNumberInputContract("backfillMaxCandidates", options.limits.maxCandidates, options.limits.defaultMaxCandidates);
|
||||||
|
applyNumberInputContract("backfillHydrationConcurrency", options.limits.maxHydrationConcurrency, options.limits.defaultHydrationConcurrency);
|
||||||
|
const signatureHelp = document.querySelector<HTMLElement>("#backfillExplicitSignaturesHelp");
|
||||||
|
if (signatureHelp) {
|
||||||
|
signatureHelp.textContent = `Une signature par ligne, ${options.limits.minSignatureTextBytes}–${options.limits.maxSignatureTextBytes} octets encodés ; maximum ${options.limits.maxCandidates}.`;
|
||||||
|
}
|
||||||
|
const validateButton = document.querySelector<HTMLButtonElement>("#validateBackfillRequest");
|
||||||
|
if (validateButton) {
|
||||||
|
validateButton.disabled = !options.compositionReady || options.httpRoutes.length === 0;
|
||||||
|
}
|
||||||
|
updateCampaignScopeFields();
|
||||||
|
frontendTrace("main", "Backfill Desk campaign contract rendered", {
|
||||||
|
commitmentCount: options.commitments.length,
|
||||||
|
compositionReady: options.compositionReady,
|
||||||
|
scopeCount: options.scopeKinds.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentScopeKind(): string {
|
||||||
|
const select = document.querySelector<HTMLSelectElement>("#backfillScopeKind");
|
||||||
|
return select?.value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCampaignScopeFields(): void {
|
||||||
|
const scopeKind = currentScopeKind();
|
||||||
|
const addressGroup = document.querySelector<HTMLElement>("#backfillAddressGroup");
|
||||||
|
const anchorGroup = document.querySelector<HTMLElement>("#backfillAnchorGroup");
|
||||||
|
const signaturesGroup = document.querySelector<HTMLElement>("#backfillExplicitSignaturesGroup");
|
||||||
|
const minContextSlotGroup = document.querySelector<HTMLElement>("#backfillMinContextSlotGroup");
|
||||||
|
const addressScope = scopeKind === "latest_address" || scopeKind === "before_address" || scopeKind === "after_address";
|
||||||
|
const anchoredScope = scopeKind === "before_address" || scopeKind === "after_address";
|
||||||
|
const explicitScope = scopeKind === "explicit_signatures";
|
||||||
|
if (addressGroup) {
|
||||||
|
addressGroup.hidden = !addressScope;
|
||||||
|
}
|
||||||
|
if (anchorGroup) {
|
||||||
|
anchorGroup.hidden = !anchoredScope;
|
||||||
|
}
|
||||||
|
if (signaturesGroup) {
|
||||||
|
signaturesGroup.hidden = !explicitScope;
|
||||||
|
}
|
||||||
|
if (minContextSlotGroup) {
|
||||||
|
minContextSlotGroup.hidden = explicitScope;
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk campaign scope fields updated", { scopeKind: scopeKind || "none" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateCampaignValidation(source: "input" | "scope" | "route"): void {
|
||||||
|
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
|
||||||
|
if (status) {
|
||||||
|
status.hidden = true;
|
||||||
|
status.textContent = "";
|
||||||
|
status.classList.remove("alert-success", "alert-danger");
|
||||||
|
status.classList.add("alert-secondary");
|
||||||
|
}
|
||||||
|
const summary = document.querySelector<HTMLElement>("#backfillRequestPreview");
|
||||||
|
if (summary) {
|
||||||
|
summary.hidden = true;
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk campaign validation invalidated", { source });
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericInputValue(inputId: string): number {
|
||||||
|
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
|
||||||
|
if (!input || !Number.isSafeInteger(input.valueAsNumber) || input.valueAsNumber < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return input.valueAsNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawOptionalInput(inputId: string, enabled: boolean): string | null {
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
|
||||||
|
if (!input || input.value === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return input.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function explicitSignatureLines(): string[] {
|
||||||
|
const textarea = document.querySelector<HTMLTextAreaElement>("#backfillExplicitSignatures");
|
||||||
|
if (!textarea || currentScopeKind() !== "explicit_signatures") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return textarea.value.split(/\r?\n/).filter(line => line.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackfillStartRequest(): BackfillStartRequestDto {
|
||||||
|
const scopeKind = currentScopeKind();
|
||||||
|
const role = document.querySelector<HTMLSelectElement>("#backfillHttpRoute")?.value ?? "";
|
||||||
|
const commitment = document.querySelector<HTMLSelectElement>("#backfillCommitment")?.value ?? "";
|
||||||
|
const addressScope = scopeKind === "latest_address" || scopeKind === "before_address" || scopeKind === "after_address";
|
||||||
|
const anchoredScope = scopeKind === "before_address" || scopeKind === "after_address";
|
||||||
|
const minContextSlotEnabled = scopeKind !== "explicit_signatures";
|
||||||
|
return {
|
||||||
|
address: rawOptionalInput("backfillAddress", addressScope),
|
||||||
|
anchorSignature: rawOptionalInput("backfillAnchorSignature", anchoredScope),
|
||||||
|
commitment,
|
||||||
|
explicitSignatures: explicitSignatureLines(),
|
||||||
|
hydrationConcurrency: numericInputValue("backfillHydrationConcurrency"),
|
||||||
|
httpRole: role,
|
||||||
|
maxCandidates: numericInputValue("backfillMaxCandidates"),
|
||||||
|
maxPages: numericInputValue("backfillMaxPages"),
|
||||||
|
minContextSlot: rawOptionalInput("backfillMinContextSlot", minContextSlotEnabled),
|
||||||
|
pageSize: numericInputValue("backfillPageSize"),
|
||||||
|
scopeKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRequestPreview(preview: BackfillRequestPreviewDto): void {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
validatedNetwork: preview.network,
|
||||||
|
validatedHttpRole: preview.httpRole,
|
||||||
|
validatedCommitment: preview.commitment,
|
||||||
|
validatedScopeKind: preview.scopeKind,
|
||||||
|
validatedExplicitSignatureCount: preview.explicitSignatureCount.toString(),
|
||||||
|
validatedBounds: `${preview.pageSize} / ${preview.maxPages} / ${preview.maxCandidates} / ${preview.hydrationConcurrency}`,
|
||||||
|
validatedMinContextSlot: preview.minContextSlotPresent ? "présent" : "absent",
|
||||||
|
};
|
||||||
|
for (const [id, value] of Object.entries(values)) {
|
||||||
|
const element = document.querySelector<HTMLElement>(`#${id}`);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const summary = document.querySelector<HTMLElement>("#backfillRequestPreview");
|
||||||
|
if (summary) {
|
||||||
|
summary.hidden = false;
|
||||||
|
}
|
||||||
|
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
|
||||||
|
if (status) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.classList.remove("alert-secondary", "alert-danger");
|
||||||
|
status.classList.add("alert-success");
|
||||||
|
status.textContent = "Requête valide. Aucun job n'a été démarré.";
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk validated request preview rendered", {
|
||||||
|
commitment: preview.commitment,
|
||||||
|
httpRole: preview.httpRole,
|
||||||
|
scopeKind: preview.scopeKind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRequestValidationError(caughtError: unknown): void {
|
||||||
|
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = safeCommandErrorText(caughtError, "Requête refusée par le backend.");
|
||||||
|
status.hidden = false;
|
||||||
|
status.classList.remove("alert-secondary", "alert-success");
|
||||||
|
status.classList.add("alert-danger");
|
||||||
|
status.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBackfillRequest(): Promise<void> {
|
||||||
|
const request = buildBackfillStartRequest();
|
||||||
|
frontendDebug("main", "Backfill Desk campaign Start requested", {
|
||||||
|
commitment: request.commitment,
|
||||||
|
httpRole: request.httpRole,
|
||||||
|
scopeKind: request.scopeKind,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const started = await invokeKsp<BackfillStartResponseDto>("main", "backfill_start", { request });
|
||||||
|
runStartAccepted = true;
|
||||||
|
refreshStartButton();
|
||||||
|
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
|
||||||
|
if (status) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.classList.remove("alert-secondary", "alert-danger");
|
||||||
|
status.classList.add("alert-success");
|
||||||
|
status.textContent = `Job ${started.jobId} démarré (${started.state}).`;
|
||||||
|
}
|
||||||
|
await syncBackfillStatus("start");
|
||||||
|
frontendInfo("main", "Backfill Desk campaign Start accepted", { jobId: started.jobId, state: started.state });
|
||||||
|
} catch (caughtError) {
|
||||||
|
renderRequestValidationError(caughtError);
|
||||||
|
frontendWarn("main", "Backfill Desk campaign Start failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateBackfillRequest(): Promise<void> {
|
||||||
|
const request = buildBackfillStartRequest();
|
||||||
|
frontendDebug("main", "Backfill Desk campaign validation requested", {
|
||||||
|
commitment: request.commitment,
|
||||||
|
httpRole: request.httpRole,
|
||||||
|
scopeKind: request.scopeKind,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const preview = await invokeKsp<BackfillRequestPreviewDto>("main", "backfill_validate_request", { request });
|
||||||
|
renderRequestPreview(preview);
|
||||||
|
frontendDebug("main", "Backfill Desk campaign validation completed", {
|
||||||
|
commitment: preview.commitment,
|
||||||
|
httpRole: preview.httpRole,
|
||||||
|
scopeKind: preview.scopeKind,
|
||||||
|
});
|
||||||
|
} catch (caughtError) {
|
||||||
|
renderRequestValidationError(caughtError);
|
||||||
|
frontendWarn("main", "Backfill Desk campaign validation failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCampaignForm(): void {
|
||||||
|
const scope = document.querySelector<HTMLSelectElement>("#backfillScopeKind");
|
||||||
|
if (scope) {
|
||||||
|
scope.addEventListener("change", () => {
|
||||||
|
frontendDebug("main", "Backfill Desk campaign scope changed", { scopeKind: scope.value || "none" });
|
||||||
|
updateCampaignScopeFields();
|
||||||
|
invalidateCampaignValidation("scope");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const form = document.querySelector<HTMLFormElement>("#backfillCampaignForm");
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("input", () => invalidateCampaignValidation("input"));
|
||||||
|
form.addEventListener("submit", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
void validateBackfillRequest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const address = document.querySelector<HTMLInputElement>("#backfillAddress");
|
||||||
|
if (address) {
|
||||||
|
address.addEventListener("change", () => {
|
||||||
|
const selected = selectedProgramAutocompleteEntry(address.value);
|
||||||
|
frontendTrace("main", "Backfill Desk address autocomplete selection evaluated", {
|
||||||
|
programCode: selected?.dataset.code ?? null,
|
||||||
|
registryMatch: selected !== null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const startButton = document.querySelector<HTMLButtonElement>("#startBackfillRequest");
|
||||||
|
if (startButton) {
|
||||||
|
startButton.addEventListener("click", () => {
|
||||||
|
void startBackfillRequest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk campaign form handlers installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeLabel(route: BackfillHttpRouteOptionDto): string {
|
||||||
|
if (route.pooled) {
|
||||||
|
return `Pool multi-provider — ${route.providers.join(" + ")}`;
|
||||||
|
}
|
||||||
|
return route.providers.length === 1 ? `Provider — ${route.providers[0]}` : route.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBackfillOptions(options: BackfillDeskOptionsDto): void {
|
||||||
|
compositionReadyForStart = options.compositionReady;
|
||||||
|
renderCampaignContract(options);
|
||||||
|
refreshStartButton();
|
||||||
|
const select = document.querySelector<HTMLSelectElement>("#backfillHttpRoute");
|
||||||
|
if (select) {
|
||||||
|
const previous = select.value;
|
||||||
|
select.replaceChildren();
|
||||||
|
for (const route of options.httpRoutes) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = route.role;
|
||||||
|
option.textContent = routeLabel(route);
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
select.disabled = !options.transportReady || options.httpRoutes.length === 0;
|
||||||
|
const reusable = options.httpRoutes.some(route => route.role === previous);
|
||||||
|
if (reusable) {
|
||||||
|
select.value = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
backfillNetwork: options.configuredNetworks.length === 1 ? options.configuredNetworks[0] : options.configuredNetworks.join(", ") || "indisponible",
|
||||||
|
backfillTransportReady: options.transportReady ? "ready" : "non ready",
|
||||||
|
backfillStoreReady: options.storeReady ? "ready" : "non ready",
|
||||||
|
backfillCompositionReady: options.compositionReady ? "ready" : "non ready",
|
||||||
|
};
|
||||||
|
for (const [id, value] of Object.entries(values)) {
|
||||||
|
const element = document.querySelector<HTMLElement>(`#${id}`);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const diagnostic = document.querySelector<HTMLElement>("#backfillOptionsDiagnostic");
|
||||||
|
if (diagnostic) {
|
||||||
|
const error = options.transportDiagnostic ?? options.storeDiagnostic;
|
||||||
|
diagnostic.hidden = error === null;
|
||||||
|
diagnostic.textContent = error === null ? "" : `${error.domain}/${error.code}: ${error.message}`;
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk HTTP route options rendered", {
|
||||||
|
compositionReady: options.compositionReady,
|
||||||
|
routeCount: options.httpRoutes.length,
|
||||||
|
transportReady: options.transportReady,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackfillOptions(source: "startup" | "user"): Promise<void> {
|
||||||
|
frontendDebug("main", "Backfill Desk HTTP route options load started", { source });
|
||||||
|
const options = await invokeKsp<BackfillDeskOptionsDto>("main", "backfill_options");
|
||||||
|
renderBackfillOptions(options);
|
||||||
|
frontendDebug("main", "Backfill Desk HTTP route options load completed", { source, routeCount: options.httpRoutes.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindBackfillRouteSelection(): void {
|
||||||
|
const select = document.querySelector<HTMLSelectElement>("#backfillHttpRoute");
|
||||||
|
if (!select) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
frontendDebug("main", "Backfill Desk HTTP route selection changed", { role: select.value || "none" });
|
||||||
|
invalidateCampaignValidation("route");
|
||||||
|
});
|
||||||
|
frontendTrace("main", "Backfill Desk HTTP route selection handler installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "cancel" | "event" | "resume" | "startup" | "start" | "user"): void {
|
||||||
|
const card = document.querySelector<HTMLElement>("#backfillRunStatusCard");
|
||||||
|
const cancelButton = document.querySelector<HTMLButtonElement>("#cancelBackfillRun");
|
||||||
|
const resumeButton = document.querySelector<HTMLButtonElement>("#resumeBackfillRun");
|
||||||
|
currentRunStatus = status;
|
||||||
|
if (!status) {
|
||||||
|
runStartAccepted = false;
|
||||||
|
if (cancelButton) {
|
||||||
|
cancelButton.disabled = true;
|
||||||
|
}
|
||||||
|
if (resumeButton) {
|
||||||
|
resumeButton.disabled = true;
|
||||||
|
}
|
||||||
|
resumeInFlight = false;
|
||||||
|
refreshStartButton();
|
||||||
|
if (card) {
|
||||||
|
card.hidden = true;
|
||||||
|
}
|
||||||
|
const terminalSummary = document.querySelector<HTMLElement>("#backfillTerminalSummary");
|
||||||
|
if (terminalSummary) {
|
||||||
|
terminalSummary.hidden = true;
|
||||||
|
terminalSummary.textContent = "";
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk latest-value status cleared", { source });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runStartAccepted = status.active;
|
||||||
|
if (cancelButton) {
|
||||||
|
cancelButton.disabled = !status.active;
|
||||||
|
}
|
||||||
|
if (status.active) {
|
||||||
|
resumeInFlight = false;
|
||||||
|
}
|
||||||
|
if (resumeButton) {
|
||||||
|
resumeButton.disabled = !status.terminal || !status.checkpointPresent || resumeInFlight;
|
||||||
|
}
|
||||||
|
refreshStartButton();
|
||||||
|
if (card) {
|
||||||
|
card.hidden = false;
|
||||||
|
}
|
||||||
|
const terminalSummary = document.querySelector<HTMLElement>("#backfillTerminalSummary");
|
||||||
|
if (terminalSummary) {
|
||||||
|
terminalSummary.hidden = !status.terminal;
|
||||||
|
terminalSummary.classList.remove("alert-success", "alert-warning", "alert-danger", "alert-secondary");
|
||||||
|
if (status.terminal) {
|
||||||
|
const failure = status.failureDomain === null || status.failureCode === null ? null : `${status.failureDomain}/${status.failureCode}`;
|
||||||
|
if (status.state === "completed") {
|
||||||
|
terminalSummary.classList.add("alert-success");
|
||||||
|
terminalSummary.textContent = `Campagne terminée — ${status.contiguousCompleted} candidats contigus durablement traités.`;
|
||||||
|
} else if (status.state === "cancelled") {
|
||||||
|
terminalSummary.classList.add("alert-warning");
|
||||||
|
terminalSummary.textContent = `Campagne annulée coopérativement — checkpoint ${status.checkpointPresent ? "disponible" : "absent"}.`;
|
||||||
|
} else {
|
||||||
|
terminalSummary.classList.add("alert-danger");
|
||||||
|
terminalSummary.textContent = `Campagne terminée en échec${failure === null ? "" : ` — ${failure}`}.`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
terminalSummary.classList.add("alert-secondary");
|
||||||
|
terminalSummary.textContent = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
runStatusJobId: status.jobId,
|
||||||
|
runStatusLifecycle: status.completion === null ? status.state : `${status.state} / ${status.completion}`,
|
||||||
|
runStatusPhase: status.phase,
|
||||||
|
runStatusSequence: status.sequence,
|
||||||
|
runStatusScope: status.scopeKind,
|
||||||
|
runStatusBoundary: status.discoveryBoundary ?? "—",
|
||||||
|
runStatusCandidates: `${status.candidatesSelected} / ${status.candidatesAdmitted} / ${status.candidatesFinished}`,
|
||||||
|
runStatusFrontier: `${status.contiguousCompleted} (holes ${status.holes})`,
|
||||||
|
runStatusEntities: `${status.entitiesInserted} / ${status.entitiesExisting} / ${status.entitiesPurged}`,
|
||||||
|
runStatusObservations: `${status.observationsInserted} / ${status.observationsExisting}`,
|
||||||
|
runStatusAnomalies: `missing ${status.missing} / conflits ${status.conflicts} / annulés ${status.cancelledCandidates}`,
|
||||||
|
runStatusConcurrency: status.maximumInFlight.toString(),
|
||||||
|
runStatusCheckpoint: status.checkpointPresent ? "présent" : "absent",
|
||||||
|
runStatusFailure: status.failureDomain === null || status.failureCode === null ? "—" : `${status.failureDomain}/${status.failureCode}`,
|
||||||
|
};
|
||||||
|
for (const [id, value] of Object.entries(values)) {
|
||||||
|
const element = document.querySelector<HTMLElement>(`#${id}`);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk latest-value status rendered", {
|
||||||
|
active: status.active,
|
||||||
|
phase: status.phase,
|
||||||
|
sequence: status.sequence,
|
||||||
|
source,
|
||||||
|
state: status.state,
|
||||||
|
terminal: status.terminal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncBackfillStatus(source: "cancel" | "resume" | "startup" | "start" | "user"): Promise<void> {
|
||||||
|
frontendDebug("main", "Backfill Desk latest-value status resynchronization started", { source });
|
||||||
|
const status = await invokeKsp<BackfillRunStatusDto | null>("main", "backfill_status");
|
||||||
|
renderBackfillRunStatus(status, source);
|
||||||
|
frontendDebug("main", "Backfill Desk latest-value status resynchronization completed", { source, statusPresent: status !== null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeBackfillRun(): Promise<void> {
|
||||||
|
const status = currentRunStatus;
|
||||||
|
if (!status || !status.terminal || !status.checkpointPresent || resumeInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resumeButton = document.querySelector<HTMLButtonElement>("#resumeBackfillRun");
|
||||||
|
resumeInFlight = true;
|
||||||
|
if (resumeButton) {
|
||||||
|
resumeButton.disabled = true;
|
||||||
|
}
|
||||||
|
frontendDebug("main", "Backfill Desk in-session Resume requested", { jobId: status.jobId, state: status.state });
|
||||||
|
try {
|
||||||
|
const response = await invokeKsp<BackfillResumeResponseDto>("main", "backfill_resume");
|
||||||
|
const feedback = document.querySelector<HTMLElement>("#backfillResumeFeedback");
|
||||||
|
if (feedback) {
|
||||||
|
feedback.hidden = false;
|
||||||
|
feedback.textContent = `Checkpoint repris dans ${response.jobId} (${response.state}).`;
|
||||||
|
}
|
||||||
|
frontendInfo("main", "Backfill Desk in-session Resume accepted", {
|
||||||
|
accepted: response.accepted,
|
||||||
|
jobId: response.jobId,
|
||||||
|
state: response.state,
|
||||||
|
});
|
||||||
|
await syncBackfillStatus("resume");
|
||||||
|
} catch (caughtError) {
|
||||||
|
resumeInFlight = false;
|
||||||
|
if (resumeButton && currentRunStatus?.terminal && currentRunStatus.checkpointPresent) {
|
||||||
|
resumeButton.disabled = false;
|
||||||
|
}
|
||||||
|
const feedback = document.querySelector<HTMLElement>("#backfillResumeFeedback");
|
||||||
|
if (feedback) {
|
||||||
|
feedback.hidden = false;
|
||||||
|
feedback.classList.remove("alert-secondary", "alert-success");
|
||||||
|
feedback.classList.add("alert-danger");
|
||||||
|
feedback.textContent = safeCommandErrorText(caughtError, "La reprise du checkpoint a été refusée.");
|
||||||
|
}
|
||||||
|
frontendWarn("main", "Backfill Desk in-session Resume failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindBackfillStatusMonitoring(): Promise<void> {
|
||||||
|
await listen<BackfillRunStatusDto>("ksp-backfill-status", event => {
|
||||||
|
renderBackfillRunStatus(event.payload, "event");
|
||||||
|
});
|
||||||
|
const button = document.querySelector<HTMLButtonElement>("#refreshBackfillStatus");
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
frontendDebug("main", "Backfill Desk latest-value status refresh button clicked");
|
||||||
|
void syncBackfillStatus("user").catch(() => frontendWarn("main", "Backfill Desk latest-value status refresh failed"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const cancelButton = document.querySelector<HTMLButtonElement>("#cancelBackfillRun");
|
||||||
|
if (cancelButton) {
|
||||||
|
cancelButton.addEventListener("click", () => {
|
||||||
|
const status = currentRunStatus;
|
||||||
|
if (!status || !status.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelButton.disabled = true;
|
||||||
|
frontendDebug("main", "Backfill Desk cooperative cancellation requested", { jobId: status.jobId, state: status.state });
|
||||||
|
void invokeKsp<BackfillCancelResponseDto>("main", "backfill_cancel", { jobId: status.jobId })
|
||||||
|
.then(response => {
|
||||||
|
const feedback = document.querySelector<HTMLElement>("#backfillCancelFeedback");
|
||||||
|
if (feedback) {
|
||||||
|
feedback.hidden = false;
|
||||||
|
feedback.textContent = response.accepted
|
||||||
|
? `Annulation coopérative acceptée pour ${response.jobId}.`
|
||||||
|
: `Aucune nouvelle annulation acceptée pour ${response.jobId} (${response.state}).`;
|
||||||
|
}
|
||||||
|
frontendInfo("main", "Backfill Desk cooperative cancellation response received", {
|
||||||
|
accepted: response.accepted,
|
||||||
|
jobId: response.jobId,
|
||||||
|
state: response.state,
|
||||||
|
});
|
||||||
|
return syncBackfillStatus("cancel");
|
||||||
|
})
|
||||||
|
.catch(caughtError => {
|
||||||
|
const feedback = document.querySelector<HTMLElement>("#backfillCancelFeedback");
|
||||||
|
if (feedback) {
|
||||||
|
feedback.hidden = false;
|
||||||
|
feedback.classList.remove("alert-secondary", "alert-success");
|
||||||
|
feedback.classList.add("alert-danger");
|
||||||
|
feedback.textContent = safeCommandErrorText(caughtError, "La demande d'annulation a été refusée.");
|
||||||
|
}
|
||||||
|
frontendWarn("main", "Backfill Desk cooperative cancellation failed");
|
||||||
|
if (currentRunStatus?.active) {
|
||||||
|
cancelButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const resumeButton = document.querySelector<HTMLButtonElement>("#resumeBackfillRun");
|
||||||
|
if (resumeButton) {
|
||||||
|
resumeButton.addEventListener("click", () => {
|
||||||
|
void resumeBackfillRun();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk latest-value monitoring, cancellation and Resume handlers installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRuntimeStatus(status: ShellStatusDto): void {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
runtimeVersion: status.applicationVersion,
|
||||||
|
runtimeShellPhase: status.shellPhase,
|
||||||
|
runtimeConfigDocuments: status.configDocumentCount.toString(),
|
||||||
|
runtimeLoggingProfile: status.activeLoggingProfile ?? "fallback transitoire",
|
||||||
|
runtimeLoggingFallback: status.fallbackLoggingActive ? "oui" : "non",
|
||||||
|
};
|
||||||
|
for (const [id, value] of Object.entries(values)) {
|
||||||
|
const element = document.querySelector<HTMLElement>(`#${id}`);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const badge = document.querySelector<HTMLElement>("#appVersionBadge");
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = status.applicationVersion;
|
||||||
|
}
|
||||||
|
const diagnostic = document.querySelector<HTMLElement>("#runtimeDiagnostic");
|
||||||
|
if (diagnostic) {
|
||||||
|
diagnostic.hidden = status.startupDiagnostic === null;
|
||||||
|
diagnostic.textContent = status.startupDiagnostic === null ? "" : `${status.startupDiagnostic.domain}/${status.startupDiagnostic.code}: ${status.startupDiagnostic.message}`;
|
||||||
|
}
|
||||||
|
frontendTrace("main", "Backfill Desk runtime status rendered", {
|
||||||
|
fallbackLoggingActive: status.fallbackLoggingActive,
|
||||||
|
configDocumentCount: status.configDocumentCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRuntimeStatus(source: "startup" | "user"): Promise<void> {
|
||||||
|
frontendDebug("main", "Backfill Desk runtime status load started", { source });
|
||||||
|
const status = await invokeKsp<ShellStatusDto>("main", "get_runtime_status");
|
||||||
|
renderRuntimeStatus(status);
|
||||||
|
frontendDebug("main", "Backfill Desk runtime status load completed", { source });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRuntimeStatusRefresh(): void {
|
||||||
|
const button = document.querySelector<HTMLButtonElement>("#refreshRuntimeStatus");
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
frontendDebug("main", "Backfill Desk runtime status refresh button clicked");
|
||||||
|
void loadRuntimeStatus("user").catch(() => frontendWarn("main", "Backfill Desk runtime status refresh failed"));
|
||||||
|
});
|
||||||
|
frontendTrace("main", "Backfill Desk runtime status refresh handler installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeMain(): Promise<void> {
|
||||||
|
const windowLabel = getCurrentWindow().label;
|
||||||
|
frontendInfo("main", "Backfill Desk main frontend loaded", { windowLabel });
|
||||||
|
bindFrontendInteractions();
|
||||||
|
bindNavigation();
|
||||||
|
bindRuntimeStatusRefresh();
|
||||||
|
bindBackfillRouteSelection();
|
||||||
|
bindCampaignForm();
|
||||||
|
await bindBackfillStatusMonitoring();
|
||||||
|
activateView("backfill", "startup");
|
||||||
|
try {
|
||||||
|
await Promise.all([loadRuntimeStatus("startup"), loadBackfillOptions("startup"), syncBackfillStatus("startup")]);
|
||||||
|
} catch {
|
||||||
|
frontendWarn("main", "Backfill Desk startup runtime status load failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
void initializeMain();
|
||||||
|
});
|
||||||
131
crates/ksp-app-backfill-desk/frontend/ts/splash.ts
Normal file
131
crates/ksp-app-backfill-desk/frontend/ts/splash.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/frontend/ts/splash.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import type { SplashOrderDto } from "./bindings/ksp_app_backfill_desk/splash/SplashOrderDto.ts";
|
||||||
|
import { frontendDebug, frontendError, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
|
||||||
|
import { invokeKsp } from "./invoke";
|
||||||
|
|
||||||
|
installFrontendConsoleBridge("splash");
|
||||||
|
|
||||||
|
let activeOpacityFrame: number | null = null;
|
||||||
|
|
||||||
|
function easeInOut(value: number): number {
|
||||||
|
return value < 0.5 ? 2 * value * value : 1 - Math.pow(-2 * value + 2, 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDurationMs(value: number | null): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function animateOpacity(element: HTMLElement, fromOpacity: number, toOpacity: number, durationMs: number): Promise<void> {
|
||||||
|
frontendTrace("splash", "Splash opacity animation started", { fromOpacity, toOpacity, durationMs });
|
||||||
|
if (activeOpacityFrame !== null) {
|
||||||
|
cancelAnimationFrame(activeOpacityFrame);
|
||||||
|
activeOpacityFrame = null;
|
||||||
|
}
|
||||||
|
element.style.opacity = fromOpacity.toString();
|
||||||
|
element.style.willChange = "opacity";
|
||||||
|
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const opacityDelta = toOpacity - fromOpacity;
|
||||||
|
const updateOpacity = (currentTime: number): void => {
|
||||||
|
const elapsedMs = currentTime - startedAt;
|
||||||
|
const rawProgress = durationMs === 0 ? 1 : Math.min(elapsedMs / durationMs, 1);
|
||||||
|
element.style.opacity = (fromOpacity + opacityDelta * easeInOut(rawProgress)).toString();
|
||||||
|
if (rawProgress >= 1) {
|
||||||
|
element.style.opacity = toOpacity.toString();
|
||||||
|
element.style.willChange = "auto";
|
||||||
|
activeOpacityFrame = null;
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeOpacityFrame = requestAnimationFrame(updateOpacity);
|
||||||
|
};
|
||||||
|
activeOpacityFrame = requestAnimationFrame(updateOpacity);
|
||||||
|
});
|
||||||
|
frontendTrace("splash", "Splash opacity animation completed", { toOpacity, durationMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToLatest(element: HTMLElement): void {
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(message: string, status: string | null): void {
|
||||||
|
const container = document.querySelector<HTMLElement>("#messages-container");
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const line = document.createElement("div");
|
||||||
|
const safeStatus = status === "warning" || status === "error" || status === "success" ? status : "info";
|
||||||
|
line.className = `splash-message ${safeStatus}`;
|
||||||
|
line.textContent = message;
|
||||||
|
container.appendChild(line);
|
||||||
|
scrollToLatest(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDebugMessage(message: string): void {
|
||||||
|
const container = document.querySelector<HTMLElement>("#debug-info");
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.hidden = false;
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
|
||||||
|
container.appendChild(line);
|
||||||
|
scrollToLatest(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSplashOrder(order: SplashOrderDto): Promise<void> {
|
||||||
|
frontendTrace("splash", "Splash order received", { action: order.action });
|
||||||
|
const container = document.querySelector<HTMLElement>("#splash-container");
|
||||||
|
if (order.action === "add_message" && order.message) {
|
||||||
|
addMessage(order.message, order.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (order.action === "add_debug" && order.message) {
|
||||||
|
addDebugMessage(order.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (order.action === "fade_in") {
|
||||||
|
await animateOpacity(container, 0, 1, normalizeDurationMs(order.durationMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (order.action === "fade_out") {
|
||||||
|
await animateOpacity(container, 1, 0, normalizeDurationMs(order.durationMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeSplash(): Promise<void> {
|
||||||
|
const windowLabel = getCurrentWindow().label;
|
||||||
|
frontendInfo("splash", "Backfill Desk splash frontend loaded", { windowLabel });
|
||||||
|
const container = document.querySelector<HTMLElement>("#splash-container");
|
||||||
|
if (container) {
|
||||||
|
container.style.opacity = "0";
|
||||||
|
container.style.willChange = "opacity";
|
||||||
|
frontendTrace("splash", "Splash container prepared for managed fade-in");
|
||||||
|
}
|
||||||
|
await listen<SplashOrderDto>("ksp-splash-order", event => {
|
||||||
|
void handleSplashOrder(event.payload);
|
||||||
|
});
|
||||||
|
frontendDebug("splash", "Splash lifecycle listener installed");
|
||||||
|
try {
|
||||||
|
await invokeKsp<void>("splash", "splash_frontend_ready");
|
||||||
|
} catch {
|
||||||
|
addMessage("Le lifecycle du splash n'a pas pu démarrer.", "error");
|
||||||
|
if (container) {
|
||||||
|
container.style.opacity = "1";
|
||||||
|
container.style.willChange = "auto";
|
||||||
|
}
|
||||||
|
frontendError("splash", "Splash readiness command failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
void initializeSplash();
|
||||||
|
});
|
||||||
BIN
crates/ksp-app-backfill-desk/icons/favicon.ico
Normal file
BIN
crates/ksp-app-backfill-desk/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
BIN
crates/ksp-app-backfill-desk/icons/favicon.png
Normal file
BIN
crates/ksp-app-backfill-desk/icons/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
26
crates/ksp-app-backfill-desk/package.json
Normal file
26
crates/ksp-app-backfill-desk/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "ksp-app-backfill-desk",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.3.7",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fltsci/tauri-plugin-tracing": "^0.3",
|
||||||
|
"@fortawesome/fontawesome-free": "^7.3",
|
||||||
|
"@tauri-apps/api": "^2.11",
|
||||||
|
"bootstrap": "^5.3",
|
||||||
|
"resize-observer-polyfill": "^1.5",
|
||||||
|
"simplebar": "^6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11",
|
||||||
|
"@types/bootstrap": "^5.2",
|
||||||
|
"@types/node": "^26.1",
|
||||||
|
"sass-embedded": "^1.102",
|
||||||
|
"typescript": "^7.0",
|
||||||
|
"vite": "^8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
448
crates/ksp-app-backfill-desk/src/app_state.rs
Normal file
448
crates/ksp-app-backfill-desk/src/app_state.rs
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/app_state.rs
|
||||||
|
// version: 10
|
||||||
|
|
||||||
|
//! Shared backend state owned by the Backfill Desk Tauri application.
|
||||||
|
|
||||||
|
/// Shared Backfill Desk application state managed by Tauri.
|
||||||
|
pub(crate) struct AppState {
|
||||||
|
backfill_runs: crate::BackfillRunState,
|
||||||
|
config_management: ksp_config_lib::ConfigManagement,
|
||||||
|
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
|
||||||
|
shutdown_started: std::sync::atomic::AtomicBool,
|
||||||
|
splash_settings: crate::SplashSettings,
|
||||||
|
splash_sequence_started: std::sync::atomic::AtomicBool,
|
||||||
|
store_startup: crate::StoreStartup,
|
||||||
|
transport_runtime: std::option::Option<crate::TransportRuntime>,
|
||||||
|
transport_startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::AppState {
|
||||||
|
/// Initializes the scaffold Config ownership, Logging runtime and splash settings.
|
||||||
|
pub(crate) fn initialize(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
|
||||||
|
let config_management = crate::config_management(arguments);
|
||||||
|
let config_management = match config_management {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime_identity = crate::launch_identity();
|
||||||
|
let runtime_identity = match runtime_identity {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let logging_startup = crate::initialize_logging(&config_management, &runtime_identity);
|
||||||
|
let logging_startup = match logging_startup {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let transport_startup = crate::initialize_transport(&config_management);
|
||||||
|
let (transport_runtime, transport_startup_diagnostic) = match transport_startup {
|
||||||
|
std::result::Result::Ok(value) => (std::option::Option::Some(value), std::option::Option::None),
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let diagnostic = crate::CommandErrorDto::from_error(&error);
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_TRANSPORT,
|
||||||
|
error_domain = diagnostic.domain.as_str(),
|
||||||
|
error_code = diagnostic.code.as_str(),
|
||||||
|
"Backfill Desk Transport readiness is unavailable; keeping desktop shell available"
|
||||||
|
);
|
||||||
|
(std::option::Option::None, std::option::Option::Some(diagnostic))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let store_startup = tauri::async_runtime::block_on(crate::initialize_store(&config_management, transport_runtime.as_ref()));
|
||||||
|
let splash_settings = crate::SplashSettings::load();
|
||||||
|
let splash_settings = match splash_settings {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
error_domain = error.code().domain(),
|
||||||
|
error_code = error.code().code(),
|
||||||
|
"managed splash timings are invalid; using transient in-memory defaults"
|
||||||
|
);
|
||||||
|
crate::SplashSettings::fallback()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
minimum_ms = splash_settings.minimum_ms(),
|
||||||
|
minimum_source = splash_settings.minimum_source(),
|
||||||
|
fade_in_ms = splash_settings.fade_in_ms(),
|
||||||
|
fade_in_source = splash_settings.fade_in_source(),
|
||||||
|
fade_out_ms = splash_settings.fade_out_ms(),
|
||||||
|
fade_out_source = splash_settings.fade_out_source(),
|
||||||
|
expected_backend_lifecycle_ms = splash_settings.expected_backend_lifecycle_ms(),
|
||||||
|
"resolved Backfill Desk splash timings"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
backfill_runs: crate::BackfillRunState::new(),
|
||||||
|
config_management,
|
||||||
|
logging_runtime: std::sync::Mutex::new(LoggingRuntimeState {
|
||||||
|
guard: logging_startup.guard,
|
||||||
|
active_profile_id: logging_startup.active_profile_id,
|
||||||
|
fallback_active: logging_startup.fallback_active,
|
||||||
|
startup_diagnostic: logging_startup.startup_diagnostic,
|
||||||
|
}),
|
||||||
|
shutdown_started: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
splash_settings,
|
||||||
|
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
store_startup,
|
||||||
|
transport_runtime,
|
||||||
|
transport_startup_diagnostic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the safe scaffold status exposed by the Backfill Desk shell.
|
||||||
|
pub(crate) fn shell_status(&self) -> ksp_core_lib::Result<crate::ShellStatusDto> {
|
||||||
|
let document_count = self.config_management.engine().registry().descriptors().count();
|
||||||
|
let document_count = u32::try_from(document_count);
|
||||||
|
let document_count = match document_count {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Config registry count cannot be represented by Backfill Desk")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let runtime = self.logging_runtime.lock();
|
||||||
|
let runtime = match runtime {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk Logging runtime state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let _keep_guard_alive = &runtime.guard;
|
||||||
|
return std::result::Result::Ok(crate::ShellStatusDto {
|
||||||
|
active_logging_profile: runtime.active_profile_id.clone(),
|
||||||
|
application_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||||
|
config_document_count: document_count,
|
||||||
|
fallback_logging_active: runtime.fallback_active,
|
||||||
|
shell_phase: "pre.012-frontend-polish".to_owned(),
|
||||||
|
startup_diagnostic: runtime.startup_diagnostic.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the safe Transport-and-Store readiness subset of Backfill Desk options.
|
||||||
|
pub(crate) fn backfill_options(&self) -> ksp_core_lib::Result<crate::BackfillDeskOptionsDto> {
|
||||||
|
let runtime = self.transport_runtime.as_ref();
|
||||||
|
let options = match runtime {
|
||||||
|
std::option::Option::Some(value) => value.options(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
let limits = crate::backfill_request_limits();
|
||||||
|
let limits = match limits {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
std::result::Result::Ok(crate::BackfillDeskOptionsDto {
|
||||||
|
commitments: crate::backfill_commitment_codes(),
|
||||||
|
composition_ready: false,
|
||||||
|
configured_networks: std::vec::Vec::new(),
|
||||||
|
http_routes: std::vec::Vec::new(),
|
||||||
|
limits,
|
||||||
|
network_coherent: false,
|
||||||
|
program_id_options: crate::program_id_autocomplete_options(),
|
||||||
|
scope_kinds: crate::backfill_scope_kind_codes(),
|
||||||
|
store_diagnostic: std::option::Option::None,
|
||||||
|
store_network: std::option::Option::None,
|
||||||
|
store_ready: false,
|
||||||
|
transport_diagnostic: self.transport_startup_diagnostic.clone(),
|
||||||
|
transport_ready: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut options = match options {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
self.store_startup.apply_to(&mut options);
|
||||||
|
return std::result::Result::Ok(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates and maps one frontend campaign request without starting a Backfill Job.
|
||||||
|
pub(crate) fn validate_backfill_request(&self, request: crate::BackfillStartRequestDto) -> ksp_core_lib::Result<crate::BackfillRequestPreviewDto> {
|
||||||
|
let options = self.backfill_options();
|
||||||
|
let options = match options {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let job_id = ksp_job_api::JobId::new("backfill-desk-validation");
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mapped = crate::map_backfill_request(request, &options, job_id);
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let preview = crate::project_backfill_request(&mapped);
|
||||||
|
let preview = match preview {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_REQUEST,
|
||||||
|
commitment = preview.commitment.as_str(),
|
||||||
|
http_role = preview.http_role.as_str(),
|
||||||
|
scope_kind = preview.scope_kind.as_str(),
|
||||||
|
"validated Backfill Desk campaign request without starting a Job"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepares one concrete Backfill runtime, installs its control handle atomically and lends execution resources before spawn.
|
||||||
|
pub(crate) fn prepare_backfill_start(&self, request: crate::BackfillStartRequestDto) -> ksp_core_lib::Result<crate::BackfillRunLaunch> {
|
||||||
|
if self.shutdown_started.load(std::sync::atomic::Ordering::Acquire) {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY,
|
||||||
|
"Backfill Desk cannot start a Backfill run while application shutdown is in progress",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let options = self.backfill_options();
|
||||||
|
let options = match options {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let job_id = self.backfill_runs.next_job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mapped = crate::map_backfill_request(request, &options, job_id.clone());
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let retained_request = mapped.clone();
|
||||||
|
let runtime = ksp_job_backfill_lib::BackfillJobRuntime::new(mapped);
|
||||||
|
let runtime = match runtime {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let handle = runtime.handle();
|
||||||
|
let snapshots = handle.snapshots();
|
||||||
|
let installed = self.backfill_runs.install(job_id.clone(), handle, retained_request);
|
||||||
|
if let std::result::Result::Err(error) = installed {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let transport = self.transport_runtime.as_ref();
|
||||||
|
let transport = match transport {
|
||||||
|
std::option::Option::Some(value) => value.pool(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
let rollback = self.backfill_runs.rollback(&job_id);
|
||||||
|
if let std::result::Result::Err(error) = rollback {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY,
|
||||||
|
"Backfill Desk cannot start a Backfill run without a ready HTTP Transport pool",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let store = self.store_startup.take_for_run();
|
||||||
|
let store = match store {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let rollback = self.backfill_runs.rollback(&job_id);
|
||||||
|
if let std::result::Result::Err(rollback_error) = rollback {
|
||||||
|
return std::result::Result::Err(rollback_error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_RUN,
|
||||||
|
job_id = job_id.as_str(),
|
||||||
|
"admitted Backfill Desk run and installed control handle before async spawn"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(crate::BackfillRunLaunch { job_id, runtime, snapshots, store, transport });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepares one in-session Resume from the retained Rust-only checkpoint using a new backend Job identity.
|
||||||
|
pub(crate) fn prepare_backfill_resume(&self) -> ksp_core_lib::Result<crate::BackfillRunLaunch> {
|
||||||
|
if self.shutdown_started.load(std::sync::atomic::Ordering::Acquire) {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY,
|
||||||
|
"Backfill Desk cannot resume a Backfill run while application shutdown is in progress",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let options = self.backfill_options();
|
||||||
|
let options = match options {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !options.composition_ready {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY,
|
||||||
|
"Backfill Desk cannot resume without ready Transport and Store composition",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let job_id = self.backfill_runs.next_job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mapped = self.backfill_runs.resume_request(job_id.clone());
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let store_network_matches = options.store_network.as_deref() == std::option::Option::Some(mapped.network().as_str());
|
||||||
|
let role_available = options.http_routes.iter().any(|route| return route.role == mapped.role().as_str());
|
||||||
|
if !store_network_matches || !role_available {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY,
|
||||||
|
"Backfill Desk retained Resume request no longer matches current Transport/Store composition",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let retained_request = mapped.clone();
|
||||||
|
let runtime = ksp_job_backfill_lib::BackfillJobRuntime::new(mapped);
|
||||||
|
let runtime = match runtime {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let handle = runtime.handle();
|
||||||
|
let snapshots = handle.snapshots();
|
||||||
|
let installed = self.backfill_runs.install(job_id.clone(), handle, retained_request);
|
||||||
|
if let std::result::Result::Err(error) = installed {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let transport = self.transport_runtime.as_ref();
|
||||||
|
let transport = match transport {
|
||||||
|
std::option::Option::Some(value) => value.pool(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
let rollback = self.backfill_runs.rollback(&job_id);
|
||||||
|
if let std::result::Result::Err(error) = rollback {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY,
|
||||||
|
"Backfill Desk cannot resume a Backfill run without a ready HTTP Transport pool",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let store = self.store_startup.take_for_run();
|
||||||
|
let store = match store {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let rollback = self.backfill_runs.rollback(&job_id);
|
||||||
|
if let std::result::Result::Err(rollback_error) = rollback {
|
||||||
|
return std::result::Result::Err(rollback_error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_RUN,
|
||||||
|
job_id = job_id.as_str(),
|
||||||
|
"admitted Backfill Desk in-session Resume with a reissued Rust-only checkpoint"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(crate::BackfillRunLaunch { job_id, runtime, snapshots, store, transport });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current active or retained terminal Backfill status for frontend resynchronization.
|
||||||
|
pub(crate) fn backfill_status(&self) -> ksp_core_lib::Result<std::option::Option<crate::BackfillRunStatusDto>> {
|
||||||
|
let notification = self.backfill_runs.current_notification();
|
||||||
|
let notification = match notification {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return match notification {
|
||||||
|
std::option::Option::Some(value) => crate::project_backfill_status(&value).map(std::option::Option::Some),
|
||||||
|
std::option::Option::None => std::result::Result::Ok(std::option::Option::None),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests cooperative cancellation of one exact backend-issued Backfill Job identity.
|
||||||
|
pub(crate) fn cancel_backfill(&self, job_id: &str) -> ksp_core_lib::Result<crate::BackfillCancelResponseDto> {
|
||||||
|
let cancelled = self.backfill_runs.cancel(job_id);
|
||||||
|
let cancelled = match cancelled {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_RUN,
|
||||||
|
accepted = cancelled.accepted,
|
||||||
|
job_id = cancelled.job_id.as_str(),
|
||||||
|
state = cancelled.state.as_str(),
|
||||||
|
"processed Backfill Desk cooperative cancellation request"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests best-effort cooperative cancellation of the active run during application shutdown.
|
||||||
|
pub(crate) fn cancel_active_backfill_for_shutdown(&self) -> ksp_core_lib::Result<bool> {
|
||||||
|
return self.backfill_runs.cancel_active_for_shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes one already-admitted Backfill launch and restores application resources after its terminal result.
|
||||||
|
pub(crate) async fn execute_backfill_run(&self, launch: crate::BackfillRunLaunch) -> ksp_core_lib::Result<()> {
|
||||||
|
let job_id = launch.job_id.clone();
|
||||||
|
let result = launch.runtime.run(&launch.transport, &launch.store).await;
|
||||||
|
let restore = self.store_startup.restore_after_run(launch.store);
|
||||||
|
let finished = self.backfill_runs.finish(&job_id);
|
||||||
|
let cancellation_requested = match finished {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if let std::result::Result::Err(error) = restore {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(snapshot) => {
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_RUN,
|
||||||
|
cancellation_requested = cancellation_requested,
|
||||||
|
contiguous_completed = snapshot.contiguous_completed(),
|
||||||
|
job_id = job_id.as_str(),
|
||||||
|
phase = snapshot.phase().code(),
|
||||||
|
"Backfill Desk run reached a terminal snapshot and released the single-run slot"
|
||||||
|
);
|
||||||
|
std::result::Result::Ok(())
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks graceful application shutdown as started and reports whether this caller won the one-shot transition.
|
||||||
|
pub(crate) fn begin_shutdown(&self) -> bool {
|
||||||
|
return self.shutdown_started.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire).is_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly closes the retained Store runtime when application shutdown begins.
|
||||||
|
pub(crate) async fn close_store(&self) -> ksp_core_lib::Result<()> {
|
||||||
|
return self.store_startup.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the resolved splash timings captured during application bootstrap.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {
|
||||||
|
return self.splash_settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the one-shot splash lifecycle as started and reports whether this caller won the transition.
|
||||||
|
pub(crate) fn begin_splash_sequence(&self) -> bool {
|
||||||
|
return self
|
||||||
|
.splash_sequence_started
|
||||||
|
.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
|
||||||
|
.is_ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoggingRuntimeState {
|
||||||
|
guard: ksp_logging_lib::LoggingGuard,
|
||||||
|
active_profile_id: std::option::Option<String>,
|
||||||
|
fallback_active: bool,
|
||||||
|
startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||||
|
}
|
||||||
313
crates/ksp-app-backfill-desk/src/backfill_request.rs
Normal file
313
crates/ksp-app-backfill-desk/src/backfill_request.rs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/backfill_request.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Strict application mapping from frontend campaign DTOs to the KSP Backfill request contract.
|
||||||
|
|
||||||
|
/// Maps one app-owned campaign request to a validated KSP Backfill request without starting a Job.
|
||||||
|
pub(crate) fn map_backfill_request(
|
||||||
|
input: crate::BackfillStartRequestDto,
|
||||||
|
options: &crate::BackfillDeskOptionsDto,
|
||||||
|
job_id: ksp_job_api::JobId,
|
||||||
|
) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillRequest> {
|
||||||
|
let composition = validate_composition(options);
|
||||||
|
if let std::result::Result::Err(error) = composition {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let network = request_network(options);
|
||||||
|
let network = match network {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let role = request_role(input.http_role.as_str(), options);
|
||||||
|
let role = match role {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let commitment = request_commitment(input.commitment.as_str());
|
||||||
|
let commitment = match commitment {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let min_context_slot = parse_min_context_slot(input.min_context_slot.as_deref());
|
||||||
|
let min_context_slot = match min_context_slot {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let scope = request_scope(&input);
|
||||||
|
let scope = match scope {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let page_size = usize::try_from(input.page_size);
|
||||||
|
let page_size = match page_size {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("page_size", error)),
|
||||||
|
};
|
||||||
|
let max_pages = usize::try_from(input.max_pages);
|
||||||
|
let max_pages = match max_pages {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_pages", error)),
|
||||||
|
};
|
||||||
|
let max_candidates = usize::try_from(input.max_candidates);
|
||||||
|
let max_candidates = match max_candidates {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_candidates", error)),
|
||||||
|
};
|
||||||
|
let hydration_concurrency = usize::try_from(input.hydration_concurrency);
|
||||||
|
let hydration_concurrency = match hydration_concurrency {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("hydration_concurrency", error)),
|
||||||
|
};
|
||||||
|
return ksp_job_backfill_lib::BackfillRequest::new(
|
||||||
|
job_id,
|
||||||
|
network,
|
||||||
|
role,
|
||||||
|
commitment,
|
||||||
|
scope,
|
||||||
|
page_size,
|
||||||
|
max_pages,
|
||||||
|
max_candidates,
|
||||||
|
hydration_concurrency,
|
||||||
|
min_context_slot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a safe request preview from one already validated KSP Backfill request.
|
||||||
|
pub(crate) fn project_backfill_request(request: &ksp_job_backfill_lib::BackfillRequest) -> ksp_core_lib::Result<crate::BackfillRequestPreviewDto> {
|
||||||
|
let explicit_signature_count = match request.scope().signatures() {
|
||||||
|
std::option::Option::Some(signatures) => u32::try_from(signatures.len()),
|
||||||
|
std::option::Option::None => std::result::Result::Ok(0),
|
||||||
|
};
|
||||||
|
let explicit_signature_count = match explicit_signature_count {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("explicit_signature_count", error)),
|
||||||
|
};
|
||||||
|
let hydration_concurrency = u32::try_from(request.hydration_concurrency());
|
||||||
|
let hydration_concurrency = match hydration_concurrency {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("hydration_concurrency", error)),
|
||||||
|
};
|
||||||
|
let max_candidates = u32::try_from(request.max_candidates());
|
||||||
|
let max_candidates = match max_candidates {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_candidates", error)),
|
||||||
|
};
|
||||||
|
let max_pages = u32::try_from(request.max_pages());
|
||||||
|
let max_pages = match max_pages {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_pages", error)),
|
||||||
|
};
|
||||||
|
let page_size = u32::try_from(request.page_size());
|
||||||
|
let page_size = match page_size {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("page_size", error)),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::BackfillRequestPreviewDto {
|
||||||
|
address_present: request.scope().address().is_some(),
|
||||||
|
anchor_present: request.scope().anchor().is_some(),
|
||||||
|
commitment: request.commitment().code().to_owned(),
|
||||||
|
explicit_signature_count,
|
||||||
|
hydration_concurrency,
|
||||||
|
http_role: request.role().as_str().to_owned(),
|
||||||
|
max_candidates,
|
||||||
|
max_pages,
|
||||||
|
min_context_slot_present: request.min_context_slot().is_some(),
|
||||||
|
network: request.network().as_str().to_owned(),
|
||||||
|
page_size,
|
||||||
|
scope_kind: request.scope().kind().code().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_min_context_slot(value: std::option::Option<&str>) -> ksp_core_lib::Result<std::option::Option<u64>> {
|
||||||
|
let value = match value {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||||
|
};
|
||||||
|
if value.is_empty() || value.trim() != value {
|
||||||
|
return std::result::Result::Err(request_field_error("min_context_slot"));
|
||||||
|
}
|
||||||
|
let parsed = value.parse::<u64>();
|
||||||
|
return match parsed {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(request_field_error("min_context_slot").with_source(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pubkey(value: &str, field: &'static str) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
|
||||||
|
if value.is_empty() || value.trim() != value {
|
||||||
|
return std::result::Result::Err(request_field_error(field));
|
||||||
|
}
|
||||||
|
let parsed = value.parse::<ksp_core_lib::Pubkey>();
|
||||||
|
return match parsed {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(request_field_error(field).with_source(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_signature(value: &str, field: &'static str) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillSignature> {
|
||||||
|
if value.is_empty() || value.trim() != value {
|
||||||
|
return std::result::Result::Err(request_field_error(field));
|
||||||
|
}
|
||||||
|
let parsed = ksp_job_backfill_lib::BackfillSignature::new(value.to_owned());
|
||||||
|
return match parsed {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(request_field_error(field).with_source(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_commitment(value: &str) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillCommitment> {
|
||||||
|
return match value {
|
||||||
|
"confirmed" => std::result::Result::Ok(ksp_job_backfill_lib::BackfillCommitment::Confirmed),
|
||||||
|
"finalized" => std::result::Result::Ok(ksp_job_backfill_lib::BackfillCommitment::Finalized),
|
||||||
|
_ => std::result::Result::Err(request_field_error("commitment")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_conversion_error(field: &'static str, source: impl std::error::Error + Send + Sync + 'static) -> ksp_core_lib::Error {
|
||||||
|
return request_field_error(field).with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_field_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_REQUEST_INVALID, "Backfill Desk campaign request is invalid").with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_network(options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result<ksp_store_lib::RawNetworkId> {
|
||||||
|
let network = options.store_network.as_deref();
|
||||||
|
let network = match network {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(request_field_error("network")),
|
||||||
|
};
|
||||||
|
let parsed = ksp_store_lib::RawNetworkId::new(network.to_owned());
|
||||||
|
return match parsed {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(request_field_error("network").with_source(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_role(value: &str, options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpRoleName> {
|
||||||
|
let admitted = options.http_routes.iter().any(|route| return route.role == value);
|
||||||
|
if !admitted || value.is_empty() || value.trim() != value {
|
||||||
|
return std::result::Result::Err(request_field_error("http_role"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ksp_onchain_transport_lib::HttpRoleName::new(value.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillScope> {
|
||||||
|
return match input.scope_kind.as_str() {
|
||||||
|
"latest_address" => latest_address_scope(input),
|
||||||
|
"before_address" => before_address_scope(input),
|
||||||
|
"after_address" => after_address_scope(input),
|
||||||
|
"explicit_signatures" => explicit_signatures_scope(input),
|
||||||
|
_ => std::result::Result::Err(request_field_error("scope_kind")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillScope> {
|
||||||
|
let shape = require_no_anchor_or_signatures(input);
|
||||||
|
if let std::result::Result::Err(error) = shape {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let address = required_address(input.address.as_deref());
|
||||||
|
let address = match address {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::latest_address(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn before_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillScope> {
|
||||||
|
let shape = require_no_explicit_signatures(input);
|
||||||
|
if let std::result::Result::Err(error) = shape {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let address = required_address(input.address.as_deref());
|
||||||
|
let address = match address {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let anchor = required_anchor(input.anchor_signature.as_deref());
|
||||||
|
let anchor = match anchor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::before_address(address, anchor));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn after_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillScope> {
|
||||||
|
let shape = require_no_explicit_signatures(input);
|
||||||
|
if let std::result::Result::Err(error) = shape {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let address = required_address(input.address.as_deref());
|
||||||
|
let address = match address {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let anchor = required_anchor(input.anchor_signature.as_deref());
|
||||||
|
let anchor = match anchor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::after_address(address, anchor));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn explicit_signatures_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillScope> {
|
||||||
|
if input.address.is_some() || input.anchor_signature.is_some() || input.min_context_slot.is_some() {
|
||||||
|
return std::result::Result::Err(request_field_error("scope"));
|
||||||
|
}
|
||||||
|
if input.explicit_signatures.is_empty() || input.explicit_signatures.len() > ksp_job_backfill_lib::MAX_BACKFILL_CANDIDATES {
|
||||||
|
return std::result::Result::Err(request_field_error("explicit_signatures"));
|
||||||
|
}
|
||||||
|
let mut signatures = std::vec::Vec::with_capacity(input.explicit_signatures.len());
|
||||||
|
for value in &input.explicit_signatures {
|
||||||
|
let signature = parse_signature(value.as_str(), "explicit_signatures");
|
||||||
|
let signature = match signature {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
signatures.push(signature);
|
||||||
|
}
|
||||||
|
return ksp_job_backfill_lib::BackfillScope::explicit_signatures(signatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_no_anchor_or_signatures(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<()> {
|
||||||
|
if input.anchor_signature.is_some() || !input.explicit_signatures.is_empty() {
|
||||||
|
return std::result::Result::Err(request_field_error("scope"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_no_explicit_signatures(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<()> {
|
||||||
|
if !input.explicit_signatures.is_empty() {
|
||||||
|
return std::result::Result::Err(request_field_error("explicit_signatures"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_address(value: std::option::Option<&str>) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
|
||||||
|
return match value {
|
||||||
|
std::option::Option::Some(value) => parse_pubkey(value, "address"),
|
||||||
|
std::option::Option::None => std::result::Result::Err(request_field_error("address")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_anchor(value: std::option::Option<&str>) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillSignature> {
|
||||||
|
return match value {
|
||||||
|
std::option::Option::Some(value) => parse_signature(value, "anchor_signature"),
|
||||||
|
std::option::Option::None => std::result::Result::Err(request_field_error("anchor_signature")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_composition(options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result<()> {
|
||||||
|
if options.composition_ready && options.transport_ready && options.store_ready && options.network_coherent {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY,
|
||||||
|
"Backfill Desk cannot admit a campaign before Transport and Store composition is ready",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/backfill_request.rs"]
|
||||||
|
mod tests;
|
||||||
333
crates/ksp-app-backfill-desk/src/backfill_run.rs
Normal file
333
crates/ksp-app-backfill-desk/src/backfill_run.rs
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/backfill_run.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Single-active-run admission and launch ownership for Backfill Desk.
|
||||||
|
|
||||||
|
/// Fully prepared Backfill execution moved into the Tauri async runtime after admission.
|
||||||
|
pub(crate) struct BackfillRunLaunch {
|
||||||
|
/// Backend-generated logical Job identity.
|
||||||
|
pub(crate) job_id: ksp_job_api::JobId,
|
||||||
|
/// Concrete Backfill runtime owning discovery, hydration, persistence and latest-value publication.
|
||||||
|
pub(crate) runtime: ksp_job_backfill_lib::BackfillJobRuntime,
|
||||||
|
/// Independent latest-value source retained by the monitoring bridge.
|
||||||
|
pub(crate) snapshots: ksp_job_backfill_lib::BackfillSnapshotSource,
|
||||||
|
/// Store facade temporarily borrowed from the application Store runtime for the duration of this run.
|
||||||
|
pub(crate) store: ksp_store_lib::Store,
|
||||||
|
/// Shareable HTTP Transport pool selected by the active composite profile.
|
||||||
|
pub(crate) transport: ksp_onchain_transport_lib::HttpTransportPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::BackfillRunLaunch {
|
||||||
|
/// Builds the safe acknowledgement returned immediately after the non-blocking Start admission.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn response(&self) -> crate::BackfillStartResponseDto {
|
||||||
|
return crate::BackfillStartResponseDto { job_id: self.job_id.as_str().to_owned(), state: ksp_job_api::JobState::Created.code().to_owned() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-run control slot retained by the application while one Backfill runtime is active.
|
||||||
|
pub(crate) struct BackfillRunState {
|
||||||
|
active: std::sync::Mutex<std::option::Option<ActiveBackfillRun>>,
|
||||||
|
last_terminal: std::sync::Mutex<std::option::Option<TerminalBackfillRun>>,
|
||||||
|
next_sequence: std::sync::atomic::AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::BackfillRunState {
|
||||||
|
/// Creates an empty single-run state for one desktop application session.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn new() -> Self {
|
||||||
|
return Self {
|
||||||
|
active: std::sync::Mutex::new(std::option::Option::None),
|
||||||
|
last_terminal: std::sync::Mutex::new(std::option::Option::None),
|
||||||
|
next_sequence: std::sync::atomic::AtomicU64::new(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocates one bounded backend-owned Job identity without accepting caller-supplied identity material.
|
||||||
|
pub(crate) fn next_job_id(&self) -> ksp_core_lib::Result<ksp_job_api::JobId> {
|
||||||
|
let sequence = self
|
||||||
|
.next_sequence
|
||||||
|
.fetch_update(std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire, |current| return current.checked_add(1));
|
||||||
|
let sequence = match sequence {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||||
|
"Backfill Desk Job identity sequence is exhausted",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return ksp_job_api::JobId::new(format!("backfill-desk-{sequence}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs one control handle atomically and rejects concurrent Starts while another run owns the slot.
|
||||||
|
pub(crate) fn install(
|
||||||
|
&self,
|
||||||
|
job_id: ksp_job_api::JobId,
|
||||||
|
handle: ksp_job_backfill_lib::BackfillJobHandle,
|
||||||
|
request: ksp_job_backfill_lib::BackfillRequest,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
let active = self.active.lock();
|
||||||
|
let mut active = match active {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk active-run state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if active.is_some() {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_RUN_ACTIVE,
|
||||||
|
"Backfill Desk already has an active Backfill run",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
*active = std::option::Option::Some(ActiveBackfillRun { handle, job_id, request });
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the matching terminal run and reports whether cancellation had been requested on its retained handle.
|
||||||
|
pub(crate) fn finish(&self, job_id: &ksp_job_api::JobId) -> ksp_core_lib::Result<bool> {
|
||||||
|
let active = self.active.lock();
|
||||||
|
let mut active = match active {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk active-run state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let current = active.as_ref();
|
||||||
|
let current = match current {
|
||||||
|
std::option::Option::Some(value) if &value.job_id == job_id => value,
|
||||||
|
std::option::Option::Some(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||||
|
"Backfill Desk terminal run does not match the active Job identity",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||||
|
"Backfill Desk terminal run has no active admission slot",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let cancellation_requested = current.handle.is_cancellation_requested();
|
||||||
|
let request = current.request.clone();
|
||||||
|
let source = current.handle.snapshots();
|
||||||
|
let notification = <ksp_job_backfill_lib::BackfillSnapshotSource as ksp_job_api::JobSnapshotSource>::current(&source);
|
||||||
|
if notification.state().is_terminal() {
|
||||||
|
let terminal = self.last_terminal.lock();
|
||||||
|
let mut terminal = match terminal {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk terminal monitoring state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
*terminal = std::option::Option::Some(TerminalBackfillRun { notification, request });
|
||||||
|
}
|
||||||
|
*active = std::option::Option::None;
|
||||||
|
return std::result::Result::Ok(cancellation_requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests cooperative cancellation for the exact backend-issued active Job identity.
|
||||||
|
pub(crate) fn cancel(&self, job_id: &str) -> ksp_core_lib::Result<crate::BackfillCancelResponseDto> {
|
||||||
|
let active = self.active.lock();
|
||||||
|
let active = match active {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk active-run cancellation state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let std::option::Option::Some(current) = active.as_ref() {
|
||||||
|
if current.job_id.as_str() != job_id {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_RUN_MISMATCH,
|
||||||
|
"Backfill Desk cancellation target does not match the active Job",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let source = current.handle.snapshots();
|
||||||
|
let notification = <ksp_job_backfill_lib::BackfillSnapshotSource as ksp_job_api::JobSnapshotSource>::current(&source);
|
||||||
|
let state = notification.state();
|
||||||
|
if state.is_terminal() {
|
||||||
|
return std::result::Result::Ok(crate::BackfillCancelResponseDto {
|
||||||
|
accepted: false,
|
||||||
|
job_id: current.job_id.as_str().to_owned(),
|
||||||
|
state: state.code().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let accepted = current.handle.cancel();
|
||||||
|
let state_code = if current.handle.is_cancellation_requested() { "cancelling" } else { state.code() };
|
||||||
|
return std::result::Result::Ok(crate::BackfillCancelResponseDto {
|
||||||
|
accepted,
|
||||||
|
job_id: current.job_id.as_str().to_owned(),
|
||||||
|
state: state_code.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drop(active);
|
||||||
|
let terminal = self.last_terminal.lock();
|
||||||
|
let terminal = match terminal {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk terminal cancellation state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let std::option::Option::Some(terminal) = terminal.as_ref() {
|
||||||
|
let notification = &terminal.notification;
|
||||||
|
if notification.id().as_str() != job_id {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_RUN_MISMATCH,
|
||||||
|
"Backfill Desk cancellation target does not match the retained terminal Job",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(crate::BackfillCancelResponseDto {
|
||||||
|
accepted: false,
|
||||||
|
job_id: notification.id().as_str().to_owned(),
|
||||||
|
state: notification.state().code().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_RUN_NOT_ACTIVE,
|
||||||
|
"Backfill Desk has no active Backfill run to cancel",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests best-effort cooperative cancellation of whichever run is active after shutdown wins admission.
|
||||||
|
pub(crate) fn cancel_active_for_shutdown(&self) -> ksp_core_lib::Result<bool> {
|
||||||
|
let active = self.active.lock();
|
||||||
|
let active = match active {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk shutdown cancellation state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match active.as_ref() {
|
||||||
|
std::option::Option::Some(current) => std::result::Result::Ok(current.handle.cancel()),
|
||||||
|
std::option::Option::None => std::result::Result::Ok(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the complete current active snapshot or the retained latest terminal snapshot for resynchronization.
|
||||||
|
pub(crate) fn current_notification(
|
||||||
|
&self,
|
||||||
|
) -> ksp_core_lib::Result<std::option::Option<ksp_job_api::JobNotification<ksp_job_backfill_lib::BackfillJobSnapshot>>> {
|
||||||
|
let active = self.active.lock();
|
||||||
|
let active = match active {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk active-run monitoring state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let std::option::Option::Some(current) = active.as_ref() {
|
||||||
|
let source = current.handle.snapshots();
|
||||||
|
let notification = <ksp_job_backfill_lib::BackfillSnapshotSource as ksp_job_api::JobSnapshotSource>::current(&source);
|
||||||
|
return std::result::Result::Ok(std::option::Option::Some(notification));
|
||||||
|
}
|
||||||
|
drop(active);
|
||||||
|
let terminal = self.last_terminal.lock();
|
||||||
|
let terminal = match terminal {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk terminal monitoring state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(terminal.as_ref().map(|terminal| return terminal.notification.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the last terminal request with its opaque checkpoint reissued onto one new Job identity.
|
||||||
|
pub(crate) fn resume_request(&self, job_id: ksp_job_api::JobId) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillRequest> {
|
||||||
|
let active = self.active.lock();
|
||||||
|
let active = match active {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk active-run resume state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if active.is_some() {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_RUN_ACTIVE,
|
||||||
|
"Backfill Desk cannot resume while another Backfill run owns the single-run slot",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
drop(active);
|
||||||
|
let terminal = self.last_terminal.lock();
|
||||||
|
let terminal = match terminal {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk terminal resume state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let terminal = match terminal.as_ref() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE,
|
||||||
|
"Backfill Desk has no retained terminal run available for in-session Resume",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let checkpoint = terminal.notification.snapshot().checkpoint();
|
||||||
|
let checkpoint = match checkpoint {
|
||||||
|
std::option::Option::Some(value) => value.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE,
|
||||||
|
"Backfill Desk retained terminal run has no safe checkpoint for in-session Resume",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return terminal.request.resume_for_job(job_id, checkpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolls back a just-installed admission when execution resources cannot be acquired before spawn.
|
||||||
|
pub(crate) fn rollback(&self, job_id: &ksp_job_api::JobId) -> ksp_core_lib::Result<()> {
|
||||||
|
let finished = self.finish(job_id);
|
||||||
|
return match finished {
|
||||||
|
std::result::Result::Ok(_) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActiveBackfillRun {
|
||||||
|
handle: ksp_job_backfill_lib::BackfillJobHandle,
|
||||||
|
job_id: ksp_job_api::JobId,
|
||||||
|
request: ksp_job_backfill_lib::BackfillRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TerminalBackfillRun {
|
||||||
|
notification: ksp_job_api::JobNotification<ksp_job_backfill_lib::BackfillJobSnapshot>,
|
||||||
|
request: ksp_job_backfill_lib::BackfillRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/backfill_run.rs"]
|
||||||
|
mod tests;
|
||||||
194
crates/ksp-app-backfill-desk/src/backfill_status.rs
Normal file
194
crates/ksp-app-backfill-desk/src/backfill_status.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/backfill_status.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Safe latest-value monitoring projection for one Backfill Desk run.
|
||||||
|
|
||||||
|
use ts_rs::TS; // rust-rules: trait-import
|
||||||
|
|
||||||
|
/// Stable Tauri event carrying the latest complete safe Backfill run status.
|
||||||
|
pub(crate) const BACKFILL_STATUS_EVENT_NAME: &str = "ksp-backfill-status";
|
||||||
|
|
||||||
|
/// Frontend-safe latest-value projection of one concrete Backfill runtime snapshot.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/backfill_status/BackfillRunStatusDto.ts")]
|
||||||
|
pub(crate) struct BackfillRunStatusDto {
|
||||||
|
/// Whether the represented Job is currently non-terminal.
|
||||||
|
pub(crate) active: bool,
|
||||||
|
/// Number of candidates admitted into hydration.
|
||||||
|
pub(crate) candidates_admitted: u32,
|
||||||
|
/// Number of admitted candidates that reached a known coordinator result.
|
||||||
|
pub(crate) candidates_finished: u32,
|
||||||
|
/// Number of candidates selected by discovery.
|
||||||
|
pub(crate) candidates_selected: u32,
|
||||||
|
/// Number of candidates cancelled before Store submission.
|
||||||
|
pub(crate) cancelled_candidates: u32,
|
||||||
|
/// Whether a safe caller-owned checkpoint currently exists.
|
||||||
|
pub(crate) checkpoint_present: bool,
|
||||||
|
/// Normal terminal completion classification, when applicable.
|
||||||
|
pub(crate) completion: std::option::Option<String>,
|
||||||
|
/// Safe contiguous completion prefix retained by the current checkpoint frontier.
|
||||||
|
pub(crate) contiguous_completed: u32,
|
||||||
|
/// Number of Store content conflicts observed by the run.
|
||||||
|
pub(crate) conflicts: u32,
|
||||||
|
/// Safe discovery boundary code after discovery completes.
|
||||||
|
pub(crate) discovery_boundary: std::option::Option<String>,
|
||||||
|
/// Number of identical canonical RAW transactions already present.
|
||||||
|
pub(crate) entities_existing: u32,
|
||||||
|
/// Number of newly inserted canonical RAW transactions.
|
||||||
|
pub(crate) entities_inserted: u32,
|
||||||
|
/// Number of durable purge tombstones respected by normal Backfill persistence.
|
||||||
|
pub(crate) entities_purged: u32,
|
||||||
|
/// Stable fatal error code retained by a failed Job, when applicable.
|
||||||
|
pub(crate) failure_code: std::option::Option<String>,
|
||||||
|
/// Stable fatal error domain retained by a failed Job, when applicable.
|
||||||
|
pub(crate) failure_domain: std::option::Option<String>,
|
||||||
|
/// Number of known candidate outcomes blocking the contiguous frontier.
|
||||||
|
pub(crate) holes: u32,
|
||||||
|
/// Backend-generated logical Job identity.
|
||||||
|
pub(crate) job_id: String,
|
||||||
|
/// Stable concrete Job family code.
|
||||||
|
pub(crate) job_kind: String,
|
||||||
|
/// Greatest observed candidate concurrency.
|
||||||
|
pub(crate) maximum_in_flight: u32,
|
||||||
|
/// Number of `getTransaction = null` candidates.
|
||||||
|
pub(crate) missing: u32,
|
||||||
|
/// Number of acquisition observations already durable.
|
||||||
|
pub(crate) observations_existing: u32,
|
||||||
|
/// Number of newly inserted acquisition observations.
|
||||||
|
pub(crate) observations_inserted: u32,
|
||||||
|
/// Stable concrete Backfill runtime phase.
|
||||||
|
pub(crate) phase: String,
|
||||||
|
/// Monotone latest-value notification sequence encoded as decimal text.
|
||||||
|
pub(crate) sequence: String,
|
||||||
|
/// Stable Backfill scope category without address/signature payloads.
|
||||||
|
pub(crate) scope_kind: String,
|
||||||
|
/// Stable generic Job lifecycle state.
|
||||||
|
pub(crate) state: String,
|
||||||
|
/// Whether this latest value is terminal.
|
||||||
|
pub(crate) terminal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projects one complete Backfill latest-value notification to the safe desktop monitoring contract.
|
||||||
|
pub(crate) fn project_backfill_status(
|
||||||
|
notification: &ksp_job_api::JobNotification<ksp_job_backfill_lib::BackfillJobSnapshot>,
|
||||||
|
) -> ksp_core_lib::Result<BackfillRunStatusDto> {
|
||||||
|
let snapshot = notification.snapshot();
|
||||||
|
let state = notification.state();
|
||||||
|
let failure = snapshot.failure_code();
|
||||||
|
let candidates_admitted = usize_to_u32(snapshot.candidates_admitted(), "candidates_admitted");
|
||||||
|
let candidates_admitted = match candidates_admitted {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let candidates_finished = usize_to_u32(snapshot.candidates_finished(), "candidates_finished");
|
||||||
|
let candidates_finished = match candidates_finished {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let candidates_selected = usize_to_u32(snapshot.candidates_selected(), "candidates_selected");
|
||||||
|
let candidates_selected = match candidates_selected {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let cancelled_candidates = usize_to_u32(snapshot.cancelled_candidates(), "cancelled_candidates");
|
||||||
|
let cancelled_candidates = match cancelled_candidates {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let contiguous_completed = usize_to_u32(snapshot.contiguous_completed(), "contiguous_completed");
|
||||||
|
let contiguous_completed = match contiguous_completed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let conflicts = usize_to_u32(snapshot.conflicts(), "conflicts");
|
||||||
|
let conflicts = match conflicts {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let entities_existing = usize_to_u32(snapshot.entities_existing(), "entities_existing");
|
||||||
|
let entities_existing = match entities_existing {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let entities_inserted = usize_to_u32(snapshot.entities_inserted(), "entities_inserted");
|
||||||
|
let entities_inserted = match entities_inserted {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let entities_purged = usize_to_u32(snapshot.entities_purged(), "entities_purged");
|
||||||
|
let entities_purged = match entities_purged {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let holes = usize_to_u32(snapshot.holes(), "holes");
|
||||||
|
let holes = match holes {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let maximum_in_flight = usize_to_u32(snapshot.maximum_in_flight(), "maximum_in_flight");
|
||||||
|
let maximum_in_flight = match maximum_in_flight {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let missing = usize_to_u32(snapshot.missing(), "missing");
|
||||||
|
let missing = match missing {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let observations_existing = usize_to_u32(snapshot.observations_existing(), "observations_existing");
|
||||||
|
let observations_existing = match observations_existing {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let observations_inserted = usize_to_u32(snapshot.observations_inserted(), "observations_inserted");
|
||||||
|
let observations_inserted = match observations_inserted {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(BackfillRunStatusDto {
|
||||||
|
active: !state.is_terminal(),
|
||||||
|
candidates_admitted,
|
||||||
|
candidates_finished,
|
||||||
|
candidates_selected,
|
||||||
|
cancelled_candidates,
|
||||||
|
checkpoint_present: snapshot.checkpoint().is_some(),
|
||||||
|
completion: state.completion().map(|completion| return completion.code().to_owned()),
|
||||||
|
contiguous_completed,
|
||||||
|
conflicts,
|
||||||
|
discovery_boundary: snapshot.discovery_boundary().map(|boundary| return boundary.code().to_owned()),
|
||||||
|
entities_existing,
|
||||||
|
entities_inserted,
|
||||||
|
entities_purged,
|
||||||
|
failure_code: failure.map(|code| return code.code().to_owned()),
|
||||||
|
failure_domain: failure.map(|code| return code.domain().to_owned()),
|
||||||
|
holes,
|
||||||
|
job_id: notification.id().as_str().to_owned(),
|
||||||
|
job_kind: notification.kind().as_str().to_owned(),
|
||||||
|
maximum_in_flight,
|
||||||
|
missing,
|
||||||
|
observations_existing,
|
||||||
|
observations_inserted,
|
||||||
|
phase: snapshot.phase().code().to_owned(),
|
||||||
|
sequence: notification.sequence().value().to_string(),
|
||||||
|
scope_kind: snapshot.scope_kind().code().to_owned(),
|
||||||
|
state: state.code().to_owned(),
|
||||||
|
terminal: state.is_terminal(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usize_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result<u32> {
|
||||||
|
let converted = u32::try_from(value);
|
||||||
|
return match converted {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Backfill Desk cannot project a bounded Job counter to the frontend")
|
||||||
|
.with_context("field", field)
|
||||||
|
.with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/backfill_status.rs"]
|
||||||
|
mod tests;
|
||||||
215
crates/ksp-app-backfill-desk/src/bootstrap.rs
Normal file
215
crates/ksp-app-backfill-desk/src/bootstrap.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/bootstrap.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Config composite and Logging bootstrap for Backfill Desk.
|
||||||
|
|
||||||
|
/// Crate-internal Logging startup state shared by the application state.
|
||||||
|
pub(crate) struct LoggingStartup {
|
||||||
|
/// Guard that keeps the installed Logging runtime alive.
|
||||||
|
pub(crate) guard: ksp_logging_lib::LoggingGuard,
|
||||||
|
/// Active managed profile when Config resolved one successfully.
|
||||||
|
pub(crate) active_profile_id: std::option::Option<String>,
|
||||||
|
/// Whether a transient in-memory fallback was installed.
|
||||||
|
pub(crate) fallback_active: bool,
|
||||||
|
/// Safe startup diagnostic retained for the shell.
|
||||||
|
pub(crate) startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoggingStartupPlan {
|
||||||
|
Managed {
|
||||||
|
active_profile_id: String,
|
||||||
|
settings: ksp_logging_lib::LoggingSettings,
|
||||||
|
},
|
||||||
|
Fallback {
|
||||||
|
initial_error: ksp_core_lib::Error,
|
||||||
|
diagnostic: crate::CommandErrorDto,
|
||||||
|
settings: ksp_logging_lib::LoggingSettings,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the Config management facade from the common KSP CLI bootstrap contract.
|
||||||
|
pub(crate) fn config_management(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_args(arguments);
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::from_args(arguments);
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(engine));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the concrete Backfill Desk Config composite using its registered logical `file_id` and autonomous default profile.
|
||||||
|
pub(crate) fn load_backfill_desk_composite(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigComposite> {
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return management.engine().load_resolved_composite(&file_id, std::option::Option::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one required standard profile from the Backfill Desk composite after validating the referenced logical `file_id`.
|
||||||
|
pub(crate) fn required_composite_component_profile(
|
||||||
|
composite: &ksp_config_lib::ResolvedConfigComposite,
|
||||||
|
component_id: &str,
|
||||||
|
expected_file_id: &str,
|
||||||
|
) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigProfile> {
|
||||||
|
let component = composite.component(component_id);
|
||||||
|
let component = match component {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_CONFIG_COMPOSITE_INVALID, "Backfill Desk composite is missing a required component")
|
||||||
|
.with_context("composite_file_id", composite.file_id().as_str())
|
||||||
|
.with_context("composite_profile_id", composite.profile_id())
|
||||||
|
.with_context("component_id", component_id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if component.resolved().file_id().as_str() != expected_file_id {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_CONFIG_COMPOSITE_INVALID, "Backfill Desk composite component references an unexpected Config document")
|
||||||
|
.with_context("composite_file_id", composite.file_id().as_str())
|
||||||
|
.with_context("composite_profile_id", composite.profile_id())
|
||||||
|
.with_context("component_id", component_id)
|
||||||
|
.with_context("expected_file_id", expected_file_id)
|
||||||
|
.with_context("actual_file_id", component.resolved().file_id().as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(component.resolved().clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes Logging from the Backfill Desk composite, with a trace-level development fallback.
|
||||||
|
pub(crate) fn initialize_logging(
|
||||||
|
management: &ksp_config_lib::ConfigManagement,
|
||||||
|
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||||
|
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||||
|
let plan = resolve_logging_startup(management);
|
||||||
|
return match plan {
|
||||||
|
LoggingStartupPlan::Managed { active_profile_id, settings } => {
|
||||||
|
let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity);
|
||||||
|
match guard {
|
||||||
|
std::result::Result::Ok(guard) => {
|
||||||
|
ksp_logging_lib::info!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_BOOTSTRAP,
|
||||||
|
active_profile = active_profile_id.as_str(),
|
||||||
|
"initialized Backfill Desk logging from composite-managed configuration"
|
||||||
|
);
|
||||||
|
std::result::Result::Ok(LoggingStartup {
|
||||||
|
guard,
|
||||||
|
active_profile_id: std::option::Option::Some(active_profile_id),
|
||||||
|
fallback_active: false,
|
||||||
|
startup_diagnostic: std::option::Option::None,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => initialize_fallback_logging(error, runtime_identity),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
LoggingStartupPlan::Fallback { initial_error, diagnostic, settings } => {
|
||||||
|
initialize_planned_fallback_logging(initial_error, diagnostic, settings, runtime_identity)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_logging_settings() -> ksp_logging_lib::LoggingSettings {
|
||||||
|
return ksp_logging_lib::LoggingSettings::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Trace,
|
||||||
|
ksp_logging_lib::SpanEvents::Off,
|
||||||
|
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_logging_startup(management: &ksp_config_lib::ConfigManagement) -> LoggingStartupPlan {
|
||||||
|
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||||
|
let environment = match environment {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||||
|
};
|
||||||
|
let composite = crate::load_backfill_desk_composite(management);
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||||
|
};
|
||||||
|
let logging_profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_LOGGING, ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
|
let logging_profile = match logging_profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||||
|
};
|
||||||
|
let transport_profile =
|
||||||
|
crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_TRANSPORT, ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||||
|
if let std::result::Result::Err(error) = transport_profile {
|
||||||
|
return fallback_startup_plan(error);
|
||||||
|
}
|
||||||
|
let store_profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_STORE, ksp_config_lib::FILE_ID_STD_STORE);
|
||||||
|
if let std::result::Result::Err(error) = store_profile {
|
||||||
|
return fallback_startup_plan(error);
|
||||||
|
}
|
||||||
|
let resolved = management.engine().resolve_logging_config_profile(&logging_profile, &environment);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||||
|
};
|
||||||
|
return LoggingStartupPlan::Managed { active_profile_id: resolved.profile_id().to_owned(), settings: resolved.into_settings() };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_startup_plan(initial_error: ksp_core_lib::Error) -> LoggingStartupPlan {
|
||||||
|
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
|
||||||
|
return LoggingStartupPlan::Fallback { initial_error, diagnostic, settings: fallback_logging_settings() };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_fallback_logging(
|
||||||
|
initial_error: ksp_core_lib::Error,
|
||||||
|
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||||
|
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||||
|
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
|
||||||
|
return initialize_planned_fallback_logging(initial_error, diagnostic, fallback_logging_settings(), runtime_identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_planned_fallback_logging(
|
||||||
|
initial_error: ksp_core_lib::Error,
|
||||||
|
diagnostic: crate::CommandErrorDto,
|
||||||
|
settings: ksp_logging_lib::LoggingSettings,
|
||||||
|
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||||
|
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||||
|
let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity);
|
||||||
|
let guard = match guard {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(fallback_error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED, "Cannot initialize Backfill Desk fallback Logging runtime")
|
||||||
|
.with_context("initial_error_domain", initial_error.code().domain())
|
||||||
|
.with_context("initial_error_code", initial_error.code().code())
|
||||||
|
.with_source(fallback_error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_BOOTSTRAP,
|
||||||
|
error_domain = diagnostic.domain.as_str(),
|
||||||
|
error_code = diagnostic.code.as_str(),
|
||||||
|
"managed Logging configuration is unavailable; using transient trace-level development fallback"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(LoggingStartup {
|
||||||
|
guard,
|
||||||
|
active_profile_id: std::option::Option::None,
|
||||||
|
fallback_active: true,
|
||||||
|
startup_diagnostic: std::option::Option::Some(diagnostic),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn development_fallback_keeps_trace_level_enabled() {
|
||||||
|
let settings = super::fallback_logging_settings();
|
||||||
|
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
crates/ksp-app-backfill-desk/src/constants.rs
Normal file
43
crates/ksp-app-backfill-desk/src/constants.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/constants.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
//! Application-owned tracing targets and domains.
|
||||||
|
|
||||||
|
/// Composite component identifier for Logging.
|
||||||
|
pub(crate) const COMPOSITE_COMPONENT_ID_LOGGING: &str = "logging";
|
||||||
|
/// Composite component identifier for Store.
|
||||||
|
pub(crate) const COMPOSITE_COMPONENT_ID_STORE: &str = "store";
|
||||||
|
/// Composite component identifier for Transport.
|
||||||
|
pub(crate) const COMPOSITE_COMPONENT_ID_TRANSPORT: &str = "transport";
|
||||||
|
/// Initial hydration concurrency proposed by the Desk for a new campaign.
|
||||||
|
pub(crate) const DEFAULT_BACKFILL_HYDRATION_CONCURRENCY: u32 = 4;
|
||||||
|
/// Initial candidate cap proposed by the Desk for a new campaign.
|
||||||
|
pub(crate) const DEFAULT_BACKFILL_MAX_CANDIDATES: u32 = 1_000;
|
||||||
|
/// Initial discovery-page cap proposed by the Desk for a new campaign.
|
||||||
|
pub(crate) const DEFAULT_BACKFILL_MAX_PAGES: u32 = 10;
|
||||||
|
/// Initial signature page size proposed by the Desk for a new campaign.
|
||||||
|
pub(crate) const DEFAULT_BACKFILL_PAGE_SIZE: u32 = 100;
|
||||||
|
/// Structured domain used while bootstrapping Config and Logging.
|
||||||
|
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "backfill.bootstrap";
|
||||||
|
/// Structured domain used by technical frontend events.
|
||||||
|
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
|
||||||
|
/// Structured domain used by Backfill campaign admission and request mapping.
|
||||||
|
pub(crate) const TRACING_DOMAIN_REQUEST: &str = "backfill.request";
|
||||||
|
/// Structured domain used by concrete Backfill run admission and execution.
|
||||||
|
pub(crate) const TRACING_DOMAIN_RUN: &str = "backfill.run";
|
||||||
|
/// Structured domain used by the Backfill Desk shell.
|
||||||
|
pub(crate) const TRACING_DOMAIN_SHELL: &str = "backfill.shell";
|
||||||
|
/// Structured domain used by Store readiness and shutdown operations.
|
||||||
|
pub(crate) const TRACING_DOMAIN_STORE: &str = "backfill.store";
|
||||||
|
/// Structured domain used by Transport readiness and role inventory operations.
|
||||||
|
pub(crate) const TRACING_DOMAIN_TRANSPORT: &str = "backfill.transport";
|
||||||
|
/// Structured domain used by Tauri window lifecycle operations.
|
||||||
|
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
|
||||||
|
/// Owning target for backend events emitted by Backfill Desk.
|
||||||
|
pub(crate) const TRACING_TARGET: &str = "ksp-app-backfill-desk";
|
||||||
|
/// Owning target for generic frontend events emitted through the KSP bridge.
|
||||||
|
pub(crate) const TRACING_TARGET_FRONTEND: &str = "ksp-app-backfill-desk.frontend";
|
||||||
|
/// Owning target for main-window frontend events.
|
||||||
|
pub(crate) const TRACING_TARGET_FRONTEND_MAIN: &str = "ksp-app-backfill-desk.frontend.main";
|
||||||
|
/// Owning target for splash-window frontend events.
|
||||||
|
pub(crate) const TRACING_TARGET_FRONTEND_SPLASH: &str = "ksp-app-backfill-desk.frontend.splash";
|
||||||
233
crates/ksp-app-backfill-desk/src/dto_backfill.rs
Normal file
233
crates/ksp-app-backfill-desk/src/dto_backfill.rs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/dto_backfill.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Application-owned Backfill campaign DTOs and backend-derived request limits.
|
||||||
|
|
||||||
|
use ts_rs::TS; // rust-rules: trait-import
|
||||||
|
|
||||||
|
/// Backend-derived limits and application defaults rendered by the Backfill campaign form.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestLimitsDto.ts")]
|
||||||
|
pub(crate) struct BackfillRequestLimitsDto {
|
||||||
|
/// Initial hydration concurrency proposed by this Desk while remaining below the Job-owned maximum.
|
||||||
|
pub(crate) default_hydration_concurrency: u32,
|
||||||
|
/// Initial candidate cap proposed by this Desk while remaining below the Job-owned maximum.
|
||||||
|
pub(crate) default_max_candidates: u32,
|
||||||
|
/// Initial discovery-page cap proposed by this Desk while remaining below the Job-owned maximum.
|
||||||
|
pub(crate) default_max_pages: u32,
|
||||||
|
/// Initial signature page size proposed by this Desk while remaining below the Job-owned maximum.
|
||||||
|
pub(crate) default_page_size: u32,
|
||||||
|
/// Maximum hydration concurrency owned by `ksp-job-backfill-lib`.
|
||||||
|
pub(crate) max_hydration_concurrency: u32,
|
||||||
|
/// Maximum candidate count owned by `ksp-job-backfill-lib`.
|
||||||
|
pub(crate) max_candidates: u32,
|
||||||
|
/// Maximum discovery-page count owned by `ksp-job-backfill-lib`.
|
||||||
|
pub(crate) max_pages: u32,
|
||||||
|
/// Maximum signature page size owned by `ksp-job-backfill-lib`.
|
||||||
|
pub(crate) max_page_size: u32,
|
||||||
|
/// Maximum encoded transaction-signature text length owned by `ksp-job-backfill-lib`.
|
||||||
|
pub(crate) max_signature_text_bytes: u32,
|
||||||
|
/// Minimum encoded transaction-signature text length owned by `ksp-job-backfill-lib`.
|
||||||
|
pub(crate) min_signature_text_bytes: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-owned request received from the Backfill campaign form before a Job is started.
|
||||||
|
///
|
||||||
|
/// Network and physical endpoint information are intentionally absent. The backend derives the
|
||||||
|
/// network from the configured Store and maps `http_role` only after checking the current safe
|
||||||
|
/// Transport inventory.
|
||||||
|
#[derive(serde::Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartRequestDto.ts")]
|
||||||
|
pub(crate) struct BackfillStartRequestDto {
|
||||||
|
/// Optional address text used only by address-based scopes.
|
||||||
|
pub(crate) address: std::option::Option<String>,
|
||||||
|
/// Optional exclusive anchor signature used only by Before/After scopes.
|
||||||
|
pub(crate) anchor_signature: std::option::Option<String>,
|
||||||
|
/// Stable Backfill commitment code.
|
||||||
|
pub(crate) commitment: String,
|
||||||
|
/// Explicit signature texts used only by the explicit-signatures scope.
|
||||||
|
pub(crate) explicit_signatures: std::vec::Vec<String>,
|
||||||
|
/// Maximum number of concurrent candidate hydrations.
|
||||||
|
pub(crate) hydration_concurrency: u32,
|
||||||
|
/// Logical HTTP role selected from `BackfillDeskOptionsDto::http_routes`.
|
||||||
|
pub(crate) http_role: String,
|
||||||
|
/// Maximum number of candidates admitted by the campaign.
|
||||||
|
pub(crate) max_candidates: u32,
|
||||||
|
/// Maximum number of address-discovery pages admitted by the campaign.
|
||||||
|
pub(crate) max_pages: u32,
|
||||||
|
/// Optional minimum context slot encoded as decimal text to avoid JavaScript integer precision loss.
|
||||||
|
pub(crate) min_context_slot: std::option::Option<String>,
|
||||||
|
/// Signature page size used by address discovery.
|
||||||
|
pub(crate) page_size: u32,
|
||||||
|
/// Stable Backfill scope kind code.
|
||||||
|
pub(crate) scope_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe immediate acknowledgement returned after one Backfill run is admitted and spawned.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartResponseDto.ts")]
|
||||||
|
pub(crate) struct BackfillStartResponseDto {
|
||||||
|
/// Backend-generated bounded Job identifier for this in-session run.
|
||||||
|
pub(crate) job_id: String,
|
||||||
|
/// Initial lifecycle state at the time Start returns to the frontend.
|
||||||
|
pub(crate) state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe acknowledgement of one targeted cooperative cancellation request.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillCancelResponseDto.ts")]
|
||||||
|
pub(crate) struct BackfillCancelResponseDto {
|
||||||
|
/// Whether this call won the first pre-terminal cancellation request for the targeted run.
|
||||||
|
pub(crate) accepted: bool,
|
||||||
|
/// Backend-generated Job identifier that the cancellation request targeted.
|
||||||
|
pub(crate) job_id: String,
|
||||||
|
/// Safe current or cancellation-intent lifecycle code at command completion.
|
||||||
|
pub(crate) state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe acknowledgement returned when one retained checkpoint is resumed into a new backend Job lifecycle.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillResumeResponseDto.ts")]
|
||||||
|
pub(crate) struct BackfillResumeResponseDto {
|
||||||
|
/// Whether the retained in-session checkpoint was accepted for a new run.
|
||||||
|
pub(crate) accepted: bool,
|
||||||
|
/// Newly allocated backend Job identifier owning the reissued checkpoint.
|
||||||
|
pub(crate) job_id: String,
|
||||||
|
/// Initial lifecycle state at the time Resume returns to the frontend.
|
||||||
|
pub(crate) state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe projection proving that one app request mapped to the KSP Backfill contract.
|
||||||
|
///
|
||||||
|
/// Address and signature values are intentionally reduced to presence/count metadata. This DTO is
|
||||||
|
/// suitable for request validation feedback before the runtime Start slice exists.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestPreviewDto.ts")]
|
||||||
|
pub(crate) struct BackfillRequestPreviewDto {
|
||||||
|
/// Whether an address participates in the validated scope.
|
||||||
|
pub(crate) address_present: bool,
|
||||||
|
/// Whether an exclusive anchor participates in the validated scope.
|
||||||
|
pub(crate) anchor_present: bool,
|
||||||
|
/// Stable validated commitment code.
|
||||||
|
pub(crate) commitment: String,
|
||||||
|
/// Number of explicit signatures after Backfill-owned stable deduplication.
|
||||||
|
pub(crate) explicit_signature_count: u32,
|
||||||
|
/// Validated hydration concurrency.
|
||||||
|
pub(crate) hydration_concurrency: u32,
|
||||||
|
/// Validated logical HTTP role.
|
||||||
|
pub(crate) http_role: String,
|
||||||
|
/// Validated maximum candidate count.
|
||||||
|
pub(crate) max_candidates: u32,
|
||||||
|
/// Validated maximum discovery-page count.
|
||||||
|
pub(crate) max_pages: u32,
|
||||||
|
/// Whether a minimum context slot participates in the validated request.
|
||||||
|
pub(crate) min_context_slot_present: bool,
|
||||||
|
/// Backend-derived logical Store network.
|
||||||
|
pub(crate) network: String,
|
||||||
|
/// Validated signature page size.
|
||||||
|
pub(crate) page_size: u32,
|
||||||
|
/// Stable validated scope kind code.
|
||||||
|
pub(crate) scope_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact commitment codes currently admitted by the Backfill runtime.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn backfill_commitment_codes() -> std::vec::Vec<String> {
|
||||||
|
return vec![ksp_job_backfill_lib::BackfillCommitment::Finalized.code().to_owned(), ksp_job_backfill_lib::BackfillCommitment::Confirmed.code().to_owned()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact HTTP scope codes currently admitted by the Backfill runtime.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn backfill_scope_kind_codes() -> std::vec::Vec<String> {
|
||||||
|
return vec![
|
||||||
|
ksp_job_backfill_lib::BackfillScopeKind::LatestAddress.code().to_owned(),
|
||||||
|
ksp_job_backfill_lib::BackfillScopeKind::BeforeAddress.code().to_owned(),
|
||||||
|
ksp_job_backfill_lib::BackfillScopeKind::AfterAddress.code().to_owned(),
|
||||||
|
ksp_job_backfill_lib::BackfillScopeKind::ExplicitSignatures.code().to_owned(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds frontend-safe request limits directly from the public Backfill constants.
|
||||||
|
pub(crate) fn backfill_request_limits() -> ksp_core_lib::Result<BackfillRequestLimitsDto> {
|
||||||
|
let max_hydration_concurrency = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_HYDRATION_CONCURRENCY, "max_hydration_concurrency");
|
||||||
|
let max_hydration_concurrency = match max_hydration_concurrency {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let max_candidates = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_CANDIDATES, "max_candidates");
|
||||||
|
let max_candidates = match max_candidates {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let max_pages = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_PAGES, "max_pages");
|
||||||
|
let max_pages = match max_pages {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let max_page_size = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_PAGE_SIZE, "max_page_size");
|
||||||
|
let max_page_size = match max_page_size {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let max_signature_text_bytes = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_SIGNATURE_TEXT_BYTES, "max_signature_text_bytes");
|
||||||
|
let max_signature_text_bytes = match max_signature_text_bytes {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let min_signature_text_bytes = usize_to_u32(ksp_job_backfill_lib::MIN_BACKFILL_SIGNATURE_TEXT_BYTES, "min_signature_text_bytes");
|
||||||
|
let min_signature_text_bytes = match min_signature_text_bytes {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if crate::DEFAULT_BACKFILL_HYDRATION_CONCURRENCY == 0 || crate::DEFAULT_BACKFILL_HYDRATION_CONCURRENCY > max_hydration_concurrency {
|
||||||
|
return std::result::Result::Err(limit_contract_error("default_hydration_concurrency"));
|
||||||
|
}
|
||||||
|
if crate::DEFAULT_BACKFILL_MAX_CANDIDATES == 0 || crate::DEFAULT_BACKFILL_MAX_CANDIDATES > max_candidates {
|
||||||
|
return std::result::Result::Err(limit_contract_error("default_max_candidates"));
|
||||||
|
}
|
||||||
|
if crate::DEFAULT_BACKFILL_MAX_PAGES == 0 || crate::DEFAULT_BACKFILL_MAX_PAGES > max_pages {
|
||||||
|
return std::result::Result::Err(limit_contract_error("default_max_pages"));
|
||||||
|
}
|
||||||
|
if crate::DEFAULT_BACKFILL_PAGE_SIZE == 0 || crate::DEFAULT_BACKFILL_PAGE_SIZE > max_page_size {
|
||||||
|
return std::result::Result::Err(limit_contract_error("default_page_size"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(BackfillRequestLimitsDto {
|
||||||
|
default_hydration_concurrency: crate::DEFAULT_BACKFILL_HYDRATION_CONCURRENCY,
|
||||||
|
default_max_candidates: crate::DEFAULT_BACKFILL_MAX_CANDIDATES,
|
||||||
|
default_max_pages: crate::DEFAULT_BACKFILL_MAX_PAGES,
|
||||||
|
default_page_size: crate::DEFAULT_BACKFILL_PAGE_SIZE,
|
||||||
|
max_hydration_concurrency,
|
||||||
|
max_candidates,
|
||||||
|
max_pages,
|
||||||
|
max_page_size,
|
||||||
|
max_signature_text_bytes,
|
||||||
|
min_signature_text_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit_contract_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Backfill Desk request-limit projection is internally inconsistent")
|
||||||
|
.with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usize_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result<u32> {
|
||||||
|
let converted = u32::try_from(value);
|
||||||
|
return match converted {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Backfill Desk cannot project a Job-owned bound to the frontend")
|
||||||
|
.with_context("field", field)
|
||||||
|
.with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/dto_backfill.rs"]
|
||||||
|
mod tests;
|
||||||
144
crates/ksp-app-backfill-desk/src/dto_common.rs
Normal file
144
crates/ksp-app-backfill-desk/src/dto_common.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/dto_common.rs
|
||||||
|
// version: 7
|
||||||
|
|
||||||
|
//! Common Tauri DTOs shared by the Backfill Desk shell.
|
||||||
|
|
||||||
|
use ts_rs::TS; // rust-rules: trait-import
|
||||||
|
|
||||||
|
/// Safe HTTP route exposed for operator selection before a Backfill run.
|
||||||
|
///
|
||||||
|
/// The route is identified only by the logical Transport role. Provider labels are safe metadata used for operator choice; endpoint URLs and credentials never
|
||||||
|
/// cross IPC.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_common/BackfillHttpRouteOptionDto.ts")]
|
||||||
|
pub(crate) struct BackfillHttpRouteOptionDto {
|
||||||
|
/// Whether more than one configured provider participates in this logical route.
|
||||||
|
pub(crate) pooled: bool,
|
||||||
|
/// Safe provider labels participating in the route, sorted and deduplicated.
|
||||||
|
pub(crate) providers: std::vec::Vec<String>,
|
||||||
|
/// Logical Transport role later passed to the Backfill runtime.
|
||||||
|
pub(crate) role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe Program ID entry exposed only to populate the free-form address autocomplete dataset.
|
||||||
|
///
|
||||||
|
/// Every value is derived from the canonical `ksp-core-lib` registry. The frontend remains free to submit any valid Solana address; this DTO is advisory only.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_common/ProgramIdAutocompleteOptionDto.ts")]
|
||||||
|
pub(crate) struct ProgramIdAutocompleteOptionDto {
|
||||||
|
/// Stable KSP machine-readable Program ID code.
|
||||||
|
pub(crate) code: String,
|
||||||
|
/// Broad functional domain from the canonical registry.
|
||||||
|
pub(crate) domain: String,
|
||||||
|
/// Functional family from the canonical registry.
|
||||||
|
pub(crate) family: String,
|
||||||
|
/// Human-readable program name.
|
||||||
|
pub(crate) name: String,
|
||||||
|
/// Canonical Base58 Program ID used as the HTML datalist value.
|
||||||
|
pub(crate) program_id: String,
|
||||||
|
/// Owning protocol/project label from the canonical registry.
|
||||||
|
pub(crate) protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projects the canonical KSP Program ID registry to the safe autocomplete dataset.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn program_id_autocomplete_options() -> std::vec::Vec<crate::ProgramIdAutocompleteOptionDto> {
|
||||||
|
let entries = ksp_core_lib::entries();
|
||||||
|
let mut options = std::vec::Vec::with_capacity(entries.len());
|
||||||
|
for entry in entries {
|
||||||
|
options.push(crate::ProgramIdAutocompleteOptionDto {
|
||||||
|
code: entry.code().to_owned(),
|
||||||
|
domain: entry.domain().to_owned(),
|
||||||
|
family: entry.family().to_owned(),
|
||||||
|
name: entry.name().to_owned(),
|
||||||
|
program_id: entry.program_id().to_owned(),
|
||||||
|
protocol: entry.protocol().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe Backfill Desk options contract combining readiness, HTTP routes and Job-owned campaign bounds.
|
||||||
|
///
|
||||||
|
/// Endpoint URLs, credentials and physical routing details are intentionally absent.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_common/BackfillDeskOptionsDto.ts")]
|
||||||
|
pub(crate) struct BackfillDeskOptionsDto {
|
||||||
|
/// Stable commitment codes currently admitted by `ksp-job-backfill-lib`.
|
||||||
|
pub(crate) commitments: std::vec::Vec<String>,
|
||||||
|
/// Selectable logical HTTP routes that support both RPC methods required by the Backfill runtime.
|
||||||
|
pub(crate) http_routes: std::vec::Vec<BackfillHttpRouteOptionDto>,
|
||||||
|
/// Backend-owned maxima plus application defaults used by the campaign form.
|
||||||
|
pub(crate) limits: crate::BackfillRequestLimitsDto,
|
||||||
|
/// Whether the Transport and Store readiness gates jointly permit later Backfill composition.
|
||||||
|
pub(crate) composition_ready: bool,
|
||||||
|
/// Canonical KSP Program IDs exposed as a frontend autocomplete dataset; never an allow-list.
|
||||||
|
pub(crate) program_id_options: std::vec::Vec<crate::ProgramIdAutocompleteOptionDto>,
|
||||||
|
/// Distinct enabled HTTP cluster labels selected by the active Transport profile.
|
||||||
|
pub(crate) configured_networks: std::vec::Vec<String>,
|
||||||
|
/// Whether the selected Store network exactly matches the one coherent Transport network.
|
||||||
|
pub(crate) network_coherent: bool,
|
||||||
|
/// Safe startup diagnostic when Store configuration, opening or readiness could not be proven.
|
||||||
|
pub(crate) store_diagnostic: std::option::Option<CommandErrorDto>,
|
||||||
|
/// Logical network selected by the active Store profile, without backend connection details.
|
||||||
|
pub(crate) store_network: std::option::Option<String>,
|
||||||
|
/// Whether Store opened successfully and its bounded health probe proved readiness.
|
||||||
|
pub(crate) store_ready: bool,
|
||||||
|
/// Safe startup diagnostic when Transport configuration could not be resolved or constructed.
|
||||||
|
pub(crate) transport_diagnostic: std::option::Option<CommandErrorDto>,
|
||||||
|
/// Stable HTTP scope codes currently admitted by `ksp-job-backfill-lib`.
|
||||||
|
pub(crate) scope_kinds: std::vec::Vec<String>,
|
||||||
|
/// Whether Transport currently has one coherent network and at least one compatible available role.
|
||||||
|
pub(crate) transport_ready: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe command error projection that never serializes arbitrary KSP error context or source values.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_common/CommandErrorDto.ts")]
|
||||||
|
pub(crate) struct CommandErrorDto {
|
||||||
|
/// Stable KSP error domain.
|
||||||
|
pub(crate) domain: String,
|
||||||
|
/// Stable KSP error code within the domain.
|
||||||
|
pub(crate) code: String,
|
||||||
|
/// Human-readable error message without arbitrary context fields.
|
||||||
|
pub(crate) message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::CommandErrorDto {
|
||||||
|
/// Builds a bounded safe projection from a KSP error.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn from_error(error: &ksp_core_lib::Error) -> Self {
|
||||||
|
return Self {
|
||||||
|
domain: error.code().domain().to_owned(),
|
||||||
|
code: error.code().code().to_owned(),
|
||||||
|
message: error.message().to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe scaffold/runtime snapshot exposed to the Backfill Desk shell.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_common/ShellStatusDto.ts")]
|
||||||
|
pub(crate) struct ShellStatusDto {
|
||||||
|
/// Active configured Logging profile, or `None` while transient fallback Logging is active.
|
||||||
|
pub(crate) active_logging_profile: std::option::Option<String>,
|
||||||
|
/// Cargo application version.
|
||||||
|
pub(crate) application_version: String,
|
||||||
|
/// Number of logical Config resources registered by the current runtime.
|
||||||
|
pub(crate) config_document_count: u32,
|
||||||
|
/// Whether Backfill Desk had to install its transient in-memory Logging fallback.
|
||||||
|
pub(crate) fallback_logging_active: bool,
|
||||||
|
/// Current implementation phase exposed by the shell.
|
||||||
|
pub(crate) shell_phase: String,
|
||||||
|
/// Safe startup diagnostic that caused fallback Logging, when applicable.
|
||||||
|
pub(crate) startup_diagnostic: std::option::Option<CommandErrorDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/dto_common.rs"]
|
||||||
|
mod tests;
|
||||||
49
crates/ksp-app-backfill-desk/src/errors.rs
Normal file
49
crates/ksp-app-backfill-desk/src/errors.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/errors.rs
|
||||||
|
// version: 9
|
||||||
|
|
||||||
|
//! Application-local error codes for Backfill Desk composition and desktop runtime surfaces.
|
||||||
|
|
||||||
|
/// Shared Backfill Desk runtime state is internally inconsistent.
|
||||||
|
pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "app_state_invalid");
|
||||||
|
/// Shared Backfill Desk runtime state cannot be locked safely.
|
||||||
|
pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "app_state_lock_failed");
|
||||||
|
/// Backfill Desk cannot admit a campaign before Transport and Store composition is ready.
|
||||||
|
pub(crate) const ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_composition_not_ready");
|
||||||
|
/// Backfill Desk received a campaign field or logical route that cannot map to the Backfill contract.
|
||||||
|
pub(crate) const ERROR_CODE_BACKFILL_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_request_invalid");
|
||||||
|
/// Backfill Desk has no retained safe checkpoint/request pair available for in-session Resume.
|
||||||
|
pub(crate) const ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_resume_unavailable");
|
||||||
|
/// Backfill Desk refuses a concurrent Start while another Backfill run owns the single-run slot.
|
||||||
|
pub(crate) const ERROR_CODE_BACKFILL_RUN_ACTIVE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_run_active");
|
||||||
|
/// Backfill Desk received a stale or mismatched Job identity for an active-run control operation.
|
||||||
|
pub(crate) const ERROR_CODE_BACKFILL_RUN_MISMATCH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_run_mismatch");
|
||||||
|
/// Backfill Desk has no active or retained matching run for the requested control operation.
|
||||||
|
pub(crate) const ERROR_CODE_BACKFILL_RUN_NOT_ACTIVE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_run_not_active");
|
||||||
|
/// Backfill Desk composite configuration is missing or references an unexpected document.
|
||||||
|
pub(crate) const ERROR_CODE_CONFIG_COMPOSITE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "config_composite_invalid");
|
||||||
|
/// Frontend logging requested an unsupported level.
|
||||||
|
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "frontend_log_level_invalid");
|
||||||
|
/// Frontend logging requested a target outside the application whitelist.
|
||||||
|
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "frontend_log_target_invalid");
|
||||||
|
/// Backfill Desk could not install the managed Logging runtime or its safe fallback.
|
||||||
|
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "logging_bootstrap_failed");
|
||||||
|
/// Splash readiness was invoked from a window other than the splash window.
|
||||||
|
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "splash_origin_invalid");
|
||||||
|
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||||
|
pub(crate) const ERROR_CODE_SPLASH_SETTING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "splash_setting_invalid");
|
||||||
|
/// Backfill Desk Store and Transport configuration target different logical networks.
|
||||||
|
pub(crate) const ERROR_CODE_STORE_NETWORK_MISMATCH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "store_network_mismatch");
|
||||||
|
/// Backfill Desk cannot prove a single Transport network before Store opening.
|
||||||
|
pub(crate) const ERROR_CODE_STORE_NETWORK_UNAVAILABLE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "store_network_unavailable");
|
||||||
|
/// Backfill Desk cannot complete the bounded Store shutdown lifecycle.
|
||||||
|
pub(crate) const ERROR_CODE_STORE_SHUTDOWN_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "store_shutdown_failed");
|
||||||
|
/// Tauri runtime assembly or execution failed.
|
||||||
|
pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "tauri_runtime_failed");
|
||||||
|
/// A required Tauri window is missing from the configured application runtime.
|
||||||
|
pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "tauri_window_missing");
|
||||||
|
/// A Tauri window show/focus/destroy/event operation failed.
|
||||||
|
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("backfill_desk", "tauri_window_operation_failed");
|
||||||
|
/// Backfill Desk cannot derive a safe Transport readiness contract from the Transport registry.
|
||||||
|
pub(crate) const ERROR_CODE_TRANSPORT_READINESS_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "transport_readiness_invalid");
|
||||||
205
crates/ksp-app-backfill-desk/src/frontend_logging.rs
Normal file
205
crates/ksp-app-backfill-desk/src/frontend_logging.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/frontend_logging.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! KSP-owned bridge for technical log events emitted by Backfill Desk frontend scripts.
|
||||||
|
|
||||||
|
use ts_rs::TS; // rust-rules: trait-import
|
||||||
|
|
||||||
|
/// Log payload sent by Backfill Desk frontend scripts.
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/frontend_logging/FrontendLogPayloadDto.ts")]
|
||||||
|
pub(crate) struct FrontendLogPayloadDto {
|
||||||
|
/// Lowercase KSP log level.
|
||||||
|
pub(crate) level: String,
|
||||||
|
/// Whitelisted logical frontend target identifier.
|
||||||
|
pub(crate) target_id: String,
|
||||||
|
/// Rendered technical frontend message.
|
||||||
|
pub(crate) message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum FrontendLogLevel {
|
||||||
|
Trace,
|
||||||
|
Debug,
|
||||||
|
Info,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum FrontendLogTarget {
|
||||||
|
Frontend,
|
||||||
|
Main,
|
||||||
|
Splash,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits one validated frontend event through the KSP Logging facade.
|
||||||
|
pub(crate) fn emit_frontend_log_event(payload: FrontendLogPayloadDto) -> ksp_core_lib::Result<()> {
|
||||||
|
let level = parse_level(payload.level.as_str());
|
||||||
|
let level = match level {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let target = parse_target(payload.target_id.as_str());
|
||||||
|
let target = match target {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
emit_validated_frontend_log(level, target, payload.message.as_str());
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_level(level: &str) -> ksp_core_lib::Result<FrontendLogLevel> {
|
||||||
|
return match level.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"trace" => std::result::Result::Ok(FrontendLogLevel::Trace),
|
||||||
|
"debug" => std::result::Result::Ok(FrontendLogLevel::Debug),
|
||||||
|
"info" => std::result::Result::Ok(FrontendLogLevel::Info),
|
||||||
|
"warn" => std::result::Result::Ok(FrontendLogLevel::Warn),
|
||||||
|
"error" => std::result::Result::Ok(FrontendLogLevel::Error),
|
||||||
|
_ => std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID, "Frontend log level is not supported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_target(target_id: &str) -> ksp_core_lib::Result<FrontendLogTarget> {
|
||||||
|
return match target_id.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"frontend" => std::result::Result::Ok(FrontendLogTarget::Frontend),
|
||||||
|
"main" => std::result::Result::Ok(FrontendLogTarget::Main),
|
||||||
|
"splash" => std::result::Result::Ok(FrontendLogTarget::Splash),
|
||||||
|
_ => {
|
||||||
|
std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID, "Frontend log target identifier is not supported"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_validated_frontend_log(level: FrontendLogLevel, target: FrontendLogTarget, message: &str) {
|
||||||
|
return match target {
|
||||||
|
FrontendLogTarget::Frontend => emit_frontend_target(level, message),
|
||||||
|
FrontendLogTarget::Main => emit_main_target(level, message),
|
||||||
|
FrontendLogTarget::Splash => emit_splash_target(level, message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_frontend_target(level: FrontendLogLevel, message: &str) {
|
||||||
|
return match level {
|
||||||
|
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "frontend",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "frontend",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "frontend",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "frontend",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "frontend",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_main_target(level: FrontendLogLevel, message: &str) {
|
||||||
|
return match level {
|
||||||
|
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "main",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "main",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "main",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "main",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "main",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_splash_target(level: FrontendLogLevel, message: &str) {
|
||||||
|
return match level {
|
||||||
|
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "splash",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "splash",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "splash",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "splash",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||||
|
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||||
|
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||||
|
action = "frontend_log",
|
||||||
|
target_id = "splash",
|
||||||
|
"{message}"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/frontend_logging.rs"]
|
||||||
|
mod tests;
|
||||||
192
crates/ksp-app-backfill-desk/src/lib.rs
Normal file
192
crates/ksp-app-backfill-desk/src/lib.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/lib.rs
|
||||||
|
// version: 11
|
||||||
|
|
||||||
|
//! Tauri desktop application scaffold for controlling and inspecting KSP RAW backfill jobs.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod app_state;
|
||||||
|
mod backfill_request;
|
||||||
|
mod backfill_run;
|
||||||
|
mod backfill_status;
|
||||||
|
mod bootstrap;
|
||||||
|
mod constants;
|
||||||
|
mod dto_backfill;
|
||||||
|
mod dto_common;
|
||||||
|
mod errors;
|
||||||
|
mod frontend_logging;
|
||||||
|
mod logging_runtime;
|
||||||
|
mod splash;
|
||||||
|
mod store_runtime;
|
||||||
|
mod tauri;
|
||||||
|
mod transport_runtime;
|
||||||
|
mod tw_main;
|
||||||
|
mod tw_splash;
|
||||||
|
|
||||||
|
/// Runs the KSP Backfill desktop application.
|
||||||
|
pub use self::tauri::run;
|
||||||
|
|
||||||
|
/// Shared Backfill Desk application state managed by Tauri.
|
||||||
|
pub(crate) use self::app_state::AppState;
|
||||||
|
/// Maps one app-owned campaign DTO to the validated KSP Backfill request contract.
|
||||||
|
pub(crate) use self::backfill_request::map_backfill_request;
|
||||||
|
/// Projects one validated Backfill request without returning address or signature values.
|
||||||
|
pub(crate) use self::backfill_request::project_backfill_request;
|
||||||
|
/// Fully prepared Backfill launch moved into the async runtime after single-run admission.
|
||||||
|
pub(crate) use self::backfill_run::BackfillRunLaunch;
|
||||||
|
/// Single-active-run admission state retained by the application.
|
||||||
|
pub(crate) use self::backfill_run::BackfillRunState;
|
||||||
|
/// Stable Tauri event carrying coalesced latest-value Backfill status.
|
||||||
|
pub(crate) use self::backfill_status::BACKFILL_STATUS_EVENT_NAME;
|
||||||
|
/// Frontend-safe latest-value Backfill runtime projection.
|
||||||
|
pub(crate) use self::backfill_status::BackfillRunStatusDto;
|
||||||
|
/// Projects one complete Job notification to the safe desktop monitoring contract.
|
||||||
|
pub(crate) use self::backfill_status::project_backfill_status;
|
||||||
|
/// Crate-internal Logging startup state shared by the application state.
|
||||||
|
pub(crate) use self::bootstrap::LoggingStartup;
|
||||||
|
/// Builds the Config management facade from the common KSP CLI bootstrap contract.
|
||||||
|
pub(crate) use self::bootstrap::config_management;
|
||||||
|
/// Initializes Logging from the Backfill Desk composite, with a trace-level development fallback.
|
||||||
|
pub(crate) use self::bootstrap::initialize_logging;
|
||||||
|
/// Loads the concrete Backfill Desk Config composite.
|
||||||
|
pub(crate) use self::bootstrap::load_backfill_desk_composite;
|
||||||
|
/// Returns one required component profile from the Backfill Desk composite.
|
||||||
|
pub(crate) use self::bootstrap::required_composite_component_profile;
|
||||||
|
/// Composite component identifier for Logging.
|
||||||
|
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_LOGGING;
|
||||||
|
/// Composite component identifier for Store.
|
||||||
|
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_STORE;
|
||||||
|
/// Composite component identifier for Transport.
|
||||||
|
pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_TRANSPORT;
|
||||||
|
/// Initial hydration concurrency proposed by the Backfill campaign form.
|
||||||
|
pub(crate) use self::constants::DEFAULT_BACKFILL_HYDRATION_CONCURRENCY;
|
||||||
|
/// Initial candidate cap proposed by the Backfill campaign form.
|
||||||
|
pub(crate) use self::constants::DEFAULT_BACKFILL_MAX_CANDIDATES;
|
||||||
|
/// Initial discovery-page cap proposed by the Backfill campaign form.
|
||||||
|
pub(crate) use self::constants::DEFAULT_BACKFILL_MAX_PAGES;
|
||||||
|
/// Initial signature page size proposed by the Backfill campaign form.
|
||||||
|
pub(crate) use self::constants::DEFAULT_BACKFILL_PAGE_SIZE;
|
||||||
|
/// Structured domain used while bootstrapping Config and Logging.
|
||||||
|
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
|
||||||
|
/// Structured domain used by technical frontend events.
|
||||||
|
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
|
||||||
|
/// Structured domain used by Backfill campaign admission and request mapping.
|
||||||
|
pub(crate) use self::constants::TRACING_DOMAIN_REQUEST;
|
||||||
|
/// Structured domain used by concrete Backfill run admission and execution.
|
||||||
|
pub(crate) use self::constants::TRACING_DOMAIN_RUN;
|
||||||
|
/// Structured domain used by the Backfill Desk shell.
|
||||||
|
pub(crate) use self::constants::TRACING_DOMAIN_SHELL;
|
||||||
|
/// Structured domain used by Store readiness and shutdown operations.
|
||||||
|
pub(crate) use self::constants::TRACING_DOMAIN_STORE;
|
||||||
|
/// Structured domain used by Transport readiness and role inventory operations.
|
||||||
|
pub(crate) use self::constants::TRACING_DOMAIN_TRANSPORT;
|
||||||
|
/// Structured domain used by Tauri window lifecycle operations.
|
||||||
|
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
|
||||||
|
/// Owning target for backend events emitted by Backfill Desk.
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET;
|
||||||
|
/// Owning target for generic frontend events emitted through the KSP bridge.
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
|
||||||
|
/// Owning target for main-window frontend events.
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
|
||||||
|
/// Owning target for splash-window frontend events.
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH;
|
||||||
|
/// Safe acknowledgement returned by targeted cooperative cancellation.
|
||||||
|
pub(crate) use self::dto_backfill::BackfillCancelResponseDto;
|
||||||
|
/// Backend-derived request bounds and Desk defaults for the campaign form.
|
||||||
|
pub(crate) use self::dto_backfill::BackfillRequestLimitsDto;
|
||||||
|
/// Safe request preview produced after strict backend mapping.
|
||||||
|
pub(crate) use self::dto_backfill::BackfillRequestPreviewDto;
|
||||||
|
/// Safe acknowledgement returned after in-session checkpoint Resume admission.
|
||||||
|
pub(crate) use self::dto_backfill::BackfillResumeResponseDto;
|
||||||
|
/// App-owned campaign request received from the frontend.
|
||||||
|
pub(crate) use self::dto_backfill::BackfillStartRequestDto;
|
||||||
|
/// Safe immediate acknowledgement returned after one Backfill run is admitted and spawned.
|
||||||
|
pub(crate) use self::dto_backfill::BackfillStartResponseDto;
|
||||||
|
/// Returns commitment codes admitted by the current Backfill contract.
|
||||||
|
pub(crate) use self::dto_backfill::backfill_commitment_codes;
|
||||||
|
/// Builds frontend-safe campaign limits from Job-owned public constants.
|
||||||
|
pub(crate) use self::dto_backfill::backfill_request_limits;
|
||||||
|
/// Returns scope kind codes admitted by the current Backfill contract.
|
||||||
|
pub(crate) use self::dto_backfill::backfill_scope_kind_codes;
|
||||||
|
/// Safe Backfill Desk options contract combining readiness and campaign metadata.
|
||||||
|
pub(crate) use self::dto_common::BackfillDeskOptionsDto;
|
||||||
|
/// Safe logical HTTP route exposed for operator selection.
|
||||||
|
pub(crate) use self::dto_common::BackfillHttpRouteOptionDto;
|
||||||
|
/// Safe command error projection exposed to Tauri commands.
|
||||||
|
pub(crate) use self::dto_common::CommandErrorDto;
|
||||||
|
/// Safe canonical Program ID entry exposed only for free-form address autocomplete.
|
||||||
|
pub(crate) use self::dto_common::ProgramIdAutocompleteOptionDto;
|
||||||
|
/// Safe scaffold/runtime snapshot exposed to the shell.
|
||||||
|
pub(crate) use self::dto_common::ShellStatusDto;
|
||||||
|
/// Projects the canonical Core Program ID registry to autocomplete entries.
|
||||||
|
pub(crate) use self::dto_common::program_id_autocomplete_options;
|
||||||
|
/// Shared Backfill Desk runtime state is internally inconsistent.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID;
|
||||||
|
/// Shared Backfill Desk runtime state cannot be locked safely.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED;
|
||||||
|
/// Backfill Desk cannot admit a campaign before Transport and Store composition is ready.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY;
|
||||||
|
/// Backfill Desk received a campaign field that cannot map to the Backfill contract.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_BACKFILL_REQUEST_INVALID;
|
||||||
|
/// Backfill Desk has no retained safe checkpoint available for in-session Resume.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE;
|
||||||
|
/// Backfill Desk refuses a concurrent Start while another Backfill run owns the single-run slot.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_BACKFILL_RUN_ACTIVE;
|
||||||
|
/// Backfill Desk rejects stale or mismatched Job identity control requests.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_BACKFILL_RUN_MISMATCH;
|
||||||
|
/// Backfill Desk has no active or retained matching run for the requested control operation.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_BACKFILL_RUN_NOT_ACTIVE;
|
||||||
|
/// Backfill Desk composite configuration is missing or references an unexpected document.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_CONFIG_COMPOSITE_INVALID;
|
||||||
|
/// Frontend logging requested an unsupported level.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
|
||||||
|
/// Frontend logging requested a target outside the application whitelist.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
|
||||||
|
/// Backfill Desk could not install the managed Logging runtime or its safe fallback.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
|
||||||
|
/// Splash readiness was invoked from a window other than the splash window.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
|
||||||
|
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_SPLASH_SETTING_INVALID;
|
||||||
|
/// Backfill Desk Store and Transport configuration target different logical networks.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_STORE_NETWORK_MISMATCH;
|
||||||
|
/// Backfill Desk cannot prove a single Transport network before Store opening.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_STORE_NETWORK_UNAVAILABLE;
|
||||||
|
/// Backfill Desk cannot complete the bounded Store shutdown lifecycle.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_STORE_SHUTDOWN_FAILED;
|
||||||
|
/// Tauri runtime assembly or execution failed.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED;
|
||||||
|
/// A required Tauri window is missing from the configured application runtime.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING;
|
||||||
|
/// A Tauri window show/focus/destroy/event operation failed.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED;
|
||||||
|
/// Backfill Desk cannot derive a safe Transport readiness contract.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_TRANSPORT_READINESS_INVALID;
|
||||||
|
/// Log payload sent by Backfill Desk frontend scripts.
|
||||||
|
pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
|
||||||
|
/// Emits one validated frontend event through the KSP Logging facade.
|
||||||
|
pub(crate) use self::frontend_logging::emit_frontend_log_event;
|
||||||
|
/// Creates the stable runtime identity for this Backfill Desk process launch.
|
||||||
|
pub(crate) use self::logging_runtime::launch_identity;
|
||||||
|
/// Command emitted by Rust to the splash frontend.
|
||||||
|
pub(crate) use self::splash::SplashOrderDto;
|
||||||
|
/// Runtime timings used by the common desk splash lifecycle.
|
||||||
|
pub(crate) use self::splash::SplashSettings;
|
||||||
|
/// Store startup state retained by the application shell.
|
||||||
|
pub(crate) use self::store_runtime::StoreStartup;
|
||||||
|
/// Initializes Store only after composite-selected Transport/Store network coherence is proven.
|
||||||
|
pub(crate) use self::store_runtime::initialize_store;
|
||||||
|
/// Safe and executable Transport runtime retained by application state.
|
||||||
|
pub(crate) use self::transport_runtime::TransportRuntime;
|
||||||
|
/// Initializes the composite-selected HTTP Transport runtime.
|
||||||
|
pub(crate) use self::transport_runtime::initialize_transport;
|
||||||
|
/// Resolves the required main window or returns a typed error.
|
||||||
|
pub(crate) use self::tw_main::require_main_window;
|
||||||
|
/// Shows and focuses the main Backfill Desk window.
|
||||||
|
pub(crate) use self::tw_main::show_main_window;
|
||||||
|
/// Resolves the required splash window or returns a typed error.
|
||||||
|
pub(crate) use self::tw_splash::require_splash_window;
|
||||||
|
/// Starts the one-shot splash lifecycle after the splash frontend reports readiness.
|
||||||
|
pub(crate) use self::tw_splash::splash_frontend_ready_service;
|
||||||
10
crates/ksp-app-backfill-desk/src/logging_runtime.rs
Normal file
10
crates/ksp-app-backfill-desk/src/logging_runtime.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/logging_runtime.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Backfill Desk Logging runtime identity helpers.
|
||||||
|
|
||||||
|
/// Creates the stable runtime identity for this Backfill Desk process launch.
|
||||||
|
pub(crate) fn launch_identity() -> ksp_core_lib::Result<ksp_logging_lib::LoggingRuntimeIdentity> {
|
||||||
|
let timestamp = format!("{}-p{}", chrono::Utc::now().format("%Y%m%d-%H%M%S%.3fZ"), std::process::id());
|
||||||
|
return ksp_logging_lib::LoggingRuntimeIdentity::new(crate::TRACING_TARGET, timestamp);
|
||||||
|
}
|
||||||
58
crates/ksp-app-backfill-desk/src/main.rs
Normal file
58
crates/ksp-app-backfill-desk/src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/main.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Binary entry point for the KSP Backfill desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use fs2::FileExt; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn main() -> std::process::ExitCode {
|
||||||
|
let working_directory = configure_runtime_working_directory();
|
||||||
|
if let std::result::Result::Err(error) = working_directory {
|
||||||
|
eprintln!("cannot configure Backfill Desk runtime working directory: {error}");
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
let mut lock_path = std::env::temp_dir();
|
||||||
|
lock_path.push("com_sasedev_ksp_app_backfill_desk.lock");
|
||||||
|
let lock_file = match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) {
|
||||||
|
std::result::Result::Ok(file) => file,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("cannot create application lock '{}': {error}", lock_path.display());
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let std::result::Result::Err(error) = lock_file.try_lock_exclusive() {
|
||||||
|
if error.kind() == std::io::ErrorKind::WouldBlock {
|
||||||
|
eprintln!("another ksp-app-backfill-desk instance is already running");
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
eprintln!("cannot acquire application lock '{}': {error}", lock_path.display());
|
||||||
|
return std::process::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
let _lock_file = lock_file;
|
||||||
|
let arguments = std::env::args_os().collect::<std::vec::Vec<std::ffi::OsString>>();
|
||||||
|
let run_result = ksp_app_backfill_desk_lib::run(arguments.as_slice());
|
||||||
|
return match run_result {
|
||||||
|
std::result::Result::Ok(()) => std::process::ExitCode::SUCCESS,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("application error: {error}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_runtime_working_directory() -> std::io::Result<()> {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
return std::env::set_current_dir(workspace_root);
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
{
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
214
crates/ksp-app-backfill-desk/src/splash.rs
Normal file
214
crates/ksp-app-backfill-desk/src/splash.rs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/splash.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Common splash settings and frontend event contracts for Backfill Desk.
|
||||||
|
|
||||||
|
use ts_rs::TS; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const DEFAULT_SPLASH_FADE_IN_MS: u32 = 300;
|
||||||
|
const DEFAULT_SPLASH_FADE_OUT_MS: u32 = 300;
|
||||||
|
const DEFAULT_SPLASH_MINIMUM_MS: u64 = 1200;
|
||||||
|
const ENV_SPLASH_FADE_IN_MS: &str = "KSP_DESK_SPLASH_FADE_IN_MS";
|
||||||
|
const ENV_SPLASH_FADE_OUT_MS: &str = "KSP_DESK_SPLASH_FADE_OUT_MS";
|
||||||
|
const ENV_SPLASH_MINIMUM_MS: &str = "KSP_DESK_SPLASH_MINIMUM_MS";
|
||||||
|
const MAX_SPLASH_FADE_MS: u32 = 10_000;
|
||||||
|
const MAX_SPLASH_MINIMUM_MS: u64 = 60_000;
|
||||||
|
|
||||||
|
/// Runtime timings used by the common desk splash lifecycle.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) struct SplashSettings {
|
||||||
|
fade_in_ms: u32,
|
||||||
|
minimum_ms: u64,
|
||||||
|
fade_out_ms: u32,
|
||||||
|
fade_in_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||||
|
minimum_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||||
|
fade_out_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SplashSettings {
|
||||||
|
/// Resolves splash timings through the Config-owned environment snapshot.
|
||||||
|
pub(crate) fn load() -> ksp_core_lib::Result<Self> {
|
||||||
|
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||||
|
let environment = match environment {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fade_in = environment.resolve_variable(ENV_SPLASH_FADE_IN_MS, std::option::Option::Some("300"));
|
||||||
|
let fade_in = match fade_in {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let minimum = environment.resolve_variable(ENV_SPLASH_MINIMUM_MS, std::option::Option::Some("1200"));
|
||||||
|
let minimum = match minimum {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fade_out = environment.resolve_variable(ENV_SPLASH_FADE_OUT_MS, std::option::Option::Some("300"));
|
||||||
|
let fade_out = match fade_out {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fade_in_ms = parse_u32_setting(ENV_SPLASH_FADE_IN_MS, fade_in.value(), MAX_SPLASH_FADE_MS);
|
||||||
|
let fade_in_ms = match fade_in_ms {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let minimum_ms = parse_u64_setting(ENV_SPLASH_MINIMUM_MS, minimum.value(), MAX_SPLASH_MINIMUM_MS);
|
||||||
|
let minimum_ms = match minimum_ms {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let fade_out_ms = parse_u32_setting(ENV_SPLASH_FADE_OUT_MS, fade_out.value(), MAX_SPLASH_FADE_MS);
|
||||||
|
let fade_out_ms = match fade_out_ms {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
fade_in_ms,
|
||||||
|
minimum_ms,
|
||||||
|
fade_out_ms,
|
||||||
|
fade_in_source: fade_in.source(),
|
||||||
|
minimum_source: minimum.source(),
|
||||||
|
fade_out_source: fade_out.source(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns safe in-memory timings used when the managed environment cannot be resolved.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn fallback() -> Self {
|
||||||
|
return Self {
|
||||||
|
fade_in_ms: DEFAULT_SPLASH_FADE_IN_MS,
|
||||||
|
minimum_ms: DEFAULT_SPLASH_MINIMUM_MS,
|
||||||
|
fade_out_ms: DEFAULT_SPLASH_FADE_OUT_MS,
|
||||||
|
fade_in_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||||
|
minimum_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||||
|
fade_out_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured fade-in duration.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn fade_in_ms(self) -> u32 {
|
||||||
|
return self.fade_in_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the minimum fully-visible splash duration.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn minimum_ms(self) -> u64 {
|
||||||
|
return self.minimum_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured fade-out duration.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn fade_out_ms(self) -> u32 {
|
||||||
|
return self.fade_out_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe provenance code for the fade-in duration.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn fade_in_source(self) -> &'static str {
|
||||||
|
return environment_source_code(self.fade_in_source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe provenance code for the minimum duration.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn minimum_source(self) -> &'static str {
|
||||||
|
return environment_source_code(self.minimum_source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe provenance code for the fade-out duration.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn fade_out_source(self) -> &'static str {
|
||||||
|
return environment_source_code(self.fade_out_source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the expected additive backend lifecycle from readiness through fade-out.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn expected_backend_lifecycle_ms(self) -> u64 {
|
||||||
|
return u64::from(self.fade_in_ms) + self.minimum_ms + u64::from(self.fade_out_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Command emitted by Rust to the splash frontend.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/splash/SplashOrderDto.ts")]
|
||||||
|
pub(crate) struct SplashOrderDto {
|
||||||
|
/// Splash action name.
|
||||||
|
pub(crate) action: String,
|
||||||
|
/// Optional status text.
|
||||||
|
pub(crate) message: std::option::Option<String>,
|
||||||
|
/// Optional message severity used by the bottom message feed.
|
||||||
|
pub(crate) status: std::option::Option<String>,
|
||||||
|
/// Optional animation duration in milliseconds.
|
||||||
|
pub(crate) duration_ms: std::option::Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SplashOrderDto {
|
||||||
|
/// Creates a new `SplashOrderDto` value.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn new(action: &str, message: std::option::Option<&str>, status: std::option::Option<&str>, duration_ms: std::option::Option<u32>) -> Self {
|
||||||
|
return Self {
|
||||||
|
action: action.to_owned(),
|
||||||
|
message: message.map(std::string::ToString::to_string),
|
||||||
|
status: status.map(std::string::ToString::to_string),
|
||||||
|
duration_ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn environment_source_code(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str {
|
||||||
|
return match source {
|
||||||
|
ksp_config_lib::ConfigEnvironmentSource::Process => "process",
|
||||||
|
ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv",
|
||||||
|
ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u64_setting(variable_name: &str, value: &str, maximum: u64) -> ksp_core_lib::Result<u64> {
|
||||||
|
let parsed = value.parse::<u64>();
|
||||||
|
let parsed = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
|
||||||
|
.with_context("variable_name", variable_name)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if parsed > maximum {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
|
||||||
|
.with_context("variable_name", variable_name)
|
||||||
|
.with_context("maximum_ms", maximum.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u32_setting(variable_name: &str, value: &str, maximum: u32) -> ksp_core_lib::Result<u32> {
|
||||||
|
let parsed = value.parse::<u32>();
|
||||||
|
let parsed = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
|
||||||
|
.with_context("variable_name", variable_name)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if parsed > maximum {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
|
||||||
|
.with_context("variable_name", variable_name)
|
||||||
|
.with_context("maximum_ms", maximum.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/splash.rs"]
|
||||||
|
mod tests;
|
||||||
288
crates/ksp-app-backfill-desk/src/store_runtime.rs
Normal file
288
crates/ksp-app-backfill-desk/src/store_runtime.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/store_runtime.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Composite-selected Store readiness and shutdown lifecycle owned by Backfill Desk.
|
||||||
|
|
||||||
|
/// Startup result for the Store layer, including safe diagnostics when the desktop shell remains available without a ready Store.
|
||||||
|
pub(crate) struct StoreStartup {
|
||||||
|
diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||||
|
network_coherent: bool,
|
||||||
|
runtime: std::option::Option<StoreRuntime>,
|
||||||
|
store_network: std::option::Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreStartup {
|
||||||
|
/// Applies Store readiness to the application-owned options DTO without exposing backend connection details.
|
||||||
|
pub(crate) fn apply_to(&self, options: &mut crate::BackfillDeskOptionsDto) {
|
||||||
|
options.network_coherent = self.network_coherent;
|
||||||
|
options.store_diagnostic = self.diagnostic.clone();
|
||||||
|
options.store_network = self.store_network.clone();
|
||||||
|
options.store_ready = self.runtime.as_ref().is_some_and(StoreRuntime::health_ready);
|
||||||
|
options.composition_ready = options.transport_ready && options.network_coherent && options.store_ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporarily lends the ready Store facade to one admitted Backfill execution.
|
||||||
|
pub(crate) fn take_for_run(&self) -> ksp_core_lib::Result<ksp_store_lib::Store> {
|
||||||
|
let runtime = self.runtime.as_ref();
|
||||||
|
let runtime = match runtime {
|
||||||
|
std::option::Option::Some(value) if value.health_ready() => value,
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY,
|
||||||
|
"Backfill Desk cannot start a Backfill run without a ready Store",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return runtime.take_for_run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores the Store facade after one Backfill execution reaches its terminal result.
|
||||||
|
pub(crate) fn restore_after_run(&self, store: ksp_store_lib::Store) -> ksp_core_lib::Result<()> {
|
||||||
|
let runtime = self.runtime.as_ref();
|
||||||
|
let runtime = match runtime {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||||
|
"Backfill Desk cannot restore Store after runtime ownership disappeared",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return runtime.restore_after_run(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the retained Store runtime if startup reached the physical Store-open phase.
|
||||||
|
pub(crate) async fn close(&self) -> ksp_core_lib::Result<()> {
|
||||||
|
let runtime = self.runtime.as_ref();
|
||||||
|
return match runtime {
|
||||||
|
std::option::Option::Some(value) => value.close().await,
|
||||||
|
std::option::Option::None => std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executable Store runtime retained after network coherence and bounded Store health checks.
|
||||||
|
pub(crate) struct StoreRuntime {
|
||||||
|
health_ready: bool,
|
||||||
|
profile_id: String,
|
||||||
|
store: std::sync::Mutex<std::option::Option<ksp_store_lib::Store>>,
|
||||||
|
store_network: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreRuntime {
|
||||||
|
/// Returns whether the startup health probe proved the Store ready.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn health_ready(&self) -> bool {
|
||||||
|
return self.health_ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporarily removes the Store from the application slot for one single-active Backfill execution.
|
||||||
|
pub(crate) fn take_for_run(&self) -> ksp_core_lib::Result<ksp_store_lib::Store> {
|
||||||
|
let store = take_store(&self.store);
|
||||||
|
let store = match store {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return match store {
|
||||||
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||||
|
std::option::Option::None => std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_BACKFILL_RUN_ACTIVE,
|
||||||
|
"Backfill Desk Store is already owned by an active Backfill run",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores the Store after one terminal Backfill execution so a later campaign can be admitted.
|
||||||
|
pub(crate) fn restore_after_run(&self, store: ksp_store_lib::Store) -> ksp_core_lib::Result<()> {
|
||||||
|
let locked = self.store.lock();
|
||||||
|
let mut locked = match locked {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk Store runtime state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if locked.is_some() {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||||
|
"Backfill Desk cannot restore Store into an occupied runtime slot",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
*locked = std::option::Option::Some(store);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly closes the Store exactly once through its backend-neutral facade.
|
||||||
|
pub(crate) async fn close(&self) -> ksp_core_lib::Result<()> {
|
||||||
|
let store = take_store(&self.store);
|
||||||
|
let store = match store {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let store = match store {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
let closed = store.close().await;
|
||||||
|
return match closed {
|
||||||
|
std::result::Result::Ok(()) => {
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_STORE,
|
||||||
|
store_profile = self.profile_id.as_str(),
|
||||||
|
store_network = self.store_network.as_str(),
|
||||||
|
"closed Backfill Desk Store runtime"
|
||||||
|
);
|
||||||
|
std::result::Result::Ok(())
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_STORE_SHUTDOWN_FAILED, "Backfill Desk Store shutdown failed").with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the composite Store target, proves Transport/Store network coherence before Store I/O, opens Store and captures one health probe.
|
||||||
|
pub(crate) async fn initialize_store(
|
||||||
|
management: &ksp_config_lib::ConfigManagement,
|
||||||
|
transport_runtime: std::option::Option<&crate::TransportRuntime>,
|
||||||
|
) -> StoreStartup {
|
||||||
|
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||||
|
let environment = match environment {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return unavailable_startup(std::option::Option::None, false, error),
|
||||||
|
};
|
||||||
|
let composite = crate::load_backfill_desk_composite(management);
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return unavailable_startup(std::option::Option::None, false, error),
|
||||||
|
};
|
||||||
|
let profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_STORE, ksp_config_lib::FILE_ID_STD_STORE);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return unavailable_startup(std::option::Option::None, false, error),
|
||||||
|
};
|
||||||
|
let resolved = management.engine().resolve_store_config_profile(&profile, &environment);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return unavailable_startup(std::option::Option::None, false, error),
|
||||||
|
};
|
||||||
|
let profile_id = resolved.profile_id().to_owned();
|
||||||
|
let store_network = resolved.settings().network().as_str().to_owned();
|
||||||
|
let transport_network = transport_runtime.and_then(crate::TransportRuntime::coherent_network);
|
||||||
|
let transport_network = match transport_network {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
let error = ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_STORE_NETWORK_UNAVAILABLE,
|
||||||
|
"Backfill Desk cannot open Store before one coherent Transport network is available",
|
||||||
|
);
|
||||||
|
return unavailable_startup(std::option::Option::Some(store_network), false, error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let coherence = validate_network_coherence(store_network.as_str(), transport_network.as_str());
|
||||||
|
if let std::result::Result::Err(error) = coherence {
|
||||||
|
return unavailable_startup(std::option::Option::Some(store_network), false, error);
|
||||||
|
}
|
||||||
|
let store = ksp_store_lib::Store::open(resolved.into_settings()).await;
|
||||||
|
let store = match store {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return unavailable_startup(std::option::Option::Some(store_network), true, error),
|
||||||
|
};
|
||||||
|
let health = store.health().await;
|
||||||
|
let health_ready = store_health_ready(health.state());
|
||||||
|
let diagnostic = if health_ready { std::option::Option::None } else { std::option::Option::Some(non_ready_health_diagnostic(&health)) };
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_STORE,
|
||||||
|
store_profile = profile_id.as_str(),
|
||||||
|
store_network = store_network.as_str(),
|
||||||
|
store_ready = health_ready,
|
||||||
|
pending_migration_count = health.pending_migration_count(),
|
||||||
|
"initialized Backfill Desk Store readiness from composite-managed configuration"
|
||||||
|
);
|
||||||
|
return StoreStartup {
|
||||||
|
diagnostic,
|
||||||
|
network_coherent: true,
|
||||||
|
runtime: std::option::Option::Some(StoreRuntime {
|
||||||
|
health_ready,
|
||||||
|
profile_id,
|
||||||
|
store: std::sync::Mutex::new(std::option::Option::Some(store)),
|
||||||
|
store_network: store_network.clone(),
|
||||||
|
}),
|
||||||
|
store_network: std::option::Option::Some(store_network),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_ready_health_diagnostic(health: &ksp_store_lib::StoreHealthSnapshot) -> crate::CommandErrorDto {
|
||||||
|
let code = health.last_error_code();
|
||||||
|
return match code {
|
||||||
|
std::option::Option::Some(value) => crate::CommandErrorDto {
|
||||||
|
code: value.code().to_owned(),
|
||||||
|
domain: value.domain().to_owned(),
|
||||||
|
message: "Store health probe did not prove readiness".to_owned(),
|
||||||
|
},
|
||||||
|
std::option::Option::None => crate::CommandErrorDto {
|
||||||
|
code: "store_not_ready".to_owned(),
|
||||||
|
domain: "backfill_desk".to_owned(),
|
||||||
|
message: "Store health probe did not prove readiness".to_owned(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_health_ready(state: ksp_store_lib::StoreHealthState) -> bool {
|
||||||
|
return match state {
|
||||||
|
ksp_store_lib::StoreHealthState::Ready => true,
|
||||||
|
ksp_store_lib::StoreHealthState::NotReady => false,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_store(store: &std::sync::Mutex<std::option::Option<ksp_store_lib::Store>>) -> ksp_core_lib::Result<std::option::Option<ksp_store_lib::Store>> {
|
||||||
|
let locked = store.lock();
|
||||||
|
let mut locked = match locked {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||||
|
"Backfill Desk Store runtime state lock is poisoned",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(locked.take());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unavailable_startup(store_network: std::option::Option<String>, network_coherent: bool, error: ksp_core_lib::Error) -> StoreStartup {
|
||||||
|
let diagnostic = crate::CommandErrorDto::from_error(&error);
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_STORE,
|
||||||
|
error_domain = diagnostic.domain.as_str(),
|
||||||
|
error_code = diagnostic.code.as_str(),
|
||||||
|
network_coherent = network_coherent,
|
||||||
|
"Backfill Desk Store readiness is unavailable; keeping desktop shell available"
|
||||||
|
);
|
||||||
|
return StoreStartup {
|
||||||
|
diagnostic: std::option::Option::Some(diagnostic),
|
||||||
|
network_coherent,
|
||||||
|
runtime: std::option::Option::None,
|
||||||
|
store_network,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_network_coherence(store_network: &str, transport_network: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if store_network == transport_network {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_STORE_NETWORK_MISMATCH, "Backfill Desk Store and Transport networks do not match")
|
||||||
|
.with_context("store_network", store_network)
|
||||||
|
.with_context("transport_network", transport_network),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/store_runtime.rs"]
|
||||||
|
mod tests;
|
||||||
343
crates/ksp-app-backfill-desk/src/tauri.rs
Normal file
343
crates/ksp-app-backfill-desk/src/tauri.rs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/tauri.rs
|
||||||
|
// version: 9
|
||||||
|
|
||||||
|
//! Tauri runtime assembly for the KSP Backfill desktop application.
|
||||||
|
|
||||||
|
use tauri::Emitter; // rust-rules: trait-import
|
||||||
|
use tauri::Manager; // rust-rules: trait-import
|
||||||
|
|
||||||
|
/// Runs the Backfill desktop application.
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> {
|
||||||
|
let context = tauri::generate_context!();
|
||||||
|
let runtime_layout = configure_packaged_runtime(&context);
|
||||||
|
if let std::result::Result::Err(error) = runtime_layout {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let app_state = crate::AppState::initialize(arguments);
|
||||||
|
let app_state = match app_state {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut builder = tauri::Builder::default();
|
||||||
|
builder = configure_state(builder, app_state);
|
||||||
|
builder = configure_plugins(builder);
|
||||||
|
builder = configure_commands(builder);
|
||||||
|
builder = configure_setup(builder);
|
||||||
|
builder = configure_window_events(builder);
|
||||||
|
let run_result = builder.run(context);
|
||||||
|
return match run_result {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot run KSP Backfill Desk Tauri runtime")
|
||||||
|
.with_context("tauri_error", error.to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_packaged_runtime(context: &tauri::Context<tauri::Wry>) -> ksp_core_lib::Result<()> {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let resource_root = tauri::utils::platform::resource_dir(context.package_info(), &tauri::Env::default());
|
||||||
|
let resource_root = match resource_root {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot resolve packaged Tauri resource directory")
|
||||||
|
.with_context("tauri_error", error.to_string()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let layout = ksp_config_lib::prepare_packaged_runtime(resource_root.as_path());
|
||||||
|
let layout = match layout {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let working_directory = std::env::set_current_dir(layout.runtime_root());
|
||||||
|
return match working_directory {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot activate packaged KSP runtime directory")
|
||||||
|
.with_context("runtime_root", layout.runtime_root().to_string_lossy().into_owned())
|
||||||
|
.with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_state(builder: tauri::Builder<tauri::Wry>, app_state: crate::AppState) -> tauri::Builder<tauri::Wry> {
|
||||||
|
return builder.manage(app_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||||
|
let tracing_plugin = tauri_plugin_tracing::Builder::new().build::<tauri::Wry>();
|
||||||
|
return builder.plugin(tracing_plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
|
||||||
|
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||||
|
return builder.invoke_handler(tauri::generate_handler![
|
||||||
|
backfill_cancel,
|
||||||
|
backfill_options,
|
||||||
|
backfill_resume,
|
||||||
|
backfill_start,
|
||||||
|
backfill_status,
|
||||||
|
backfill_validate_request,
|
||||||
|
emit_frontend_log,
|
||||||
|
get_runtime_status,
|
||||||
|
splash_frontend_ready
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||||
|
return builder.setup(|app| {
|
||||||
|
let splash = crate::require_splash_window(app);
|
||||||
|
if let std::result::Result::Err(error) = splash {
|
||||||
|
return std::result::Result::Err(std::boxed::Box::new(error));
|
||||||
|
}
|
||||||
|
let main = crate::require_main_window(app);
|
||||||
|
if let std::result::Result::Err(error) = main {
|
||||||
|
return std::result::Result::Err(std::boxed::Box::new(error));
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_SHELL, "Backfill Desk Tauri shell setup completed");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_window_events(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||||
|
return builder.on_window_event(|window, event| {
|
||||||
|
if window.label() != "main" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
api.prevent_close();
|
||||||
|
let app_handle = window.app_handle().clone();
|
||||||
|
let state = app_handle.state::<crate::AppState>();
|
||||||
|
if !state.begin_shutdown() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancellation = state.cancel_active_backfill_for_shutdown();
|
||||||
|
match cancellation {
|
||||||
|
std::result::Result::Ok(accepted) => {
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
cancellation_accepted = accepted,
|
||||||
|
"Backfill Desk main-window close requested; cooperative Backfill cancellation attempted before bounded Store shutdown"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
error_domain = error.code().domain(),
|
||||||
|
error_code = error.code().code(),
|
||||||
|
"Backfill Desk could not request cooperative cancellation during shutdown; continuing bounded shutdown"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = app_handle.state::<crate::AppState>();
|
||||||
|
let closed = state.close_store().await;
|
||||||
|
match closed {
|
||||||
|
std::result::Result::Ok(()) => {
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
"Backfill Desk Store shutdown completed; exiting application"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
error_domain = error.code().domain(),
|
||||||
|
error_code = error.code().code(),
|
||||||
|
"Backfill Desk Store shutdown failed; exiting application after bounded close attempt"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
app_handle.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_command_error(command: &'static str, domain: &'static str, error: &ksp_core_lib::Error) -> crate::CommandErrorDto {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = domain,
|
||||||
|
command = command,
|
||||||
|
error_domain = error.code().domain(),
|
||||||
|
error_code = error.code().code(),
|
||||||
|
"Backfill Desk Tauri command failed"
|
||||||
|
);
|
||||||
|
return crate::CommandErrorDto::from_error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn backfill_cancel(job_id: String, state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::BackfillCancelResponseDto, crate::CommandErrorDto> {
|
||||||
|
let result = state.cancel_backfill(job_id.as_str());
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("backfill_cancel", crate::TRACING_DOMAIN_RUN, &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn backfill_options(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::BackfillDeskOptionsDto, crate::CommandErrorDto> {
|
||||||
|
let result = state.backfill_options();
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("backfill_options", crate::TRACING_DOMAIN_TRANSPORT, &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn backfill_start(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
request: crate::BackfillStartRequestDto,
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> std::result::Result<crate::BackfillStartResponseDto, crate::CommandErrorDto> {
|
||||||
|
let launch = state.prepare_backfill_start(request);
|
||||||
|
let launch = match launch {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(project_command_error("backfill_start", crate::TRACING_DOMAIN_RUN, &error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let response = launch.response();
|
||||||
|
spawn_backfill_launch(app, launch);
|
||||||
|
return std::result::Result::Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn backfill_resume(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> std::result::Result<crate::BackfillResumeResponseDto, crate::CommandErrorDto> {
|
||||||
|
let launch = state.prepare_backfill_resume();
|
||||||
|
let launch = match launch {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(project_command_error("backfill_resume", crate::TRACING_DOMAIN_RUN, &error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let response = crate::BackfillResumeResponseDto {
|
||||||
|
accepted: true,
|
||||||
|
job_id: launch.job_id.as_str().to_owned(),
|
||||||
|
state: ksp_job_api::JobState::Created.code().to_owned(),
|
||||||
|
};
|
||||||
|
spawn_backfill_launch(app, launch);
|
||||||
|
return std::result::Result::Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_backfill_launch(app: tauri::AppHandle, launch: crate::BackfillRunLaunch) {
|
||||||
|
let monitor_app = app.clone();
|
||||||
|
let monitor_source = launch.snapshots.clone();
|
||||||
|
let _monitor_task = tauri::async_runtime::spawn(async move {
|
||||||
|
monitor_backfill_status(monitor_app, monitor_source).await;
|
||||||
|
});
|
||||||
|
let _run_task = tauri::async_runtime::spawn(async move {
|
||||||
|
let state = app.state::<crate::AppState>();
|
||||||
|
let executed = state.execute_backfill_run(launch).await;
|
||||||
|
if let std::result::Result::Err(error) = executed {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_RUN,
|
||||||
|
error_domain = error.code().domain(),
|
||||||
|
error_code = error.code().code(),
|
||||||
|
"Backfill Desk async run terminated with an error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn monitor_backfill_status(app: tauri::AppHandle, source: ksp_job_backfill_lib::BackfillSnapshotSource) {
|
||||||
|
let mut notification = <ksp_job_backfill_lib::BackfillSnapshotSource as ksp_job_api::JobSnapshotSource>::current(&source);
|
||||||
|
loop {
|
||||||
|
let projected = crate::project_backfill_status(¬ification);
|
||||||
|
match projected {
|
||||||
|
std::result::Result::Ok(status) => {
|
||||||
|
let main = app.get_webview_window("main");
|
||||||
|
if let std::option::Option::Some(window) = main {
|
||||||
|
let emitted = window.emit(crate::BACKFILL_STATUS_EVENT_NAME, status);
|
||||||
|
if emitted.is_err() {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_RUN,
|
||||||
|
"Backfill Desk could not emit latest-value monitoring status to the main window"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_RUN,
|
||||||
|
error_domain = error.code().domain(),
|
||||||
|
error_code = error.code().code(),
|
||||||
|
"Backfill Desk could not project latest-value monitoring status"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if notification.state().is_terminal() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let observed = notification.sequence();
|
||||||
|
notification = <ksp_job_backfill_lib::BackfillSnapshotSource as ksp_job_api::JobSnapshotSource>::wait_for_change(&source, observed).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn backfill_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result<std::option::Option<crate::BackfillRunStatusDto>, crate::CommandErrorDto> {
|
||||||
|
let result = state.backfill_status();
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("backfill_status", crate::TRACING_DOMAIN_RUN, &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn backfill_validate_request(
|
||||||
|
request: crate::BackfillStartRequestDto,
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> std::result::Result<crate::BackfillRequestPreviewDto, crate::CommandErrorDto> {
|
||||||
|
let result = state.validate_backfill_request(request);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("backfill_validate_request", crate::TRACING_DOMAIN_REQUEST, &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||||
|
let result = crate::emit_frontend_log_event(payload);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("emit_frontend_log", crate::TRACING_DOMAIN_FRONTEND, &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::ShellStatusDto, crate::CommandErrorDto> {
|
||||||
|
let result = state.shell_status();
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("get_runtime_status", crate::TRACING_DOMAIN_SHELL, &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn splash_frontend_ready(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
webview_window: tauri::WebviewWindow,
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||||
|
let result = crate::splash_frontend_ready_service(app, webview_window, &state).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("splash_frontend_ready", crate::TRACING_DOMAIN_WINDOWS, &error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
179
crates/ksp-app-backfill-desk/src/transport_runtime.rs
Normal file
179
crates/ksp-app-backfill-desk/src/transport_runtime.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/transport_runtime.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
//! Composite-selected HTTP Transport readiness and route inventory owned by Backfill Desk.
|
||||||
|
|
||||||
|
/// Safe and executable Transport runtime retained by the application state.
|
||||||
|
pub(crate) struct TransportRuntime {
|
||||||
|
pool: ksp_onchain_transport_lib::HttpTransportPool,
|
||||||
|
profile_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportRuntime {
|
||||||
|
/// Returns a shareable clone of the Transport-owned HTTP pool for one admitted Backfill execution.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn pool(&self) -> ksp_onchain_transport_lib::HttpTransportPool {
|
||||||
|
return self.pool.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the composite-selected Transport profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the one configured Transport network only when the active HTTP pool is logically coherent.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn coherent_network(&self) -> std::option::Option<String> {
|
||||||
|
let networks = configured_networks(&self.pool.snapshot());
|
||||||
|
if networks.len() != 1 {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return networks.into_iter().next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the current safe Transport-only projection for the Backfill options surface.
|
||||||
|
pub(crate) fn options(&self) -> ksp_core_lib::Result<crate::BackfillDeskOptionsDto> {
|
||||||
|
let snapshot = self.pool.snapshot();
|
||||||
|
let configured_networks = configured_networks(&snapshot);
|
||||||
|
let http_routes = compatible_backfill_http_routes(&self.pool, &snapshot);
|
||||||
|
let http_routes = match http_routes {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let limits = crate::backfill_request_limits();
|
||||||
|
let limits = match limits {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let transport_ready = configured_networks.len() == 1 && !http_routes.is_empty() && snapshot.available_endpoint_count() > 0;
|
||||||
|
return std::result::Result::Ok(crate::BackfillDeskOptionsDto {
|
||||||
|
commitments: crate::backfill_commitment_codes(),
|
||||||
|
composition_ready: false,
|
||||||
|
configured_networks,
|
||||||
|
http_routes,
|
||||||
|
limits,
|
||||||
|
network_coherent: false,
|
||||||
|
program_id_options: crate::program_id_autocomplete_options(),
|
||||||
|
scope_kinds: crate::backfill_scope_kind_codes(),
|
||||||
|
store_diagnostic: std::option::Option::None,
|
||||||
|
store_network: std::option::Option::None,
|
||||||
|
store_ready: false,
|
||||||
|
transport_diagnostic: std::option::Option::None,
|
||||||
|
transport_ready,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the composite-selected Transport profile and builds the executable HTTP pool.
|
||||||
|
pub(crate) fn initialize_transport(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<TransportRuntime> {
|
||||||
|
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||||
|
let environment = match environment {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let composite = crate::load_backfill_desk_composite(management);
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_TRANSPORT, ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let resolved = management.engine().resolve_transport_config_profile(&profile, &environment);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let profile_id = resolved.profile_id().to_owned();
|
||||||
|
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings());
|
||||||
|
let pool = match pool {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let runtime = TransportRuntime { pool, profile_id };
|
||||||
|
let options = runtime.options();
|
||||||
|
let options = match options {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_TRANSPORT,
|
||||||
|
transport_profile = runtime.profile_id(),
|
||||||
|
network_count = options.configured_networks.len(),
|
||||||
|
http_route_count = options.http_routes.len(),
|
||||||
|
transport_ready = options.transport_ready,
|
||||||
|
"initialized Backfill Desk HTTP Transport readiness from composite-managed configuration"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatible_backfill_http_routes(
|
||||||
|
pool: &ksp_onchain_transport_lib::HttpTransportPool,
|
||||||
|
snapshot: &ksp_onchain_transport_lib::HttpTransportPoolSnapshot,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<crate::BackfillHttpRouteOptionDto>> {
|
||||||
|
let signatures = required_http_rpc_method("getSignaturesForAddress");
|
||||||
|
let signatures = match signatures {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let transaction = required_http_rpc_method("getTransaction");
|
||||||
|
let transaction = match transaction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut candidates = std::collections::BTreeMap::<String, std::collections::BTreeSet<String>>::new();
|
||||||
|
for endpoint in snapshot.endpoints() {
|
||||||
|
if !endpoint.enabled() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for role in endpoint.roles() {
|
||||||
|
if role.enabled() {
|
||||||
|
candidates.entry(role.role().to_owned()).or_default().insert(endpoint.provider().to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut compatible = std::vec::Vec::new();
|
||||||
|
for (candidate, providers) in candidates {
|
||||||
|
let role = ksp_onchain_transport_lib::HttpRoleName::new(candidate.clone());
|
||||||
|
if pool.select_for_method(&role, signatures).is_ok() && pool.select_for_method(&role, transaction).is_ok() {
|
||||||
|
let providers = providers.into_iter().collect::<std::vec::Vec<_>>();
|
||||||
|
compatible.push(crate::BackfillHttpRouteOptionDto { pooled: providers.len() > 1, providers, role: candidate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(compatible);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured_networks(snapshot: &ksp_onchain_transport_lib::HttpTransportPoolSnapshot) -> std::vec::Vec<String> {
|
||||||
|
let mut values = snapshot
|
||||||
|
.endpoints()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| {
|
||||||
|
if endpoint.enabled() {
|
||||||
|
return std::option::Option::Some(endpoint.cluster().to_owned());
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
values.sort();
|
||||||
|
values.dedup();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_http_rpc_method(method: &'static str) -> ksp_core_lib::Result<&'static ksp_onchain_transport_lib::HttpRpcMethodDescriptor> {
|
||||||
|
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method);
|
||||||
|
return match descriptor {
|
||||||
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||||
|
std::option::Option::None => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TRANSPORT_READINESS_INVALID, "Backfill Desk Transport registry is missing a required HTTP RPC method")
|
||||||
|
.with_context("rpc_method", method),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/transport_runtime.rs"]
|
||||||
|
mod tests;
|
||||||
53
crates/ksp-app-backfill-desk/src/tw_main.rs
Normal file
53
crates/ksp-app-backfill-desk/src/tw_main.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/tw_main.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Tauri-window helpers for the Backfill Desk main window.
|
||||||
|
|
||||||
|
use tauri::Manager; // rust-rules: trait-import
|
||||||
|
|
||||||
|
/// Crate-internal `WINDOW_LABEL_MAIN` constant.
|
||||||
|
pub(crate) const WINDOW_LABEL_MAIN: &str = "main";
|
||||||
|
|
||||||
|
/// Resolves the required main window or returns a typed error.
|
||||||
|
pub(crate) fn require_main_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
|
||||||
|
let window = manager.get_webview_window(WINDOW_LABEL_MAIN);
|
||||||
|
return match window {
|
||||||
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||||
|
std::option::Option::None => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "Backfill Desk main window is missing")
|
||||||
|
.with_context("window_label", WINDOW_LABEL_MAIN),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows main window.
|
||||||
|
pub(crate) fn show_main_window(app: &tauri::AppHandle) -> ksp_core_lib::Result<()> {
|
||||||
|
let window = crate::require_main_window(app);
|
||||||
|
let window = match window {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let shown = window.show();
|
||||||
|
if let std::result::Result::Err(error) = shown {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot show Backfill Desk main window")
|
||||||
|
.with_context("window_label", WINDOW_LABEL_MAIN)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let focused = window.set_focus();
|
||||||
|
if let std::result::Result::Err(error) = focused {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot focus Backfill Desk main window")
|
||||||
|
.with_context("window_label", WINDOW_LABEL_MAIN)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
window_label = WINDOW_LABEL_MAIN,
|
||||||
|
"main window shown and focused"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
173
crates/ksp-app-backfill-desk/src/tw_splash.rs
Normal file
173
crates/ksp-app-backfill-desk/src/tw_splash.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/src/tw_splash.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Tauri-window lifecycle for the Backfill Desk splash window.
|
||||||
|
|
||||||
|
use tauri::Emitter; // rust-rules: trait-import
|
||||||
|
use tauri::Manager; // rust-rules: trait-import
|
||||||
|
|
||||||
|
/// Crate-internal splash window label.
|
||||||
|
pub(crate) const WINDOW_LABEL_SPLASH: &str = "splash";
|
||||||
|
|
||||||
|
const SPLASH_EVENT_NAME: &str = "ksp-splash-order";
|
||||||
|
|
||||||
|
/// Resolves the required splash window or returns a typed error.
|
||||||
|
pub(crate) fn require_splash_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
|
||||||
|
let window = manager.get_webview_window(WINDOW_LABEL_SPLASH);
|
||||||
|
return match window {
|
||||||
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||||
|
std::option::Option::None => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "Backfill Desk splash window is missing")
|
||||||
|
.with_context("window_label", WINDOW_LABEL_SPLASH),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the one-shot splash lifecycle after the splash frontend reports readiness.
|
||||||
|
pub(crate) async fn splash_frontend_ready_service(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
invoking_window: tauri::WebviewWindow,
|
||||||
|
state: &crate::AppState,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
if invoking_window.label() != WINDOW_LABEL_SPLASH {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_ORIGIN_INVALID, "Splash readiness may only originate from the splash window")
|
||||||
|
.with_context("window_label", invoking_window.label()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !state.begin_splash_sequence() {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WINDOWS, window_label = WINDOW_LABEL_SPLASH, "duplicate splash frontend readiness ignored");
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let settings = state.splash_settings();
|
||||||
|
let lifecycle_started = std::time::Instant::now();
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
window_label = WINDOW_LABEL_SPLASH,
|
||||||
|
fade_in_ms = settings.fade_in_ms(),
|
||||||
|
fade_in_source = settings.fade_in_source(),
|
||||||
|
minimum_ms = settings.minimum_ms(),
|
||||||
|
minimum_source = settings.minimum_source(),
|
||||||
|
fade_out_ms = settings.fade_out_ms(),
|
||||||
|
fade_out_source = settings.fade_out_source(),
|
||||||
|
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
|
||||||
|
"splash frontend readiness accepted; starting splash to main lifecycle"
|
||||||
|
);
|
||||||
|
let emitted = emit_order(
|
||||||
|
&invoking_window,
|
||||||
|
crate::SplashOrderDto::new("fade_in", std::option::Option::None, std::option::Option::None, std::option::Option::Some(settings.fade_in_ms())),
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let emitted = emit_message(&invoking_window, "Initialisation de Backfill Desk...", "info");
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let emitted = emit_debug(&invoking_window, "Listener frontend prêt; fade-in piloté par Rust.");
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
sleep_and_log(&invoking_window, "fade-in", u64::from(settings.fade_in_ms()), lifecycle_started).await;
|
||||||
|
let emitted = emit_message(&invoking_window, "Configuration KSP chargée.", "success");
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let emitted = emit_debug(&invoking_window, "Configuration et provenance des timings résolues via ksp-config-lib.");
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let emitted = emit_message(&invoking_window, "Runtime Logging initialisé.", "success");
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let emitted = emit_message(&invoking_window, "Scaffold desktop prêt.", "success");
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
sleep_and_log(&invoking_window, "minimum", settings.minimum_ms(), lifecycle_started).await;
|
||||||
|
let emitted = emit_message(&invoking_window, "Initialisation terminée.", "success");
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let emitted = emit_order(
|
||||||
|
&invoking_window,
|
||||||
|
crate::SplashOrderDto::new("fade_out", std::option::Option::None, std::option::Option::None, std::option::Option::Some(settings.fade_out_ms())),
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = emitted {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
sleep_and_log(&invoking_window, "fade-out", u64::from(settings.fade_out_ms()), lifecycle_started).await;
|
||||||
|
let show_main = crate::show_main_window(&app);
|
||||||
|
if let std::result::Result::Err(error) = show_main {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let destroyed = invoking_window.destroy();
|
||||||
|
if let std::result::Result::Err(error) = destroyed {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot destroy Backfill Desk splash window")
|
||||||
|
.with_context("window_label", WINDOW_LABEL_SPLASH)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
window_label = WINDOW_LABEL_SPLASH,
|
||||||
|
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
|
||||||
|
actual_backend_lifecycle_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
|
||||||
|
"splash window destroyed after main activation"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_message(window: &tauri::WebviewWindow, message: &str, status: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
return emit_order(
|
||||||
|
window,
|
||||||
|
crate::SplashOrderDto::new("add_message", std::option::Option::Some(message), std::option::Option::Some(status), std::option::Option::None),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_debug(window: &tauri::WebviewWindow, message: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if !cfg!(debug_assertions) {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
return emit_order(
|
||||||
|
window,
|
||||||
|
crate::SplashOrderDto::new("add_debug", std::option::Option::Some(message), std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sleep_and_log(window: &tauri::WebviewWindow, phase: &str, duration_ms: u64, lifecycle_started: std::time::Instant) {
|
||||||
|
let wait_started = std::time::Instant::now();
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||||
|
window_label = window.label(),
|
||||||
|
phase,
|
||||||
|
configured_wait_ms = duration_ms,
|
||||||
|
actual_wait_ms = wait_started.elapsed().as_secs_f64() * 1000.0,
|
||||||
|
lifecycle_elapsed_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
|
||||||
|
"splash lifecycle wait completed"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_order(window: &tauri::WebviewWindow, order: crate::SplashOrderDto) -> ksp_core_lib::Result<()> {
|
||||||
|
let action = order.action.clone();
|
||||||
|
let emitted = window.emit(SPLASH_EVENT_NAME, order);
|
||||||
|
return match emitted {
|
||||||
|
std::result::Result::Ok(()) => {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WINDOWS, window_label = WINDOW_LABEL_SPLASH, action = action.as_str(), "splash order emitted");
|
||||||
|
std::result::Result::Ok(())
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot emit Backfill Desk splash order")
|
||||||
|
.with_context("window_label", WINDOW_LABEL_SPLASH)
|
||||||
|
.with_context("action", action)
|
||||||
|
.with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
73
crates/ksp-app-backfill-desk/tauri.conf.json
Normal file
73
crates/ksp-app-backfill-desk/tauri.conf.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "KSP Backfill Desk",
|
||||||
|
"identifier": "com.sasedev.ksp-app-backfill-desk",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": {
|
||||||
|
"script": "npm run dev",
|
||||||
|
"cwd": "."
|
||||||
|
},
|
||||||
|
"devUrl": "http://localhost:1436",
|
||||||
|
"beforeBuildCommand": {
|
||||||
|
"script": "npm run build",
|
||||||
|
"cwd": "."
|
||||||
|
},
|
||||||
|
"frontendDist": "../../../builds/khadhroony-solana-project/ksp-app-backfill-desk/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "splash",
|
||||||
|
"url": "splash.html",
|
||||||
|
"title": "Chargement — KSP Backfill Desk",
|
||||||
|
"width": 960,
|
||||||
|
"height": 637,
|
||||||
|
"resizable": false,
|
||||||
|
"decorations": false,
|
||||||
|
"transparent": true,
|
||||||
|
"center": true,
|
||||||
|
"alwaysOnTop": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"url": "main.html",
|
||||||
|
"title": "KSP Backfill Desk",
|
||||||
|
"width": 1500,
|
||||||
|
"height": 960,
|
||||||
|
"minWidth": 1024,
|
||||||
|
"minHeight": 768,
|
||||||
|
"center": true,
|
||||||
|
"visible": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/favicon.png",
|
||||||
|
"icons/favicon.ico"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"../../config/composite.ksp-app-backfill-desk.json": "config/composite.ksp-app-backfill-desk.json",
|
||||||
|
"../../config/composite.ksp-app-raw-transaction-ingest-desk.json": "config/composite.ksp-app-raw-transaction-ingest-desk.json",
|
||||||
|
"../../config/composite.ksp-app-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
|
||||||
|
"../../config/composite.ksp-app-store-desk.json": "config/composite.ksp-app-store-desk.json",
|
||||||
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
|
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
|
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||||
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json",
|
||||||
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json": "config/std.store.json",
|
||||||
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
|
"../../config/std.wallet.json": "config/std.wallet.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
308
crates/ksp-app-backfill-desk/tests/config_composition.rs
Normal file
308
crates/ksp-app-backfill-desk/tests/config_composition.rs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/config_composition.rs
|
||||||
|
// version: 7
|
||||||
|
|
||||||
|
//! Config composite, Transport readiness and Store-network composition contracts for Backfill Desk through pre.006.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn workspace_engine() -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentEngine> {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
return match registry {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_component(composite: &ksp_config_lib::ResolvedConfigComposite, component_id: &str, file_id: &str, profile_id: &str) {
|
||||||
|
let component = composite.component(component_id);
|
||||||
|
assert!(component.is_some(), "missing component {component_id}");
|
||||||
|
if let std::option::Option::Some(component) = component {
|
||||||
|
assert_eq!(component.resolved().file_id().as_str(), file_id);
|
||||||
|
assert_eq!(component.resolved().profile_id(), profile_id);
|
||||||
|
assert_eq!(component.resolved().selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_devnet_profile_selects_trace_logging_transport_and_store() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
assert!(file_id.is_ok(), "Backfill Desk composite file_id should be valid: {file_id:?}");
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::Some("devnet"));
|
||||||
|
assert!(composite.is_ok(), "committed Backfill Desk devnet profile should resolve: {composite:?}");
|
||||||
|
if let std::result::Result::Ok(composite) = composite {
|
||||||
|
assert_eq!(composite.profile_id(), "devnet");
|
||||||
|
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, "supertrace");
|
||||||
|
assert_component(&composite, "transport", ksp_config_lib::FILE_ID_STD_TRANSPORT, "devnet_public");
|
||||||
|
assert_component(&composite, "store", ksp_config_lib::FILE_ID_STD_STORE, "devnet");
|
||||||
|
let logging = composite.component("logging");
|
||||||
|
assert!(logging.is_some(), "Backfill Desk composite should expose Logging");
|
||||||
|
if let std::option::Option::Some(logging) = logging {
|
||||||
|
assert_eq!(logging.resolved().profile().get("default_filter").and_then(serde_json::Value::as_str), std::option::Option::Some("trace"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_named_profiles_keep_transport_and_store_network_selection_paired() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for (profile_id, logging_profile, transport_profile, store_profile) in
|
||||||
|
[("mainnet", "supertrace", "mainnet_backfill_pool", "mainnet"), ("testnet", "console_info", "publicnode_testnet", "testnet")]
|
||||||
|
{
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::Some(profile_id));
|
||||||
|
assert!(composite.is_ok(), "Backfill Desk composite profile {profile_id} should resolve: {composite:?}");
|
||||||
|
if let std::result::Result::Ok(composite) = composite {
|
||||||
|
assert_eq!(composite.profile_id(), profile_id);
|
||||||
|
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, logging_profile);
|
||||||
|
assert_component(&composite, "transport", ksp_config_lib::FILE_ID_STD_TRANSPORT, transport_profile);
|
||||||
|
assert_component(&composite, "store", ksp_config_lib::FILE_ID_STD_STORE, store_profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_default_composite_builds_http_pool_for_both_backfill_rpc_methods() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::Some("devnet"));
|
||||||
|
assert!(composite.is_ok(), "committed Backfill Desk devnet profile should resolve: {composite:?}");
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let transport = composite.component("transport");
|
||||||
|
assert!(transport.is_some(), "Backfill Desk composite should expose Transport");
|
||||||
|
let transport = match transport {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||||
|
assert!(environment.is_ok(), "Config environment should load for committed public devnet profile");
|
||||||
|
let environment = match environment {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let resolved = engine.resolve_transport_config_profile(transport.resolved(), &environment);
|
||||||
|
assert!(resolved.is_ok(), "composite-selected Transport should map: {resolved:?}");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.profile_id(), "devnet_public");
|
||||||
|
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
|
||||||
|
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings());
|
||||||
|
assert!(pool.is_ok(), "composite-selected Transport settings should construct an HTTP pool");
|
||||||
|
let pool = match pool {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let snapshot = pool.snapshot();
|
||||||
|
let mut clusters = snapshot
|
||||||
|
.endpoints()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| {
|
||||||
|
if endpoint.enabled() {
|
||||||
|
return std::option::Option::Some(endpoint.cluster().to_owned());
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
clusters.sort();
|
||||||
|
clusters.dedup();
|
||||||
|
assert_eq!(clusters, vec!["devnet".to_owned()]);
|
||||||
|
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||||
|
for method in ["getSignaturesForAddress", "getTransaction"] {
|
||||||
|
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method);
|
||||||
|
assert!(descriptor.is_some(), "required Transport method {method} should exist");
|
||||||
|
if let std::option::Option::Some(descriptor) = descriptor {
|
||||||
|
let selection = pool.select_for_method(&role, descriptor);
|
||||||
|
assert!(selection.is_ok(), "role default should route {method}: {selection:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn declared_http_networks(profile: &ksp_config_lib::ResolvedConfigProfile) -> std::vec::Vec<String> {
|
||||||
|
let endpoints = profile.profile().get("endpoints").and_then(serde_json::Value::as_array);
|
||||||
|
let endpoints = match endpoints {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
let mut networks = endpoints
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| {
|
||||||
|
let enabled = endpoint.get("enabled").and_then(serde_json::Value::as_bool);
|
||||||
|
if enabled != std::option::Option::Some(true) {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return endpoint.get("cluster").and_then(serde_json::Value::as_str).map(str::to_owned);
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
networks.sort();
|
||||||
|
networks.dedup();
|
||||||
|
return networks;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_all_composite_profiles_keep_declared_http_and_store_networks_identical_without_external_secrets() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for profile_id in ["devnet", "mainnet", "testnet"] {
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::Some(profile_id));
|
||||||
|
assert!(composite.is_ok(), "Backfill Desk composite profile {profile_id} should resolve: {composite:?}");
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let transport = composite.component("transport");
|
||||||
|
let store = composite.component("store");
|
||||||
|
assert!(transport.is_some(), "profile {profile_id} should expose Transport");
|
||||||
|
assert!(store.is_some(), "profile {profile_id} should expose Store");
|
||||||
|
let (transport, store) = match (transport, store) {
|
||||||
|
(std::option::Option::Some(transport), std::option::Option::Some(store)) => (transport, store),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let networks = declared_http_networks(transport.resolved());
|
||||||
|
assert_eq!(networks.len(), 1, "profile {profile_id} must declare exactly one enabled HTTP network");
|
||||||
|
let store_network = store.resolved().profile().get("network").and_then(serde_json::Value::as_str);
|
||||||
|
assert!(store_network.is_some(), "profile {profile_id} Store should declare one logical network");
|
||||||
|
if let (std::option::Option::Some(transport_network), std::option::Option::Some(store_network)) = (networks.first(), store_network) {
|
||||||
|
assert_eq!(transport_network.as_str(), store_network, "profile {profile_id} Store/Transport network mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_default_mainnet_profile_exposes_pool_and_targeted_http_routes_without_secret_provider_data() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
|
||||||
|
assert!(composite.is_ok(), "default Backfill Desk composite should resolve: {composite:?}");
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(composite.profile_id(), "mainnet");
|
||||||
|
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, "supertrace");
|
||||||
|
assert_component(&composite, "transport", ksp_config_lib::FILE_ID_STD_TRANSPORT, "mainnet_backfill_pool");
|
||||||
|
assert_component(&composite, "store", ksp_config_lib::FILE_ID_STD_STORE, "mainnet");
|
||||||
|
let transport = match composite.component("transport") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let profile = transport.resolved().profile();
|
||||||
|
let endpoints = profile.get("endpoints").and_then(serde_json::Value::as_array);
|
||||||
|
assert!(endpoints.is_some(), "mainnet Backfill Transport profile should expose HTTP endpoints");
|
||||||
|
let endpoints = match endpoints {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let providers = endpoints
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| return endpoint.get("provider").and_then(serde_json::Value::as_str))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(providers, std::collections::BTreeSet::from(["publicnode", "solana-public"]));
|
||||||
|
let roles = endpoints
|
||||||
|
.iter()
|
||||||
|
.flat_map(|endpoint| return endpoint.get("roles").and_then(serde_json::Value::as_array).into_iter().flatten())
|
||||||
|
.filter_map(|role| return role.get("role").and_then(serde_json::Value::as_str))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert!(roles.contains("backfill_pool"));
|
||||||
|
assert!(roles.contains("backfill_publicnode"));
|
||||||
|
assert!(roles.contains("backfill_solana_public"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_composite_profile_and_component_inventory_is_exact() {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let source = std::fs::read_to_string(workspace.join("config/composite.ksp-app-backfill-desk.json"));
|
||||||
|
assert!(source.is_ok(), "Backfill Desk composite should be readable");
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let document = serde_json::from_str::<serde_json::Value>(source.as_str());
|
||||||
|
assert!(document.is_ok(), "Backfill Desk composite should remain valid JSON");
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(document.get("default_profile").and_then(serde_json::Value::as_str), std::option::Option::Some("mainnet"));
|
||||||
|
let profiles = document.get("profiles").and_then(serde_json::Value::as_array);
|
||||||
|
assert!(profiles.is_some(), "Backfill Desk composite should expose profiles");
|
||||||
|
let profiles = match profiles {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let profile_ids = profiles
|
||||||
|
.iter()
|
||||||
|
.filter_map(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(profile_ids, std::collections::BTreeSet::from(["devnet", "mainnet", "testnet"]));
|
||||||
|
for profile in profiles {
|
||||||
|
let documents = profile.get("documents").and_then(serde_json::Value::as_array);
|
||||||
|
assert!(documents.is_some(), "every Backfill Desk profile should expose component documents");
|
||||||
|
let documents = match documents {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let component_ids = documents
|
||||||
|
.iter()
|
||||||
|
.filter_map(|document| return document.get("component_id").and_then(serde_json::Value::as_str))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(component_ids, std::collections::BTreeSet::from(["logging", "store", "transport"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
78
crates/ksp-app-backfill-desk/tests/dependency_boundary.rs
Normal file
78
crates/ksp-app-backfill-desk/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/dependency_boundary.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Final dependency-boundary checks for the KSP Backfill desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn app_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(path: &std::path::Path) -> String {
|
||||||
|
let source = std::fs::read_to_string(path);
|
||||||
|
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_manifest_ksp_dependency_surface_is_exact_and_backend_neutral() {
|
||||||
|
let manifest = read_text(app_root().join("Cargo.toml").as_path());
|
||||||
|
let dependency_section = manifest.split("[dependencies]").nth(1).and_then(|tail| return tail.split("[dev-dependencies]").next());
|
||||||
|
assert!(dependency_section.is_some(), "Backfill Desk manifest should expose a dependency section");
|
||||||
|
let dependency_section = match dependency_section {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let ksp_dependencies = dependency_section
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let name = line.split('=').next().map(str::trim);
|
||||||
|
return match name {
|
||||||
|
std::option::Option::Some(value) if value.starts_with("ksp-") => std::option::Option::Some(value),
|
||||||
|
_ => std::option::Option::None,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
ksp_dependencies,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"ksp-config-lib",
|
||||||
|
"ksp-core-lib",
|
||||||
|
"ksp-job-api",
|
||||||
|
"ksp-job-backfill-lib",
|
||||||
|
"ksp-logging-lib",
|
||||||
|
"ksp-onchain-transport-lib",
|
||||||
|
"ksp-store-lib",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
for forbidden in ["ksp-store-api", "ksp-store-postgres-lib", "tokio-postgres", "reqwest", "tonic", "yellowstone-grpc-proto", "mpl-token-metadata"] {
|
||||||
|
assert!(!dependency_section.contains(forbidden), "Backfill Desk manifest owns forbidden dependency {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_production_sources_do_not_cross_into_physical_store_or_provider_clients() {
|
||||||
|
let source_root = app_root().join("src");
|
||||||
|
let entries = std::fs::read_dir(source_root.as_path());
|
||||||
|
assert!(entries.is_ok(), "Backfill Desk source directory should be readable");
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("rs") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source = read_text(path.as_path());
|
||||||
|
for forbidden in ["ksp_store_api", "ksp_store_postgres_lib", "tokio_postgres", "reqwest::", "tonic::", "yellowstone_grpc_proto"] {
|
||||||
|
assert!(!source.contains(forbidden), "{} crosses forbidden dependency boundary via {forbidden}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
466
crates/ksp-app-backfill-desk/tests/desktop_contract.rs
Normal file
466
crates/ksp-app-backfill-desk/tests/desktop_contract.rs
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/desktop_contract.rs
|
||||||
|
// version: 18
|
||||||
|
|
||||||
|
//! Structural desktop contract checks for the Backfill Desk scaffold.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn app_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return app_root().join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(path: &std::path::Path) -> String {
|
||||||
|
let source = std::fs::read_to_string(path);
|
||||||
|
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||||
|
let source = read_text(path);
|
||||||
|
let parsed = serde_json::from_str(source.as_str());
|
||||||
|
assert!(parsed.is_ok(), "unable to parse {}", path.display());
|
||||||
|
return match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => serde_json::Value::Null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bytes(path: &std::path::Path) -> std::vec::Vec<u8> {
|
||||||
|
let source = std::fs::read(path);
|
||||||
|
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_rust_package_is_mixed_lib_bin_and_workspace_member() {
|
||||||
|
let root = app_root();
|
||||||
|
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||||
|
assert!(manifest.contains("name = \"ksp-app-backfill-desk\""));
|
||||||
|
assert!(manifest.contains("name = \"ksp_app_backfill_desk_lib\""));
|
||||||
|
assert!(manifest.contains("[[bin]]"));
|
||||||
|
let workspace = read_text(workspace_root().join("Cargo.toml").as_path());
|
||||||
|
assert!(workspace.contains("crates/ksp-app-backfill-desk"));
|
||||||
|
let launcher = read_text(root.join("src/main.rs").as_path());
|
||||||
|
assert!(launcher.contains("ksp_app_backfill_desk_lib::run"));
|
||||||
|
assert!(launcher.contains("try_lock_exclusive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_vite_and_tauri_identity_use_reserved_ports_and_crate_local_hooks() {
|
||||||
|
let root = app_root();
|
||||||
|
let vite = read_text(root.join("vite.config.ts").as_path());
|
||||||
|
assert!(vite.contains("port: 1436"));
|
||||||
|
assert!(vite.contains("port: 1437"));
|
||||||
|
assert!(vite.contains("strictPort: true"));
|
||||||
|
let tauri = read_text(root.join("tauri.conf.json").as_path());
|
||||||
|
assert!(tauri.contains("KSP Backfill Desk"));
|
||||||
|
assert!(tauri.contains("com.sasedev.ksp-app-backfill-desk"));
|
||||||
|
assert!(tauri.contains("http://localhost:1436"));
|
||||||
|
assert!(tauri.contains("npm run dev"));
|
||||||
|
assert!(tauri.contains("npm run build"));
|
||||||
|
assert!(tauri.contains("../../../builds/khadhroony-solana-project/ksp-app-backfill-desk/dist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_package_dependencies_match_desk_baseline() {
|
||||||
|
let package = read_text(app_root().join("package.json").as_path());
|
||||||
|
for required in [
|
||||||
|
"@fltsci/tauri-plugin-tracing",
|
||||||
|
"@fortawesome/fontawesome-free",
|
||||||
|
"@tauri-apps/api",
|
||||||
|
"bootstrap",
|
||||||
|
"resize-observer-polyfill",
|
||||||
|
"simplebar",
|
||||||
|
"@tauri-apps/cli",
|
||||||
|
"@types/bootstrap",
|
||||||
|
"@types/node",
|
||||||
|
"sass-embedded",
|
||||||
|
"typescript",
|
||||||
|
"vite",
|
||||||
|
] {
|
||||||
|
assert!(package.contains(required), "missing Desk package baseline dependency {required}");
|
||||||
|
}
|
||||||
|
assert!(!package.contains("package-lock"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_splash_assets_and_styles_match_existing_desk_template() {
|
||||||
|
let root = app_root();
|
||||||
|
let reference = workspace_root().join("crates/ksp-app-solprices-desk");
|
||||||
|
for relative in ["frontend/fonts/DOS_Amazigh.ttf", "frontend/imgs/logo.png", "frontend/imgs/splash.png", "icons/favicon.png", "icons/favicon.ico"] {
|
||||||
|
assert_eq!(read_bytes(root.join(relative).as_path()), read_bytes(reference.join(relative).as_path()), "Desk asset differs: {relative}");
|
||||||
|
}
|
||||||
|
let splash = read_text(root.join("frontend/splash.html").as_path());
|
||||||
|
assert!(splash.contains("imgs/splash.png"));
|
||||||
|
assert!(splash.contains("Backfill Desk"));
|
||||||
|
let splash_style = read_text(root.join("frontend/sass/splash.scss").as_path());
|
||||||
|
let reference_style = read_text(reference.join("frontend/sass/splash.scss").as_path());
|
||||||
|
let normalized = splash_style.replace("crates/ksp-app-backfill-desk", "crates/ksp-app-solprices-desk").replace("// version: 1", "// version: 2");
|
||||||
|
assert_eq!(normalized, reference_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_shell_and_splash_use_shared_frontend_tooling() {
|
||||||
|
let root = app_root();
|
||||||
|
let shell = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
for required in ["app-header", "app-sidebar", "app-sidebar-scroll", "app-content", "app-footer", "data-simplebar"] {
|
||||||
|
assert!(shell.contains(required), "Backfill Desk shell is missing template marker {required}");
|
||||||
|
}
|
||||||
|
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(main.contains("resize-observer-polyfill"));
|
||||||
|
assert!(main.contains("simplebar"));
|
||||||
|
assert!(main.contains("bootstrap"));
|
||||||
|
assert!(main.contains("Backfill Desk frontend control clicked"));
|
||||||
|
assert!(main.contains("Backfill Desk navigation tab clicked"));
|
||||||
|
assert!(main.contains("Backfill Desk runtime status refresh button clicked"));
|
||||||
|
let splash = read_text(root.join("frontend/ts/splash.ts").as_path());
|
||||||
|
assert!(splash.contains("Splash opacity animation started"));
|
||||||
|
assert!(splash.contains("Backfill Desk splash frontend loaded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_composite_packaging_follows_atomic_config_registry_contract() {
|
||||||
|
let root = app_root();
|
||||||
|
let config = read_json(root.join("tauri.conf.json").as_path());
|
||||||
|
let resources = config
|
||||||
|
.get("bundle")
|
||||||
|
.and_then(|value| {
|
||||||
|
return value.get("resources");
|
||||||
|
})
|
||||||
|
.and_then(serde_json::Value::as_object);
|
||||||
|
assert!(resources.is_some());
|
||||||
|
let resources = match resources {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resources.len(), 16);
|
||||||
|
for required in [
|
||||||
|
"../../config/composite.ksp-app-backfill-desk.json",
|
||||||
|
"../../config/composite.ksp-app-raw-transaction-ingest-desk.json",
|
||||||
|
"../../config/composite.ksp-app-solprices-desk.json",
|
||||||
|
"../../config/composite.ksp-app-store-desk.json",
|
||||||
|
"../../config/composite.ksp-app-wallet-desk.json",
|
||||||
|
"../../config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json",
|
||||||
|
"../../config/std.transport.json",
|
||||||
|
"../../config/std.wallet.json",
|
||||||
|
"../../config/schemas/composite.schema.json",
|
||||||
|
"../../config/schemas/std.logging.schema.json",
|
||||||
|
"../../config/schemas/std.offchain_transport.schema.json",
|
||||||
|
"../../config/schemas/std.store.schema.json",
|
||||||
|
"../../config/schemas/std.transport.schema.json",
|
||||||
|
"../../config/schemas/std.wallet.schema.json",
|
||||||
|
] {
|
||||||
|
assert!(resources.contains_key(required), "missing packaged Config resource {required}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri.contains("prepare_packaged_runtime"));
|
||||||
|
assert!(tauri.contains("resource_dir"));
|
||||||
|
assert!(tauri.contains("set_current_dir"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_bootstrap_keeps_logging_composition_separate_from_transport_runtime() {
|
||||||
|
let root = app_root();
|
||||||
|
let bootstrap = read_text(root.join("src/bootstrap.rs").as_path());
|
||||||
|
assert!(bootstrap.contains("FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK"));
|
||||||
|
assert!(bootstrap.contains("COMPOSITE_COMPONENT_ID_LOGGING"));
|
||||||
|
assert!(bootstrap.contains("COMPOSITE_COMPONENT_ID_TRANSPORT"));
|
||||||
|
assert!(bootstrap.contains("COMPOSITE_COMPONENT_ID_STORE"));
|
||||||
|
assert!(bootstrap.contains("LogFilterLevel::Trace"));
|
||||||
|
for forbidden in ["HttpTransportPool", "Store::open", "BackfillJobRuntime", "BackfillRequest"] {
|
||||||
|
assert!(!bootstrap.contains(forbidden), "bootstrap.rs absorbs another runtime responsibility: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_transport_runtime_builds_pool_and_validates_both_required_rpc_methods() {
|
||||||
|
let root = app_root();
|
||||||
|
let transport = read_text(root.join("src/transport_runtime.rs").as_path());
|
||||||
|
assert!(transport.contains("HttpTransportPool::new"));
|
||||||
|
assert!(transport.contains("getSignaturesForAddress"));
|
||||||
|
assert!(transport.contains("getTransaction"));
|
||||||
|
assert!(transport.contains("select_for_method"));
|
||||||
|
assert!(transport.contains("configured_networks"));
|
||||||
|
assert!(transport.contains("compatible_backfill_http_routes"));
|
||||||
|
assert!(transport.contains("endpoint.provider()"));
|
||||||
|
for forbidden in ["url()", "Store::open", "BackfillJobRuntime", "BackfillRequest"] {
|
||||||
|
assert!(!transport.contains(forbidden), "pre.004 opens or projects forbidden surface {forbidden}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri.contains("backfill_options"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_store_readiness_proves_network_before_open_and_closes_on_main_window_exit() {
|
||||||
|
let root = app_root();
|
||||||
|
let store = read_text(root.join("src/store_runtime.rs").as_path());
|
||||||
|
let coherence = store.find("validate_network_coherence(store_network.as_str(), transport_network.as_str())");
|
||||||
|
let open = store.find("ksp_store_lib::Store::open");
|
||||||
|
assert!(coherence.is_some());
|
||||||
|
assert!(open.is_some());
|
||||||
|
if let (std::option::Option::Some(coherence), std::option::Option::Some(open)) = (coherence, open) {
|
||||||
|
assert!(coherence < open, "Store network coherence must be proven before physical Store open");
|
||||||
|
}
|
||||||
|
assert!(store.contains("store.health().await"));
|
||||||
|
assert!(store.contains("store.close().await"));
|
||||||
|
assert!(store.contains("StoreHealthState::Ready"));
|
||||||
|
assert!(store.contains("StoreHealthState::NotReady"));
|
||||||
|
assert!(!store.contains("BackfillJobRuntime"));
|
||||||
|
assert!(!store.contains("BackfillRequest"));
|
||||||
|
let app_state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
assert!(app_state.contains("initialize_store"));
|
||||||
|
assert!(app_state.contains("composition_ready"));
|
||||||
|
assert!(app_state.contains("close_store"));
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
for required in ["on_window_event", "WindowEvent::CloseRequested", "prevent_close", "async_runtime::spawn", "close_store", "app_handle.exit(0)"] {
|
||||||
|
assert!(tauri.contains(required), "missing bounded Store shutdown lifecycle marker {required}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_mainnet_default_and_http_route_selector_are_explicit_without_start_runtime() {
|
||||||
|
let root = app_root();
|
||||||
|
let workspace = root.join("../..");
|
||||||
|
let composite = read_text(workspace.join("config/composite.ksp-app-backfill-desk.json").as_path());
|
||||||
|
assert!(composite.contains("\"default_profile\": \"mainnet\""));
|
||||||
|
assert!(composite.contains("\"profile_id\": \"mainnet_backfill_pool\""));
|
||||||
|
let transport = read_text(workspace.join("config/std.transport.json").as_path());
|
||||||
|
for required in ["mainnet_backfill_pool", "backfill_pool", "backfill_publicnode", "backfill_solana_public", "solana-rpc.publicnode.com"] {
|
||||||
|
assert!(transport.contains(required), "missing Backfill HTTP route marker {required}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(frontend.contains("Backfill Desk HTTP route selection changed"));
|
||||||
|
assert!(frontend.contains("backfill_options"));
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
assert!(html.contains("backfillHttpRoute"));
|
||||||
|
for forbidden in ["grpc", "websocket"] {
|
||||||
|
assert!(!frontend.contains(forbidden), "pre.006 frontend opens deferred runtime marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_campaign_dto_and_backend_mapping_remain_the_authoritative_start_input() {
|
||||||
|
let root = app_root();
|
||||||
|
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||||
|
assert!(manifest.contains("ksp-job-api = { path = \"../ksp-job-api\" }"));
|
||||||
|
assert!(manifest.contains("ksp-job-backfill-lib = { path = \"../ksp-job-backfill-lib\" }"));
|
||||||
|
let dto = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
for required in [
|
||||||
|
"BackfillStartRequestDto",
|
||||||
|
"BackfillRequestPreviewDto",
|
||||||
|
"MAX_BACKFILL_CANDIDATES",
|
||||||
|
"MAX_BACKFILL_HYDRATION_CONCURRENCY",
|
||||||
|
"MAX_BACKFILL_PAGES",
|
||||||
|
"MAX_BACKFILL_PAGE_SIZE",
|
||||||
|
] {
|
||||||
|
assert!(dto.contains(required), "missing pre.007 DTO/request bound marker {required}");
|
||||||
|
}
|
||||||
|
let mapping = read_text(root.join("src/backfill_request.rs").as_path());
|
||||||
|
for required in ["BackfillRequest::new", "HttpRoleName::new", "latest_address", "before_address", "after_address", "explicit_signatures"] {
|
||||||
|
assert!(mapping.contains(required), "missing strict Backfill request mapping marker {required}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri.contains("backfill_validate_request"));
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
for required in [
|
||||||
|
"backfillCampaignForm",
|
||||||
|
"backfillScopeKind",
|
||||||
|
"backfillCommitment",
|
||||||
|
"backfillAddress",
|
||||||
|
"backfillAnchorSignature",
|
||||||
|
"backfillExplicitSignatures",
|
||||||
|
"backfillMinContextSlot",
|
||||||
|
"validateBackfillRequest",
|
||||||
|
"Valider la requête",
|
||||||
|
] {
|
||||||
|
assert!(html.contains(required), "missing campaign form marker {required}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(frontend.contains("backfill_validate_request"));
|
||||||
|
assert!(frontend.contains("Backfill Desk campaign validation requested"));
|
||||||
|
assert!(frontend.contains("Aucun job n'a été démarré"));
|
||||||
|
assert!(frontend.contains("backfill_validate_request"));
|
||||||
|
assert!(tauri.contains("backfill_validate_request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_start_installs_handle_before_non_blocking_spawn_and_keeps_single_run_backend_owned() {
|
||||||
|
let root = app_root();
|
||||||
|
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(state.contains("BackfillJobRuntime::new(mapped)"));
|
||||||
|
let handle_index = state.find("let handle = runtime.handle();");
|
||||||
|
let install_index = state.find("self.backfill_runs.install(job_id.clone(), handle, retained_request)");
|
||||||
|
assert!(handle_index.is_some());
|
||||||
|
assert!(install_index.is_some());
|
||||||
|
if let (std::option::Option::Some(handle_index), std::option::Option::Some(install_index)) = (handle_index, install_index) {
|
||||||
|
assert!(handle_index < install_index);
|
||||||
|
}
|
||||||
|
assert!(run.contains("ERROR_CODE_BACKFILL_RUN_ACTIVE"));
|
||||||
|
assert!(run.contains("std::sync::Mutex<std::option::Option<ActiveBackfillRun>>"));
|
||||||
|
assert!(tauri.contains("backfill_start"));
|
||||||
|
let prepare_index = tauri.find("state.prepare_backfill_start(request)");
|
||||||
|
assert!(prepare_index.is_some());
|
||||||
|
if let std::option::Option::Some(prepare_index) = prepare_index {
|
||||||
|
let start_tail = &tauri[prepare_index..];
|
||||||
|
let spawn_index = start_tail.find("tauri::async_runtime::spawn(async move");
|
||||||
|
assert!(spawn_index.is_some());
|
||||||
|
}
|
||||||
|
assert!(html.contains("startBackfillRequest"));
|
||||||
|
assert!(html.contains("Démarrer le backfill"));
|
||||||
|
assert!(frontend.contains("Backfill Desk campaign Start requested"));
|
||||||
|
assert!(frontend.contains("backfill_start"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_latest_value_monitoring_uses_snapshot_source_event_bridge_and_resynchronization_command() {
|
||||||
|
let root = app_root();
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
let status = read_text(root.join("src/backfill_status.rs").as_path());
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(run.contains("last_terminal"));
|
||||||
|
assert!(run.contains("current_notification"));
|
||||||
|
assert!(status.contains("BackfillRunStatusDto"));
|
||||||
|
assert!(status.contains("checkpoint_present"));
|
||||||
|
assert!(status.contains("contiguous_completed"));
|
||||||
|
assert!(tauri.contains("backfill_status"));
|
||||||
|
assert!(tauri.contains("wait_for_change"));
|
||||||
|
assert!(tauri.contains("ksp-backfill-status") || tauri.contains("BACKFILL_STATUS_EVENT_NAME"));
|
||||||
|
assert!(frontend.contains("listen<BackfillRunStatusDto>"));
|
||||||
|
assert!(frontend.contains("syncBackfillStatus"));
|
||||||
|
assert!(!frontend.contains("parseBackfillLog"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_cancel_is_targeted_idempotent_and_shutdown_requests_cooperative_cancellation_before_exit() {
|
||||||
|
let root = app_root();
|
||||||
|
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(run.contains("current.job_id.as_str() != job_id"));
|
||||||
|
assert!(run.contains("current.handle.cancel()"));
|
||||||
|
assert!(run.contains("ERROR_CODE_BACKFILL_RUN_MISMATCH"));
|
||||||
|
assert!(state.contains("cancel_backfill"));
|
||||||
|
assert!(state.contains("cancel_active_backfill_for_shutdown"));
|
||||||
|
assert!(tauri.contains("backfill_cancel"));
|
||||||
|
assert!(tauri.contains("cancel_active_backfill_for_shutdown"));
|
||||||
|
let shutdown_cancel = tauri.find("cancel_active_backfill_for_shutdown");
|
||||||
|
let shutdown_exit = tauri.find("app_handle.exit(0)");
|
||||||
|
assert!(shutdown_cancel.is_some());
|
||||||
|
assert!(shutdown_exit.is_some());
|
||||||
|
if let (std::option::Option::Some(cancel), std::option::Option::Some(exit)) = (shutdown_cancel, shutdown_exit) {
|
||||||
|
assert!(cancel < exit);
|
||||||
|
}
|
||||||
|
assert!(html.contains("cancelBackfillRun"));
|
||||||
|
assert!(html.contains("Annuler"));
|
||||||
|
assert!(frontend.contains("backfill_cancel"));
|
||||||
|
assert!(frontend.contains("{ jobId: status.jobId }"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_011_resume_reissues_rust_only_checkpoint_for_new_backend_job_without_ipc_checkpoint_material() {
|
||||||
|
let root = app_root();
|
||||||
|
let workspace = root.join("../..");
|
||||||
|
let checkpoint = read_text(workspace.join("crates/ksp-job-backfill-lib/src/checkpoint.rs").as_path());
|
||||||
|
assert!(checkpoint.contains("reissue_for_job"));
|
||||||
|
let request = read_text(workspace.join("crates/ksp-job-backfill-lib/src/request.rs").as_path());
|
||||||
|
assert!(request.contains("pub fn resume_for_job"));
|
||||||
|
assert!(request.contains("validate_request_checkpoint"));
|
||||||
|
assert!(request.contains("checkpoint.reissue_for_job(job_id.clone())"));
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
for required in ["TerminalBackfillRun", "request: ksp_job_backfill_lib::BackfillRequest", "snapshot().checkpoint()", "resume_for_job(job_id, checkpoint)"] {
|
||||||
|
assert!(run.contains(required), "missing Rust-only Resume ownership marker {required}");
|
||||||
|
}
|
||||||
|
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
for required in ["prepare_backfill_resume", "options.composition_ready", "store_network_matches", "role_available"] {
|
||||||
|
assert!(state.contains(required), "missing Resume admission marker {required}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri.contains("backfill_resume"));
|
||||||
|
assert!(tauri.contains("spawn_backfill_launch"));
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(frontend.contains("resumeBackfillRun"));
|
||||||
|
assert!(frontend.contains("checkpointPresent"));
|
||||||
|
assert!(frontend.contains("backfill_resume"));
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
assert!(html.contains("resumeBackfillRun"));
|
||||||
|
assert!(html.contains("Reprendre"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_012_free_address_autocomplete_is_html_datalist_derived_from_core_registry() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
assert!(dto.contains("ksp_core_lib::entries()"));
|
||||||
|
assert!(dto.contains("ProgramIdAutocompleteOptionDto"));
|
||||||
|
assert!(dto.contains("program_id_autocomplete_options"));
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
assert!(html.contains("list=\"backfillProgramIds\""));
|
||||||
|
assert!(html.contains("<datalist id=\"backfillProgramIds\"></datalist>"));
|
||||||
|
assert!(html.contains("toute adresse Solana valide reste acceptée"));
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for required in [
|
||||||
|
"renderProgramIdAutocomplete(options.programIdOptions)",
|
||||||
|
"option.value = program.programId",
|
||||||
|
"option.dataset.code = program.code",
|
||||||
|
"option.dataset.domain = program.domain",
|
||||||
|
"registryMatch: selected !== null",
|
||||||
|
"Backfill Desk Program ID autocomplete dataset rendered",
|
||||||
|
] {
|
||||||
|
assert!(frontend.contains(required), "missing pre.012 autocomplete marker {required}");
|
||||||
|
}
|
||||||
|
for forbidden in
|
||||||
|
["AddressLookupTab1e1111111111111111111111111", "ComputeBudget111111111111111111111111111111", "Vote111111111111111111111111111111111111111"]
|
||||||
|
{
|
||||||
|
assert!(!frontend.contains(forbidden), "frontend hardcodes canonical Program ID {forbidden}");
|
||||||
|
assert!(!html.contains(forbidden), "HTML hardcodes canonical Program ID {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_final_v1_command_and_frontend_control_inventory_is_exact() {
|
||||||
|
let root = app_root();
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let commands = [
|
||||||
|
"backfill_cancel",
|
||||||
|
"backfill_options",
|
||||||
|
"backfill_resume",
|
||||||
|
"backfill_start",
|
||||||
|
"backfill_status",
|
||||||
|
"backfill_validate_request",
|
||||||
|
"emit_frontend_log",
|
||||||
|
"get_runtime_status",
|
||||||
|
"splash_frontend_ready",
|
||||||
|
];
|
||||||
|
for command in commands {
|
||||||
|
assert!(tauri.contains(format!("fn {command}").as_str()), "missing final Tauri command {command}");
|
||||||
|
}
|
||||||
|
assert_eq!(tauri.matches("#[tauri::command]").count(), commands.len());
|
||||||
|
for forbidden in ["backfill_reset", "backfill_delete", "backfill_retry", "backfill_schedule", "backfill_history"] {
|
||||||
|
assert!(!tauri.contains(forbidden), "unexpected V1 Tauri command marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
for required in ["startBackfillRequest", "cancelBackfillRun", "resumeBackfillRun", "refreshBackfillStatus", "backfillProgramIds"] {
|
||||||
|
assert!(frontend.contains(required), "missing final frontend control {required}");
|
||||||
|
}
|
||||||
|
}
|
||||||
395
crates/ksp-app-backfill-desk/tests/desktop_security.rs
Normal file
395
crates/ksp-app-backfill-desk/tests/desktop_security.rs
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/desktop_security.rs
|
||||||
|
// version: 16
|
||||||
|
|
||||||
|
//! Security and dependency-boundary checks for the Backfill Desk scaffold.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn app_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn struct_source<'a>(source: &'a str, marker: &str) -> &'a str {
|
||||||
|
let start = match source.find(marker) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return "",
|
||||||
|
};
|
||||||
|
let tail = &source[start..];
|
||||||
|
let end = match tail.find("\n}") {
|
||||||
|
std::option::Option::Some(value) => value + 2,
|
||||||
|
std::option::Option::None => return "",
|
||||||
|
};
|
||||||
|
return &tail[..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files(directory: &std::path::Path, extension: &str, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||||
|
let entries = std::fs::read_dir(directory);
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_files(path.as_path(), extension, files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some(extension) {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(path: &std::path::Path) -> String {
|
||||||
|
let source = std::fs::read_to_string(path);
|
||||||
|
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_surface_remains_core_plus_tracing_while_backfill_runtime_stays_backend_only() {
|
||||||
|
let root = app_root();
|
||||||
|
let capability = read_text(root.join("capabilities/default.json").as_path());
|
||||||
|
assert!(capability.contains("\"core:default\""));
|
||||||
|
assert!(capability.contains("\"tracing:default\""));
|
||||||
|
for forbidden in ["dialog:", "fs:", "http:", "shell:"] {
|
||||||
|
assert!(!capability.contains(forbidden));
|
||||||
|
}
|
||||||
|
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||||
|
assert!(manifest.contains("tauri-plugin-tracing.workspace = true"));
|
||||||
|
assert!(manifest.contains("ksp-job-api = { path = \"../ksp-job-api\" }"));
|
||||||
|
assert!(manifest.contains("ksp-job-backfill-lib = { path = \"../ksp-job-backfill-lib\" }"));
|
||||||
|
assert!(manifest.contains("ksp-onchain-transport-lib = { path = \"../ksp-onchain-transport-lib\" }"));
|
||||||
|
assert!(manifest.contains("ksp-store-lib = { path = \"../ksp-store-lib\" }"));
|
||||||
|
for forbidden in ["ksp-store-api", "ksp-store-postgres-lib", "reqwest", "tokio-postgres", "tonic", "yellowstone-grpc"] {
|
||||||
|
assert!(!manifest.contains(forbidden), "current Backfill Desk opens a forbidden direct dependency: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_frontend_has_no_network_persistence_or_native_dialog_surface() {
|
||||||
|
let root = app_root();
|
||||||
|
let mut typescript = std::vec::Vec::new();
|
||||||
|
collect_files(root.join("frontend/ts").as_path(), "ts", &mut typescript);
|
||||||
|
assert!(!typescript.is_empty());
|
||||||
|
for path in typescript {
|
||||||
|
let source = read_text(path.as_path());
|
||||||
|
for forbidden in ["fetch(", "XMLHttpRequest", "localStorage", "sessionStorage", "window.alert(", "window.confirm(", "window.prompt("] {
|
||||||
|
assert!(!source.contains(forbidden), "{} contains forbidden scaffold surface {forbidden}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_tauri_commands_remain_centralized() {
|
||||||
|
let root = app_root();
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_files(root.join("src").as_path(), "rs", &mut rust_files);
|
||||||
|
let mut command_count = 0usize;
|
||||||
|
for path in rust_files {
|
||||||
|
let source = read_text(path.as_path());
|
||||||
|
let count = source.matches("#[tauri::command]").count();
|
||||||
|
if path.file_name().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("tauri.rs") {
|
||||||
|
command_count += count;
|
||||||
|
} else {
|
||||||
|
assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(command_count, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_frontend_instrumentation_avoids_business_or_secret_payloads() {
|
||||||
|
let root = app_root();
|
||||||
|
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for required in [
|
||||||
|
"Backfill Desk frontend control clicked",
|
||||||
|
"Backfill Desk navigation tab clicked",
|
||||||
|
"Backfill Desk runtime status refresh button clicked",
|
||||||
|
"Frontend IPC command requested",
|
||||||
|
] {
|
||||||
|
if required == "Frontend IPC command requested" {
|
||||||
|
let invoke = read_text(root.join("frontend/ts/invoke.ts").as_path());
|
||||||
|
assert!(invoke.contains(required));
|
||||||
|
} else {
|
||||||
|
assert!(main.contains(required));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"JSON.stringify(status)",
|
||||||
|
"apiKey",
|
||||||
|
"authorization",
|
||||||
|
"database_url",
|
||||||
|
"campaign validation requested\", {\n address",
|
||||||
|
"campaign validation requested\", {\n anchorSignature",
|
||||||
|
"campaign validation requested\", {\n explicitSignatures",
|
||||||
|
"campaign validation requested\", {\n minContextSlot",
|
||||||
|
] {
|
||||||
|
assert!(!main.contains(forbidden), "frontend tracing includes forbidden request payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_store_runtime_remains_backend_neutral_after_job_mapping_opens() {
|
||||||
|
let root = app_root();
|
||||||
|
let bootstrap = read_text(root.join("src/bootstrap.rs").as_path());
|
||||||
|
assert!(bootstrap.contains("LogFilterLevel::Trace"));
|
||||||
|
assert!(!bootstrap.contains("HttpTransportPool"));
|
||||||
|
assert!(!bootstrap.contains("Store::open"));
|
||||||
|
let store = read_text(root.join("src/store_runtime.rs").as_path());
|
||||||
|
assert!(store.contains("ksp_store_lib::Store::open"));
|
||||||
|
assert!(store.contains("store.health().await"));
|
||||||
|
assert!(store.contains("store.close().await"));
|
||||||
|
for forbidden in ["ksp_job_", "BackfillRequest", "connection_uri", "postgres", "database_url", "provider()", "endpoint_url"] {
|
||||||
|
assert!(!store.contains(forbidden), "Store runtime leaks or absorbs another responsibility: {forbidden}");
|
||||||
|
}
|
||||||
|
let dto = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
assert!(dto.contains("BackfillDeskOptionsDto"));
|
||||||
|
for forbidden in ["endpoint_url", "pub(crate) url", "pub(crate) credential", "pub(crate) token", "connection_uri"] {
|
||||||
|
assert!(!dto.contains(forbidden), "readiness options DTO source contains forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_frontend_receives_only_safe_http_route_metadata_and_no_endpoint_material() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
assert!(dto.contains("BackfillHttpRouteOptionDto"));
|
||||||
|
assert!(dto.contains("pub(crate) providers"));
|
||||||
|
assert!(dto.contains("pub(crate) role"));
|
||||||
|
for forbidden in ["endpoint_url", "pub(crate) url", "pub(crate) credential", "pub(crate) api_key", "pub(crate) authorization"] {
|
||||||
|
assert!(!dto.contains(forbidden), "route DTO leaks forbidden endpoint material marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for forbidden in ["http://", "https://", "apiKey", "authorization", "token"] {
|
||||||
|
assert!(!frontend.contains(forbidden), "frontend embeds forbidden provider material marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_request_mapping_keeps_network_endpoint_and_payload_secrets_backend_owned() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
let request_source = struct_source(dto.as_str(), "pub(crate) struct BackfillStartRequestDto");
|
||||||
|
assert!(!request_source.is_empty());
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) network:",
|
||||||
|
"pub(crate) provider",
|
||||||
|
"pub(crate) endpoint",
|
||||||
|
"pub(crate) url",
|
||||||
|
"pub(crate) job_id",
|
||||||
|
"pub(crate) credential",
|
||||||
|
"pub(crate) token",
|
||||||
|
] {
|
||||||
|
assert!(!request_source.contains(forbidden), "frontend request DTO owns forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
let mapping = read_text(root.join("src/backfill_request.rs").as_path());
|
||||||
|
assert!(mapping.contains("options.store_network.as_deref()"));
|
||||||
|
assert!(mapping.contains("options.http_routes.iter().any"));
|
||||||
|
assert!(mapping.contains("BackfillRequest::new"));
|
||||||
|
assert!(!mapping.contains("HttpTransportPool::new"));
|
||||||
|
assert!(!mapping.contains("Store::open"));
|
||||||
|
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
assert!(state.contains("backfill-desk-validation"));
|
||||||
|
assert!(state.contains("validated Backfill Desk campaign request without starting a Job"));
|
||||||
|
for forbidden in [
|
||||||
|
"preview.address",
|
||||||
|
"preview.anchor_signature",
|
||||||
|
"preview.explicit_signatures",
|
||||||
|
"request.address.as_str()",
|
||||||
|
"request.anchor_signature.as_str()",
|
||||||
|
] {
|
||||||
|
assert!(!state.contains(forbidden), "request validation logging includes forbidden payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_start_surface_keeps_payload_and_physical_resources_backend_owned() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
let response_source = struct_source(dto.as_str(), "pub(crate) struct BackfillStartResponseDto");
|
||||||
|
assert!(!response_source.is_empty());
|
||||||
|
assert!(response_source.contains("pub(crate) job_id: String"));
|
||||||
|
assert!(response_source.contains("pub(crate) state: String"));
|
||||||
|
for forbidden in ["address", "signature", "provider", "endpoint", "url", "credential", "token", "network"] {
|
||||||
|
assert!(!response_source.contains(forbidden), "Start acknowledgement leaks forbidden marker {forbidden}");
|
||||||
|
}
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
assert!(run.contains("BackfillJobHandle"));
|
||||||
|
assert!(run.contains("ERROR_CODE_BACKFILL_RUN_ACTIVE"));
|
||||||
|
assert!(!run.contains("reqwest"));
|
||||||
|
assert!(!run.contains("tokio_postgres"));
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
let start_marker = frontend.find("Backfill Desk campaign Start requested");
|
||||||
|
assert!(start_marker.is_some());
|
||||||
|
let start_source = match start_marker {
|
||||||
|
std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 420, frontend.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
for forbidden in ["address:", "anchorSignature:", "explicitSignatures:", "minContextSlot:"] {
|
||||||
|
assert!(!start_source.contains(forbidden), "Start tracing includes forbidden request payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_monitoring_projection_exposes_counters_and_codes_without_checkpoint_or_business_payloads() {
|
||||||
|
let root = app_root();
|
||||||
|
let status = read_text(root.join("src/backfill_status.rs").as_path());
|
||||||
|
let dto = struct_source(status.as_str(), "BackfillRunStatusDto");
|
||||||
|
for required in ["checkpoint_present", "contiguous_completed", "failure_code", "failure_domain", "sequence", "scope_kind"] {
|
||||||
|
assert!(dto.contains(required), "monitoring DTO missing required safe marker {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) address:",
|
||||||
|
"pub(crate) signature:",
|
||||||
|
"pub(crate) endpoint:",
|
||||||
|
"pub(crate) provider:",
|
||||||
|
"pub(crate) credential:",
|
||||||
|
"pub(crate) token:",
|
||||||
|
"pub(crate) checkpoint:",
|
||||||
|
"pub(crate) payload:",
|
||||||
|
"pub(crate) raw_transaction:",
|
||||||
|
] {
|
||||||
|
assert!(!dto.contains(forbidden), "monitoring DTO leaks forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_cancel_surface_targets_backend_job_identity_without_business_payloads() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto_source = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
let dto = struct_source(dto_source.as_str(), "BackfillCancelResponseDto");
|
||||||
|
for required in ["accepted", "job_id", "state"] {
|
||||||
|
assert!(dto.contains(required), "Cancel acknowledgement missing safe field {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) address:",
|
||||||
|
"pub(crate) signature:",
|
||||||
|
"pub(crate) endpoint:",
|
||||||
|
"pub(crate) provider:",
|
||||||
|
"pub(crate) credential:",
|
||||||
|
"pub(crate) token:",
|
||||||
|
"pub(crate) checkpoint:",
|
||||||
|
"pub(crate) payload:",
|
||||||
|
] {
|
||||||
|
assert!(!dto.contains(forbidden), "Cancel acknowledgement leaks forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
let cancel_marker = frontend.find("Backfill Desk cooperative cancellation requested");
|
||||||
|
assert!(cancel_marker.is_some());
|
||||||
|
let cancel_source = match cancel_marker {
|
||||||
|
std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 700, frontend.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
assert!(cancel_source.contains("jobId: status.jobId"));
|
||||||
|
for forbidden in ["address:", "anchorSignature:", "explicitSignatures:", "minContextSlot:"] {
|
||||||
|
assert!(!cancel_source.contains(forbidden), "Cancel tracing includes forbidden campaign payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_011_resume_surface_exposes_only_new_job_acknowledgement_and_never_checkpoint_payload() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto_source = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
let dto = struct_source(dto_source.as_str(), "BackfillResumeResponseDto");
|
||||||
|
for required in ["accepted", "job_id", "state"] {
|
||||||
|
assert!(dto.contains(required), "Resume acknowledgement missing safe field {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) address:",
|
||||||
|
"pub(crate) signature:",
|
||||||
|
"pub(crate) endpoint:",
|
||||||
|
"pub(crate) provider:",
|
||||||
|
"pub(crate) credential:",
|
||||||
|
"pub(crate) token:",
|
||||||
|
"pub(crate) checkpoint:",
|
||||||
|
"pub(crate) payload:",
|
||||||
|
"pub(crate) network:",
|
||||||
|
] {
|
||||||
|
assert!(!dto.contains(forbidden), "Resume acknowledgement leaks forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let resume_marker = tauri.find("fn backfill_resume");
|
||||||
|
assert!(resume_marker.is_some());
|
||||||
|
let resume_source = match resume_marker {
|
||||||
|
std::option::Option::Some(index) => &tauri[index..std::cmp::min(index + 1300, tauri.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
for forbidden in ["BackfillCheckpoint", "checkpoint:", "address:", "signature:", "provider:", "endpoint:"] {
|
||||||
|
assert!(!resume_source.contains(forbidden), "Resume IPC command leaks forbidden marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
let resume_marker = frontend.find("Backfill Desk in-session Resume requested");
|
||||||
|
assert!(resume_marker.is_some());
|
||||||
|
let resume_source = match resume_marker {
|
||||||
|
std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 1100, frontend.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
assert!(resume_source.contains("backfill_resume"));
|
||||||
|
for forbidden in ["checkpoint:", "address:", "anchorSignature:", "explicitSignatures:", "minContextSlot:"] {
|
||||||
|
assert!(!resume_source.contains(forbidden), "Resume frontend sends or logs forbidden payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_012_program_id_autocomplete_exposes_only_public_registry_metadata_and_no_browser_storage() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto_source = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
let dto = struct_source(dto_source.as_str(), "ProgramIdAutocompleteOptionDto");
|
||||||
|
for required in ["code", "domain", "family", "name", "program_id", "protocol"] {
|
||||||
|
assert!(dto.contains(required), "Program ID autocomplete DTO missing safe registry field {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["url", "credential", "token", "secret", "endpoint", "connection", "private_key"] {
|
||||||
|
assert!(!dto.contains(forbidden), "Program ID autocomplete DTO leaks forbidden marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for forbidden in ["localStorage", "sessionStorage", "indexedDB", "document.cookie"] {
|
||||||
|
assert!(!frontend.contains(forbidden), "frontend persists campaign/autocomplete material via {forbidden}");
|
||||||
|
}
|
||||||
|
let selection_marker = frontend.find("Backfill Desk address autocomplete selection evaluated");
|
||||||
|
assert!(selection_marker.is_some());
|
||||||
|
let selection_source = match selection_marker {
|
||||||
|
std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 320, frontend.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
assert!(selection_source.contains("programCode"));
|
||||||
|
assert!(selection_source.contains("registryMatch"));
|
||||||
|
assert!(!selection_source.contains("address.value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_final_frontend_and_ipc_surface_keeps_physical_and_checkpoint_material_backend_owned() {
|
||||||
|
let root = app_root();
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for forbidden in ["http://", "https://", "localStorage", "sessionStorage", "indexedDB", "document.cookie", "privateKey", "secretKey"] {
|
||||||
|
assert!(!frontend.contains(forbidden), "final frontend contains forbidden marker {forbidden}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
for forbidden in ["BackfillCheckpoint", "HttpTransportPool", "ksp_store_postgres_lib", "tokio_postgres", "reqwest", "tonic"] {
|
||||||
|
assert!(!tauri.contains(forbidden), "Tauri IPC shell owns forbidden physical/runtime marker {forbidden}");
|
||||||
|
}
|
||||||
|
let dto = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
for marker in ["BackfillStartResponseDto", "BackfillCancelResponseDto", "BackfillResumeResponseDto"] {
|
||||||
|
let source = struct_source(dto.as_str(), marker);
|
||||||
|
assert!(!source.is_empty(), "missing DTO {marker}");
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) checkpoint:",
|
||||||
|
"pub(crate) endpoint:",
|
||||||
|
"pub(crate) provider:",
|
||||||
|
"pub(crate) credential:",
|
||||||
|
"pub(crate) token:",
|
||||||
|
"pub(crate) payload:",
|
||||||
|
] {
|
||||||
|
assert!(!source.contains(forbidden), "DTO {marker} leaks forbidden field {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/ksp-app-backfill-desk/tests/public_api.rs
Normal file
14
crates/ksp-app-backfill-desk/tests/public_api.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/public_api.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Public API checks for the KSP Backfill desktop application library.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_entry_point_is_available_from_crate_root() {
|
||||||
|
let run_entry_point: fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()> = ksp_app_backfill_desk_lib::run;
|
||||||
|
assert_eq!(std::mem::size_of_val(&run_entry_point), std::mem::size_of::<fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()>>());
|
||||||
|
}
|
||||||
121
crates/ksp-app-backfill-desk/tests/release_completeness.rs
Normal file
121
crates/ksp-app-backfill-desk/tests/release_completeness.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/release_completeness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Final V1 completeness canaries for the KSP Backfill desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn app_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(path: &std::path::Path) -> String {
|
||||||
|
let source = std::fs::read_to_string(path);
|
||||||
|
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_production_module_inventory_is_exact() {
|
||||||
|
let lib = read_text(app_root().join("src/lib.rs").as_path());
|
||||||
|
let modules = lib
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let line = line.trim();
|
||||||
|
if !line.starts_with("mod ") || !line.ends_with(';') {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return std::option::Option::Some(line.trim_start_matches("mod ").trim_end_matches(';'));
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
modules,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"app_state",
|
||||||
|
"backfill_request",
|
||||||
|
"backfill_run",
|
||||||
|
"backfill_status",
|
||||||
|
"bootstrap",
|
||||||
|
"constants",
|
||||||
|
"dto_backfill",
|
||||||
|
"dto_common",
|
||||||
|
"errors",
|
||||||
|
"frontend_logging",
|
||||||
|
"logging_runtime",
|
||||||
|
"splash",
|
||||||
|
"store_runtime",
|
||||||
|
"tauri",
|
||||||
|
"transport_runtime",
|
||||||
|
"tw_main",
|
||||||
|
"tw_splash",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_integration_suite_inventory_closes_v1_contracts() {
|
||||||
|
let tests_root = app_root().join("tests");
|
||||||
|
let entries = std::fs::read_dir(tests_root.as_path());
|
||||||
|
assert!(entries.is_ok(), "Backfill Desk tests directory should be readable");
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let files = entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("rs") {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return path.file_name().and_then(std::ffi::OsStr::to_str).map(str::to_owned);
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
files,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"config_composition.rs".to_owned(),
|
||||||
|
"dependency_boundary.rs".to_owned(),
|
||||||
|
"desktop_contract.rs".to_owned(),
|
||||||
|
"desktop_security.rs".to_owned(),
|
||||||
|
"public_api.rs".to_owned(),
|
||||||
|
"release_completeness.rs".to_owned(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_final_surface_contains_every_planned_v1_capability_without_deferred_extensions() {
|
||||||
|
let tauri = read_text(app_root().join("src/tauri.rs").as_path());
|
||||||
|
for required in ["backfill_start", "backfill_status", "backfill_cancel", "backfill_resume"] {
|
||||||
|
assert!(tauri.contains(required), "missing final V1 capability marker {required}");
|
||||||
|
}
|
||||||
|
let options = read_text(app_root().join("src/dto_common.rs").as_path());
|
||||||
|
assert!(options.contains("program_id_options"));
|
||||||
|
assert!(options.contains("program_id_autocomplete_options"));
|
||||||
|
let status = read_text(app_root().join("src/backfill_status.rs").as_path());
|
||||||
|
for required in ["checkpoint_present", "contiguous_completed", "failure_code", "failure_domain"] {
|
||||||
|
assert!(status.contains(required), "missing final monitoring marker {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["Grpc", "Yellowstone", "WebSocketBackfill", "scheduler", "job_history"] {
|
||||||
|
assert!(!tauri.contains(forbidden), "deferred capability leaked into V1 Tauri surface via {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_shared_autocomplete_dto_remains_exported_and_consumed_through_crate_root() {
|
||||||
|
let root = app_root();
|
||||||
|
let lib = read_text(root.join("src/lib.rs").as_path());
|
||||||
|
assert!(lib.contains("pub(crate) use self::dto_common::ProgramIdAutocompleteOptionDto;"));
|
||||||
|
let dto = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
assert!(dto.contains("pub(crate) struct ProgramIdAutocompleteOptionDto"));
|
||||||
|
assert!(dto.contains("std::vec::Vec<crate::ProgramIdAutocompleteOptionDto>"));
|
||||||
|
assert!(dto.contains("crate::ProgramIdAutocompleteOptionDto {"));
|
||||||
|
let bare_construction = dto.matches("\n ProgramIdAutocompleteOptionDto {").count();
|
||||||
|
assert_eq!(bare_construction, 0, "shared autocomplete DTO must not bypass the crate-root facade");
|
||||||
|
}
|
||||||
31
crates/ksp-app-backfill-desk/tsconfig.json
Normal file
31
crates/ksp-app-backfill-desk/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client",
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"frontend",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
199
crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs
Normal file
199
crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
fn ready_options() -> crate::BackfillDeskOptionsDto {
|
||||||
|
let limits = crate::backfill_request_limits();
|
||||||
|
assert!(limits.is_ok());
|
||||||
|
let limits = match limits {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => crate::BackfillRequestLimitsDto {
|
||||||
|
default_hydration_concurrency: 1,
|
||||||
|
default_max_candidates: 1,
|
||||||
|
default_max_pages: 1,
|
||||||
|
default_page_size: 1,
|
||||||
|
max_hydration_concurrency: 1,
|
||||||
|
max_candidates: 1,
|
||||||
|
max_pages: 1,
|
||||||
|
max_page_size: 1,
|
||||||
|
max_signature_text_bytes: 88,
|
||||||
|
min_signature_text_bytes: 64,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::BackfillDeskOptionsDto {
|
||||||
|
commitments: crate::backfill_commitment_codes(),
|
||||||
|
composition_ready: true,
|
||||||
|
configured_networks: vec!["mainnet".to_owned()],
|
||||||
|
http_routes: vec![
|
||||||
|
crate::BackfillHttpRouteOptionDto {
|
||||||
|
pooled: true,
|
||||||
|
providers: vec!["publicnode".to_owned(), "solana-public".to_owned()],
|
||||||
|
role: "backfill_pool".to_owned(),
|
||||||
|
},
|
||||||
|
crate::BackfillHttpRouteOptionDto { pooled: false, providers: vec!["publicnode".to_owned()], role: "backfill_publicnode".to_owned() },
|
||||||
|
],
|
||||||
|
limits,
|
||||||
|
network_coherent: true,
|
||||||
|
program_id_options: crate::program_id_autocomplete_options(),
|
||||||
|
scope_kinds: crate::backfill_scope_kind_codes(),
|
||||||
|
store_diagnostic: std::option::Option::None,
|
||||||
|
store_network: std::option::Option::Some("mainnet".to_owned()),
|
||||||
|
store_ready: true,
|
||||||
|
transport_diagnostic: std::option::Option::None,
|
||||||
|
transport_ready: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(scope_kind: &str) -> crate::BackfillStartRequestDto {
|
||||||
|
return crate::BackfillStartRequestDto {
|
||||||
|
address: std::option::Option::Some("11111111111111111111111111111111".to_owned()),
|
||||||
|
anchor_signature: std::option::Option::None,
|
||||||
|
commitment: "finalized".to_owned(),
|
||||||
|
explicit_signatures: std::vec::Vec::new(),
|
||||||
|
hydration_concurrency: 4,
|
||||||
|
http_role: "backfill_pool".to_owned(),
|
||||||
|
max_candidates: 100,
|
||||||
|
max_pages: 10,
|
||||||
|
min_context_slot: std::option::Option::Some("123456".to_owned()),
|
||||||
|
page_size: 100,
|
||||||
|
scope_kind: scope_kind.to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_id() -> std::option::Option<ksp_job_api::JobId> {
|
||||||
|
let job_id = ksp_job_api::JobId::new("request-mapping-test");
|
||||||
|
assert!(job_id.is_ok());
|
||||||
|
return match job_id {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_address_maps_to_backend_network_role_commitment_and_bounds() {
|
||||||
|
let job_id = job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mapped = crate::map_backfill_request(request("latest_address"), &ready_options(), job_id);
|
||||||
|
assert!(mapped.is_ok());
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(mapped.network().as_str(), "mainnet");
|
||||||
|
assert_eq!(mapped.role().as_str(), "backfill_pool");
|
||||||
|
assert_eq!(mapped.commitment(), ksp_job_backfill_lib::BackfillCommitment::Finalized);
|
||||||
|
assert_eq!(mapped.scope().kind(), ksp_job_backfill_lib::BackfillScopeKind::LatestAddress);
|
||||||
|
assert_eq!(mapped.page_size(), 100);
|
||||||
|
assert_eq!(mapped.max_pages(), 10);
|
||||||
|
assert_eq!(mapped.max_candidates(), 100);
|
||||||
|
assert_eq!(mapped.hydration_concurrency(), 4);
|
||||||
|
assert_eq!(mapped.min_context_slot(), std::option::Option::Some(123456));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn before_and_after_scopes_require_an_exact_anchor_shape() {
|
||||||
|
for scope_kind in ["before_address", "after_address"] {
|
||||||
|
let job_id = job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mut input = request(scope_kind);
|
||||||
|
input.anchor_signature = std::option::Option::Some("1".repeat(64));
|
||||||
|
let mapped = crate::map_backfill_request(input, &ready_options(), job_id);
|
||||||
|
assert!(mapped.is_ok(), "scope {scope_kind} should map with a bounded anchor");
|
||||||
|
}
|
||||||
|
let job_id = job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let missing_anchor = crate::map_backfill_request(request("before_address"), &ready_options(), job_id);
|
||||||
|
assert!(missing_anchor.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_signatures_reject_address_context_and_deduplicate_in_job_contract() {
|
||||||
|
let request_job_id = job_id();
|
||||||
|
let request_job_id = match request_job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mut input = request("explicit_signatures");
|
||||||
|
input.address = std::option::Option::None;
|
||||||
|
input.min_context_slot = std::option::Option::None;
|
||||||
|
input.explicit_signatures = vec!["1".repeat(64), "1".repeat(64), "2".repeat(64)];
|
||||||
|
let mapped = crate::map_backfill_request(input, &ready_options(), request_job_id);
|
||||||
|
assert!(mapped.is_ok());
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let signatures = mapped.scope().signatures();
|
||||||
|
assert!(signatures.is_some());
|
||||||
|
if let std::option::Option::Some(signatures) = signatures {
|
||||||
|
assert_eq!(signatures.len(), 2);
|
||||||
|
}
|
||||||
|
let mut hostile = request("explicit_signatures");
|
||||||
|
hostile.address = std::option::Option::None;
|
||||||
|
hostile.min_context_slot = std::option::Option::None;
|
||||||
|
hostile.explicit_signatures = vec![" 1".to_owned()];
|
||||||
|
let hostile_job_id = job_id();
|
||||||
|
let hostile_job_id = match hostile_job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert!(crate::map_backfill_request(hostile, &ready_options(), hostile_job_id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_inventory_and_composition_are_revalidated_before_mapping() {
|
||||||
|
let first_job_id = job_id();
|
||||||
|
let first_job_id = match first_job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mut unknown_route = request("latest_address");
|
||||||
|
unknown_route.http_role = "provider-from-frontend".to_owned();
|
||||||
|
let rejected = crate::map_backfill_request(unknown_route, &ready_options(), first_job_id);
|
||||||
|
assert!(rejected.is_err());
|
||||||
|
let mut unavailable = ready_options();
|
||||||
|
unavailable.composition_ready = false;
|
||||||
|
let second_job_id = job_id();
|
||||||
|
let second_job_id = match second_job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert!(crate::map_backfill_request(request("latest_address"), &unavailable, second_job_id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_preview_never_contains_address_or_signature_values() {
|
||||||
|
let job_id = job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mapped = crate::map_backfill_request(request("latest_address"), &ready_options(), job_id);
|
||||||
|
assert!(mapped.is_ok());
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let preview = crate::project_backfill_request(&mapped);
|
||||||
|
assert!(preview.is_ok());
|
||||||
|
let preview = match preview {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let serialized = serde_json::to_string(&preview);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
assert!(!serialized.contains("11111111111111111111111111111111"));
|
||||||
|
assert!(!serialized.contains(&"1".repeat(64)));
|
||||||
|
assert!(serialized.contains("mainnet"));
|
||||||
|
assert!(serialized.contains("backfill_pool"));
|
||||||
|
}
|
||||||
|
}
|
||||||
117
crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
Normal file
117
crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_run_ids_are_backend_owned_bounded_and_unique_in_session() {
|
||||||
|
let state = crate::BackfillRunState::new();
|
||||||
|
let first = state.next_job_id();
|
||||||
|
let second = state.next_job_id();
|
||||||
|
assert!(first.is_ok());
|
||||||
|
assert!(second.is_ok());
|
||||||
|
let first = match first {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let second = match second {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert!(first.as_str().starts_with("backfill-desk-"));
|
||||||
|
assert!(second.as_str().starts_with("backfill-desk-"));
|
||||||
|
assert!(first.as_str().len() <= ksp_job_api::MAX_JOB_ID_BYTES);
|
||||||
|
assert!(second.as_str().len() <= ksp_job_api::MAX_JOB_ID_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancellable_runtime(
|
||||||
|
job_id: &str,
|
||||||
|
) -> std::option::Option<(ksp_job_api::JobId, ksp_job_backfill_lib::BackfillRequest, ksp_job_backfill_lib::BackfillJobRuntime)> {
|
||||||
|
let job_id = match ksp_job_api::JobId::new(job_id) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let network = match ksp_store_lib::RawNetworkId::new("mainnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let request = ksp_job_backfill_lib::BackfillRequest::new(
|
||||||
|
job_id.clone(),
|
||||||
|
network,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("backfill_pool"),
|
||||||
|
ksp_job_backfill_lib::BackfillCommitment::Finalized,
|
||||||
|
ksp_job_backfill_lib::BackfillScope::latest_address(ksp_core_lib::Pubkey::new_from_array([1_u8; 32])),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let request = match request {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let retained_request = request.clone();
|
||||||
|
let runtime = match ksp_job_backfill_lib::BackfillJobRuntime::new(request) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some((job_id, retained_request, runtime));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn targeted_cancel_is_idempotent_and_rejects_stale_job_identity() {
|
||||||
|
let state = crate::BackfillRunState::new();
|
||||||
|
let runtime = cancellable_runtime("backfill-desk-41");
|
||||||
|
assert!(runtime.is_some());
|
||||||
|
let (job_id, request, runtime) = match runtime {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let installed = state.install(job_id.clone(), runtime.handle(), request);
|
||||||
|
assert!(installed.is_ok());
|
||||||
|
let first = state.cancel(job_id.as_str());
|
||||||
|
assert!(first.is_ok());
|
||||||
|
if let std::result::Result::Ok(first) = first {
|
||||||
|
assert!(first.accepted);
|
||||||
|
assert_eq!(first.state, "cancelling");
|
||||||
|
}
|
||||||
|
let repeated = state.cancel(job_id.as_str());
|
||||||
|
assert!(repeated.is_ok());
|
||||||
|
if let std::result::Result::Ok(repeated) = repeated {
|
||||||
|
assert!(!repeated.accepted);
|
||||||
|
assert_eq!(repeated.state, "cancelling");
|
||||||
|
}
|
||||||
|
let stale = state.cancel("backfill-desk-40");
|
||||||
|
assert!(stale.is_err());
|
||||||
|
if let std::result::Result::Err(error) = stale {
|
||||||
|
assert_eq!(error.code().domain(), crate::ERROR_CODE_BACKFILL_RUN_MISMATCH.domain());
|
||||||
|
assert_eq!(error.code().code(), crate::ERROR_CODE_BACKFILL_RUN_MISMATCH.code());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_requires_terminal_checkpoint_and_rejects_active_run() {
|
||||||
|
let state = crate::BackfillRunState::new();
|
||||||
|
let next = match ksp_job_api::JobId::new("backfill-desk-50") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let unavailable = state.resume_request(next.clone());
|
||||||
|
assert!(unavailable.is_err());
|
||||||
|
if let std::result::Result::Err(error) = unavailable {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
let runtime = cancellable_runtime("backfill-desk-49");
|
||||||
|
assert!(runtime.is_some());
|
||||||
|
let (job_id, request, runtime) = match runtime {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let installed = state.install(job_id, runtime.handle(), request);
|
||||||
|
assert!(installed.is_ok());
|
||||||
|
let active = state.resume_request(next);
|
||||||
|
assert!(active.is_err());
|
||||||
|
if let std::result::Result::Err(error) = active {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_RUN_ACTIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
crates/ksp-app-backfill-desk/unit_tests/backfill_status.rs
Normal file
76
crates/ksp-app-backfill-desk/unit_tests/backfill_status.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_status.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
fn initial_notification() -> std::option::Option<ksp_job_api::JobNotification<ksp_job_backfill_lib::BackfillJobSnapshot>> {
|
||||||
|
let signature = match ksp_job_backfill_lib::BackfillSignature::new("1".repeat(ksp_job_backfill_lib::MIN_BACKFILL_SIGNATURE_TEXT_BYTES)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let scope = match ksp_job_backfill_lib::BackfillScope::explicit_signatures(vec![signature]) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let job_id = match ksp_job_api::JobId::new("backfill-desk-monitor-test") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let network = match ksp_store_lib::RawNetworkId::new("mainnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let request = ksp_job_backfill_lib::BackfillRequest::new(
|
||||||
|
job_id,
|
||||||
|
network,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("backfill_pool"),
|
||||||
|
ksp_job_backfill_lib::BackfillCommitment::Confirmed,
|
||||||
|
scope,
|
||||||
|
100,
|
||||||
|
10,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let request = match request {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let runtime = match ksp_job_backfill_lib::BackfillJobRuntime::new(request) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let source = runtime.handle().snapshots();
|
||||||
|
return std::option::Option::Some(<ksp_job_backfill_lib::BackfillSnapshotSource as ksp_job_api::JobSnapshotSource>::current(&source));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_value_projection_is_safe_complete_and_uses_decimal_sequence_text() {
|
||||||
|
let notification = match initial_notification() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let status = crate::project_backfill_status(¬ification);
|
||||||
|
assert!(status.is_ok());
|
||||||
|
let status = match status {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(status.active);
|
||||||
|
assert!(!status.terminal);
|
||||||
|
assert_eq!(status.job_id, "backfill-desk-monitor-test");
|
||||||
|
assert_eq!(status.job_kind, ksp_job_backfill_lib::BACKFILL_JOB_KIND_CODE);
|
||||||
|
assert_eq!(status.sequence, "0");
|
||||||
|
assert_eq!(status.state, "created");
|
||||||
|
assert_eq!(status.phase, "created");
|
||||||
|
assert_eq!(status.scope_kind, "explicit_signatures");
|
||||||
|
assert!(!status.checkpoint_present);
|
||||||
|
assert_eq!(status.candidates_selected, 0);
|
||||||
|
assert_eq!(status.failure_code, std::option::Option::None);
|
||||||
|
assert_eq!(status.failure_domain, std::option::Option::None);
|
||||||
|
let serialized = serde_json::to_string(&status);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
for forbidden in ["address", "signature\"", "endpoint", "credential", "token", "postgres"] {
|
||||||
|
assert!(!serialized.contains(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
Normal file
75
crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_limits_are_derived_from_job_owned_public_bounds() {
|
||||||
|
let limits = crate::backfill_request_limits();
|
||||||
|
assert!(limits.is_ok());
|
||||||
|
let limits = match limits {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(usize::try_from(limits.max_page_size), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_PAGE_SIZE));
|
||||||
|
assert_eq!(usize::try_from(limits.max_pages), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_PAGES));
|
||||||
|
assert_eq!(usize::try_from(limits.max_candidates), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_CANDIDATES));
|
||||||
|
assert_eq!(usize::try_from(limits.max_hydration_concurrency), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_HYDRATION_CONCURRENCY));
|
||||||
|
assert_eq!(usize::try_from(limits.min_signature_text_bytes), std::result::Result::Ok(ksp_job_backfill_lib::MIN_BACKFILL_SIGNATURE_TEXT_BYTES));
|
||||||
|
assert_eq!(usize::try_from(limits.max_signature_text_bytes), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_SIGNATURE_TEXT_BYTES));
|
||||||
|
assert!(limits.default_page_size <= limits.max_page_size);
|
||||||
|
assert!(limits.default_max_pages <= limits.max_pages);
|
||||||
|
assert!(limits.default_max_candidates <= limits.max_candidates);
|
||||||
|
assert!(limits.default_hydration_concurrency <= limits.max_hydration_concurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_options_expose_exact_current_commitments_and_http_scopes() {
|
||||||
|
assert_eq!(crate::backfill_commitment_codes(), vec!["finalized".to_owned(), "confirmed".to_owned()]);
|
||||||
|
assert_eq!(
|
||||||
|
crate::backfill_scope_kind_codes(),
|
||||||
|
vec!["latest_address".to_owned(), "before_address".to_owned(), "after_address".to_owned(), "explicit_signatures".to_owned()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_acknowledgement_contains_only_backend_job_identity_and_initial_state() {
|
||||||
|
let response = crate::BackfillStartResponseDto { job_id: "backfill-desk-1".to_owned(), state: "created".to_owned() };
|
||||||
|
let serialized = serde_json::to_string(&response);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
assert!(serialized.contains("backfill-desk-1"));
|
||||||
|
assert!(serialized.contains("created"));
|
||||||
|
for forbidden in ["address", "signature", "provider", "endpoint", "credential", "token"] {
|
||||||
|
assert!(!serialized.contains(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_acknowledgement_contains_only_backend_job_identity_acceptance_and_state() {
|
||||||
|
let response = crate::BackfillCancelResponseDto { accepted: true, job_id: "backfill-desk-1".to_owned(), state: "cancelling".to_owned() };
|
||||||
|
let serialized = serde_json::to_string(&response);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
assert!(serialized.contains("backfill-desk-1"));
|
||||||
|
assert!(serialized.contains("cancelling"));
|
||||||
|
assert!(serialized.contains("accepted"));
|
||||||
|
for forbidden in ["address", "signature", "provider", "endpoint", "credential", "token", "checkpoint", "payload"] {
|
||||||
|
assert!(!serialized.contains(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_acknowledgement_contains_only_new_backend_job_identity_acceptance_and_state() {
|
||||||
|
let response = crate::BackfillResumeResponseDto { accepted: true, job_id: "backfill-desk-2".to_owned(), state: "created".to_owned() };
|
||||||
|
let serialized = serde_json::to_string(&response);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
assert!(serialized.contains("backfill-desk-2"));
|
||||||
|
assert!(serialized.contains("created"));
|
||||||
|
assert!(serialized.contains("accepted"));
|
||||||
|
for forbidden in ["address", "signature", "provider", "endpoint", "credential", "token", "checkpoint", "payload"] {
|
||||||
|
assert!(!serialized.contains(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/ksp-app-backfill-desk/unit_tests/dto_common.rs
Normal file
67
crates/ksp-app-backfill-desk/unit_tests/dto_common.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/dto_common.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_error_projection_excludes_arbitrary_context_and_source_values() {
|
||||||
|
let source = std::io::Error::other("secret-source");
|
||||||
|
let error = ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "failed"), "safe message")
|
||||||
|
.with_context("secret", "hidden")
|
||||||
|
.with_source(source);
|
||||||
|
let dto = crate::CommandErrorDto::from_error(&error);
|
||||||
|
assert_eq!(dto.domain, "test");
|
||||||
|
assert_eq!(dto.code, "failed");
|
||||||
|
assert_eq!(dto.message, "safe message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_options_projection_contains_only_safe_transport_metadata() {
|
||||||
|
let limits = crate::backfill_request_limits();
|
||||||
|
assert!(limits.is_ok());
|
||||||
|
let limits = match limits {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let dto = crate::BackfillDeskOptionsDto {
|
||||||
|
commitments: crate::backfill_commitment_codes(),
|
||||||
|
http_routes: vec![crate::BackfillHttpRouteOptionDto { pooled: false, providers: vec!["solana-public".to_owned()], role: "default".to_owned() }],
|
||||||
|
limits,
|
||||||
|
composition_ready: true,
|
||||||
|
configured_networks: vec!["devnet".to_owned()],
|
||||||
|
network_coherent: true,
|
||||||
|
program_id_options: crate::program_id_autocomplete_options(),
|
||||||
|
scope_kinds: crate::backfill_scope_kind_codes(),
|
||||||
|
store_diagnostic: std::option::Option::None,
|
||||||
|
store_network: std::option::Option::Some("devnet".to_owned()),
|
||||||
|
store_ready: true,
|
||||||
|
transport_diagnostic: std::option::Option::None,
|
||||||
|
transport_ready: true,
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(dto);
|
||||||
|
assert!(value.is_ok());
|
||||||
|
if let std::result::Result::Ok(value) = value {
|
||||||
|
let serialized = value.to_string();
|
||||||
|
assert!(serialized.contains("default"));
|
||||||
|
assert!(serialized.contains("devnet"));
|
||||||
|
assert!(serialized.contains("solana-public"));
|
||||||
|
assert!(!serialized.contains("url"));
|
||||||
|
assert!(!serialized.contains("endpoint"));
|
||||||
|
assert!(!serialized.contains("postgres"));
|
||||||
|
assert!(!serialized.contains("connection"));
|
||||||
|
assert!(!serialized.contains("database"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn program_id_autocomplete_dataset_is_derived_exactly_from_core_registry() {
|
||||||
|
let options = crate::program_id_autocomplete_options();
|
||||||
|
let entries = ksp_core_lib::entries();
|
||||||
|
assert_eq!(options.len(), entries.len());
|
||||||
|
for (option, entry) in options.iter().zip(entries.iter()) {
|
||||||
|
assert_eq!(option.code, entry.code());
|
||||||
|
assert_eq!(option.name, entry.name());
|
||||||
|
assert_eq!(option.program_id, entry.program_id());
|
||||||
|
assert_eq!(option.domain, entry.domain());
|
||||||
|
assert_eq!(option.family, entry.family());
|
||||||
|
assert_eq!(option.protocol, entry.protocol());
|
||||||
|
}
|
||||||
|
}
|
||||||
40
crates/ksp-app-backfill-desk/unit_tests/frontend_logging.rs
Normal file
40
crates/ksp-app-backfill-desk/unit_tests/frontend_logging.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/frontend_logging.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_log_levels_are_explicit_and_case_insensitive() {
|
||||||
|
assert!(matches!(super::parse_level(" TRACE "), std::result::Result::Ok(super::FrontendLogLevel::Trace)));
|
||||||
|
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(super::FrontendLogLevel::Debug)));
|
||||||
|
assert!(matches!(super::parse_level("Info"), std::result::Result::Ok(super::FrontendLogLevel::Info)));
|
||||||
|
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(super::FrontendLogLevel::Warn)));
|
||||||
|
assert!(matches!(super::parse_level("ERROR"), std::result::Result::Ok(super::FrontendLogLevel::Error)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_frontend_log_level_is_rejected() {
|
||||||
|
let result = super::parse_level("notice");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let error = match result {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_log_targets_are_whitelisted() {
|
||||||
|
assert!(matches!(super::parse_target("frontend"), std::result::Result::Ok(super::FrontendLogTarget::Frontend)));
|
||||||
|
assert!(matches!(super::parse_target(" MAIN "), std::result::Result::Ok(super::FrontendLogTarget::Main)));
|
||||||
|
assert!(matches!(super::parse_target("Splash"), std::result::Result::Ok(super::FrontendLogTarget::Splash)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrary_frontend_log_target_is_rejected() {
|
||||||
|
let result = super::parse_target("ksp-app-backfill-desk.test.logging");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let error = match result {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID);
|
||||||
|
}
|
||||||
36
crates/ksp-app-backfill-desk/unit_tests/splash.rs
Normal file
36
crates/ksp-app-backfill-desk/unit_tests/splash.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/splash.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_splash_settings_are_short_and_ordered() {
|
||||||
|
let settings = crate::SplashSettings::fallback();
|
||||||
|
assert_eq!(settings.fade_in_ms(), 300);
|
||||||
|
assert_eq!(settings.minimum_ms(), 1200);
|
||||||
|
assert_eq!(settings.fade_out_ms(), 300);
|
||||||
|
assert_eq!(settings.expected_backend_lifecycle_ms(), 1800);
|
||||||
|
assert_eq!(settings.fade_in_source(), "fallback");
|
||||||
|
assert_eq!(settings.minimum_source(), "fallback");
|
||||||
|
assert_eq!(settings.fade_out_source(), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splash_order_contract_keeps_action_message_status_and_duration() {
|
||||||
|
let order =
|
||||||
|
crate::SplashOrderDto::new("add_message", std::option::Option::Some("ready"), std::option::Option::Some("success"), std::option::Option::Some(300));
|
||||||
|
assert_eq!(order.action, "add_message");
|
||||||
|
assert_eq!(order.message.as_deref(), std::option::Option::Some("ready"));
|
||||||
|
assert_eq!(order.status.as_deref(), std::option::Option::Some("success"));
|
||||||
|
assert_eq!(order.duration_ms, std::option::Option::Some(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splash_duration_parser_rejects_invalid_or_unbounded_values() {
|
||||||
|
let malformed = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "invalid", 60_000);
|
||||||
|
let oversized_minimum = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "60001", 60_000);
|
||||||
|
let oversized_fade_in = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_IN_MS", "10001", 10_000);
|
||||||
|
let oversized_fade_out = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_OUT_MS", "10001", 10_000);
|
||||||
|
assert!(malformed.is_err());
|
||||||
|
assert!(oversized_minimum.is_err());
|
||||||
|
assert!(oversized_fade_in.is_err());
|
||||||
|
assert!(oversized_fade_out.is_err());
|
||||||
|
}
|
||||||
18
crates/ksp-app-backfill-desk/unit_tests/store_runtime.rs
Normal file
18
crates/ksp-app-backfill-desk/unit_tests/store_runtime.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/store_runtime.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_health_mapping_is_explicit_and_conservative() {
|
||||||
|
assert!(super::store_health_ready(ksp_store_lib::StoreHealthState::Ready));
|
||||||
|
assert!(!super::store_health_ready(ksp_store_lib::StoreHealthState::NotReady));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_transport_network_coherence_requires_exact_logical_identity() {
|
||||||
|
assert!(super::validate_network_coherence("devnet", "devnet").is_ok());
|
||||||
|
let mismatch = super::validate_network_coherence("mainnet", "devnet");
|
||||||
|
assert!(mismatch.is_err());
|
||||||
|
if let std::result::Result::Err(error) = mismatch {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_STORE_NETWORK_MISMATCH);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/ksp-app-backfill-desk/unit_tests/transport_runtime.rs
Normal file
133
crates/ksp-app-backfill-desk/unit_tests/transport_runtime.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/transport_runtime.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
fn endpoint(
|
||||||
|
name: &str,
|
||||||
|
cluster: &str,
|
||||||
|
role_name: &str,
|
||||||
|
request_kinds: std::vec::Vec<ksp_onchain_transport_lib::HttpRequestKind>,
|
||||||
|
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpEndpointSettings> {
|
||||||
|
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse(format!("https://{name}.example.invalid"));
|
||||||
|
let url = match url {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new(role_name),
|
||||||
|
true,
|
||||||
|
request_kinds,
|
||||||
|
0,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||||
|
name,
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::HttpProviderName::new("fixture"),
|
||||||
|
ksp_onchain_transport_lib::HttpClusterName::new(cluster),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
std::option::Option::Some(1),
|
||||||
|
vec![role],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool(endpoints: std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointSettings>) -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpTransportPool> {
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||||
|
endpoints,
|
||||||
|
ksp_onchain_transport_lib::HttpRetrySettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10)),
|
||||||
|
);
|
||||||
|
return ksp_onchain_transport_lib::HttpTransportPool::new(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_routes_require_both_backfill_rpc_methods() {
|
||||||
|
let signatures = ksp_onchain_transport_lib::find_http_rpc_method("getSignaturesForAddress");
|
||||||
|
let transaction = ksp_onchain_transport_lib::find_http_rpc_method("getTransaction");
|
||||||
|
assert!(signatures.is_some());
|
||||||
|
assert!(transaction.is_some());
|
||||||
|
let signatures = match signatures {
|
||||||
|
std::option::Option::Some(value) => ksp_onchain_transport_lib::HttpRequestKind::new(value.request_kind()),
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let transaction = match transaction {
|
||||||
|
std::option::Option::Some(value) => ksp_onchain_transport_lib::HttpRequestKind::new(value.request_kind()),
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let complete = endpoint("complete", "devnet", "complete", vec![signatures.clone(), transaction]);
|
||||||
|
let complete = match complete {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let partial = endpoint("partial", "devnet", "partial", vec![signatures]);
|
||||||
|
let partial = match partial {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let runtime_pool = pool(vec![complete, partial]);
|
||||||
|
assert!(runtime_pool.is_ok());
|
||||||
|
let runtime_pool = match runtime_pool {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let snapshot = runtime_pool.snapshot();
|
||||||
|
let roles = super::compatible_backfill_http_routes(&runtime_pool, &snapshot);
|
||||||
|
assert!(roles.is_ok());
|
||||||
|
if let std::result::Result::Ok(roles) = roles {
|
||||||
|
assert_eq!(roles.len(), 1);
|
||||||
|
if let std::option::Option::Some(route) = roles.first() {
|
||||||
|
assert_eq!(route.role, "complete");
|
||||||
|
assert_eq!(route.providers, vec!["fixture".to_owned()]);
|
||||||
|
assert!(!route.pooled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_networks_are_safe_sorted_and_deduplicated() {
|
||||||
|
let one = endpoint("one", "testnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let two = endpoint("two", "devnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let three = endpoint("three", "devnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let (one, two, three) = match (one, two, three) {
|
||||||
|
(std::result::Result::Ok(one), std::result::Result::Ok(two), std::result::Result::Ok(three)) => (one, two, three),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let runtime_pool = pool(vec![one, two, three]);
|
||||||
|
assert!(runtime_pool.is_ok());
|
||||||
|
if let std::result::Result::Ok(runtime_pool) = runtime_pool {
|
||||||
|
let values = super::configured_networks(&runtime_pool.snapshot());
|
||||||
|
assert_eq!(values, vec!["devnet".to_owned(), "testnet".to_owned()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coherent_network_requires_exactly_one_enabled_http_cluster() {
|
||||||
|
let one = endpoint("one", "devnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let one = match one {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let one_pool = pool(vec![one]);
|
||||||
|
assert!(one_pool.is_ok());
|
||||||
|
if let std::result::Result::Ok(one_pool) = one_pool {
|
||||||
|
let runtime = crate::TransportRuntime { pool: one_pool, profile_id: "fixture".to_owned() };
|
||||||
|
assert_eq!(runtime.coherent_network(), std::option::Option::Some("devnet".to_owned()));
|
||||||
|
}
|
||||||
|
let two = endpoint("two", "devnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let three = endpoint("three", "testnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let (two, three) = match (two, three) {
|
||||||
|
(std::result::Result::Ok(two), std::result::Result::Ok(three)) => (two, three),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let multi_pool = pool(vec![two, three]);
|
||||||
|
assert!(multi_pool.is_ok());
|
||||||
|
if let std::result::Result::Ok(multi_pool) = multi_pool {
|
||||||
|
let runtime = crate::TransportRuntime { pool: multi_pool, profile_id: "fixture".to_owned() };
|
||||||
|
assert_eq!(runtime.coherent_network(), std::option::Option::None);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
crates/ksp-app-backfill-desk/vite.config.ts
Normal file
72
crates/ksp-app-backfill-desk/vite.config.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/vite.config.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { NodePackageImporter } from "sass-embedded";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { defineConfig, normalizePath } from "vite";
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
||||||
|
const frontendDist = normalizePath(resolve(appRoot, "../../../builds/khadhroony-solana-project/ksp-app-backfill-desk/dist"));
|
||||||
|
const devHost = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
root: frontendRoot,
|
||||||
|
publicDir: false,
|
||||||
|
input: {
|
||||||
|
main: normalizePath(resolve(frontendRoot, "main.html")),
|
||||||
|
splash: normalizePath(resolve(frontendRoot, "splash.html")),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: frontendDist,
|
||||||
|
emptyOutDir: true,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "js/[name]-[hash].js",
|
||||||
|
chunkFileNames: "js/chunks/[name]-[hash].js",
|
||||||
|
assetFileNames: assetInfo => {
|
||||||
|
const originalName = assetInfo.names[0] ?? "";
|
||||||
|
const extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
if (extension === "css") {
|
||||||
|
return "css/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
if (["eot", "otf", "ttf", "woff", "woff2"].includes(extension)) {
|
||||||
|
return "fonts/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(extension)) {
|
||||||
|
return "imgs/[name][extname]";
|
||||||
|
}
|
||||||
|
return "otherassets/[name][extname]";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
quietDeps: true,
|
||||||
|
silenceDeprecations: ["import", "color-functions", "global-builtin"],
|
||||||
|
verbose: false,
|
||||||
|
importers: [new NodePackageImporter()],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 1436,
|
||||||
|
strictPort: true,
|
||||||
|
host: devHost || false,
|
||||||
|
ws: {
|
||||||
|
protocol: "ws",
|
||||||
|
host: devHost || "localhost",
|
||||||
|
port: 1437,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
ignored: ["**/src/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
||||||
<!-- version: 3 -->
|
<!-- version: 4 -->
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="splash-container" style="opacity: 0;">
|
<div id="splash-container" style="opacity: 0;">
|
||||||
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Config Desk">
|
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Config Desk">
|
||||||
<div id="app-name">Config Desk</div>
|
<div id="app-name">Config</div>
|
||||||
<div id="debug-info" aria-live="off" hidden></div>
|
<div id="debug-info" aria-live="off" hidden></div>
|
||||||
<div id="messages-container" aria-live="polite"></div>
|
<div id="messages-container" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,17 +52,22 @@
|
|||||||
"icons/favicon.ico"
|
"icons/favicon.ico"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
|
"../../config/composite.ksp-app-backfill-desk.json": "config/composite.ksp-app-backfill-desk.json",
|
||||||
|
"../../config/composite.ksp-app-raw-transaction-ingest-desk.json": "config/composite.ksp-app-raw-transaction-ingest-desk.json",
|
||||||
"../../config/composite.ksp-app-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
|
"../../config/composite.ksp-app-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
|
||||||
|
"../../config/composite.ksp-app-store-desk.json": "config/composite.ksp-app-store-desk.json",
|
||||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
"../../config/std.logging.json": "config/std.logging.json",
|
|
||||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
|
||||||
"../../config/std.transport.json": "config/std.transport.json",
|
|
||||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
|
||||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
|
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json",
|
||||||
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json": "config/std.store.json",
|
||||||
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
|
"../../config/std.wallet.json": "config/std.wallet.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||||
// version: 10
|
// version: 14
|
||||||
|
|
||||||
//! Desktop build/shell contract audits for Config Desk.
|
//! Desktop build/shell contract audits for Config Desk.
|
||||||
|
|
||||||
@@ -110,11 +110,23 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab
|
|||||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||||
if let std::option::Option::Some(resources) = resources {
|
if let std::option::Option::Some(resources) = resources {
|
||||||
assert_eq!(resources.len(), 11);
|
assert_eq!(resources.len(), 16);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/composite.ksp-app-backfill-desk.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/composite.ksp-app-backfill-desk.json"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/composite.ksp-app-raw-transaction-ingest-desk.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/composite.ksp-app-raw-transaction-ingest-desk.json"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/composite.ksp-app-store-desk.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/composite.ksp-app-store-desk.json"),
|
||||||
|
);
|
||||||
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
|
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
||||||
@@ -124,6 +136,11 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab
|
|||||||
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(resources.get("../../config/std.store.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.store.json"),);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/schemas/std.store.schema.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/schemas/std.store.schema.json"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/schemas/std.wallet.schema.json"),
|
std::option::Option::Some("config/schemas/std.wallet.schema.json"),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||||
// version: 7
|
// version: 9
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_inventory_exposes_registered_profile_documents() {
|
fn profile_inventory_exposes_registered_profile_documents() {
|
||||||
@@ -15,9 +15,15 @@ fn profile_inventory_exposes_registered_profile_documents() {
|
|||||||
assert!(inventory.iter().any(|document| -> bool {
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
return document.file_id == ksp_config_lib::FILE_ID_STD_TRANSPORT;
|
return document.file_id == ksp_config_lib::FILE_ID_STD_TRANSPORT;
|
||||||
}));
|
}));
|
||||||
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
|
return document.file_id == ksp_config_lib::FILE_ID_STD_STORE;
|
||||||
|
}));
|
||||||
assert!(inventory.iter().any(|document| -> bool {
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
return document.file_id == ksp_config_lib::FILE_ID_STD_WALLET;
|
return document.file_id == ksp_config_lib::FILE_ID_STD_WALLET;
|
||||||
}));
|
}));
|
||||||
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
|
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_RAW_TRANSACTION_INGEST_DESK;
|
||||||
|
}));
|
||||||
assert!(inventory.iter().any(|document| -> bool {
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
|
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
|
||||||
}));
|
}));
|
||||||
|
|||||||
44
crates/ksp-app-raw-transaction-ingest-desk/Cargo.toml
Normal file
44
crates/ksp-app-raw-transaction-ingest-desk/Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# file: crates/ksp-app-raw-transaction-ingest-desk/Cargo.toml
|
||||||
|
# version: 4
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-app-raw-transaction-ingest-desk"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ksp_app_raw_transaction_ingest_desk_lib"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ksp-app-raw-transaction-ingest-desk"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { workspace = true, features = ["std", "now"] }
|
||||||
|
fs2.workspace = true
|
||||||
|
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||||
|
ksp-store-lib = { path = "../ksp-store-lib" }
|
||||||
|
ksp-worker-api = { path = "../ksp-worker-api" }
|
||||||
|
ksp-worker-raw-transaction-ingest-lib = { path = "../ksp-worker-raw-transaction-ingest-lib" }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
tauri.workspace = true
|
||||||
|
tauri-plugin-tracing.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["time"] }
|
||||||
|
ts-rs.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
76
crates/ksp-app-raw-transaction-ingest-desk/README.md
Normal file
76
crates/ksp-app-raw-transaction-ingest-desk/README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<!-- file: crates/ksp-app-raw-transaction-ingest-desk/README.md -->
|
||||||
|
<!-- version: 15 -->
|
||||||
|
|
||||||
|
# `ksp-app-raw-transaction-ingest-desk`
|
||||||
|
|
||||||
|
Application desktop Tauri KSP destinée à composer et superviser les routes live alimentant le Store commun en `RawTransaction`.
|
||||||
|
|
||||||
|
Le composant conserve les frontières KSP : Config possède les profils/endpoints/secrets, Transport les clients et sessions, Store la persistance et le Worker la logique d'ingestion. Le shell desktop ne déplace jamais ces responsabilités vers le frontend.
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
```text
|
||||||
|
package : ksp-app-raw-transaction-ingest-desk
|
||||||
|
lib : ksp_app_raw_transaction_ingest_desk_lib
|
||||||
|
bin : ksp-app-raw-transaction-ingest-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontières
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config -> composite, profils, secrets et résolution effective
|
||||||
|
Transport -> capabilities HTTP/WS/gRPC et ressources physiques
|
||||||
|
Desk -> catalogue logique, revalidation Start et ownership du lifecycle mono/multi-route
|
||||||
|
Store -> persistance via la façade KSP
|
||||||
|
Worker -> validation des contrats source, acquisition continue et lifecycle d'exécution
|
||||||
|
```
|
||||||
|
|
||||||
|
L'inventaire de routes est calculé côté Rust à partir du composite Config validé et des settings Transport/Store résolus. Il ne lance aucun probe réseau et n'ouvre ni Store ni Worker.
|
||||||
|
|
||||||
|
Au Start, le backend revalide une sélection logique par `profile_id + route_id + inventory_generation + commitment`. Il recharge Config, reproouve le réseau Store/Transport, reconstruit le pool HTTP ou l'endpoint WS/gRPC exact requis, ouvre le Store via `ksp-store-lib`, exige un health `Ready` puis lance le Worker exact via `RawTransactionIngestWorker::start_with_runtime_resources`. Les ressources physiques et handles restent exclusivement côté Rust.
|
||||||
|
|
||||||
|
Le runtime accepte désormais plusieurs routes simultanées sur un même réseau. Chaque route possède son Worker indépendant ; deux Starts de la même identité logique `profile_id + route_id` sont refusés, et un Start d'un autre réseau est refusé tant que le Store partagé reste ouvert. Le premier Worker ouvre et valide le Store, les suivants réutilisent le même `Arc<Store>`, puis la dernière route terminale déclenche seule la fermeture explicite du Store. Un fault d'une route ne stoppe pas les Workers siblings. Stop est ciblé par route et reste idempotent lorsqu'un terminal sûr a déjà été retenu.
|
||||||
|
|
||||||
|
Les races de contrôle sont fermées côté backend : un Stop qui rencontre une route encore `Starting` marque cette réservation pour arrêt coopératif et attend son cleanup au lieu de la considérer inactive. Dès qu'une fermeture de l'application commence, tout nouveau Start est refusé, les routes actives reçoivent Stop et les Starts déjà réservés sont marqués avant activation. La fenêtre principale ne quitte le processus qu'après cleanup borné des Workers et du Store partagé, ou avec un code d'échec après expiration de la borne.
|
||||||
|
|
||||||
|
Les profils applicatifs sont réseau-centriques (`devnet`, `mainnet`, `testnet`). Un profil peut agréger plusieurs profils Transport du même réseau : le provider reste une source de capability et ne devient jamais une identité réseau ni un choix de profil applicatif.
|
||||||
|
Une route explicitement liée à un provider n’est projetée pour un réseau que si le composite déclare une source Transport de ce provider. Ainsi, la route Helius Transaction existe sur Devnet/Mainnet mais n’est pas présentée sur Testnet tant qu’Helius ne fournit pas de source Testnet configurée.
|
||||||
|
|
||||||
|
Le frontend ne reçoit aucune URL, credential, URI Store, metadata secrète, source key, handle runtime ou payload `RawTransaction`.
|
||||||
|
|
||||||
|
Les requêtes IPC Start/Stop sont shape-strict : les champs inconnus sont rejetés et `profile_id` est borné/validé avant toute reconstruction. Le bridge de logging frontend applique également des bornes strictes et refuse les caractères de contrôle avant émission dans la façade Logging.
|
||||||
|
|
||||||
|
## Routes V1
|
||||||
|
|
||||||
|
```text
|
||||||
|
yellowstone-hydrated
|
||||||
|
standard-logs-hydrated
|
||||||
|
standard-block-direct
|
||||||
|
helius-transaction-hydrated
|
||||||
|
http-block-polling
|
||||||
|
```
|
||||||
|
|
||||||
|
Les profils standard publics committés déclarent `Block` en plus de `Logs`; `standard-block-direct` est donc composable depuis Config sur Devnet, Mainnet et Testnet. Cette déclaration ne transforme pas `blockSubscribe` en méthode stable et ne remplace pas la revalidation runtime.
|
||||||
|
|
||||||
|
Une route `Configured` est uniquement **composable depuis Config**. La disponibilité réelle du Store et du Worker est revalidée lors du Start avant publication de l'accusé runtime. Le backend relaie désormais chaque Worker actif par un snapshot latest-value sûr : lifecycle, health, activity, admission/persistence, backpressure, reconnect/replay, continuité, gaps et repair. Les mises à jour sont coalescées côté backend à une cadence bornée de `500 ms` maximum par route, émises via l'événement Tauri `ksp-raw-ingest-route-status` et peuvent être resynchronisées avec `get_route_monitoring`. Les compteurs et slots `u64` sont projetés en texte décimal afin de rester exacts côté JavaScript. Le frontend consomme maintenant ce flux en temps réel et via resynchronisation explicite : chaque route affiche lifecycle/health/activity, persisted/source/gap summaries et ouvre un détail complet pipeline/persistence, reconnect/replay, continuité, gaps et repair. Les événements monitoring mettent aussi à jour les états actifs/terminaux afin que le sélecteur réseau et le Store partagé suivent le lifecycle réellement publié par le Worker. Les snapshots steady-state patchent les champs de carte en place ; la reconstruction des contrôles Start/Stop est réservée aux changements de lifecycle, afin de préserver la réactivité des interactions.
|
||||||
|
|
||||||
|
Pour `yellowstone-hydrated`, la stratégie Mainnet n'effectue plus un `getTransaction` par notification transactionnelle. Le Desk construit un abonnement Yellowstone Block léger ; chaque bloc observé déclenche une reconciliation HTTP `getBlock` Full/Base64 sur le même réseau. Le profil `publicnode_mainnet` expose un pool HTTP logique `default` contenant PublicNode et le RPC public Solana comme fallback de même priorité. Le pool conserve les limites propres à chaque endpoint, son health/cooldown et son round-robin interne. La reconciliation Yellowstone tolère un décalage bref entre le gRPC et les RPC HTTP grâce à un retry borné et interruptible par Stop. Les slots gRPC et hydrations `getBlock` restent bornés ; lorsqu'un consumer est saturé, la queue Transport attend de la capacité et propage la backpressure au stream au lieu de produire un `grpc_backpressure_overflow` local. Après un Stop explicite, un status provider reçu pendant le half-close ne remplace plus la fermeture coopérative ; un status observé pendant une session active reste soumis au reconnect/failure normal. Le chemin protobuf -> RAW direct reste différé tant que la canonicalisation exacte du meta n'est pas prouvée pour toutes les formes supportées.
|
||||||
|
|
||||||
|
La convergence Store de `0.3.15` conserve également une exception volontairement étroite : si un canonique complet existe déjà et qu'une route apporte le même RAW hors `logMessages` avec un marqueur exact `Log truncated` après un préfixe identique, l'observation moins complète est acceptée sans remplacer le canonique ni fault la route. Le sens inverse et toute divergence non prouvée restent des conflits terminaux dans cette version ; la gestion durable de variantes/résolutions appartient à `0.3.16`.
|
||||||
|
|
||||||
|
## Ports de développement
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vite HTTP : 1440
|
||||||
|
Vite WS : 1441
|
||||||
|
```
|
||||||
|
|
||||||
|
Lancement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-raw-transaction-ingest-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le cycle frontend est déclenché par Tauri via ses hooks configurés ; les scripts npm applicatifs ne sont pas lancés directement par l'opérateur.
|
||||||
|
|
||||||
|
Voir [`USAGE.md`](USAGE.md) et [`../../docs/plans/036-V0_3_15_RAW_TRANSACTION_INGEST_DESK_PLAN.md`](../../docs/plans/036-V0_3_15_RAW_TRANSACTION_INGEST_DESK_PLAN.md).
|
||||||
84
crates/ksp-app-raw-transaction-ingest-desk/USAGE.md
Normal file
84
crates/ksp-app-raw-transaction-ingest-desk/USAGE.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!-- file: crates/ksp-app-raw-transaction-ingest-desk/USAGE.md -->
|
||||||
|
<!-- version: 14 -->
|
||||||
|
|
||||||
|
# Utilisation de `ksp-app-raw-transaction-ingest-desk`
|
||||||
|
|
||||||
|
## 1. Lancement
|
||||||
|
|
||||||
|
Depuis la racine du workspace :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-raw-transaction-ingest-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tauri déclenche lui-même les hooks frontend configurés. Les scripts npm applicatifs de développement, contrôle ou build ne sont pas lancés directement par l'opérateur.
|
||||||
|
|
||||||
|
Le runtime prépare les ressources Config nécessaires, charge le composite dédié, initialise Logging puis ouvre le shell desktop.
|
||||||
|
|
||||||
|
## 2. Vue Routes
|
||||||
|
|
||||||
|
La vue Routes charge un inventaire déterministe construit côté Rust depuis le composite Config validé. Le sélecteur présente les réseaux logiques `devnet`, `mainnet` et `testnet`, jamais les providers. Chaque réseau agrège les profils Transport same-network configurés pour ses capabilities. La vue projette, pour chaque route V1 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
route_id
|
||||||
|
famille et label sûrs
|
||||||
|
réseau logique lorsque résolu
|
||||||
|
état Config
|
||||||
|
selectable oui/non
|
||||||
|
raison sûre lorsque la route est indisponible
|
||||||
|
génération d'inventaire
|
||||||
|
```
|
||||||
|
|
||||||
|
`Configured` signifie uniquement que toutes les requirements Config nécessaires à la route sont cohérentes. Les profils `solana-public` standard committés déclarent explicitement les capabilities `Logs` et `Block`; `standard-block-direct` est donc Configured sur les trois réseaux logiques tant que ce socle Config reste valide. L'inventaire seul ne fait aucun probe réseau et n'ouvre aucun Store ni Worker.
|
||||||
|
|
||||||
|
Un refresh reconstruit l'inventaire depuis Config et publie une nouvelle génération. La sélection visuelle d'un réseau ne choisit aucune URL, provider ou ressource physique et ne démarre aucune acquisition. Une capability optionnelle dont le secret n'est pas résolu rend uniquement la route qui en dépend indisponible ; les routes satisfaites par le Transport de base du même réseau restent composables.
|
||||||
|
Une route provider-specific dont aucune source n’est déclarée pour le réseau n’est pas une erreur de configuration : elle est hors inventaire pour ce réseau. Par exemple, Helius Transaction est projetée sur Devnet/Mainnet lorsque `transport.helius` existe, mais pas sur Testnet lorsqu’aucune source Helius Testnet n’est déclarée.
|
||||||
|
|
||||||
|
Les URLs, tokens, metadata secrètes, handles Transport/Store/Worker, URI Store, source keys et payloads RAW restent backend-only.
|
||||||
|
|
||||||
|
Les commandes Start/Stop refusent les champs IPC inconnus et les identifiants logiques non bornés ou contenant des caractères de contrôle. Le logging frontend est lui aussi borné avant l'IPC ; une entrée hostile ne doit jamais être recopiée dans les diagnostics backend.
|
||||||
|
|
||||||
|
### Start/Stop multi-route
|
||||||
|
|
||||||
|
Pour une route `Configured`, choisir `confirmed` ou `finalized` puis utiliser `Start`. Le backend revalide la génération d'inventaire et la composition Config, ouvre le Store correspondant lorsqu'aucun Store compatible n'est encore actif, exige son état `Ready`, construit le Worker exact puis installe son handle avant de renvoyer l'accusé runtime.
|
||||||
|
|
||||||
|
Plusieurs routes peuvent être actives simultanément lorsqu'elles appartiennent au même réseau logique. Chaque route conserve son Worker indépendant et son Stop ciblé. Le Store est partagé entre ces Workers ; le dernier Worker terminal déclenche la fermeture explicite du Store. Une route `Faulted` ne force pas l'arrêt des autres routes. Un Start d'un autre réseau est refusé tant que le Store partagé du réseau courant reste ouvert.
|
||||||
|
|
||||||
|
Un Stop peut être demandé même si le Start ciblé n'a pas encore fini d'ouvrir le Store ou d'installer le Worker : la réservation est alors marquée pour arrêt et le backend attend sa résolution. Lors de la fermeture de la fenêtre principale, le Desk ferme d'abord l'admission Start, demande l'arrêt de toutes les routes actives ou encore en démarrage et attend un cleanup borné avant de quitter. Il n'est donc pas nécessaire d'arrêter manuellement chaque route avant de fermer l'application.
|
||||||
|
|
||||||
|
Le backend expose désormais un flux latest-value par route via l'événement Tauri `ksp-raw-ingest-route-status`. Chaque projection contient uniquement l'identité logique sûre de la route, lifecycle/health/activity, compteurs admission/persistence, backpressure, reconnect/replay, continuité, gaps et repair. La commande `get_route_monitoring` permet une resynchronisation explicite des routes actives et des derniers terminaux retenus dans la session runtime courante. Les séquences, slots et compteurs `u64` sont transmis en texte décimal pour préserver leur exactitude côté JavaScript. Le bouton `Resync monitoring` recharge explicitement les latest values backend ; les événements `ksp-raw-ingest-route-status` actualisent ensuite les cartes sans polling frontend. Le relais backend coalesce les changements à une cadence maximale d'une émission toutes les `500 ms` par route, et les changements steady-state mettent à jour la carte en place sans recréer les boutons Start/Stop. Une carte disposant d'un snapshot montre un résumé sûr (`health`, `activity`, persisted, sources actives, gaps) et le bouton `Supervision` ouvre le détail pipeline/persistence, sources, reconnect/replay, continuité et repair. Les derniers terminaux de la session restent consultables jusqu'à remplacement par un nouveau Start ou changement de session réseau.
|
||||||
|
|
||||||
|
Sur Mainnet, `yellowstone-hydrated` utilise un abonnement Yellowstone Block comme signal de temps réel puis reconcile chaque slot via `getBlock` Full/Base64. Cette stratégie évite l'hydration `getTransaction` unitaire par transaction. Le profil PublicNode peut exposer plusieurs endpoints HTTP sous le même rôle logique ; Transport distribue alors les requêtes selon ses règles de priorité, disponibilité, concurrence et cooldown. La livraison gRPC et les hydrations restent bornées : une saturation locale applique de la backpressure au stream au lieu de dropper les updates ou de fault sur `grpc_backpressure_overflow`. Lors d'un Stop ciblé, un status distant reçu après le half-close local est une fermeture coopérative ; une erreur gRPC observée avant Stop reste une panne source/reconnect normale. La conversion directe du payload Yellowstone vers le RAW canonique reste réservée à une évolution ultérieure tant que la parité complète du meta n'est pas prouvée.
|
||||||
|
|
||||||
|
Lorsque plusieurs routes observent la même transaction, l'entité canonique reste unique et les provenances restent séparées. Le cas `canonique complet + entrant explicitement Log truncated compatible` est accepté sans remplacer le canonique ni passer la route en `Faulted`. Les autres divergences de contenu restent fail-closed en `0.3.15`; les variantes conflictuelles durables et leur résolution via Store Desk sont prévues pour `0.3.16`.
|
||||||
|
|
||||||
|
## 3. Raisons d'indisponibilité
|
||||||
|
|
||||||
|
Les reasons exposées sont des codes applicatifs bornés, par exemple :
|
||||||
|
|
||||||
|
```text
|
||||||
|
profile_unresolved
|
||||||
|
network_mismatch
|
||||||
|
missing_http_get_block
|
||||||
|
missing_http_get_transaction
|
||||||
|
missing_http_block_scan
|
||||||
|
missing_ws_logs_capability
|
||||||
|
missing_ws_block_capability
|
||||||
|
missing_helius_transaction_capability
|
||||||
|
missing_yellowstone_grpc
|
||||||
|
missing_required_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Le texte arbitraire d'une erreur provider ou d'un secret Config n'est jamais projeté dans l'inventaire.
|
||||||
|
|
||||||
|
## 4. Diagnostics
|
||||||
|
|
||||||
|
La vue Diagnostics expose uniquement des informations techniques sûres nécessaires à l'exploitation du Desk, notamment l'identité de version, l'état du shell, la composition Config bootstrap, l'état Logging et la génération d'inventaire.
|
||||||
|
|
||||||
|
Les diagnostics ne doivent pas exposer de credentials, d'URI Store, d'URL d'endpoint, de source key, de handle runtime ou de payload `RawTransaction`.
|
||||||
|
|
||||||
|
## 5. Runtime packagé
|
||||||
|
|
||||||
|
Le build Tauri embarque les documents Config et schemas enregistrés. `ksp-config-lib` prépare ensuite la racine KSP user-writable sans embarquer `.env`.
|
||||||
|
|
||||||
|
Les fichiers Config et les secrets restent administrés par les mécanismes Config KSP ; l'interface ne contourne pas cette ownership.
|
||||||
12
crates/ksp-app-raw-transaction-ingest-desk/build.rs
Normal file
12
crates/ksp-app-raw-transaction-ingest-desk/build.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/build.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Build script for the KSP Raw Transaction Ingest desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for KSP Raw Transaction Ingest Desk",
|
||||||
|
"windows": [
|
||||||
|
"splash",
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"tracing:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- file: crates/ksp-app-raw-transaction-ingest-desk/frontend/fonts/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
|
||||||
|
`DOS_Amazigh.ttf` est repris byte-exact du gabarit Desk KSP existant pour préserver l'identité visuelle commune.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
231
crates/ksp-app-raw-transaction-ingest-desk/frontend/main.html
Normal file
231
crates/ksp-app-raw-transaction-ingest-desk/frontend/main.html
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<!-- file: crates/ksp-app-raw-transaction-ingest-desk/frontend/main.html -->
|
||||||
|
<!-- version: 7 -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<link rel="icon" href="imgs/logo.png">
|
||||||
|
<link rel="stylesheet" href="sass/main.scss">
|
||||||
|
<title>Raw Transaction Ingest Desk — Routes</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<nav class="navbar h-100 py-0 bg-light text-dark">
|
||||||
|
<div class="container-fluid px-4 flex-nowrap">
|
||||||
|
<div class="navbar-brand d-flex align-items-center me-4 flex-nowrap">
|
||||||
|
<img alt="KSP" src="imgs/logo.png" class="app-logo">
|
||||||
|
<span class="ps-2 fs-4 fw-semibold text-primary text-nowrap">Raw Transaction Ingest Desk</span>
|
||||||
|
<span class="mx-2 fs-5 text-body-secondary" aria-hidden="true">—</span>
|
||||||
|
<span id="headerViewTitle" class="fs-5 text-body text-nowrap">Routes</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
|
<span id="headerProfile" class="badge text-bg-light border text-dark">—</span>
|
||||||
|
<span id="headerStore" class="badge text-bg-secondary">Store idle</span>
|
||||||
|
<span id="headerPhase" class="badge text-bg-secondary">inventory</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main container-fluid p-0">
|
||||||
|
<div class="row g-0 h-100 flex-nowrap">
|
||||||
|
<aside class="col-auto app-sidebar border-end bg-body-tertiary">
|
||||||
|
<div class="app-sidebar-scroll h-100" data-simplebar>
|
||||||
|
<nav class="nav nav-pills flex-column gap-1 p-3" aria-label="Navigation Raw Transaction Ingest Desk">
|
||||||
|
<button class="nav-link text-start active" type="button" data-view="routes">
|
||||||
|
<i class="fa-solid fa-route me-2" aria-hidden="true"></i>Routes
|
||||||
|
</button>
|
||||||
|
<button class="nav-link text-start" type="button" data-view="diagnostics">
|
||||||
|
<i class="fa-solid fa-stethoscope me-2" aria-hidden="true"></i>Diagnostics
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section class="col app-content app-scrollable h-100" data-simplebar>
|
||||||
|
<div class="container-fluid px-4 py-4">
|
||||||
|
<div class="card app-shell-card mx-auto shadow-sm border-0">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<section data-view-panel="routes">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Routes live</h1>
|
||||||
|
<p class="text-body-secondary mb-0">Inventaire Config et runtime multi-route avec un Worker indépendant par route et Store partagé same-network.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button id="refreshRouteMonitoring" class="btn btn-outline-secondary btn-sm" type="button">
|
||||||
|
<i class="fa-solid fa-wave-square me-2" aria-hidden="true"></i>Resync monitoring
|
||||||
|
</button>
|
||||||
|
<button id="refreshRouteInventory" class="btn btn-outline-primary btn-sm" type="button">
|
||||||
|
<i class="fa-solid fa-rotate me-2" aria-hidden="true"></i>Refresh inventory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 align-items-end mb-4">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label" for="routeProfile">Réseau logique</label>
|
||||||
|
<select id="routeProfile" class="form-select" aria-label="Réseau Raw Transaction Ingest Desk"></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label" for="routeCommitment">Commitment</label>
|
||||||
|
<select id="routeCommitment" class="form-select" aria-label="Commitment Raw Transaction Ingest Desk">
|
||||||
|
<option value="confirmed" selected>confirmed</option>
|
||||||
|
<option value="finalized">finalized</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<div class="small text-body-secondary">Réseau</div>
|
||||||
|
<div id="routeNetwork" class="fw-semibold app-route-code">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="small text-body-secondary">Génération</div>
|
||||||
|
<div id="routeInventoryGeneration" class="fw-semibold app-route-code">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info" role="status">
|
||||||
|
<strong>Configured</strong> signifie que Config prouve la composabilité. Start revalide Config puis lance un Worker indépendant. Les routes du même réseau partagent le Store ; chaque fault reste isolé à sa route.
|
||||||
|
</div>
|
||||||
|
<div id="routeCommandFeedback" class="alert alert-info" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div class="card border-primary-subtle mb-4">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div class="small text-body-secondary">Runtime multi-route / Store partagé</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-1">
|
||||||
|
<span id="routeRuntimeState" class="badge text-bg-secondary">idle</span>
|
||||||
|
<span id="routeRuntimeIdentity" class="app-route-code small text-body-secondary">aucun Worker actif</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="small text-body-secondary mb-3">Réseau affiché : <span id="routeSelectedProfile" class="app-route-code">—</span></div>
|
||||||
|
<div id="routeFoundation" class="row g-3 app-route-grid" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<section id="routeMonitoringDetail" class="card shadow-sm mt-4 app-monitoring-detail" aria-live="polite" hidden>
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Supervision route</div>
|
||||||
|
<div id="monitorRouteId" class="small text-body-secondary app-route-code">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||||
|
<span id="monitorLifecycle" class="badge text-bg-secondary">—</span>
|
||||||
|
<span id="monitorHealthBadge" class="badge text-bg-secondary">—</span>
|
||||||
|
<span id="monitorActivityBadge" class="badge text-bg-light border text-dark">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-6 col-lg-3"><div class="small text-body-secondary">Network</div><div id="monitorNetwork" class="app-route-code fw-semibold">—</div></div>
|
||||||
|
<div class="col-6 col-lg-3"><div class="small text-body-secondary">Commitment</div><div id="monitorCommitment" class="app-route-code fw-semibold">—</div></div>
|
||||||
|
<div class="col-6 col-lg-3"><div class="small text-body-secondary">Sequence</div><div id="monitorSequence" class="app-route-code fw-semibold">—</div></div>
|
||||||
|
<div class="col-6 col-lg-3"><div class="small text-body-secondary">Fault</div><div id="monitorFault" class="app-route-code fw-semibold">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card h-100 border-light-subtle">
|
||||||
|
<div class="card-header fw-semibold">Pipeline / persistence</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 app-monitoring-list">
|
||||||
|
<dt class="col-7">Health</dt><dd id="monitorHealth" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Activity</dt><dd id="monitorActivity" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Admission queue</dt><dd id="monitorAdmissionQueue" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Persistence in-flight</dt><dd id="monitorPersistence" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Admitted</dt><dd id="monitorAdmittedTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Canonicalized</dt><dd id="monitorCanonicalizedTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Persisted</dt><dd id="monitorPersistedTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">RAW inserted</dt><dd id="monitorEntityInsertedTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">RAW already present</dt><dd id="monitorEntityAlreadyPresentTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">RAW purged skipped</dt><dd id="monitorEntitySkippedPurgedTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Observations inserted</dt><dd id="monitorObservationInsertedTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Observations already present</dt><dd id="monitorObservationAlreadyPresentTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Content conflicts</dt><dd id="monitorContentConflictTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Store failures</dt><dd id="monitorStoreFailureTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Source failures</dt><dd id="monitorSourceFailureTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Backpressure waits</dt><dd id="monitorBackpressureWaitTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Hydration pending</dt><dd id="monitorHydrationPending" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Processing frontier</dt><dd id="monitorProcessingFrontier" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Oldest pending slot</dt><dd id="monitorOldestPending" class="col-5">—</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card h-100 border-light-subtle">
|
||||||
|
<div class="card-header fw-semibold">Sources / continuité / repair</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 app-monitoring-list">
|
||||||
|
<dt class="col-7">Source state</dt><dd id="monitorSourceState" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Source counts</dt><dd id="monitorSourceCounts" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Reconnects</dt><dd id="monitorSourceReconnectTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Replay attempts</dt><dd id="monitorSourceReplayAttemptTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Continuity gaps source</dt><dd id="monitorSourceContinuityGapTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Policy observed</dt><dd id="monitorContinuityPolicyObserved" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Continuity frontier</dt><dd id="monitorContinuityFrontier" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Open gaps</dt><dd id="monitorContinuityHasOpenGaps" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Failed losses reconciled</dt><dd id="monitorFailedSourceLossesReconciled" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Future coverage</dt><dd id="monitorFutureTargetCoverage" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Open / repairing</dt><dd class="col-5"><span id="monitorOpenGapCount">—</span> / <span id="monitorRepairingGapCount">—</span></dd>
|
||||||
|
<dt class="col-7">Repaired / unresolved</dt><dd class="col-5"><span id="monitorRepairedGapTotal">—</span> / <span id="monitorUnresolvedGapTotal">—</span></dd>
|
||||||
|
<dt class="col-7">Oldest open gap</dt><dd id="monitorOldestOpenGap" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Replay repair</dt><dd id="monitorReplayRepairTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Redundant coverage repair</dt><dd id="monitorRedundantCoverageRepairTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">HTTP scan repair</dt><dd id="monitorHttpScanRepairTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Block fetch repair</dt><dd id="monitorRepairBlockFetchTotal" class="col-5">—</dd>
|
||||||
|
<dt class="col-7">Transaction hydration repair</dt><dd id="monitorRepairTransactionHydrationTotal" class="col-5">—</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="fw-semibold mb-2">Gaps courants / récents</div>
|
||||||
|
<div id="monitorGapList" class="list-group app-monitoring-gaps"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-view-panel="diagnostics" hidden>
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Diagnostics</h1>
|
||||||
|
<p class="text-body-secondary mb-0">État sûr du shell Config, Logging et de la génération d'inventaire.</p>
|
||||||
|
</div>
|
||||||
|
<button id="refreshRuntimeStatus" class="btn btn-outline-primary btn-sm" type="button">
|
||||||
|
<i class="fa-solid fa-rotate me-2" aria-hidden="true"></i>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Runtime</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 app-runtime-list">
|
||||||
|
<dt class="col-sm-5">Version</dt><dd id="runtimeVersion" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Phase</dt><dd id="runtimeShellPhase" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Documents Config</dt><dd id="runtimeConfigDocuments" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Profil composite bootstrap</dt><dd id="runtimeCompositeProfile" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Profil Logging</dt><dd id="runtimeLoggingProfile" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Fallback Logging</dt><dd id="runtimeLoggingFallback" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Inventaire default</dt><dd id="runtimeInventoryDefaultProfile" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Génération inventaire</dt><dd id="runtimeInventoryGeneration" class="col-sm-7">—</dd>
|
||||||
|
</dl>
|
||||||
|
<div id="runtimeDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer bg-dark text-light">
|
||||||
|
<div class="container h-100 d-flex align-items-center justify-content-center">
|
||||||
|
<small>© 2026 SASEDEV</small>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="module" src="ts/main.ts" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/sass/_app.scss
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
$app-header-height: 72px;
|
||||||
|
$app-footer-height: 42px;
|
||||||
|
$app-sidebar-width: 250px;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: $app-header-height;
|
||||||
|
z-index: 1020;
|
||||||
|
border-bottom: 2px solid rgba($primary, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto 0 0;
|
||||||
|
height: $app-footer-height;
|
||||||
|
z-index: 1020;
|
||||||
|
border-top: 2px solid rgba($primary, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100vh - $app-header-height - $app-footer-height);
|
||||||
|
margin-top: $app-header-height;
|
||||||
|
margin-bottom: $app-footer-height;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main > .row {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-scrollable {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1320px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
width: $app-sidebar-width;
|
||||||
|
min-width: $app-sidebar-width;
|
||||||
|
max-width: $app-sidebar-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-scroll,
|
||||||
|
.app-content {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar .nav-link {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-runtime-list dd,
|
||||||
|
.app-route-code {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-route-grid {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-route-grid > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-route-card {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 4px solid rgba($primary, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-route-code {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-route-monitoring-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-monitoring-detail {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-monitoring-list dt,
|
||||||
|
.app-monitoring-list dd {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-monitoring-list dd,
|
||||||
|
.app-monitoring-gaps,
|
||||||
|
.app-monitoring-detail .app-route-code {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-monitoring-gaps .list-group-item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-placeholder {
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px dashed var(--bs-border-color);
|
||||||
|
border-radius: var(--bs-border-radius-lg);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-placeholder i {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.app-sidebar {
|
||||||
|
width: 190px;
|
||||||
|
min-width: 190px;
|
||||||
|
max-width: 190px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/sass/_bootswatch.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
// Pulse 5.3.8
|
||||||
|
// Bootswatch
|
||||||
|
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:active,
|
||||||
|
&:active:focus,
|
||||||
|
&.active:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-secondary {
|
||||||
|
color: $gray-900;
|
||||||
|
background-color: $white;
|
||||||
|
border-color: #ccc;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gray-900;
|
||||||
|
background-color: $gray-300;
|
||||||
|
border-color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: tint-color($gray-900, 5%);
|
||||||
|
background-color: $white;
|
||||||
|
border-color: tint-color(#ccc, 5%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-warning {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-primary:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($primary, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-secondary:focus {
|
||||||
|
box-shadow: 0 0 5px $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-success:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($success, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-info:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($info, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-warning:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($warning, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-danger:focus {
|
||||||
|
box-shadow: 0 0 5px tint-color($danger, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
|
||||||
|
.table .thead-dark th {
|
||||||
|
background-color: $secondary;
|
||||||
|
border-color: $table-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-primary,
|
||||||
|
.table-secondary,
|
||||||
|
.table-success,
|
||||||
|
.table-warning,
|
||||||
|
.table-danger,
|
||||||
|
.table-info,
|
||||||
|
.table-light {
|
||||||
|
--#{$prefix}table-color: #{$body-color};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
box-shadow: 0 0 5px rgba(100, 65, 164, .4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navs
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
|
||||||
|
.nav-link,
|
||||||
|
.nav-link.active {
|
||||||
|
border-width: 0 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.active,
|
||||||
|
.nav-link.active:hover,
|
||||||
|
.nav-link.active:focus {
|
||||||
|
border-bottom: 1px solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item+.nav-item {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
&-item.active {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indicators
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
&.bg-light {
|
||||||
|
color: $dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bars
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containers
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
&-item {
|
||||||
|
color: rgba(255, 255, 255, .8);
|
||||||
|
|
||||||
|
&.active,
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $list-group-hover-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled:hover {
|
||||||
|
color: $list-group-disabled-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/sass/_fontawesome.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||||
|
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
|
||||||
|
// $font-path: '../webfonts',
|
||||||
|
//);
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||||
|
// use fonts from @fortawesome/fontawesome-free
|
||||||
|
$font-path: '@fortawesome/fontawesome-free/webfonts',
|
||||||
|
);
|
||||||
|
// load Font Awesome core
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/fontawesome';
|
||||||
|
|
||||||
|
// load and make available Font Awesome helpers (mixins, functions, and variables)
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
||||||
|
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/sass/_simplebar.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/* Rtl support */
|
||||||
|
[data-simplebar] {
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-wrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
max-width: inherit;
|
||||||
|
max-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-mask {
|
||||||
|
direction: inherit;
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
// z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-offset {
|
||||||
|
direction: inherit !important;
|
||||||
|
box-sizing: inherit !important;
|
||||||
|
resize: none !important;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-content-wrapper {
|
||||||
|
direction: inherit;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-hide-scrollbar {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
&:before {
|
||||||
|
content: ' ';
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: ' ';
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-placeholder {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-height-auto-observer-wrapper {
|
||||||
|
box-sizing: inherit !important;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1px;
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
max-height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
// z-index: -1;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
flex-grow: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-height-auto-observer {
|
||||||
|
box-sizing: inherit;
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 1000%;
|
||||||
|
width: 1000%;
|
||||||
|
min-height: 1px;
|
||||||
|
min-width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
// z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track {
|
||||||
|
// z-index: 1;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-simplebar].simplebar-dragging {
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
min-height: 10px;
|
||||||
|
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
background: black;
|
||||||
|
border-radius: 7px;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s 0.5s linear;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar.simplebar-visible {
|
||||||
|
&:before {
|
||||||
|
opacity: 0.5;
|
||||||
|
transition-delay: 0s;
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
top: 0;
|
||||||
|
width: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-horizontal {
|
||||||
|
left: 0;
|
||||||
|
height: 11px;
|
||||||
|
|
||||||
|
.simplebar-scrollbar {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 10px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-simplebar-direction='rtl'] {
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-dummy-scrollbar-size {
|
||||||
|
direction: rtl;
|
||||||
|
position: fixed;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
height: 500px;
|
||||||
|
width: 500px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overflow-x: scroll;
|
||||||
|
-ms-overflow-style: scrollbar !important;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/sass/_variables.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
// Pulse 5.3.8
|
||||||
|
// Bootswatch
|
||||||
|
|
||||||
|
$theme: "pulse" !default;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Color system
|
||||||
|
//
|
||||||
|
|
||||||
|
$white: #fff !default;
|
||||||
|
$gray-100: #fafafa !default;
|
||||||
|
$gray-200: #f9f8fc !default;
|
||||||
|
$gray-300: #ededed !default;
|
||||||
|
$gray-400: #cbc8d0 !default;
|
||||||
|
$gray-500: #adb5bd !default;
|
||||||
|
$gray-600: #868e96 !default;
|
||||||
|
$gray-700: #444 !default;
|
||||||
|
$gray-800: #343a40 !default;
|
||||||
|
$gray-900: #17141f !default;
|
||||||
|
$black: #000 !default;
|
||||||
|
|
||||||
|
$blue: #007bff !default;
|
||||||
|
$indigo: #6610f2 !default;
|
||||||
|
$purple: #593196 !default;
|
||||||
|
$pink: #e83e8c !default;
|
||||||
|
$red: #fc3939 !default;
|
||||||
|
$orange: #fd7e14 !default;
|
||||||
|
$yellow: #efa31d !default;
|
||||||
|
$green: #13b955 !default;
|
||||||
|
$teal: #20c997 !default;
|
||||||
|
$cyan: #009cdc !default;
|
||||||
|
|
||||||
|
$primary: $purple !default;
|
||||||
|
$secondary: #a991d4 !default;
|
||||||
|
$success: $green !default;
|
||||||
|
$info: $cyan !default;
|
||||||
|
$warning: $yellow !default;
|
||||||
|
$danger: $red !default;
|
||||||
|
$light: $gray-200 !default;
|
||||||
|
$dark: $gray-900 !default;
|
||||||
|
|
||||||
|
$min-contrast-ratio: 2.1 !default;
|
||||||
|
|
||||||
|
// Options
|
||||||
|
|
||||||
|
$enable-rounded: false !default;
|
||||||
|
|
||||||
|
// Body
|
||||||
|
|
||||||
|
$body-color: $gray-700 !default;
|
||||||
|
|
||||||
|
// Links
|
||||||
|
|
||||||
|
$link-hover-color: $primary !default;
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
|
||||||
|
$table-color: initial !default;
|
||||||
|
|
||||||
|
$table-border-color: rgba(0, 0, 0, .05) !default;
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
|
||||||
|
$input-focus-border-color: $primary !default;
|
||||||
|
|
||||||
|
// Dropdowns
|
||||||
|
|
||||||
|
$dropdown-link-hover-color: $white !default;
|
||||||
|
$dropdown-link-hover-bg: $primary !default;
|
||||||
|
|
||||||
|
// Navs
|
||||||
|
|
||||||
|
$nav-tabs-border-color: $gray-300 !default;
|
||||||
|
$nav-tabs-link-hover-border-color: $primary !default;
|
||||||
|
|
||||||
|
// Navbar
|
||||||
|
|
||||||
|
$navbar-padding-y: 1.2rem !default;
|
||||||
|
|
||||||
|
// Progress bars
|
||||||
|
|
||||||
|
$progress-bg: $gray-300 !default;
|
||||||
|
$progress-bar-bg: $primary !default;
|
||||||
|
|
||||||
|
// List group
|
||||||
|
|
||||||
|
$list-group-bg: $gray-900 !default;
|
||||||
|
$list-group-border-color: transparent !default;
|
||||||
|
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
||||||
|
$list-group-active-color: $white !default;
|
||||||
|
$list-group-active-bg: $list-group-bg !default;
|
||||||
|
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/sass/main.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
@import "bootstrap/scss/functions";
|
||||||
|
@import "variables";
|
||||||
|
@import "fontawesome";
|
||||||
|
@import "simplebar";
|
||||||
|
@import "bootstrap/scss/bootstrap";
|
||||||
|
@import "bootswatch";
|
||||||
|
@import "app";
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/sass/splash.scss
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Dos Amazigh";
|
||||||
|
src: url("../fonts/DOS_Amazigh.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splash-container {
|
||||||
|
position: relative;
|
||||||
|
width: 960px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 637px;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
will-change: opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splash-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-name {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #fff;
|
||||||
|
font-family: "Dos Amazigh", sans-serif;
|
||||||
|
font-size: 76px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#debug-info {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 30%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messages-container {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
max-height: 30%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 10px 36px;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#debug-info::-webkit-scrollbar,
|
||||||
|
#messages-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-message {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
animation: splashMessageFadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-message.info { background: rgba(33, 150, 243, 0.22); }
|
||||||
|
.splash-message.warning { background: rgba(255, 152, 0, 0.22); }
|
||||||
|
.splash-message.error { background: rgba(244, 67, 54, 0.22); }
|
||||||
|
.splash-message.success { background: rgba(76, 175, 80, 0.22); }
|
||||||
|
|
||||||
|
@keyframes splashMessageFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- file: crates/ksp-app-raw-transaction-ingest-desk/frontend/splash.html -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<link rel="icon" href="imgs/logo.png">
|
||||||
|
<link rel="stylesheet" href="sass/splash.scss">
|
||||||
|
<title>KSP Raw Transaction Ingest Desk — Chargement</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="splash-container" style="opacity: 0;">
|
||||||
|
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Raw Transaction Ingest Desk">
|
||||||
|
<div id="app-name">RAW Ingest</div>
|
||||||
|
<div id="debug-info" aria-live="off" hidden></div>
|
||||||
|
<div id="messages-container" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="ts/splash.ts" defer></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/ts/frontend_log.ts
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { FrontendLogPayloadDto } from "./bindings/ksp_app_raw_transaction_ingest_desk/frontend_logging/FrontendLogPayloadDto.ts";
|
||||||
|
|
||||||
|
export type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error";
|
||||||
|
export type FrontendLogTargetId = "frontend" | "main" | "splash";
|
||||||
|
|
||||||
|
type ConsoleMethod = (...items: unknown[]) => void;
|
||||||
|
|
||||||
|
const FRONTEND_LOG_MESSAGE_MAX_CODE_UNITS = 1024;
|
||||||
|
|
||||||
|
const originalConsole = {
|
||||||
|
trace: console.trace.bind(console),
|
||||||
|
debug: console.debug.bind(console),
|
||||||
|
log: console.log.bind(console),
|
||||||
|
info: console.info.bind(console),
|
||||||
|
warn: console.warn.bind(console),
|
||||||
|
error: console.error.bind(console),
|
||||||
|
};
|
||||||
|
|
||||||
|
function stringifyItem(item: unknown): string {
|
||||||
|
if (item instanceof Error) {
|
||||||
|
return item.stack ?? item.message;
|
||||||
|
}
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(item);
|
||||||
|
return serialized ?? String(item);
|
||||||
|
} catch {
|
||||||
|
return String(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(items: unknown[]): string {
|
||||||
|
return items.map(item => stringifyItem(item)).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundMessage(message: string): string {
|
||||||
|
return message.length <= FRONTEND_LOG_MESSAGE_MAX_CODE_UNITS ? message : `${message.slice(0, FRONTEND_LOG_MESSAGE_MAX_CODE_UNITS)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
|
||||||
|
const payload: FrontendLogPayloadDto = {
|
||||||
|
level,
|
||||||
|
targetId,
|
||||||
|
message: boundMessage(message),
|
||||||
|
};
|
||||||
|
await invoke("emit_frontend_log", { payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeOriginalConsole(level: FrontendLogLevel, message: string): void {
|
||||||
|
return level === "trace"
|
||||||
|
? originalConsole.trace(message)
|
||||||
|
: level === "debug"
|
||||||
|
? originalConsole.debug(message)
|
||||||
|
: level === "info"
|
||||||
|
? originalConsole.info(message)
|
||||||
|
: level === "warn"
|
||||||
|
? originalConsole.warn(message)
|
||||||
|
: originalConsole.error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
|
||||||
|
writeOriginalConsole(level, message);
|
||||||
|
await sendFrontendLog(level, targetId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendTrace(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.trace(message);
|
||||||
|
void sendFrontendLog("trace", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendDebug(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.debug(message);
|
||||||
|
void sendFrontendLog("debug", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendInfo(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.info(message);
|
||||||
|
void sendFrontendLog("info", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendWarn(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.warn(message);
|
||||||
|
void sendFrontendLog("warn", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendError(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||||
|
const message = formatMessage(items);
|
||||||
|
originalConsole.error(message);
|
||||||
|
void sendFrontendLog("error", targetId, message).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConsoleBridge(level: FrontendLogLevel, targetId: FrontendLogTargetId, original: ConsoleMethod): ConsoleMethod {
|
||||||
|
return (...items: unknown[]) => {
|
||||||
|
original(...items);
|
||||||
|
void sendFrontendLog(level, targetId, formatMessage(items)).catch(caughtError => originalConsole.error("KSP frontend logging bridge failed", caughtError));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installFrontendConsoleBridge(targetId: FrontendLogTargetId): void {
|
||||||
|
console.trace = buildConsoleBridge("trace", targetId, originalConsole.trace);
|
||||||
|
console.debug = buildConsoleBridge("debug", targetId, originalConsole.debug);
|
||||||
|
console.log = buildConsoleBridge("info", targetId, originalConsole.log);
|
||||||
|
console.info = buildConsoleBridge("info", targetId, originalConsole.info);
|
||||||
|
console.warn = buildConsoleBridge("warn", targetId, originalConsole.warn);
|
||||||
|
console.error = buildConsoleBridge("error", targetId, originalConsole.error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// file: crates/ksp-app-raw-transaction-ingest-desk/frontend/ts/invoke.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { frontendDebug, frontendError, frontendTrace, type FrontendLogTargetId } from "./frontend_log";
|
||||||
|
|
||||||
|
export async function invokeKsp<T>(targetId: FrontendLogTargetId, command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
frontendDebug(targetId, "Frontend IPC command requested", { command });
|
||||||
|
try {
|
||||||
|
const result = await invoke<T>(command, args);
|
||||||
|
frontendTrace(targetId, "Frontend IPC command completed", { command });
|
||||||
|
return result;
|
||||||
|
} catch (caughtError) {
|
||||||
|
frontendError(targetId, "Frontend IPC command failed", { command });
|
||||||
|
throw caughtError;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user