Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 31461cf9be | |||
| 8c8b7ee986 | |||
| 5bfe6f0820 | |||
| 8c4e835fdd | |||
| 695d1080bd | |||
| e774a065b4 | |||
| 29f27ae109 | |||
| 0ee28eeb95 | |||
| 900444bff5 | |||
| 99477d0d2b | |||
| 9148920f42 | |||
| e42688794a | |||
| c73e4c0aec | |||
| e701267135 | |||
| 4f71dbc62d | |||
| ca6af3d904 | |||
| 233aaf9bd8 | |||
| d3bb026345 | |||
| d363991f78 | |||
| f0493df09e | |||
| fe46cbe3bd | |||
| f1e365ec97 | |||
| 2042482592 | |||
| 7d0fe8ac5f | |||
| a00e485ebb | |||
| 9d351dd1c3 | |||
| 5ac2197b6e | |||
| c5a70a2eff | |||
| e7c3379f44 | |||
| afc50c1e48 | |||
| 5a165296d9 | |||
| 0d01017c31 | |||
| d1d683d101 | |||
| f7e4ea09bc | |||
| 9a7eda1aa6 | |||
| db1f8d4a53 | |||
| 9852613426 | |||
| 81eb295a09 | |||
| b5a1293953 | |||
| 251f9f9ad8 | |||
| 98708df6e0 | |||
| 2db3c4be78 | |||
| 69294a153f | |||
| 2fdff17651 | |||
| 87d7314bf4 | |||
| 5aeff5ca14 | |||
| 98093d859b | |||
| 4021d08541 | |||
| 5c97a772be | |||
| 60afb51451 | |||
| f4413ebbb0 | |||
| be2a06bc80 | |||
| 2dbddfe367 | |||
| 126e8abfcd | |||
| 3fd540a9db | |||
| 4f92fb03f1 | |||
| f42fa8f4f1 | |||
| a8e980b225 |
31
.env.example
31
.env.example
@@ -1,10 +1,19 @@
|
|||||||
# file: .env.example
|
# file: .env.example
|
||||||
# version: 9
|
# version: 13
|
||||||
|
|
||||||
# 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
|
||||||
@@ -50,6 +59,26 @@ KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
|
|||||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
||||||
|
|
||||||
|
# Birdeye Standard API key used by the optional `all_free` profile in config/std.offchain_transport.json.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_BIRDEYE_API_KEY=replace-me
|
||||||
|
|
||||||
|
# CoinGecko Demo API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_COINGECKO_DEMO_API_KEY=replace-me
|
||||||
|
|
||||||
|
# CoinMarketCap Basic API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_COINMARKETCAP_API_KEY=replace-me
|
||||||
|
|
||||||
|
# Public Solana pair address used by DexScreener for explicit SOL/USD pair lookup in the optional `all_free` profile.
|
||||||
|
# This value is intentionally public and must identify the exact pair chosen by the operator.
|
||||||
|
# KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS=replace-with-solana-pair-pubkey
|
||||||
|
|
||||||
|
# Jupiter Free API key used by the optional `all_free` Off-chain Transport profile.
|
||||||
|
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||||
|
# KSP_SECRET_JUPITER_API_KEY=replace-me
|
||||||
|
|
||||||
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||||
KSP_DESK_SPLASH_FADE_IN_MS=300
|
KSP_DESK_SPLASH_FADE_IN_MS=300
|
||||||
|
|
||||||
|
|||||||
124
CHANGELOG.md
124
CHANGELOG.md
@@ -1,8 +1,130 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 14 -->
|
<!-- version: 27 -->
|
||||||
|
|
||||||
# Changelog KSP
|
# Changelog KSP
|
||||||
|
|
||||||
|
## 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` 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`.
|
||||||
|
|
||||||
|
La dependency direction finale reste strictement `ksp-interface-lib -> ksp-core-lib -> solana-pubkey`. Aucun `serde`, codec générique (`borsh`, `wincode`, `bincode`), `solana-instruction`, runtime réseau ou logging n’est ajouté à cette foundation. Les canaris public API, consumer externe, dependency firewall et release completeness verrouillent les six exports crate-root, les quatre modules de production, l’absence de narrowing/codec caché, le transfert des `Vec` admis sans réallocation interne et la non-répétition de matériel hostile dans les erreurs. `pre.005-fix.001` corrige uniquement un faux positif du scanner Logging provoqué par le littéral de test `tracing::`, sans changement de production ni de dépendance. Les gates `pre.006` et `pre.007` passent ensuite audits Rust/Markdown, `cargo check`, Clippy, tests Interface, ownership Logging, workspace complet et graphes Cargo avant la réconciliation documentaire finale.
|
||||||
|
|
||||||
|
`prompts/019-V0_2_14_START_PROMPT.md` ouvre `0.2.14 — Program API foundation` exclusivement depuis le tag stable `v0.2.13`. Le gate `pre.001` de cette prochaine release exige aussi l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` afin d’auditer les anciens contrats decoder/executor sous une matrice `REPRENDRE / REDESSINER / REPORTER / REJETER`, sans faire de kbot3 une autorité architecturale ni recréer son monolithe `ks-lib`.
|
||||||
|
|
||||||
|
## 0.2.12 — SOL Prices Desk + projection prix Wallet — 2026-08-27
|
||||||
|
|
||||||
|
`0.2.12` ajoute `ksp-app-solprices-desk`, troisième Desk Tauri spécialisé de KSP, comme HID strictement provider-neutral au-dessus de `ksp-offchain-transport-lib`. Son composite dédié sélectionne `std.logging` et `std.offchain_transport`; la vue Prices expose l’inventaire des huit providers SOL/USD, disponibilité, sémantique, auth, prix exact sous forme de chaîne, timestamps provider/KSP distincts et retry deadline sans inventer de fraîcheur. Les refresh manuel individuel, sélectionné et global passent exclusivement par `MarketPriceService`; les batchs sont bornés à `1..=64`, les états `in_flight` restent backend-owned et aucun polling, scheduler, fallback, moyenne ou prix canonique n’est introduit dans le Desk. Le frontend conserve l’absence de réseau/persistence navigateur et journalise les interactions sans prix, credential, URL, header ou payload provider.
|
||||||
|
|
||||||
|
La même release étend `ksp-app-wallet-desk` de façon volontairement minimale. Le refresh de balance lance `getBalance` et le refresh SOL/USD générique, puis calcule côté Rust une moyenne arithmétique uniquement à partir des observations réussies du refresh courant et l’équivalent USD exact du solde, sans `f64`/`f32`. Le prix reste auxiliaire : une indisponibilité Off-chain n’invalide jamais une balance Solana réussie et produit simplement `N.A.`. Le Wallet ne connaît aucun provider concret, n’expose aucune commande Market Price dédiée au frontend et n’ajoute aucune valorisation persistée ou logique de consensus.
|
||||||
|
|
||||||
|
Le hardening final ajoute une garde IPC hostile avant parsing pour les sélections de plus de 64 providers, un smoke live de composition `SOL Prices composite -> Config -> MarketPriceService -> refresh_all`, les canaris de frontières et les builds Tauri Linux. `pre.009-fix.001` corrige en outre la politique de version desktop : la version KSP/bundle est Cargo-owned, `tauri.conf.json` omet désormais `version` et les `package.json` privés ne sont plus forcés à suivre chaque bump workspace. Le gate opérateur passe audits Rust/Markdown, `cargo check`, Clippy, tests ciblés, `cargo test --workspace`, smoke live, puis builds `.deb`/`.rpm`/`.AppImage` pour SOL Prices, Wallet et Config Desk.
|
||||||
|
|
||||||
|
La clôture documentaire réconcilie README/USAGE, architectures, plan et validation, puis prépare `prompts/018-V0_2_13_START_PROMPT.md`. Ce prompt ouvre `0.2.13 — Interface foundation` exclusivement depuis le tag stable `v0.2.12`. Une reconnaissance préalable de l’archive historique `khadhroony-bot3` fournit des idées de contrats wire et d’extension, mais `0.2.13-pre.001` doit les réauditer contre l’architecture KSP actuelle avant toute implémentation : `ksp-interface-lib` porte les données/wires publics, `ksp-program-api` reste réservé au comportement extensible de `0.2.14`, et les implémentations officielles `ksp-program-lib` arrivent plus tard.
|
||||||
|
|
||||||
|
## 0.2.11 — Off-chain price transport — 2026-08-26
|
||||||
|
|
||||||
|
`0.2.11` crée `ksp-offchain-transport-lib` avec une première surface volontairement bornée à **SOL/USD** et huit adapters REST V1 : CoinGecko, CoinMarketCap, CoinPaprika, Kraken, Coinbase Exchange, Jupiter Price V3, Birdeye et DexScreener. Les adapters utilisent `reqwest` directement, sans SDK provider, avec origines HTTPS possédées par la crate, DTOs wire privés et prix normalisés par `MarketPriceDecimal` sans passage canonique par `f64`. `MarketPriceObservation` conserve paire, sémantique, provenance et timestamps réellement disponibles afin de ne pas confondre prix agrégé, dernier trade d'exchange, heuristique Solana, spot provider et prix DEX pair. DexScreener exige une paire Solana explicite et ne réalise aucune discovery ou sélection automatique de pool.
|
||||||
|
|
||||||
|
La crate possède désormais le registry provider-neutral, les descriptors/capabilities, les limites et quotas informatifs, les états `MarketPriceProviderAvailability`, le cooldown et le service générique `MarketPriceService`. Les consumers disposent de `registry`, `refresh`, `refresh_many` et `refresh_all`; le batch V1 reste séquentiel et déterministe, ne dort jamais pour attendre un rate limit et n'introduit ni fallback, ni consensus, ni moyenne KSP. Les erreurs provider sont normalisées en états génériques et les credentials/endpoints/raw bodies restent absents des projections et diagnostics publics.
|
||||||
|
|
||||||
|
Config ajoute `std.offchain_transport` V1, son schema, les profils `public_keyless` et `all_free`, ainsi que `ResolvedOffchainTransportConfig` qui construit le service sans dépendance inverse. Les credentials CoinGecko/CoinMarketCap/Jupiter/Birdeye restent sous `KSP_SECRET_*`; la paire DexScreener utilise une provenance `KSP_PUBLIC_*`. Les deux applications desktop existantes embarquent les nouvelles resources Config sans encore consommer fonctionnellement le prix. Les canaris de sécurité verrouillent notamment l'absence d'URL/rate-limit provider configurable au-dessus de Config, de lecture directe d'environnement dans Off-chain Transport et de fuite de clés par `Debug`.
|
||||||
|
|
||||||
|
Le gate technique final a passé les audits Rust/Markdown, `cargo check`, Clippy, les tests ciblés Config/Off-chain, le workspace complet et les graphes Cargo. Le premier smoke live keyless a isolé une erreur de query CoinMarketCap (`ids=5426` au lieu de `id=5426`); `pre.011-fix.001` la corrige pour les modes Keyless et Basic, puis le re-smoke passe **7/7** providers keyless. Le smoke keyed Birdeye/CoinGecko Demo/CoinMarketCap Basic/Jupiter Free reste explicitement `SKIP opérateur`. `prompts/017-V0_2_12_START_PROMPT.md` ouvre ensuite `0.2.12 — SOL Prices Desk + intégration prix Wallet Desk` uniquement depuis le tag stable `v0.2.11`, avec audit/sizing desktop strict avant toute implémentation UI lourde.
|
||||||
|
|
||||||
## 0.2.10 — OrbitFlare Yellowstone gRPC — 2026-08-25
|
## 0.2.10 — OrbitFlare Yellowstone gRPC — 2026-08-25
|
||||||
|
|
||||||
`0.2.10` ajoute OrbitFlare comme provider Yellowstone gRPC sur le moteur N1 et le standard N2 acquis dans `0.2.9`, sans modifier le moteur gRPC ni créer de façade provider inutile. L'audit et le smoke live ont établi le contrat réel du service Solana Free Devnet : endpoint `http://devnet.rpc.orbitflare.com:10000`, authentification par metadata gRPC secrète `x-token` portant la License Key OrbitFlare, et émission du Ping Yellowstone standard. Config Transport V3 représente ce provider avec `provider = orbitflare`, `cluster = devnet`, `protocol = solana_yellowstone` et `secret_metadata`, tandis que `.env.example` inventorie `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` sans valeur réelle.
|
`0.2.10` ajoute OrbitFlare comme provider Yellowstone gRPC sur le moteur N1 et le standard N2 acquis dans `0.2.9`, sans modifier le moteur gRPC ni créer de façade provider inutile. L'audit et le smoke live ont établi le contrat réel du service Solana Free Devnet : endpoint `http://devnet.rpc.orbitflare.com:10000`, authentification par metadata gRPC secrète `x-token` portant la License Key OrbitFlare, et émission du Ping Yellowstone standard. Config Transport V3 représente ce provider avec `provider = orbitflare`, `cluster = devnet`, `protocol = solana_yellowstone` et `secret_metadata`, tandis que `.env.example` inventorie `KSP_SECRET_ORBITFLARE_DEVNET_GRPC_X_TOKEN` sans valeur réelle.
|
||||||
|
|||||||
16
Cargo.toml
16
Cargo.toml
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 268
|
# version: 444
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
members = ["crates/ksp-app-backfill-desk", "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-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-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.10"
|
version = "0.3.7"
|
||||||
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.52", 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,6 +43,8 @@ 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 }
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: README.md -->
|
<!-- file: README.md -->
|
||||||
<!-- version: 7 -->
|
<!-- version: 9 -->
|
||||||
|
|
||||||
# Khadhroony Solana Project
|
# Khadhroony Solana Project
|
||||||
|
|
||||||
@@ -47,6 +47,14 @@ 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.
|
||||||
|
|
||||||
|
Ces contrats restent distincts des futurs workers continus : un job borné n'est ni un service worker ni un pipeline générique imposé aux autres couches.
|
||||||
|
|
||||||
## 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 ;
|
||||||
|
|||||||
78
ROADMAP.md
78
ROADMAP.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 87 -->
|
<!-- version: 102 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -55,10 +55,10 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
|
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
|
||||||
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes d’update, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
|
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes d’update, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
|
||||||
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
|
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
|
||||||
- [ ] `0.2.11` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix provider-neutral, au minimum SOL/USD et SOL/EUR, après audit actuel des sémantiques provider et de la testabilité live.
|
- [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.
|
||||||
- [ ] `0.2.12` — Introduire une petite application desk de visualisation/validation des prix offchain, puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation possédée par le composant spécialisé.
|
- [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.
|
||||||
- [ ] `0.2.13` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
- [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.
|
- [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,63 @@ 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 N1, 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 N1 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é.
|
||||||
|
- [ ] `0.3.8` — Introduire `ksp-app-store-desk` V1 sur le **gabarit KSP courant** (shell/splash/styles/assets/logging et dépendances npm de base des Desk KSP), avec DataTables selon le pattern déjà utilisé par Config/Wallet Desk. La V1 reste backend-agnostique et consulte le Store uniquement via `ksp-store-lib` + Config : health/runtime sûr, tableaux `RawTransaction` et `RawAccountState`, observations lorsque la surface de listing backend-neutral est disponible/ajoutée proprement, rétention/tombstones en lecture et pagination Store opaque distincte du filtrage/paging local DataTables. kbot3 sert uniquement de référence fonctionnelle/UX des anciens tableaux RAW, jamais de gabarit, source de code, SQL, DTO, commandes ou versions npm. L’application évoluera ensuite avec STRUCTURAL, DECODED, processing/materialization et DOMAIN réellement persistés.
|
||||||
|
- [ ] `0.3.9` — Introduire `ksp-worker-api` comme API générique de lifecycle/health/progression pour services continus, en reprenant le pattern latest-value stabilisé par Job tout en gardant les sémantiques Worker distinctes des jobs terminables et des wake-ups Store post-commit.
|
||||||
|
- [ ] `0.3.10` — Introduire `ksp-worker-raw-transaction-ingest-lib` pour l'acquisition continue de `RawTransaction` via les surfaces live de `ksp-onchain-transport-lib`, persistance atomique par `ksp-store-lib`, reprise/backpressure/idempotence et notifications `ksp-worker-api`, sans decode Program ni dépendance backend/provider directe.
|
||||||
|
- [ ] `0.3.11` — Introduire `ksp-app-raw-transaction-ingest-desk`, application Tauri spécialisée de contrôle et monitoring du worker live : lifecycle, health, rates, backpressure, reconnect/recovery et compteurs sûrs ; la consultation détaillée des données persistées reste la responsabilité de `ksp-app-store-desk`.
|
||||||
|
|
||||||
## 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'un niveau N3/D3 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 N1, processing et rétention
|
||||||
|
|
||||||
|
- [ ] **TODO** — maintenir la matrice d’admission HTTP/WS/gRPC/provider lors de toute nouvelle famille N1 : plusieurs sources ne convergent vers un même struct que si elles satisfont la même sémantique sans perte.
|
||||||
|
- [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 N2/N3 ; 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 N1 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 +149,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 +173,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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
51
config/composite.ksp-app-solprices-desk.json
Normal file
51
config/composite.ksp-app-solprices-desk.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "public_keyless",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "public_keyless",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "console_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "public_keyless"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "all_free",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "console_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "all_free"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "tests",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "console_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "public_keyless"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@
|
|||||||
"file_id": "cfg.std.logging",
|
"file_id": "cfg.std.logging",
|
||||||
"profile_id": "supertrace"
|
"profile_id": "supertrace"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "public_keyless"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component_id": "transport",
|
"component_id": "transport",
|
||||||
"file_id": "cfg.std.transport",
|
"file_id": "cfg.std.transport",
|
||||||
@@ -30,6 +35,11 @@
|
|||||||
"file_id": "cfg.std.logging",
|
"file_id": "cfg.std.logging",
|
||||||
"profile_id": "supertrace"
|
"profile_id": "supertrace"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "public_keyless"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component_id": "transport",
|
"component_id": "transport",
|
||||||
"file_id": "cfg.std.transport",
|
"file_id": "cfg.std.transport",
|
||||||
@@ -50,6 +60,11 @@
|
|||||||
"file_id": "cfg.std.logging",
|
"file_id": "cfg.std.logging",
|
||||||
"profile_id": "supertrace"
|
"profile_id": "supertrace"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "public_keyless"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component_id": "transport",
|
"component_id": "transport",
|
||||||
"file_id": "cfg.std.transport",
|
"file_id": "cfg.std.transport",
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
"file_id": "cfg.std.logging",
|
"file_id": "cfg.std.logging",
|
||||||
"profile_id": "supertrace"
|
"profile_id": "supertrace"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "public_keyless"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component_id": "transport",
|
"component_id": "transport",
|
||||||
"file_id": "cfg.std.transport",
|
"file_id": "cfg.std.transport",
|
||||||
@@ -30,6 +35,11 @@
|
|||||||
"file_id": "cfg.std.logging",
|
"file_id": "cfg.std.logging",
|
||||||
"profile_id": "supertrace"
|
"profile_id": "supertrace"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "public_keyless"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component_id": "transport",
|
"component_id": "transport",
|
||||||
"file_id": "cfg.std.transport",
|
"file_id": "cfg.std.transport",
|
||||||
@@ -50,6 +60,11 @@
|
|||||||
"file_id": "cfg.std.logging",
|
"file_id": "cfg.std.logging",
|
||||||
"profile_id": "supertrace"
|
"profile_id": "supertrace"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component_id": "offchain_transport",
|
||||||
|
"file_id": "cfg.std.offchain_transport",
|
||||||
|
"profile_id": "public_keyless"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component_id": "transport",
|
"component_id": "transport",
|
||||||
"file_id": "cfg.std.transport",
|
"file_id": "cfg.std.transport",
|
||||||
|
|||||||
43
config/examples/std.offchain_transport.example.json
Normal file
43
config/examples/std.offchain_transport.example.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "custom_free",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "custom_free",
|
||||||
|
"market_price": {
|
||||||
|
"birdeye": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinbase_exchange": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "demo",
|
||||||
|
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "basic",
|
||||||
|
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinpaprika": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"enabled": true,
|
||||||
|
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "free",
|
||||||
|
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
|
||||||
|
},
|
||||||
|
"kraken": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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-beta",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
366
config/schemas/std.offchain_transport.schema.json
Normal file
366
config/schemas/std.offchain_transport.schema.json
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:ksp:schema:std.offchain_transport:v1",
|
||||||
|
"title": "KSP standard Off-chain Transport configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"profileId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"enabledOnly": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"enum": [
|
||||||
|
"demo",
|
||||||
|
"keyless"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"access_mode": {
|
||||||
|
"const": "keyless"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"const": "demo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"enum": [
|
||||||
|
"basic",
|
||||||
|
"keyless"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"access_mode": {
|
||||||
|
"const": "keyless"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"const": "basic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"enum": [
|
||||||
|
"free",
|
||||||
|
"keyless"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"access_mode": {
|
||||||
|
"const": "keyless"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"access_mode": {
|
||||||
|
"const": "free"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"access_mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"birdeye": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sol_usd_pair_address": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"const": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"sol_usd_pair_address"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"marketPrice": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"birdeye",
|
||||||
|
"coinbase_exchange",
|
||||||
|
"coingecko",
|
||||||
|
"coinmarketcap",
|
||||||
|
"coinpaprika",
|
||||||
|
"dexscreener",
|
||||||
|
"jupiter",
|
||||||
|
"kraken"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"birdeye": {
|
||||||
|
"$ref": "#/$defs/birdeye"
|
||||||
|
},
|
||||||
|
"coinbase_exchange": {
|
||||||
|
"$ref": "#/$defs/enabledOnly"
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"$ref": "#/$defs/coingecko"
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"$ref": "#/$defs/coinmarketcap"
|
||||||
|
},
|
||||||
|
"coinpaprika": {
|
||||||
|
"$ref": "#/$defs/enabledOnly"
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"$ref": "#/$defs/dexscreener"
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"$ref": "#/$defs/jupiter"
|
||||||
|
},
|
||||||
|
"kraken": {
|
||||||
|
"$ref": "#/$defs/enabledOnly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"market_price"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"market_price": {
|
||||||
|
"$ref": "#/$defs/marketPrice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
config/std.offchain_transport.json
Normal file
75
config/std.offchain_transport.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "public_keyless",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "public_keyless",
|
||||||
|
"market_price": {
|
||||||
|
"birdeye": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"coinbase_exchange": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "keyless"
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "keyless"
|
||||||
|
},
|
||||||
|
"coinpaprika": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "keyless"
|
||||||
|
},
|
||||||
|
"kraken": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "all_free",
|
||||||
|
"market_price": {
|
||||||
|
"birdeye": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "${KSP_SECRET_BIRDEYE_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinbase_exchange": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"coingecko": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "demo",
|
||||||
|
"api_key": "${KSP_SECRET_COINGECKO_DEMO_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinmarketcap": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "basic",
|
||||||
|
"api_key": "${KSP_SECRET_COINMARKETCAP_API_KEY}"
|
||||||
|
},
|
||||||
|
"coinpaprika": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"dexscreener": {
|
||||||
|
"enabled": true,
|
||||||
|
"sol_usd_pair_address": "${KSP_PUBLIC_DEXSCREENER_SOL_USD_PAIR_ADDRESS}"
|
||||||
|
},
|
||||||
|
"jupiter": {
|
||||||
|
"enabled": true,
|
||||||
|
"access_mode": "free",
|
||||||
|
"api_key": "${KSP_SECRET_JUPITER_API_KEY}"
|
||||||
|
},
|
||||||
|
"kraken": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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-beta",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -178,6 +178,101 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet_backfill_pool",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet-beta",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.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-beta",
|
||||||
|
"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-beta",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"profile_id": "publicnode_mainnet",
|
"profile_id": "publicnode_mainnet",
|
||||||
"endpoints": [
|
"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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
71
crates/ksp-app-backfill-desk/tauri.conf.json
Normal file
71
crates/ksp-app-backfill-desk/tauri.conf.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"$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-solprices-desk.json": "config/composite.ksp-app-solprices-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.store.json": "config/std.store.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/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
464
crates/ksp-app-backfill-desk/tests/desktop_contract.rs
Normal file
464
crates/ksp-app-backfill-desk/tests/desktop_contract.rs
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/desktop_contract.rs
|
||||||
|
// version: 16
|
||||||
|
|
||||||
|
//! 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(), 14);
|
||||||
|
for required in [
|
||||||
|
"../../config/composite.ksp-app-backfill-desk.json",
|
||||||
|
"../../config/composite.ksp-app-solprices-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: 3
|
||||||
|
|
||||||
|
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-beta".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-beta".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-beta");
|
||||||
|
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-beta"));
|
||||||
|
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: 3
|
||||||
|
|
||||||
|
#[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-beta") {
|
||||||
|
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: 1
|
||||||
|
|
||||||
|
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-beta") {
|
||||||
|
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: 1
|
||||||
|
|
||||||
|
#[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-beta", "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,11 +1,12 @@
|
|||||||
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
||||||
<!-- version: 2 -->
|
<!-- version: 4 -->
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
<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">
|
<link rel="stylesheet" href="sass/splash.scss">
|
||||||
<title>KSP Config Desk — Chargement</title>
|
<title>KSP Config Desk — Chargement</title>
|
||||||
</head>
|
</head>
|
||||||
@@ -13,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>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KSP Config Desk",
|
"productName": "KSP Config Desk",
|
||||||
"version": "0.2.6",
|
|
||||||
"identifier": "com.sasedev.ksp-app-config-desk",
|
"identifier": "com.sasedev.ksp-app-config-desk",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": {
|
"beforeDevCommand": {
|
||||||
@@ -53,12 +52,18 @@
|
|||||||
"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-solprices-desk.json": "config/composite.ksp-app-solprices-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.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.transport.json": "config/std.transport.json",
|
||||||
"../../config/std.wallet.json": "config/std.wallet.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.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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 7
|
// version: 12
|
||||||
|
|
||||||
//! Desktop build/shell contract audits for Config Desk.
|
//! Desktop build/shell contract audits for Config Desk.
|
||||||
|
|
||||||
@@ -26,43 +26,6 @@ fn read_json(path: &std::path::Path) -> serde_json::Value {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
|
||||||
let core = match value.split_once('-') {
|
|
||||||
std::option::Option::Some((core, _)) => core,
|
|
||||||
std::option::Option::None => value,
|
|
||||||
};
|
|
||||||
let mut parts = core.split('.');
|
|
||||||
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
|
||||||
std::option::Option::Some(value) => value,
|
|
||||||
std::option::Option::None => return std::option::Option::None,
|
|
||||||
};
|
|
||||||
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
|
||||||
std::option::Option::Some(value) => value,
|
|
||||||
std::option::Option::None => return std::option::Option::None,
|
|
||||||
};
|
|
||||||
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
|
||||||
std::option::Option::Some(value) => value,
|
|
||||||
std::option::Option::None => return std::option::Option::None,
|
|
||||||
};
|
|
||||||
if parts.next().is_some() {
|
|
||||||
return std::option::Option::None;
|
|
||||||
}
|
|
||||||
return std::option::Option::Some((major, minor, patch));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
|
||||||
assert!(value.is_some(), "{field} packaged version must exist");
|
|
||||||
let value = match value {
|
|
||||||
std::option::Option::Some(value) => value,
|
|
||||||
std::option::Option::None => return,
|
|
||||||
};
|
|
||||||
let parsed = parse_semver_core(value);
|
|
||||||
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
|
||||||
if let std::option::Option::Some(parsed) = parsed {
|
|
||||||
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tauri_and_frontend_build_contracts_remain_explicit() {
|
fn tauri_and_frontend_build_contracts_remain_explicit() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
@@ -141,17 +104,35 @@ fn pre_014_template_uses_sidebar_navigation_and_kbot_style_splash_contract() {
|
|||||||
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
|
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||||
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
assert!(tauri.pointer("/version").is_none(), "Config Desk must let Tauri derive the bundle version from Cargo");
|
||||||
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
|
||||||
let package = read_json(root.join("package.json").as_path());
|
let package = read_json(root.join("package.json").as_path());
|
||||||
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
assert_eq!(package.pointer("/private").and_then(serde_json::Value::as_bool), std::option::Option::Some(true));
|
||||||
assert_packaged_version_floor(package_version, "package.json");
|
|
||||||
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
|
||||||
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(), 8);
|
assert_eq!(resources.len(), 14);
|
||||||
|
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-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/composite.ksp-app-solprices-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!(
|
||||||
|
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/std.offchain_transport.json"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
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"),
|
||||||
|
);
|
||||||
|
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: 6
|
// version: 8
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_inventory_exposes_registered_profile_documents() {
|
fn profile_inventory_exposes_registered_profile_documents() {
|
||||||
@@ -15,12 +15,18 @@ 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 {
|
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;
|
||||||
}));
|
}));
|
||||||
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
|
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK;
|
||||||
|
}));
|
||||||
for document in inventory {
|
for document in inventory {
|
||||||
assert!(!document.default_profile.is_empty());
|
assert!(!document.default_profile.is_empty());
|
||||||
assert!(document.profile_ids.iter().any(|profile_id| -> bool {
|
assert!(document.profile_ids.iter().any(|profile_id| -> bool {
|
||||||
|
|||||||
41
crates/ksp-app-solprices-desk/Cargo.toml
Normal file
41
crates/ksp-app-solprices-desk/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# file: crates/ksp-app-solprices-desk/Cargo.toml
|
||||||
|
# version: 2
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-app-solprices-desk"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ksp_app_solprices_desk_lib"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ksp-app-solprices-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-offchain-transport-lib = { path = "../ksp-offchain-transport-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
|
||||||
154
crates/ksp-app-solprices-desk/README.md
Normal file
154
crates/ksp-app-solprices-desk/README.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<!-- file: crates/ksp-app-solprices-desk/README.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# `ksp-app-solprices-desk`
|
||||||
|
|
||||||
|
`ksp-app-solprices-desk` est l'application desktop spécialisée de visualisation et de refresh manuel des prix SOL/USD fournis par `ksp-offchain-transport-lib`.
|
||||||
|
|
||||||
|
L'application reste une **HID provider-neutral** : elle consomme le registry, les observations, les disponibilités et les opérations génériques du service Off-chain sans connaître les endpoints, credentials, limites ou wire formats propres aux providers.
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
```text
|
||||||
|
package : ksp-app-solprices-desk
|
||||||
|
lib : ksp_app_solprices_desk_lib
|
||||||
|
bin : ksp-app-solprices-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
Le binaire reste un launcher mince. La bibliothèque applicative possède l'assemblage Tauri, le bootstrap Config/Logging/Off-chain, l'état de présentation en mémoire et les commands Tauri.
|
||||||
|
|
||||||
|
## Responsabilités
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> composite, profils, secrets et runtime packagé
|
||||||
|
ksp-offchain-transport-lib -> providers, HTTP, registry, availability, limits et refresh
|
||||||
|
ksp-logging-lib -> logging/tracing applicatif
|
||||||
|
ksp-app-solprices-desk -> orchestration Tauri + projections provider-neutral sûres
|
||||||
|
```
|
||||||
|
|
||||||
|
Le Desk ne possède aucun client HTTP provider et ne réimplémente ni admission, ni cooldown, ni retry provider.
|
||||||
|
|
||||||
|
## Vues
|
||||||
|
|
||||||
|
### Prices
|
||||||
|
|
||||||
|
La vue `Prices` affiche l'inventaire SOL/USD dans l'ordre stable du registry et conserve chaque observation séparée.
|
||||||
|
|
||||||
|
La projection comprend notamment :
|
||||||
|
|
||||||
|
- identité/display provider et paire ;
|
||||||
|
- sémantique et mode d'authentification ;
|
||||||
|
- availability générique ;
|
||||||
|
- prix exact sous forme de `String` ;
|
||||||
|
- timestamp provider lorsqu'il existe ;
|
||||||
|
- timestamp de réception KSP ;
|
||||||
|
- `retry_at` lorsqu'il existe ;
|
||||||
|
- état `Refreshing` local à la présentation.
|
||||||
|
|
||||||
|
Aucune moyenne, aucun fallback automatique et aucun prix canonique ne sont produits par cette application.
|
||||||
|
|
||||||
|
Les actions manuelles sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Refresh
|
||||||
|
Refresh selected
|
||||||
|
Refresh all
|
||||||
|
Select all providers
|
||||||
|
Clear selection
|
||||||
|
```
|
||||||
|
|
||||||
|
Le batch applicatif est borné à `1..=64` IDs opaques avant parsing/dispatch. Les doublons, IDs inconnus et conflits `in_flight` sont rejetés avant mutation partielle.
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
La vue `Diagnostics` expose uniquement des informations de runtime sûres : identité/version applicative, profils résolus et compte de providers. Aucun endpoint, header, API key, payload provider ou prix n'est journalisé comme diagnostic brut.
|
||||||
|
|
||||||
|
## Config et profils
|
||||||
|
|
||||||
|
Le composite dédié est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.composite.ksp-app-solprices-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
Il compose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
logging
|
||||||
|
Off-chain Transport
|
||||||
|
```
|
||||||
|
|
||||||
|
Profils committed :
|
||||||
|
|
||||||
|
```text
|
||||||
|
public_keyless profil par défaut
|
||||||
|
all_free modes gratuits incluant les credentials Config requis
|
||||||
|
tests profil déterministe de composition
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend ne sélectionne ni provider concret ni secret. La résolution des credentials reste entièrement possédée par Config.
|
||||||
|
|
||||||
|
## Frontières desktop
|
||||||
|
|
||||||
|
Le frontend utilise uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
core:default
|
||||||
|
tracing:default
|
||||||
|
```
|
||||||
|
|
||||||
|
Il ne possède aucun accès direct filesystem, HTTP, shell ou persistence navigateur. Les refresh passent uniquement par les commands Rust centralisées dans `tauri.rs`.
|
||||||
|
|
||||||
|
Il n'existe aucun polling, auto-refresh ou scheduler prix dans le Desk. Les deadlines/cooldowns affichés proviennent de `ksp-offchain-transport-lib`.
|
||||||
|
|
||||||
|
## Ports et build
|
||||||
|
|
||||||
|
Les ports réservés sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vite HTTP : 1434
|
||||||
|
Vite WS : 1435
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend build est isolé sous :
|
||||||
|
|
||||||
|
```text
|
||||||
|
builds/khadhroony-solana-project/ksp-app-solprices-desk/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
La version du binaire et des bundles Tauri provient de la version Cargo résolue. `tauri.conf.json` ne duplique pas cette version ; `package.json` reste un manifeste frontend privé non autoritatif pour la release KSP.
|
||||||
|
|
||||||
|
## Runtime packagé
|
||||||
|
|
||||||
|
Les onze resources Config/Schemas enregistrées sont embarquées dans le bundle. Au démarrage release, `ksp-config-lib` prépare la racine KSP user-writable commune, seed les documents absents, resynchronise les schemas et ne bundle jamais `.env`.
|
||||||
|
|
||||||
|
## Développement et validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Gate Rust courant :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-app-solprices-desk
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke live de composition opt-in :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p ksp-app-solprices-desk --test market_price_composition_live_smoke -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Build production :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-solprices-desk && cargo tauri build)
|
||||||
|
```
|
||||||
|
|
||||||
|
Voir également [`USAGE.md`](USAGE.md), [`../ksp-offchain-transport-lib/README.md`](../ksp-offchain-transport-lib/README.md) et [`../../docs/validation/015-V0_2_12_SOL_PRICES_DESK.md`](../../docs/validation/015-V0_2_12_SOL_PRICES_DESK.md).
|
||||||
149
crates/ksp-app-solprices-desk/USAGE.md
Normal file
149
crates/ksp-app-solprices-desk/USAGE.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<!-- file: crates/ksp-app-solprices-desk/USAGE.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Utilisation de `ksp-app-solprices-desk`
|
||||||
|
|
||||||
|
## 1. Lancement de développement
|
||||||
|
|
||||||
|
Depuis la racine du workspace :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le launcher debug normalise son current working directory vers la racine du workspace avant le bootstrap Config.
|
||||||
|
|
||||||
|
Les ports réservés sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vite HTTP : 1434
|
||||||
|
Vite WS : 1435
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Profil de prix
|
||||||
|
|
||||||
|
Le composite `cfg.composite.ksp-app-solprices-desk` utilise `public_keyless` par défaut.
|
||||||
|
|
||||||
|
Profils committed :
|
||||||
|
|
||||||
|
```text
|
||||||
|
public_keyless providers utilisables sans secret
|
||||||
|
all_free modes gratuits avec credentials Config lorsqu'ils sont requis
|
||||||
|
tests composition de test
|
||||||
|
```
|
||||||
|
|
||||||
|
La sélection d'un autre profil appartient aux mécanismes Config ; l'interface ne demande jamais directement une API key et ne choisit pas d'endpoint provider.
|
||||||
|
|
||||||
|
## 3. Vue Prices
|
||||||
|
|
||||||
|
La vue `Prices` présente une row par provider du registry Off-chain.
|
||||||
|
|
||||||
|
Les informations principales sont :
|
||||||
|
|
||||||
|
- paire SOL/USD ;
|
||||||
|
- sémantique de prix ;
|
||||||
|
- mode d'authentification ;
|
||||||
|
- availability ;
|
||||||
|
- prix exact lorsqu'une observation existe ;
|
||||||
|
- timestamp provider lorsqu'il est réellement fourni ;
|
||||||
|
- timestamp de réception KSP ;
|
||||||
|
- prochaine deadline `retry_at` lorsqu'elle existe.
|
||||||
|
|
||||||
|
`Not supplied` signifie qu'un provider n'a pas fourni de timestamp exploitable. Cette absence n'est jamais remplacée artificiellement par le timestamp KSP.
|
||||||
|
|
||||||
|
## 4. Refresh individuel
|
||||||
|
|
||||||
|
Le bouton `Refresh` d'une row déclenche uniquement le provider correspondant via `MarketPriceService::refresh`.
|
||||||
|
|
||||||
|
Pendant l'appel, la row passe temporairement à `Refreshing`. Le Desk ne garde aucun mutex de présentation pendant l'I/O réseau.
|
||||||
|
|
||||||
|
Si le service ne produit pas de nouvelle observation, la dernière observation réussie peut rester visible tandis que l'availability/retry est actualisée.
|
||||||
|
|
||||||
|
## 5. Refresh sélectionné
|
||||||
|
|
||||||
|
Cocher une ou plusieurs rows puis utiliser :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Refresh selected
|
||||||
|
```
|
||||||
|
|
||||||
|
La sélection est reconstruite dans l'ordre courant du registry. Les doublons, IDs inconnus, conflits `in_flight`, batch vide et batch supérieur à 64 IDs sont rejetés côté Rust avant dispatch partiel.
|
||||||
|
|
||||||
|
Les contrôles :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Select all providers
|
||||||
|
Clear selection
|
||||||
|
```
|
||||||
|
|
||||||
|
ne déclenchent aucun accès réseau par eux-mêmes.
|
||||||
|
|
||||||
|
## 6. Refresh global
|
||||||
|
|
||||||
|
```text
|
||||||
|
Refresh all
|
||||||
|
```
|
||||||
|
|
||||||
|
appelle la surface générique `MarketPriceService::refresh_all` et conserve l'ordre déterministe du service.
|
||||||
|
|
||||||
|
Les providers disabled, en cooldown ou temporairement indisponibles restent classifiés par Off-chain Transport. Le frontend ne contourne jamais ces règles et n'implémente aucun retry local.
|
||||||
|
|
||||||
|
## 7. Lecture des états
|
||||||
|
|
||||||
|
Les états visibles sont provider-neutral, notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ready
|
||||||
|
Cooling down
|
||||||
|
Authentication unavailable
|
||||||
|
Disabled
|
||||||
|
Misconfigured
|
||||||
|
Quota unavailable
|
||||||
|
Temporarily unavailable
|
||||||
|
Refreshing
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résumé en haut de la vue indique les nombres de providers, rows ready, observations disponibles, états nécessitant attention et refresh en cours.
|
||||||
|
|
||||||
|
Aucune moyenne ou fusion de prix n'est calculée dans SOL Prices Desk.
|
||||||
|
|
||||||
|
## 8. Diagnostics et sécurité
|
||||||
|
|
||||||
|
La vue `Diagnostics` expose uniquement les informations sûres de composition/runtime.
|
||||||
|
|
||||||
|
Le frontend :
|
||||||
|
|
||||||
|
- ne fait aucun `fetch`/XHR provider ;
|
||||||
|
- ne possède aucun endpoint ou header provider ;
|
||||||
|
- ne stocke aucun secret dans `localStorage`/`sessionStorage` ;
|
||||||
|
- n'utilise aucun scheduler ou polling prix ;
|
||||||
|
- ne journalise aucun prix ou payload provider brut.
|
||||||
|
|
||||||
|
Les actions utilisateur sont instrumentées via le bridge Logging KSP.
|
||||||
|
|
||||||
|
## 9. Runtime packagé
|
||||||
|
|
||||||
|
En build release, les documents Config et schemas enregistrés sont embarqués comme resources Tauri. `ksp-config-lib` prépare ensuite la racine KSP user-writable commune ; les Config utilisateur existantes sont conservées, les schemas sont resynchronisés et `.env` n'est jamais embarqué.
|
||||||
|
|
||||||
|
La version de bundle est dérivée de Cargo. `package.json` reste un manifeste frontend privé.
|
||||||
|
|
||||||
|
## 10. Validation de publication
|
||||||
|
|
||||||
|
Avant le build :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-app-solprices-desk
|
||||||
|
cargo test --workspace
|
||||||
|
cargo test -p ksp-app-solprices-desk --test market_price_composition_live_smoke -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Le build production est :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-solprices-desk && cargo tauri build)
|
||||||
|
```
|
||||||
12
crates/ksp-app-solprices-desk/build.rs
Normal file
12
crates/ksp-app-solprices-desk/build.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// file: crates/ksp-app-solprices-desk/build.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Build script for the KSP SOL prices desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
13
crates/ksp-app-solprices-desk/capabilities/default.json
Normal file
13
crates/ksp-app-solprices-desk/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for KSP SOL Prices Desk",
|
||||||
|
"windows": [
|
||||||
|
"splash",
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"tracing:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
BIN
crates/ksp-app-solprices-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
Binary file not shown.
25
crates/ksp-app-solprices-desk/frontend/fonts/README.md
Normal file
25
crates/ksp-app-solprices-desk/frontend/fonts/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!-- file: crates/ksp-app-solprices-desk/frontend/fonts/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Fonts du splash SOL Prices Desk
|
||||||
|
|
||||||
|
SOL Prices 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-solprices-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-solprices-desk/frontend/imgs/logo.png
Normal file
BIN
crates/ksp-app-solprices-desk/frontend/imgs/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/ksp-app-solprices-desk/frontend/imgs/splash.png
Normal file
BIN
crates/ksp-app-solprices-desk/frontend/imgs/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
188
crates/ksp-app-solprices-desk/frontend/main.html
Normal file
188
crates/ksp-app-solprices-desk/frontend/main.html
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<!-- file: crates/ksp-app-solprices-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>SOL Prices Desk — Prices</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">SOL Prices 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">Prices</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 SOL Prices Desk">
|
||||||
|
<button class="nav-link text-start active" type="button" data-view="prices">
|
||||||
|
<i class="fa-solid fa-dollar-sign me-2" aria-hidden="true"></i>Prices
|
||||||
|
</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="prices">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Prices</h1>
|
||||||
|
<p class="text-body-secondary mb-0">Inventaire provider-neutral SOL/USD. Les observations restent distinctes : aucune moyenne, aucun fallback automatique et aucun polling.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap app-market-toolbar">
|
||||||
|
<span id="selectedProviderCount" class="small text-body-secondary" aria-live="polite">0 selected</span>
|
||||||
|
<button id="clearSelectionButton" class="btn btn-sm btn-outline-secondary" type="button" disabled>
|
||||||
|
<i class="fa-solid fa-xmark me-1" aria-hidden="true"></i>Clear selection
|
||||||
|
</button>
|
||||||
|
<button id="refreshSelectedButton" class="btn btn-sm btn-outline-primary" type="button" disabled>
|
||||||
|
<i class="fa-solid fa-list-check me-1" aria-hidden="true"></i>Refresh selected
|
||||||
|
</button>
|
||||||
|
<button id="refreshAllButton" class="btn btn-sm btn-primary" type="button" disabled>
|
||||||
|
<i class="fa-solid fa-arrows-rotate me-1" aria-hidden="true"></i>Refresh all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pricesSummary" class="row g-2 mb-3" aria-label="Résumé des providers">
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="app-market-summary-card">
|
||||||
|
<span class="app-market-summary-label">Providers</span>
|
||||||
|
<strong id="pricesSummaryProviders">0</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="app-market-summary-card">
|
||||||
|
<span class="app-market-summary-label">Ready</span>
|
||||||
|
<strong id="pricesSummaryReady">0</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="app-market-summary-card">
|
||||||
|
<span class="app-market-summary-label">Observed</span>
|
||||||
|
<strong id="pricesSummaryObserved">0</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg">
|
||||||
|
<div class="app-market-summary-card">
|
||||||
|
<span class="app-market-summary-label">Attention</span>
|
||||||
|
<strong id="pricesSummaryAttention">0</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg">
|
||||||
|
<div class="app-market-summary-card">
|
||||||
|
<span class="app-market-summary-label">Refreshing</span>
|
||||||
|
<strong id="pricesSummaryLoading">0</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pricesLoading" class="app-placeholder">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-spinner fa-spin text-body-secondary mb-3" aria-hidden="true"></i>
|
||||||
|
<h2 class="h5">Chargement du registry</h2>
|
||||||
|
<p class="text-body-secondary mb-0">Projection de l'inventaire provider-neutral depuis le runtime backend.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pricesEmpty" class="app-placeholder" hidden>
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-table-list text-body-secondary mb-3" aria-hidden="true"></i>
|
||||||
|
<h2 class="h5">Aucun provider configuré</h2>
|
||||||
|
<p class="text-body-secondary mb-0">Le runtime Off-chain ne contient actuellement aucune ligne provider.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pricesDiagnostic" class="alert alert-warning mb-3" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div id="pricesTableContainer" class="table-responsive app-market-table-container" hidden>
|
||||||
|
<table id="marketPriceTable" class="table table-hover align-middle mb-0 app-market-table">
|
||||||
|
<caption class="visually-hidden">Providers SOL/USD, disponibilité, prix exacts, fraîcheur et actions de refresh.</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="app-market-selection-column">
|
||||||
|
<input id="selectAllProviders" class="form-check-input" type="checkbox" aria-label="Select all providers" disabled>
|
||||||
|
</th>
|
||||||
|
<th scope="col">Provider</th>
|
||||||
|
<th scope="col">Pair</th>
|
||||||
|
<th scope="col" class="d-none d-xl-table-cell">Semantics</th>
|
||||||
|
<th scope="col" class="d-none d-xl-table-cell">Auth</th>
|
||||||
|
<th scope="col">Availability</th>
|
||||||
|
<th scope="col">Exact price</th>
|
||||||
|
<th scope="col" class="app-market-time-column">Observation times</th>
|
||||||
|
<th scope="col" class="app-market-time-column">Retry</th>
|
||||||
|
<th scope="col" class="text-end">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="marketPriceRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section data-view-panel="diagnostics" hidden>
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Diagnostics</h1>
|
||||||
|
<p class="text-body-secondary mb-0">État sûr du bootstrap Config, Logging et runtime prix provider-neutral.</p>
|
||||||
|
</div>
|
||||||
|
</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</dt>
|
||||||
|
<dd id="runtimeCompositeProfile" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Profil Off-chain</dt>
|
||||||
|
<dd id="runtimeOffchainProfile" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Providers</dt>
|
||||||
|
<dd id="runtimeProviderCount" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Providers ready</dt>
|
||||||
|
<dd id="runtimeReadyProviderCount" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Providers unavailable</dt>
|
||||||
|
<dd id="runtimeUnavailableProviderCount" 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>
|
||||||
235
crates/ksp-app-solprices-desk/frontend/sass/_app.scss
Normal file
235
crates/ksp-app-solprices-desk/frontend/sass/_app.scss
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
// file: crates/ksp-app-solprices-desk/frontend/sass/_app.scss
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
$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,
|
||||||
|
.app-market-code,
|
||||||
|
.app-market-price,
|
||||||
|
.app-market-time-exact {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-toolbar {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-summary-card {
|
||||||
|
display: flex;
|
||||||
|
min-height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: var(--bs-border-radius);
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-summary-card strong {
|
||||||
|
min-width: 2ch;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-summary-label {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-table-container {
|
||||||
|
max-height: min(62vh, 720px);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: var(--bs-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-table {
|
||||||
|
min-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
box-shadow: inset 0 -1px 0 var(--bs-border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-table tbody tr[data-availability="ready"] {
|
||||||
|
--bs-table-accent-bg: rgba($success, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-table tbody tr[data-availability="cooling_down"],
|
||||||
|
.app-market-table tbody tr[data-availability="temporarily_unavailable"] {
|
||||||
|
--bs-table-accent-bg: rgba($warning, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-table tbody tr[data-availability="authentication_unavailable"],
|
||||||
|
.app-market-table tbody tr[data-availability="misconfigured"] {
|
||||||
|
--bs-table-accent-bg: rgba($danger, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-selection-column {
|
||||||
|
width: 42px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-time-column {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-provider-name,
|
||||||
|
.app-market-price {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-code,
|
||||||
|
.app-market-time-exact {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-price {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-time {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-time-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-time-label {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-time-value {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-row-loading {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.app-sidebar {
|
||||||
|
width: 190px;
|
||||||
|
min-width: 190px;
|
||||||
|
max-width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-market-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss
Normal file
160
crates/ksp-app-solprices-desk/frontend/sass/_bootswatch.scss
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// file: crates/ksp-app-solprices-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-solprices-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-solprices-desk/frontend/sass/_simplebar.scss
Normal file
248
crates/ksp-app-solprices-desk/frontend/sass/_simplebar.scss
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
// file: crates/ksp-app-solprices-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-solprices-desk/frontend/sass/_variables.scss
Normal file
95
crates/ksp-app-solprices-desk/frontend/sass/_variables.scss
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// file: crates/ksp-app-solprices-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-solprices-desk/frontend/sass/main.scss
Normal file
10
crates/ksp-app-solprices-desk/frontend/sass/main.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// file: crates/ksp-app-solprices-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-solprices-desk/frontend/sass/splash.scss
Normal file
137
crates/ksp-app-solprices-desk/frontend/sass/splash.scss
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// file: crates/ksp-app-solprices-desk/frontend/sass/splash.scss
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
@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-solprices-desk/frontend/splash.html
Normal file
24
crates/ksp-app-solprices-desk/frontend/splash.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- file: crates/ksp-app-solprices-desk/frontend/splash.html -->
|
||||||
|
<!-- version: 3 -->
|
||||||
|
<!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 SOL Prices Desk — Chargement</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="splash-container" style="opacity: 0;">
|
||||||
|
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP SOL Prices Desk">
|
||||||
|
<div id="app-name">SOL Prices</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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user