Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9785cda39d | |||
| 0cea99cd5e | |||
| 71bc754feb | |||
| cabbfed730 | |||
| 8b6ef7778a | |||
| 06319655b0 | |||
| f636169783 | |||
| 0f8f38ff52 | |||
| 142998afb3 | |||
| 665d7d2bb6 | |||
| 1746d47412 | |||
| 9bb2f7bcae | |||
| 1f2720463b | |||
| 8bb530e9b4 | |||
| 72df965a9c | |||
| f688325170 | |||
| c711213dcc | |||
| 11ecdccd65 | |||
| 27ab84d198 | |||
| 28e4da3879 | |||
| 31c0a83e51 | |||
| 34fddc3bf5 | |||
| 13c3dac554 | |||
| f7ed053e8e | |||
| 2e8feb56dd | |||
| 793178b345 | |||
| ee0359efd5 | |||
| 1dc57a85c2 | |||
| e7e8960fec | |||
| 79e278064d | |||
| 474b894d1c | |||
| 0c76b74afe | |||
| b608ed2c50 | |||
| f9d4e6bc74 | |||
| 223f11b989 | |||
| 5095be0b0d | |||
| 2aa64cf0a4 | |||
| 90c266d68a | |||
| 8410b2f4ef | |||
| ba69a5ad20 | |||
| 07b9b20eb5 | |||
| 901a0515b7 | |||
| 50c01a19c9 | |||
| 3a8fa87acb | |||
| df1dbbb8fb | |||
| 531ca851c4 | |||
| b3fd74529d | |||
| 00b376d234 | |||
| fa720d0054 | |||
| 91438e8214 | |||
| 08e9a34183 | |||
| 85937b8cbb | |||
| 107986bf85 | |||
| a8cabbebd5 | |||
| 636a0c5f43 | |||
| bf46782db3 | |||
| 02919e959e | |||
| d231eba8be | |||
| 637382f364 | |||
| bf6ef5b426 | |||
| 822e8fcd86 | |||
| 63e1a866a3 | |||
| 22c88c449d | |||
| ed6c0ac10c | |||
| 355c5d1e9a | |||
| 61d8b1dee2 | |||
| 0db32a865c | |||
| a4061ee1e1 | |||
| ec64be340e | |||
| f1c383c392 | |||
| 984c327162 | |||
| e2689d1b04 | |||
| b5c84f3df8 | |||
| 36c683df9e | |||
| 510adcb49b | |||
| ab87fd23cd | |||
| 1a352aa8d0 | |||
| 919b4ed46d | |||
| f7c57f21c5 | |||
| d4f4237723 | |||
| ba574f3052 | |||
| 0683b70abe | |||
| eaf6fae94c | |||
| e19a202565 | |||
| 8309d829cc | |||
| aabe8dedb2 | |||
| d0da907c00 | |||
| 6b434750c5 | |||
| c9047af626 | |||
| 52d692e8ec | |||
| 5c3c8b3fef | |||
| b0079fc3ee | |||
| ecfa123a79 | |||
| 9da372e4a2 | |||
| 9a998e7b52 | |||
| c3c9a80310 | |||
| 116eabba7d | |||
| e6c2649410 | |||
| e85d6f6963 | |||
| eb39aaa904 | |||
| bb7ee3cc3a | |||
| 84647c12ea | |||
| 156d237460 | |||
| 7d0710a1e6 | |||
| 5c797827f7 | |||
| b87b7f4505 | |||
| 92f08ca5a8 | |||
| 85f292a36d | |||
| 877411c6e5 | |||
| 081a5352c0 | |||
| c0b3190256 | |||
| eaefec2259 | |||
| cfbf849e2a | |||
| e01fe88594 | |||
| 80fe2fd9e2 | |||
| 516c52ece9 | |||
| 2b79818848 | |||
| e2e603ca6d | |||
| 01502678bf | |||
| 32c4b67541 | |||
| 8c23625fee | |||
| 489d686023 | |||
| 10515d1562 | |||
| c9e52d1926 | |||
| 32577ea48d | |||
| d5b4aef194 | |||
| 8130e01b70 | |||
| 3a38914be7 | |||
| 62d6cf9dac | |||
| 4e2647cce1 | |||
| 9b92900dd7 | |||
| bf73481180 | |||
| 3bb73e81e4 | |||
| c4198ac656 | |||
| 009b957523 | |||
| 9300dccbe2 | |||
| 74890a6268 | |||
| 6a1892792c | |||
| 3df8fc74c1 | |||
| 68678a22c6 | |||
| 4506c2d12d | |||
| d8f7c9bd8b | |||
| a266a4d7b6 | |||
| 76f671b8c5 | |||
| 0847edda4c | |||
| fa04bdbcf6 | |||
| 7697f407db | |||
| 4c16607e22 | |||
| 618f940310 | |||
| 313d2a1ea6 | |||
| 97f84e5c58 | |||
| db78fea161 | |||
| ec8875e864 | |||
| ed72aaba67 | |||
| d49d7af0a4 | |||
| d7db8d69e0 | |||
| 48d26b43f9 | |||
| 6e406a6b84 | |||
| 9c4469248a | |||
| 6fc5b5b607 | |||
| 3c4ae51ae2 | |||
| 57ff11b774 | |||
| 500b87ef57 | |||
| 46a4ad7487 | |||
| 03a94f4fb6 | |||
| 862f40ad26 | |||
| bc7ccd97a5 | |||
| dd85ac5e77 | |||
| dd48ed9140 | |||
| 5a65c28898 | |||
| 42374115c7 | |||
| 9506df9487 | |||
| 7f8ce987a5 | |||
| 8dbd86c297 | |||
| 75b2d7e7f1 | |||
| b861a1e3b8 | |||
| d89aee9910 | |||
| 75cbf9df09 | |||
| 138395ac35 | |||
| be0032e76b | |||
| 8096e6f07b | |||
| 032919fa07 | |||
| ea5f756d17 | |||
| 4fdeab4d67 | |||
| 478548b7f4 | |||
| 20cf700f22 | |||
| f92c22d55a | |||
| cb670952e8 | |||
| a25d8dd55a | |||
| ecfc30a94b | |||
| 6f2d0a092a | |||
| 3de0488cab | |||
| 2460162243 | |||
| 1b9104d64b | |||
| 80be36bfdd | |||
| e8d2382ac3 | |||
| 749e53edc2 | |||
| 7377a6f6cd | |||
| 45601ce386 | |||
| 7d490eb5d8 | |||
| ca018095bf | |||
| 0dd722ffca | |||
| 75e8030b07 | |||
| c24e4c48f8 | |||
| c7cfb9557d | |||
| c27428d6c0 | |||
| dfa01b1381 | |||
| 5fc7f65b2c | |||
| bbf804b7fc | |||
| 13687ec2fe | |||
| b756da5149 | |||
| 04234926ce | |||
| bfc69f5631 | |||
| 902b5fba99 | |||
| fccb7d876c | |||
| 50d4142797 | |||
| eb6dbc31e8 | |||
| fb8b585aa2 | |||
| dad18c16d5 | |||
| a704a5722e | |||
| dfa3723a7f | |||
| 6bd593f467 | |||
| 03ad0ba063 | |||
| d95b1095e5 |
10
.env.example
10
.env.example
@@ -1,5 +1,5 @@
|
|||||||
# file: .env.example
|
# file: .env.example
|
||||||
# version: 13
|
# version: 14
|
||||||
|
|
||||||
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||||
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||||
@@ -28,16 +28,16 @@ KSP_WALLETS_DIRECTORY=wallets
|
|||||||
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
||||||
|
|
||||||
# Optional public Solana Mainnet HTTP endpoint override used by config/std.transport.json and its example.
|
# Optional public Solana Mainnet HTTP endpoint override used by config/std.transport.json and its example.
|
||||||
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
|
# The committed Transport document falls back to https://api.mainnet.solana.com when this variable is absent.
|
||||||
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
|
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet.solana.com
|
||||||
|
|
||||||
# Optional public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
# Optional public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
||||||
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
||||||
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
||||||
|
|
||||||
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
||||||
# The committed Transport document falls back to wss://api.mainnet-beta.solana.com when this variable is absent.
|
# The committed Transport document falls back to wss://api.mainnet.solana.com when this variable is absent.
|
||||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet-beta.solana.com
|
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://api.mainnet.solana.com
|
||||||
|
|
||||||
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
||||||
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||||
|
|||||||
142
CHANGELOG.md
142
CHANGELOG.md
@@ -1,8 +1,148 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 22 -->
|
<!-- version: 33 -->
|
||||||
|
|
||||||
# Changelog KSP
|
# Changelog KSP
|
||||||
|
|
||||||
|
## 0.3.13 — convergence live multi-source du Worker RawTransaction — 2026-09-10
|
||||||
|
|
||||||
|
`0.3.13` généralise `ksp-worker-raw-transaction-ingest-lib` de la verticale Yellowstone de `0.3.12` vers une composition caller-owned de `1..32` sources logiques appartenant au même réseau. Cinq familles live convergent désormais vers le même pipeline Common RAW / admission / Store : Yellowstone, Standard Logs avec hydration `getTransaction observed`, Standard Block RAW-direct, Helius `transactionSubscribe` avec hydration et HTTP Block Polling run-local RAW-direct. Le Worker reste sans Config, Job Backfill, backend Store physique, client réseau direct ou SDK provider parallèle.
|
||||||
|
|
||||||
|
La convergence partage un registre global d'hydration entre Yellowstone, Standard Logs et Helius afin de coalescer `(network, signature, commitment)` avant fan-out HTTP. Après canonicalisation, les acquisitions de même `(network, signature)` sont sérialisées de manière bornée : un contenu identique conserve les observations/provenances distinctes, tandis qu'une divergence devient un `content_conflict` terminal explicite. Aucune majorité provider, préférence silencieuse ou stratégie `first-provider-wins` n'est introduite. Standard Block et HTTP Block Polling admettent directement les transactions Legacy/V0/V1 qualifiées avec `maxSupportedTransactionVersion = 1`.
|
||||||
|
|
||||||
|
Le supervisor multi-source possède toutes les tâches et applique des bornes source-neutral sur admission, pending signals, hydrations et persistence avec une fairness minimale entre sources reference-bearing. Les snapshots agrègent activity/health/backpressure/reconnect/replay/gap sans exposer le matériau provider. La politique reste conservative : une source configurée qui échoue est terminale faute d'équivalence de coverage prouvée ; le gap repair, les rôles degraded/failover et la preuve de couverture restent donc `0.3.14`. Le shutdown stop/fault/drain/abort+join est durci contre les races, leaders d'hydration abandonnés, Store lent et publications tardives.
|
||||||
|
|
||||||
|
Le gate déterministe final passe `cargo fmt`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features` et les graphes Cargo : `1 850` tests passent, `0` échoue et `15` restent ignored opt-in/operator-only. Les smokes keyless Solana HTTP Devnet et WebSocket Devnet passent `1/1` chacun. Yellowstone provider-gated, Helius `transactionSubscribe` live, `blockSubscribe` provider dédié, HTTP polling Worker end-to-end et Worker multi-source vers Store restent explicitement non exécutés faute de gate provisionné ; aucune fixture ne les requalifie en PASS.
|
||||||
|
|
||||||
|
`prompts/033-V0_3_14_START_PROMPT.md` ouvre `0.3.14` exclusivement depuis le futur tag stable `v0.3.13`. Cette release doit fermer le gap repair/hardening multi-source limité au run actif — replay adressable, coverage redondante, scan HTTP borné, hydration de réparation, unresolved gaps et health policy — sans transformer le Worker en moteur historique ni créer de dépendance vers `ksp-job-backfill-lib`.
|
||||||
|
|
||||||
|
## 0.3.12 — Yellowstone + hydration HTTP + continuité de run du Worker RawTransaction — 2026-09-09
|
||||||
|
|
||||||
|
`0.3.12` ouvre la première source réseau productive de `ksp-worker-raw-transaction-ingest-lib` sans remettre en cause la fondation source-neutral de `0.3.11`. Le Worker dépend désormais de la façade `ksp-onchain-transport-lib` mais reste sans Config, Job Backfill, backend Store physique, `reqwest`, `tonic` ou proto provider direct. `RawTransactionIngestRuntimeResources` reçoit une source Yellowstone caller-composed et un pool HTTP cohérent ; `start` conserve la fondation sans source, tandis que `start_with_runtime_resources` lance la verticale live supervisée.
|
||||||
|
|
||||||
|
Les updates Yellowstone `Transaction`, `TransactionStatus` et les transactions incluses dans `Block` sont projetées vers des signaux source-neutral, coalescées de manière bornée par `(network, signature, commitment)`, hydratées via `getTransaction` observed puis converties par `ksp-raw-transaction-lib` avant admission/persistence Store. `BlockMeta` et `Slot` restent continuity-only. La provenance composite conserve les identités logiques sûres de la source et de la route d’hydration, sans URL, secret ni payload distant. Les gardes réseau/signature/slot/index/commitment empêchent les croisements de matériau avant admission.
|
||||||
|
|
||||||
|
La release matérialise une processing frontier strictement run-local : le Worker distingue travail pending/settled, n’avance jamais au travers d’un pending plus ancien et compacte ses états sans revendiquer une preuve de complétude durable. Le reconnect/replay reste Transport-owned : `from_slot` et `SubscribeReplayInfo` ne sont pas pilotés par le Worker. Le Worker projette un état source-neutral `Active/Reconnecting/Closing/Closed/Failed`, compte reconnect/replay-attempt/continuity-gap et fault avec `source_failed` lorsqu’une hausse du gap Transport prouve que la rétention ne permet plus la reprise demandée. Aucun Backfill ou repair automatique n’est déclenché.
|
||||||
|
|
||||||
|
Le hardening final ferme les duplicate storms, saturation/coalescence, Store lent/fautif, stop/fault pendant hydration/reconnect, overflow de compteurs, abandon des pending sans faux `settled`, abort/join des tâches privées et absence de retry HTTP possédé par le Worker. `pre.010` ajoute les canaris cross-layer Transport -> Worker -> Common RAW -> Store et verrouille les dependency firewalls, fixtures Legacy/V0, redaction et inventaires publics.
|
||||||
|
|
||||||
|
Le gate déterministe `pre.011` passe rustfmt check, audits Rust/Markdown, `cargo check --workspace`, Clippy strict, `cargo test --workspace --all-targets --all-features`, les suites ciblées et les trois inspections Cargo demandées. Les smokes live keyless HTTP Devnet et WebSocket Devnet sont exécutés séparément et passent ; les smokes Yellowstone token-gated et le Worker end-to-end Yellowstone + HTTP + Store restent explicitement non exécutés faute de ressources fournies, sans faux PASS. `pre.012` réconcilie ensuite README/USAGE et les architectures concernées avec cette verticale réellement matérialisée.
|
||||||
|
|
||||||
|
La suite reste découpée : `0.3.13` ajoute WS standard, Helius `transactionSubscribe`, HTTP live polling et la convergence multi-source ; `0.3.14` ferme le gap repair/hardening multi-source. `prompts/032-V0_3_13_START_PROMPT.md` ouvre `0.3.13` uniquement depuis le tag stable `v0.3.12` et impose un `pre.001` d’audit/sizing avant toute extension productive.
|
||||||
|
|
||||||
|
## 0.3.11 — Fondation runtime source-neutral du Worker RawTransaction ingest — 2026-09-08
|
||||||
|
|
||||||
|
`0.3.11` introduit `ksp-worker-raw-transaction-ingest-lib` comme premier Worker concret KSP pour l’acquisition continue de `RawTransaction`, tout en fermant volontairement la release **sans source réseau productive**. La crate consomme `ksp-worker-api`, `ksp-raw-transaction-lib` et la seule façade `ksp-store-lib`; elle ne dépend ni de Config, ni d’un Job, ni d’un backend Store physique, ni encore de `ksp-onchain-transport-lib`. Son identité stable est `raw_transaction_ingest`, ses settings bornent la capacité d’admission, la concurrence de persistence et la deadline de drain, et son `start` utilise exclusivement le runtime Tokio actif fourni par le caller.
|
||||||
|
|
||||||
|
Le runtime possède un supervisor privé, des tâches source/persistence détenues, une admission centrale `mpsc` bornée avec backpressure, la canonicalisation/assembly Common RAW, une observation key déterministe et la persistence atomique en mode `Normal`. Les outcomes distinguent insert, idempotence et tombstone purgée ; un contenu divergent devient `worker_raw_transaction_ingest.content_conflict`, les erreurs Store restent classifiées sans texte distant, et aucun provider gagnant n’est choisi implicitement. Les snapshots concrets latest-value se projettent directement sur `WorkerSnapshotSource`, utilisent séquences/compteurs checked et conservent le terminal pour les lecteurs tardifs.
|
||||||
|
|
||||||
|
Le hardening final ferme les races stop/fault, les source failures, la saturation observable, le Store lent/fautif et le shutdown borné. À l’expiration de `shutdown_drain_timeout`, le Worker ferme les admissions, abort les tâches restantes, les rejoint toutes puis publie `drain_timeout`; aucune tâche privée ne survit au terminal. Les tests externes de hardening/release verrouillent les sept codes d’erreur publics, les huit modules de production, les vingt-quatre exports crate-root, la redaction, le dependency firewall et l’absence de surface historique/Backfill/Transport/Config/backend direct.
|
||||||
|
|
||||||
|
Le gate technique final `pre.011` passe rustfmt check, audits Rust/Markdown, `cargo check --workspace`, Clippy strict, 56 tests Worker, `cargo test --workspace --all-targets --all-features`, les arbres Cargo Worker normal/features et `cargo tree --duplicates`. La réconciliation `pre.012` ajoute ensuite les README/USAGE durables du Worker et réaligne l’architecture sur l’état matériel : la fondation `0.3.11` n’a aucun adapter HTTP/WS/Yellowstone productif, aucune API publique d’enqueue/source registration et aucun replay/gap-repair live.
|
||||||
|
|
||||||
|
La trajectoire live reste découpée : `0.3.12` ouvre Yellowstone transactions/blocks/status + hydration HTTP + continuité de run, `0.3.13` ajoute WS standard/Helius/HTTP live et la convergence multi-source, puis `0.3.14` ferme le gap repair/hardening multi-source. `prompts/031-V0_3_12_START_PROMPT.md` ouvre `0.3.12` uniquement depuis le tag stable `v0.3.11`; son `pre.001` doit réauditer les surfaces Yellowstone/Solana/provider actuelles, l’injection Transport, l’hydration, le frontier, `from_slot`/ReplayInfo, les smokes accessibles et le sizing avant toute implémentation lourde.
|
||||||
|
|
||||||
|
## 0.3.10 — Lower-layer RAW Transaction commune + qualification cross-source — 2026-09-08
|
||||||
|
|
||||||
|
`0.3.10` introduit `ksp-raw-transaction-lib` comme lower-layer source-neutral commune aux producteurs `RawTransaction`. La canonicalisation RAW v1 auparavant locale à `ksp-job-backfill-lib` est extraite sans modifier les golden bytes/hash existants ; le Backfill consomme désormais cette common crate sans créer de dépendance vers un Worker. La bibliothèque construit les modèles `RawTransaction`/`RawTransactionObservation` de `ksp-store-api`, conserve l'identité canonique `(network, signature)`, borne et redacted les entrées hostiles, et reste volontairement sans Transport, Config, runtime async, Job, Worker ou backend Store physique.
|
||||||
|
|
||||||
|
La common matérialise également un wire Solana source-neutral Legacy/V0/V1 avec sérialisation Base64 et extraction de signature depuis le matériau transactionnel complet. La représentation V1 couvre header, compiled instructions, configuration V1 et signatures terminales ; les gardes structurelles rejettent les états non supportés plutôt que d'inventer une compatibilité. `ksp-onchain-transport-lib` ajoute `get_block_observed`, qui conserve l'identité sûre de la route réellement gagnante tout en réutilisant la validation `getBlock` existante.
|
||||||
|
|
||||||
|
Les preuves cross-source ferment explicitement ce qui est qualifié et ce qui exige encore hydration. Un `blockSubscribe` standard `full/base64` peut produire le même RAW v1 qu'un `getBlock` HTTP sur le sous-ensemble déterministe prouvé. Helius `transactionSubscribe` reste un signal nécessitant hydration HTTP lorsque `blockTime`/version manquent. Yellowstone qualifie le wire Transaction V1 et la conservation du `block_time` dans Block, mais `Transaction` nécessite hydration pour le temps de bloc et le chemin Block complet reste conservateur tant que l'identité byte-for-byte entre meta protobuf et meta JSON HTTP n'est pas prouvée. Aucun RAW-direct Yellowstone complet ni feed EARLY n'est revendiqué sans preuve.
|
||||||
|
|
||||||
|
Le gate technique final `pre.007` passe les audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features`, les suites ciblées common/Transport/Backfill et les graphes Cargo demandés ; les smokes live opt-in restent ignorés conformément à leur contrat. La réconciliation `pre.008` aligne ensuite la terminologie durable des couches Store sur `D1 RAW -> D2 STRUCTURAL -> D3 DECODED -> D4 DOMAIN`, réserve `N1–N4` aux niveaux architecturaux et complète `README.md`/`USAGE.md` de `ksp-raw-transaction-lib`. Son gate opérateur repasse format, audits, `cargo check --workspace` et Clippy strict sur `0.3.10-pre.8`.
|
||||||
|
|
||||||
|
Le runtime concret a été volontairement retiré de `0.3.10` après recalibrage du sizing. La trajectoire devient `0.3.11` fondation runtime/persistence déterministe, `0.3.12` Yellowstone + hydration/continuité, `0.3.13` WS standard/Helius/HTTP live + convergence multi-source, `0.3.14` gap repair/hardening/smokes, `0.3.15` Desk d'ingestion et `0.3.16` extension Backfill multi-source. Le Job historique paramétré et le Worker continu restent des producteurs indépendants du même Store.
|
||||||
|
|
||||||
|
`prompts/030-V0_3_11_START_PROMPT.md` ouvre `0.3.11` uniquement depuis le tag stable `v0.3.10`. Son `pre.001` doit auditer la base réelle, le graphe de dépendances, le runtime/supervision, les bornes de channels, la persistence/déduplication déterministe, les snapshots et le dimensionnement avant toute implémentation lourde ; aucune source live complexe n'est requise pour fermer cette release.
|
||||||
|
|
||||||
|
## 0.3.9 — Worker API générique + audit exhaustif d'acquisition RawTransaction — 2026-09-05
|
||||||
|
|
||||||
|
`0.3.9` introduit `ksp-worker-api` comme façade générique, courte et runtime-neutral pour services continus. La surface stable reste Core-only et expose `WorkerId`, `WorkerKindCode`, `WorkerState`, `WorkerHealth`, `WorkerActivity`, `WorkerLifecycle`, `WorkerStopToken`, `WorkerSnapshotSequence`, `WorkerSnapshot`, `WorkerSnapshotFuture` et `WorkerSnapshotSource`. Les identités sont bornées et redacted, le lifecycle possède des transitions explicites avec terminaux immuables, le stop token est partagé/idempotent, la séquence ne wrappe pas et le snapshot source latest-value reste object-safe, `Send + Sync` et implémentable depuis l'extérieur. L'API ne possède aucun runtime `start/stop` universel, aucune sémantique Job/checkpoint, aucun domaine Solana et aucune dépendance Transport/Store/Config/Tauri.
|
||||||
|
|
||||||
|
Après freeze de cette API, la seconde moitié de la release réalise puis consolide l'audit exhaustif des voies d'acquisition `RawTransaction`. Le document durable `docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md` est réécrit comme une synthèse unique Store-centrique : `RawTransaction` est le cœur RAW durable, tandis que `RawTransactionObservation` conserve les provenances multiples. L'audit distingue systématiquement possibilité protocolaire/provider, support architectural KSP, support implémenté et preuve live ; il couvre HTTP, WebSocket standard, Helius `transactionSubscribe`, Yellowstone gRPC, acquisition par blocs/slots, discovery + hydration, replay/gap repair, archives et sources EARLY/pre-execution. Les matrices recensent aussi les principaux providers/réseaux/tier/coûts connus sans réduire l'architecture aux seuls comptes gratuits actuellement disponibles.
|
||||||
|
|
||||||
|
La séparation des producteurs RAW est figée. `ksp-job-backfill-lib` reste un producteur historique **paramétré, borné et terminable** ; le futur `ksp-worker-raw-transaction-ingest-lib` sera un producteur live **continu start/stop sans requête métier historique**. Ils ne s'appellent pas, ne se supervisent pas et ne collaborent pas : chacun alimente indépendamment le même Store. HTTP, WS, gRPC, replay et archive sont des capabilities orthogonales à ces rôles ; un Worker peut utiliser HTTP pour hydration ou réparation de sa propre continuité live, tandis qu'un Job pourra utiliser gRPC/replay si une campagne historique bornée le justifie. Le handoff `0.3.10` retient `ksp-raw-transaction-lib` comme lower-layer source-neutral commune afin d'extraire la canonicalisation RAW v1 aujourd'hui locale au Backfill sans duplication ni edge Job ↔ Worker ; les golden bytes/hash RAW v1 doivent rester inchangés.
|
||||||
|
|
||||||
|
La release normalise aussi l'identité réseau KSP : `mainnet` devient l'identité canonique dans Config/Store/Transport/tests et `mainnet-beta` reste seulement un alias legacy/externe lorsque la frontière provider l'exige. Les données Mainnet N1 encore utilisées comme données de test ne dictent aucune compatibilité durable et aucune migration SQL n'est introduite pour préserver l'ancien libellé. Pendant le gate final, les baselines workspace sont également relevées à `jsonschema ^0.53` et `yellowstone-grpc-proto ^12.7`; Yellowstone 12.7 ajoute le RPC serveur `SubscribeGossip`, ce qui nécessite uniquement l'adaptation des fixtures `Geyser` avec une réponse `UNIMPLEMENTED` et n'ouvre aucune capability Gossip de production.
|
||||||
|
|
||||||
|
Le gate technique final passe les audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, 385/385 tests unitaires `ksp-onchain-transport-lib`, 43/43 canaries `release_completeness`, `cargo test --workspace --all-targets --all-features`, le bundle complet `ksp-worker-api` et les graphes Cargo jusqu'à `cargo tree --duplicates`; seuls les smokes/probes explicitement opt-in restent ignorés. La réconciliation documentaire `pre.008` réaligne ensuite README/USAGE/indexes/architecture/plan/validation et son gate opérateur repasse audits Rust/Markdown plus `cargo check --workspace` en `0.3.9-pre.8`.
|
||||||
|
|
||||||
|
`prompts/029-V0_3_10_START_PROMPT.md` ouvre `0.3.10` exclusivement depuis le tag stable `v0.3.9`. La prochaine release doit d'abord matérialiser `ksp-raw-transaction-lib`, migrer le Backfill vers cette canonicalisation commune sans changement fonctionnel, puis construire `ksp-worker-raw-transaction-ingest-lib` multi-source dès V1. Son `pre.001` est obligatoirement un gate d'audit/sizing : inventaire exact des gaps Transport/Config, runtime Worker, capabilities live, continuité, preuves provider et dépendances avant toute implémentation lourde.
|
||||||
|
|
||||||
|
## 0.3.8 — Store Desk V1 RAW, inspection backend-neutral et trajectoire Worker multi-source — 2026-09-04
|
||||||
|
|
||||||
|
`0.3.8` introduit `ksp-app-store-desk`, application Tauri KSP read-only dédiée à l'inspection du Store. Le package reprend le gabarit Desk commun (splash/shell/assets/styles/tracing), compose `ksp-config-lib`, `ksp-logging-lib` et la seule façade `ksp-store-lib`, et interdit à l'application tout accès direct à `ksp-store-api`, `ksp-store-postgres-lib`, SQL, driver/pool PostgreSQL, Transport on-chain ou ressource physique. Le bootstrap ouvre un Store lié au profil/réseau composite, expose health/runtime backend-neutral et effectue un shutdown borné à la fermeture de la fenêtre principale.
|
||||||
|
|
||||||
|
Pour éviter de détourner la pagination machine existante, Store API ajoute une primitive d'inspection backend-neutral distincte : `RawInspectionPageRequest`/`RawInspectionPage<T>`, summaries `RawTransaction`/`RawAccountState` sans gros bytes et quatre capabilities d'inspection couvrant entités et observations. Le backend PostgreSQL implémente les vues d'inspection par `COUNT` exact + `OFFSET/LIMIT` contrôlé, tandis que les queries historiques `RawPage` restent keyset/cursor, sans `OFFSET`, pour workers, backfills et replays. La façade `ksp-store-lib` conserve ainsi le même ensemble de 14 capabilities RAW que le backend sans exposer de détail physique.
|
||||||
|
|
||||||
|
La Desk projette `RawTransaction` et `RawAccountState` via DataTables `serverSide` avec **un seul pager visible**, filtres Store explicites, tailles de page bornées et counts vérifiés avant conversion JavaScript. Les détails sont chargés à la demande avec preview RAW bornée à 512 octets ; les rows ne transportent pas le payload transactionnel ni les bytes account. Les observations Transaction/Account sont elles aussi paginées côté serveur dans les détails et n'exposent que la provenance sûre déjà normalisée par Store API : provider/protocol/method/origin, timestamps, endpoint/session/filter/commitment optionnels, hash/taille de payload source et metadata Yellowstone account lorsqu'elle existe, jamais les bytes source. Les identifiants longs utilisent affichage tronqué, tooltip et copie explicite ; le tracing frontend reste metadata-only et ne journalise aucune valeur métier copiée.
|
||||||
|
|
||||||
|
Le hardening final fige les inventaires Cargo/npm/modules/commands/capabilities Tauri, la composition Config, l'absence de réseau/browser-storage/SQL/backend physique dans le frontend, les DTO IPC sans curseur machine et la coexistence exacte entre navigation keyset et inspection random-access. Le correctif `pre.012-fix.001` réaligne aussi `tsconfig.json` Store Desk byte-for-byte sur le gabarit TypeScript commun des autres Desk, ce qui rétablit le build `tsc && vite build` des imports CSS DataTables et ajoute un canari de non-divergence.
|
||||||
|
|
||||||
|
Le gate technique autoritaire passe `cargo fmt --all -- --check`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features`, `ksp-store-lib --no-default-features`, graphes Cargo et build Tauri Store Desk ; il comptabilise 1 592 tests passés, 0 échec et 15 tests opt-in/operator-only ignorés. Après la réconciliation documentaire, un rerun propre depuis `cargo clean` confirme encore les audits/check/Clippy/tests et reconstruit avec succès les cinq Desk KSP Config, SOL Prices, Wallet, Backfill et Store en bundles Linux `.deb` + `.rpm`, sans rouvrir le runtime.
|
||||||
|
|
||||||
|
La trajectoire suivante est explicitement redécoupée pour éviter de répéter l'hypothèse HTTP du premier backfill. `0.3.9` introduira d'abord `ksp-worker-api` comme abstraction générique de service continu, puis seulement **après fermeture fonctionnelle de cette API** produira un audit exhaustif des sources/méthodes `RawTransaction`. Cet audit couvrira HTTP, WS standard, extensions provider, Yellowstone gRPC, block/slot, discovery + hydration, replay/gap-repair et multi-provider, et classera les sources comme alternatives, complémentaires, redondantes ou spécialisées. Il servira d'entrée architecturale à `0.3.10`, qui introduira `ksp-worker-raw-transaction-ingest-lib` multi-source dès V1 et ne modifiera Transport/Config que pour les adaptations réellement justifiées par l'audit. Les futures sources Helius HTTP/WS Mainnet/Devnet devront réutiliser `KSP_SECRET_HELIUS_API_KEY` via Config ; aucune URL Helius n'est ajoutée par `0.3.8`. La terminologie `mainnet`/`mainnet-beta` sera auditée avant tout changement afin de préserver une identité KSP unique pour le même cluster.
|
||||||
|
|
||||||
|
`0.3.11` ouvrira ensuite `ksp-app-raw-transaction-ingest-desk`, capable de choisir/superviser une ou plusieurs sources/méthodes sans dupliquer la logique du worker. `0.3.12` reviendra sur `ksp-job-backfill-lib` et `ksp-app-backfill-desk` pour ajouter les autres stratégies historiques/catch-up pertinentes issues du même audit ; la verticale `getSignaturesForAddress + getTransaction` de `0.3.6`/`0.3.7` reste une première stratégie HTTP valide, pas la définition générale du backfill KSP. kbot3 reste uniquement une référence fonctionnelle pour ces audits, jamais une source de code, DTO, Config, URL ou dépendance.
|
||||||
|
|
||||||
|
`prompts/028-V0_3_9_START_PROMPT.md` ouvre `0.3.9` sur cette base : Worker API doit rester Core-only/générique et être stabilisée avant l'audit RAW Transaction, lequel prépare `0.3.10` sans ajouter de worker concret ni d'endpoints provider pendant `0.3.9`.
|
||||||
|
|
||||||
|
## 0.3.7 — Backfill Desk : contrôle, monitoring, Cancel/Resume et autocomplete — 2026-09-03
|
||||||
|
|
||||||
|
`0.3.7` introduit `ksp-app-backfill-desk`, première application Tauri KSP spécialisée dans le contrôle d'un `ksp-job-backfill-lib` historique `RawTransaction`. L'application reste une couche de composition : `ksp-config-lib` possède les profils/composites/secrets, `ksp-onchain-transport-lib` le pool HTTP et les politiques provider/retry/rate-limit, `ksp-store-lib` la persistence backend-neutral, `ksp-job-backfill-lib` la découverte/hydratation/frontier/checkpoint/runtime et `ksp-job-api` le lifecycle commun. La Desk ne dépend ni d'un backend Store physique, ni de SQL, ni d'un SDK provider.
|
||||||
|
|
||||||
|
Le package reprend le gabarit Tauri KSP courant avec cible lib + bin, launcher mince, splash commun, bridge `tracing`, frontend sous `frontend/`, bindings TS-RS applicatifs, ports stricts `1436/1437` et packaging Config/Schemas distribué. Le composite `cfg.composite.ksp-app-backfill-desk` couvre `mainnet`, `devnet` et `testnet`; Mainnet est le profil par défaut et la cohérence réseau Transport/Store est vérifiée avant ouverture d'une campagne. Les profils Store committed ont été réconciliés sur TLS PostgreSQL `disabled` pour l'environnement local de validation, sans retirer le support `verify_full` du Store/schema.
|
||||||
|
|
||||||
|
Le formulaire Backfill couvre `LatestAddress`, `BeforeAddress`, `AfterAddress` et `ExplicitSignatures`, les commitments `finalized`/`confirmed`, la sélection d'un rôle HTTP logique, `min_context_slot` lorsque le scope l'autorise, ainsi que les bornes publiques de page/candidats/concurrence. Le frontend ne choisit ni réseau physique, ni endpoint/provider, ni JobId; le réseau dérive de la composition Store et les validations finales restent déléguées à `BackfillRequest`.
|
||||||
|
|
||||||
|
Le Start réel conserve un seul run actif, génère le JobId côté backend et installe le handle de contrôle avant le spawn asynchrone. Le monitoring est latest-value : `BackfillSnapshotSource` est projeté via l'événement `ksp-backfill-status`, avec commande de resynchronisation explicite et rétention du dernier terminal. La projection expose lifecycle, phase, scope, boundary, compteurs de candidats/entités/observations, missing/conflicts/holes, concurrence maximale, frontier contiguë, présence de checkpoint et codes d'échec sûrs, sans adresse/signatures de campagne, payload RAW, URL, credential, contexte d'erreur arbitraire ni cursor/checkpoint concret.
|
||||||
|
|
||||||
|
Le Cancel est coopératif, ciblé par le JobId backend et idempotent : un Cancel IPC retardé ne peut pas annuler le run suivant, un terminal gagné reste autoritaire et la fermeture de l'application bloque d'abord les nouveaux Starts puis demande best-effort l'annulation avant le shutdown Store. Le Resume est limité à la session courante : la Desk conserve en mémoire Rust la requête terminale et le checkpoint opaque, `ksp-job-backfill-lib` valide puis réémet le checkpoint pour un nouveau JobId sans changer le scope fingerprint/frontier, et aucun checkpoint durable n'est promis après redémarrage.
|
||||||
|
|
||||||
|
Le polish final ajoute une saisie d'adresse libre avec autocomplete HTML `datalist` dérivé exclusivement de `ksp-core-lib::entries()`/`ProgramIdEntry`; aucune liste de Program IDs n'est hardcodée côté frontend et la sélection n'est jamais une allow-list. Les canaris de hardening figent ensuite l'inventaire des dépendances, modules, commandes Tauri, contrôles frontend, profils Config, frontières Store/provider, sécurité IPC et discipline crate-root des DTO partagés.
|
||||||
|
|
||||||
|
Le gate final passe `cargo fmt --all -- --check`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features avec `-D warnings`, `cargo test --workspace --all-targets --all-features`, arbres Cargo ciblés, `cargo tree --duplicates` et `cargo tauri build`. Le run workspace enregistre 1 494 tests passés, 0 échec et 15 tests ignorés explicitement opt-in/operator-only; le build produit les bundles Linux `.deb`, `.rpm` et `.AppImage`.
|
||||||
|
|
||||||
|
`prompts/027-V0_3_8_START_PROMPT.md` ouvre `0.3.8` sur `ksp-app-store-desk` V1. Le gabarit, le visuel, la structure Tauri et les dépendances npm de base viennent exclusivement des Desk KSP actuelles; DataTables reprend le pattern déjà utilisé par Config/Wallet Desk. L'archive kbot3 reste obligatoire uniquement comme référence fonctionnelle/UX des anciens diagnostics et tableaux RAW : aucune source, DTO, commande, SQL, gabarit ou version npm kbot3 n'est reprise. La première tranche doit auditer les capabilities Store existantes et les gaps éventuels de listing/diagnostic avant d'ouvrir des tableaux `RawTransaction`, `RawAccountState`, observations et rétention/tombstones via la seule façade `ksp-store-lib`.
|
||||||
|
|
||||||
|
## 0.3.6 — Job API + backfill RAW historique observable — 2026-09-01
|
||||||
|
|
||||||
|
`0.3.6` introduit `ksp-job-api` comme contrat passif et runtime-neutral pour les traitements bornés/terminables : identité `JobId`/`JobKindCode`, lifecycle explicite, annulation coopérative partagée, snapshots typés et notifications latest-value consommables par plusieurs listeners sans transformer l'API en scheduler, runtime Tokio ou bus d'événements. La surface reste Core-only et sert immédiatement au premier consumer concret `ksp-job-backfill-lib`.
|
||||||
|
|
||||||
|
`ksp-job-backfill-lib` ferme une première verticale historique `RawTransaction` sur les abstractions KSP existantes. Les scopes `LatestAddress`, `BeforeAddress`, `AfterAddress` et `ExplicitSignatures` sont bornés par pages/candidats/concurrence, dédupliqués de manière stable et liés à un fingerprint sémantique réseau/scope qui exclut volontairement provider, endpoint, rôle Transport et `JobId`. La découverte utilise les wrappers typés de `ksp-onchain-transport-lib`; l'hydratation passe exclusivement par `get_transaction_observed`, conserve la provenance du provider/endpoint gagnant et transforme la réponse en RAW v1 canonique avec hash SHA-256 exact, sans SDK provider ni modèle wire dupliqué.
|
||||||
|
|
||||||
|
La persistance passe uniquement par `ksp-store-lib` et son écriture atomique `RawTransaction + RawTransactionObservation` en mode normal. Les résultats distinguent insertion, présence idempotente, tombstone purgé, transaction manquante et conflit de contenu ; aucun read-before-write, `ForceRehydrate`, SQL, backend PostgreSQL direct ou décision de rétention n'est déplacé dans Job. Le réseau Store est vérifié avant écriture et l'identité logique de transaction reste `(RawNetworkId, Signature)` indépendamment de la source d'acquisition.
|
||||||
|
|
||||||
|
Le runtime Backfill ajoute concurrence bornée, réconciliation des terminaisons hors ordre, frontier strictement contiguë et checkpoint caller-owned. Les reprises `Latest`, `Before`, `After` et `ExplicitSignatures` ne sautent que le préfixe durable réellement prouvé. `BackfillJobRuntime` expose un `BackfillJobHandle` clonable avec annulation coopérative et `BackfillSnapshotSource` latest-value ; les opérations pré-Store peuvent être abandonnées, tandis qu'une persistance déjà soumise est toujours drainée avant la décision terminale. Les snapshots conservent phase, compteurs, holes/conflicts/missing, maximum in-flight, frontier/checkpoint et code d'échec sans projeter payload RAW, secret, URL ou état runtime interne.
|
||||||
|
|
||||||
|
Le hardening externe verrouille les codes d'erreur, bornes adversariales, redaction `Debug`, indépendance du fingerprint, absence de fuite Tokio/Futures/backend dans la façade, dépendances backend-neutral et discipline d'import/visibilité crate-root. La réconciliation documentaire finale ajoute les README/USAGE durables de Job API et Backfill et aligne les architectures Jobs/Workers/Apps sans transformer les guides d'utilisation en journal de release.
|
||||||
|
|
||||||
|
Les gates de clôture passent `cargo fmt --all -- --check`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace `--all-targets --all-features -- -D warnings`, `cargo test --workspace --all-targets --all-features`, tests ciblés Job/Backfill et graphes Cargo. Les smokes réseau/Tauri déjà explicitement opt-in restent ignorés par défaut ; aucune preuve non exécutée n'est déclarée PASS.
|
||||||
|
|
||||||
|
## 0.3.5 — Interface acquisition events partagés — 2026-08-31
|
||||||
|
|
||||||
|
`0.3.5` étend `ksp-interface-lib` avec deux familles passives d'acquisition réellement partagées, sans transformer Interface en runtime, en event bus ou en seconde couche RAW. `SlotLifecycleEvent` expose un `slot` et un `SlotLifecycleStage` non exhaustif limité à `Processed`, `FirstShredReceived`, `Completed`, `CreatedBank`, `Dead`, `OptimisticallyConfirmed` et `Rooted`. La normalisation conserve la sémantique commune : les notifications Solana `optimisticConfirmation` et Yellowstone `Confirmed` convergent vers `OptimisticallyConfirmed`, tandis que Solana `root` et Yellowstone `Finalized` convergent vers `Rooted`; les différences d'ordre, de complétude, de replay et de transport restent la responsabilité du producteur/Transport.
|
||||||
|
|
||||||
|
La seconde famille matérialise le fait minimal d'exécution transactionnelle partagé par plusieurs sources. `TransactionSignature` possède exactement 64 octets et un `Debug` redacted, `TransactionExecutionOutcome` distingue seulement `Succeeded` et `Failed`, et `TransactionExecutionEvent` transporte uniquement `slot + signature + outcome`. Les logs, erreurs provider détaillées, commitments, indexes, timestamps, filtres et payloads complets restent Transport-owned. Les snapshots HTTP `getSignatureStatuses`, les transitions one-shot `signatureSubscribe`, un éventuel `TransactionLogEvent`, les votes et les entrées Yellowstone ne sont pas fusionnés artificiellement dans ce contrat.
|
||||||
|
|
||||||
|
La frontière d'ownership reste stricte : les DTOs wire/provider demeurent dans `ksp-onchain-transport-lib`, les événements passifs provider-neutral sont Interface-owned, et les modèles persistants/replayables `RawTransaction` / `RawAccountState` restent `ksp-store-api`. `ksp-interface-lib` conserve exactement `ksp-core-lib` comme seule dépendance normale, sans feature propre, dev/build dependency, serde, codec, logging ou runtime. Les canaris publics, external consumer, inventaires exacts et hardening vérifient également qu'aucun second RAW, metadata source ou payload hostile n'entre dans la surface Interface.
|
||||||
|
|
||||||
|
Les gates de clôture passent audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, tests ciblés Interface/Program API, `cargo test --workspace` et graphes Cargo. `cargo tree -p ksp-interface-lib --edges normal` confirme le chemin `ksp-interface-lib -> ksp-core-lib -> solana-pubkey -> solana-address`; le graphe features ne montre aucune feature propre Interface et les doublons éventuels restent ceux du workspace global. La documentation durable a été réconciliée et `TransactionLogEvent` est conservé comme idée différée soumise à un nouveau gate consumer/bornes.
|
||||||
|
|
||||||
|
`prompts/025-V0_3_6_START_PROMPT.md` ouvre `0.3.6` sur le développement parallèle de `ksp-job-api` et `ksp-job-backfill-lib`. La release doit reprendre fonctionnellement le backfill historique kbot3 sans en copier le code : audit obligatoire de l'archive historique, `RawTransaction` par adresse via `getSignaturesForAddress` + `getTransaction`, directions/anchors et déduplication, hydratation/persistence via les façades KSP, frontier/checkpoint/reprise, cancellation/concurrency bornées, idempotence et distinctions missing/conflit. `ksp-job-api` doit en parallèle stabiliser lifecycle/progress/outcome et un contrat de notifications/listeners borné pour qu'une couche supérieure puisse visualiser l'état sans parser les logs. Le ROADMAP enchaîne ensuite l'app de backfill/inspection `0.3.7`, `ksp-worker-api` `0.3.8`, `ksp-worker-live-transactions-retriever-lib` `0.3.9` puis une application de monitoring/visualisation Jobs + Workers `0.3.10`, avec reprise du même pattern d'observabilité côté Worker sans fusionner les sémantiques Job/Worker/Store.
|
||||||
|
|
||||||
|
## 0.3.4 — Store/PostgreSQL RawAccountState + complétude RAW — 2026-08-31
|
||||||
|
|
||||||
|
`0.3.4` complète la seconde vertical slice RAW physique sur le couple `ksp-store-lib` / `ksp-store-postgres-lib` et ferme la conformance PostgreSQL des **10 capabilities** backend-agnostic de `ksp-store-api` : les six capabilities `RawTransaction*` acquises en `0.3.3` restent intactes et les quatre capabilities `RawAccountStateRead`, `RawAccountStateWrite`, `RawAccountObservationRead` et `RawAccountObservationWrite` sont désormais implémentées par `PostgresBackend` puis dispatchées par la façade `Store`. La séparation reste stricte : les consommateurs ordinaires passent par `ksp-store-lib`, le backend PostgreSQL conserve SQL/driver/pool/TLS/migrations privés, et la façade reste compilable/testable sans backend via `--no-default-features`.
|
||||||
|
|
||||||
|
La migration additive V002 introduit `ksp_raw_account_states` et `ksp_raw_account_observations` au-dessus de V000/V001 sans modifier leurs bytes. L'identité canonique account est `(pubkey, slot, state_hash)` : plusieurs états d'un même compte dans un même slot restent représentables lorsque `state_hash` diffère, tandis qu'une collision sur la référence complète déclenche une comparaison exacte de `lamports`, `owner`, `executable`, `rent_epoch` et `data` avant de conclure à l'idempotence ou à `store_api.raw_conflict`. Les `u64` physiques utilisent `NUMERIC(20,0)`, les clés/hashes/signatures utilisent des `BYTEA` de largeur contrainte, et les bytes account restent complets jusqu'à la borne KSP de 16 MiB. V002 contient exactement 32 ressources gérées et son checksum final est `ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e`; V000 et V001 restent respectivement `d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450` et `31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51`.
|
||||||
|
|
||||||
|
L'acquisition `RawAccountState + RawAccountObservation` est transactionnelle : `INSERT ... ON CONFLICT DO NOTHING`, lecture/verrouillage du canonical en collision, comparaison exacte et rollback complet lorsque l'observation diverge. Une observation supplémentaire vérifie la référence existante sous transaction et ne crée jamais implicitement son state. Les métadonnées Yellowstone `is_startup`, `transaction_signature` et `write_version` restent optionnelles et observation-only ; aucune FK transaction n'est inventée. La navigation account utilise une keyset `(slot, pubkey, state_hash)` ASC/DESC, avec filtre pubkey optionnel et cursor KSPA V1 opaque de 109 octets lié au réseau, au filtre, au range, à la direction et à la famille afin d'empêcher les replays cross-query/cross-family. Aucun `OFFSET`, plafond métier de batch, index owner/provider/time ou lifecycle destructif account n'est introduit.
|
||||||
|
|
||||||
|
Les gates de clôture valident audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, tests ciblés Store/API/PostgreSQL/Config, `cargo test --workspace`, façade sans feature PostgreSQL et graphes Cargo. Le live `postgres_raw_account_live` passe sur **PostgreSQL 17** avec bootstrap V000/V001/V002, round-trip des bytes et du domaine `u64`, idempotence/conflit, états distincts même pubkey+slot, observations/metadata optionnelles, pagination/cursors, concurrence, annulation/rollback et coexistence `RawTransaction`. La réconciliation documentaire finale est ensuite validée sans réouvrir code ni migrations.
|
||||||
|
|
||||||
|
`prompts/024-V0_3_5_START_PROMPT.md` ouvre `0.3.5` sur `ksp-interface-lib` uniquement. Cette release doit réauditer les surfaces d'acquisition actuelles et matérialiser seulement les modèles passifs/event-only réellement partagés, sans recopier `RawTransaction`/`RawAccountState`, sans créer un event bus et sans déplacer les DTOs provider/transport. Les candidats logs, slot/root/slotsUpdates, transaction status et vote sont traités par matrice sémantique ; une famille reste reportée si la convergence ou le consumer réel n'est pas démontré. L'archive historique kbot3 n'est pas requise pour ce gate : les sources de vérité sont la base KSP stable et les contrats officiels actuels des transports concernés.
|
||||||
|
|
||||||
## 0.3.3 — Store/PostgreSQL RawTransaction vertical slice — 2026-08-30
|
## 0.3.3 — 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.
|
`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.
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 363
|
# version: 565
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"]
|
members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-raw-transaction-lib", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib", "crates/ksp-worker-api", "crates/ksp-worker-raw-transaction-ingest-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.3"
|
version = "0.3.13"
|
||||||
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"
|
||||||
@@ -25,7 +25,7 @@ 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.52", default-features = false }
|
jsonschema = { version = "^0.53", default-features = false }
|
||||||
reqwest = { version = "^0.13", default-features = false }
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
rustls = { version = "^0.23", default-features = false }
|
rustls = { version = "^0.23", default-features = false }
|
||||||
rustls-native-certs = { version = "^0.8", default-features = false }
|
rustls-native-certs = { version = "^0.8", default-features = false }
|
||||||
@@ -49,7 +49,7 @@ tokio-tungstenite = { version = "^0.30", default-features = false }
|
|||||||
tonic = { version = "^0.14", default-features = false }
|
tonic = { version = "^0.14", default-features = false }
|
||||||
tonic-prost = { version = "^0.14", default-features = false }
|
tonic-prost = { version = "^0.14", default-features = false }
|
||||||
ts-rs = { version = "^12.0" }
|
ts-rs = { version = "^12.0" }
|
||||||
yellowstone-grpc-proto = { version = "^12.6", default-features = false }
|
yellowstone-grpc-proto = { version = "^12.7", default-features = false }
|
||||||
zeroize = { version = "^1.9" }
|
zeroize = { version = "^1.9" }
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: README.md -->
|
<!-- file: README.md -->
|
||||||
<!-- version: 7 -->
|
<!-- version: 14 -->
|
||||||
|
|
||||||
# Khadhroony Solana Project
|
# Khadhroony Solana Project
|
||||||
|
|
||||||
@@ -47,6 +47,18 @@ Les besoins du trading constituent une priorité produit à court terme mais ne
|
|||||||
- Aucun `rust-toolchain.toml` n'est utilisé.
|
- Aucun `rust-toolchain.toml` n'est utilisé.
|
||||||
- Les environnements et contraintes des démonstrations doivent être visibles dans leur nomenclature lorsqu'ils ne sont pas sélectionnables.
|
- Les environnements et contraintes des démonstrations doivent être visibles dans leur nomenclature lorsqu'ils ne sont pas sélectionnables.
|
||||||
|
|
||||||
|
## Fondations opérationnelles actuelles
|
||||||
|
|
||||||
|
La couche RAW dispose d'une façade Store backend-neutral et d'un premier job historique concret. `ksp-job-api` porte les contrats passifs communs des jobs bornés ; `ksp-job-backfill-lib` compose Transport observé et Store pour découvrir, hydrater et persister des transactions historiques RAW avec concurrence bornée, checkpoint contigu caller-owned, annulation coopérative et snapshots latest-value.
|
||||||
|
|
||||||
|
`ksp-app-backfill-desk` fournit la surface desktop spécialisée de composition de ce runtime : sélection d'un scope historique et d'un rôle HTTP logique, validation backend des bornes, Start single-run, monitoring latest-value, Cancel ciblé et Resume in-session sur checkpoint Rust-only. L'application ouvre Transport et Store via leurs façades KSP, ne dépend d'aucun backend Store concret et n'expose ni URL/credential, ni payload RAW, ni checkpoint opaque au frontend.
|
||||||
|
|
||||||
|
`ksp-app-store-desk` fournit l'inspection desktop read-only du Store RAW via `ksp-store-lib` : health/runtime backend-neutral, tables server-side `RawTransaction` et `RawAccountState`, observations associées, détails avec previews bornées et lecture des états de rétention/tombstones. DataTables possède l'unique pagination visible de l'inspection random-access ; la pagination cursor/keyset des consumers machine reste distincte et intacte.
|
||||||
|
|
||||||
|
`ksp-worker-api` fournit désormais la fondation générique des services continus : identité Worker, lifecycle borné, health/activity, intention de stop coopératif et observation latest-value. Cette API reste Core-only, runtime-neutral et sans connaissance Solana, Transport, Store ou Job. Elle ne démarre ni n'arrête elle-même un runtime concret.
|
||||||
|
|
||||||
|
`ksp-worker-raw-transaction-ingest-lib` est le premier Worker concret distinct du Job Backfill. Il conserve sa fondation Start/Stop, admission bornée, Common RAW, Store backend-neutral, shutdown borné et snapshots latest-value. Ses sources productives couvrent maintenant Yellowstone + hydration HTTP `getTransaction`, Standard WS `logsSubscribe` + hydration, Standard WS `blockSubscribe` RAW-direct, Helius `transactionSubscribe` + hydration et Solana HTTP live block polling. Le polling HTTP démarre au `getSlot` observé du run, découvre uniquement les blocs live via `getBlocksWithLimit` puis matérialise les slots listés via `getBlock observed`; il n'est jamais utilisé comme Backfill implicite. Les signaux hydratés de même `(network, signature, commitment)` convergent vers le même coordinator, tandis que les voies block RAW-direct passent par la même admission Common RAW. Le reconnect/replay reste propriétaire de Transport, la frontier de traitement reste run-local et un gap de rétention prouvé provoque un fault au lieu de lancer une campagne historique. `ksp-job-backfill-lib` conserve son rôle historique paramétré et borné ; les deux producteurs restent indépendants et convergent uniquement vers les mêmes contrats RAW/Store.
|
||||||
|
|
||||||
## 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 ;
|
||||||
|
|||||||
54
ROADMAP.md
54
ROADMAP.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 97 -->
|
<!-- version: 111 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
|
|
||||||
### Cadrage
|
### Cadrage
|
||||||
|
|
||||||
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/CORE/DECODE/SPECIALIZED stabilisés.
|
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/STRUCTURAL/DECODED/DOMAIN stabilisés.
|
||||||
- [X] `0.2.1` — HTTP foundation stable : matrice 52+14, runtime/routing/résilience/exécution HTTP, 4 canaris typés, `std.transport`, adapter Config -> Transport, canaries de clôture, smoke Devnet opt-in et documentation durable validés ; les 48 wrappers typés restants sont reportés à `0.2.2`–`0.2.4`.
|
- [X] `0.2.1` — HTTP foundation stable : matrice 52+14, runtime/routing/résilience/exécution HTTP, 4 canaris typés, `std.transport`, adapter Config -> Transport, canaries de clôture, smoke Devnet opt-in et documentation durable validés ; les 48 wrappers typés restants sont reportés à `0.2.2`–`0.2.4`.
|
||||||
|
|
||||||
### Releases fonctionnelles décidées/pressenties
|
### Releases fonctionnelles décidées/pressenties
|
||||||
@@ -87,28 +87,45 @@ RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **RAW** et **STRUCTURAL** ne nécessitent aucun décodage Program.
|
- **RAW** et **STRUCTURAL** ne nécessitent aucun décodage Program.
|
||||||
- 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.
|
- La progression n'est pas une chaîne obligatoire pour chaque famille : une donnée event-only peut s'arrêter en D1, et un état de compte pourra aller directement vers un futur decoder si aucune décomposition STRUCTURAL utile n'existe.
|
||||||
- À la fin des couches horizontales RAW/STRUCTURAL réellement persistées, ajouter les jobs/workers/apps nécessaires avant d'ouvrir la couche suivante.
|
- À 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.
|
- À 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
|
||||||
|
|
||||||
- [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.
|
- [X] `0.3.1` — `ksp-store-api` stable : modèles D1 RAW backend-agnostic `RawTransaction` et `RawAccountState` avec observations, provenance, payload/hash/timestamps bornés, 10 capabilities object-safe, queries cursorisées sans plafond métier arbitraire, outcomes idempotence/conflit et lifecycle logique rétention/tombstone/force-rehydrate ; aucun backend physique, Config, runtime Store, notification dédiée ni surface STRUCTURAL/DECODED/DOMAIN.
|
||||||
- [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.
|
- [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.
|
||||||
- [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.
|
- [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` — Étendre le même couple avec `RawAccountState` + `RawAccountObservation` : quatre capabilities account, migration additive au-dessus de V000/V001, acquisition state+observation atomique, idempotence/conflit, get/list cursorisé, puis complétude des dix capabilities RAW, indexes justifiés par les queries et hardening PostgreSQL cross-family final.
|
- [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.
|
||||||
- [ ] `0.3.5` — Étendre `ksp-interface-lib` uniquement avec les modèles passifs/events réellement partagés par les premiers consumers d’acquisition, sans dupliquer les modèles persistants de `ksp-store-api`.
|
- [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.
|
||||||
- [ ] `0.3.6` — Introduire `ksp-job-api` et un premier job de backfill historique concret consommant `ksp-store-lib`, avec policy/batch-size/progression possédés par le job et non par Store.
|
- [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.
|
||||||
- [ ] `0.3.7` — Introduire une application spécialisée de backfill/inspection RAW.
|
- [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é.
|
||||||
- [ ] 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 à la couche de normalisation générique suivante.
|
- [X] `0.3.8` — `ksp-app-store-desk` V1 RAW livrée sur le gabarit KSP courant comme application Tauri read-only backend-neutral. La Desk compose Config + Logging + `ksp-store-lib`, expose health/runtime sûrs, DataTables `serverSide` pour `RawTransaction`, `RawAccountState` et leurs observations, détails bornés, provenance sûre et états de rétention/tombstone, sans SQL/backend physique/Transport dans l'application. La nouvelle inspection random-access `offset + limit + counts exacts` reste distincte de la pagination machine cursor/keyset conservée pour workers/backfills/replays. Le gate final et les builds Linux `.deb`/`.rpm` sont verts.
|
||||||
|
- [X] `0.3.9` — `ksp-worker-api` stable comme API générique et volontairement courte pour services continus : identité/kind bornés, lifecycle explicite, health/activity sûrs, stop token partagé, séquence/snapshot latest-value et `WorkerSnapshotSource` object-safe, avec dépendance Core-only et sans runtime/start-stop universel, Solana, Transport, Store, Config ou Tauri. Après freeze Worker API, l'audit `RawTransaction` a été consolidé dans une synthèse Store-centrique exhaustive séparant possibilités/support/preuve et classant HTTP, WS standard/provider, Yellowstone gRPC, blocs/slots, discovery + hydration, replay de continuité, archives et sources EARLY. Le Job Backfill historique paramétré et le Worker Ingest live start/stop sont deux producteurs indépendants du même Store ; `mainnet` est désormais l'identité KSP canonique et `mainnet-beta` un alias legacy/externe. Le handoff retient `ksp-raw-transaction-lib` comme lower-layer commune de canonicalisation RAW v1 pour `0.3.10`, sans edge Job ↔ Worker.
|
||||||
|
- [X] `0.3.10` — Lower-layer RAW Transaction commune stabilisée : `ksp-raw-transaction-lib` possède la canonicalisation RAW v1 source-neutral partagée, le Backfill est migré sans changement des golden bytes/hash, Transport expose `get_block_observed`, le wire Solana Legacy/V0/V1 est matérialisé et les canaris HTTP/WS standard/Helius/Yellowstone qualifient précisément les voies directes ou nécessitant hydration. La release se ferme sans runtime Worker concret ; celui-ci commence en `0.3.11` selon le redécoupage `0.3.11`–`0.3.14`.
|
||||||
|
- [X] `0.3.11` — Fondation runtime de `ksp-worker-raw-transaction-ingest-lib` livrée : crate/dependency firewall, settings source-neutral, handle/start-stop, lifecycle, snapshots, supervisor, admission bornée, canonicalisation Common RAW, persistence/déduplication Store déterministe, backpressure et shutdown/fault hardening. La release se ferme volontairement sans source réseau productive ; Yellowstone + hydration HTTP commencent en `0.3.12`.
|
||||||
|
- [X] `0.3.12` — Première source live productive du Worker livrée : Yellowstone `Transaction`/`TransactionStatus`/`Block` vers signaux source-neutral, coalescence bornée puis hydration HTTP `getTransaction` observed avant Common RAW/Store ; `BlockMeta`/`Slot` restent continuity-only. Processing frontier run-local, reconnect/from_slot/ReplayInfo Transport-owned, compteurs source-neutral et fault sur gap de rétention prouvé sans Backfill automatique. Hardening duplicate/backpressure/stop/fault et canaris cross-layer fermés ; gate workspace complet vert, smokes live HTTP Devnet + WebSocket Devnet verts, Yellowstone token-gated/Worker end-to-end laissés explicitement non exécutés.
|
||||||
|
- [X] `0.3.13` — Convergence live multi-source du Worker RAW livrée : composition caller-owned `1..32` sources d’un même réseau, cinq familles Yellowstone / Standard Logs / Standard Block / Helius Transaction / HTTP Block Polling, hydration globale des trois voies reference-bearing, RAW-direct Legacy/V0/V1 pour les deux voies blocs, observations multiples, content conflict explicite, coalescence cross-source, fairness/backpressure/health source-neutral et shutdown hardening sans second actor Transport. Gate workspace complet vert (`1 850` PASS / `0` échec / `15` ignored) et smokes keyless HTTP + WebSocket Devnet verts ; les preuves provider/resource-gated non exécutées restent explicitement non revendiquées.
|
||||||
|
- [ ] `0.3.14` — Fermer le Worker par le **gap repair/hardening multi-source** : replay natif, source redondante, scan HTTP blocs, hydration de réparation, unresolved gaps visibles, politiques degraded/unhealthy/faulted, shutdown pendant repair, smokes provider accessibles et adapter EARLY seulement si réellement prouvé.
|
||||||
|
- [ ] `0.3.15` — Introduire `ksp-app-raw-transaction-ingest-desk`, Desk Tauri KSP spécialisée qui choisit et supervise une ou plusieurs sources/méthodes réellement admises par le Worker finalisé : lifecycle, health, rates, backpressure, reconnect/recovery, gap state et compteurs sûrs. La Desk ne réimplémente ni discovery, hydration, déduplication, reprise ni persistence.
|
||||||
|
- [ ] `0.3.16` — Étendre `ksp-job-backfill-lib` et `ksp-app-backfill-desk` au **backfill multi-source/multi-stratégie** à partir de la même matrice d'acquisition auditée en `0.3.9`. Conserver la stratégie actuelle `getSignaturesForAddress + getTransaction` comme première voie HTTP valide, puis ajouter seulement les voies historiques/catch-up/gap-repair réellement pertinentes et sûres, sans supposer qu'une source live WS constitue un historique universel.
|
||||||
|
|
||||||
### TODO/IDEAS — taxonomie N1, processing et rétention
|
### TODO/IDEAS — applications spécialisées et control plane
|
||||||
|
|
||||||
- [ ] **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.
|
- [ ] **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`.
|
||||||
- [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 reste réservée à `0.3.4`.
|
- [ ] **TODO** — différer une future `ksp-app-control-desk` jusqu'à ce que KSP dispose au minimum d'une couche D3 DECODED de processing/materialization exploitable et de plusieurs decoders réels. Cette application sera une surface **end-user simplifiée** : démarrer/arrêter les flux autorisés, lancer une recherche courante, voir l'état global et les erreurs importantes. Elle ne recopiera pas le diagnostic détaillé, les réglages avancés, les tables complètes ni toutes les fonctions des `ksp-app-*-desk` spécialisées, qui resteront les outils d'administration, développement et investigation approfondie.
|
||||||
- [ ] **TODO** — `TransactionStatusObservation` : réauditer `signatureSubscribe`, `getSignatureStatuses`, Yellowstone TransactionStatus et extensions provider lorsqu’un consumer réel apparaît ; ne pas fusionner snapshot, transition et update dans un modèle Option-soup.
|
|
||||||
- [ ] **TODO** — logs realtime : conserver `logMessages` dans `RawTransaction` jusqu’à la décomposition STRUCTURAL ; traiter `logsSubscribe` comme event-only candidat et décider son contrat passif dans `ksp-interface-lib`, sans table Store par défaut. Le format canonique d’un wake-up « donnée persistée disponible » reste distinct et appartient à `ksp-store-api` conformément à `KSP-NOTIFY-*`, mais ne sera matérialisé qu’avec un publisher/consumer réel.
|
### TODO/IDEAS — taxonomie D1, processing et rétention
|
||||||
- [ ] **TODO** — slot/root/slotsUpdates et vote : ne créer un modèle passif commun que si un consumer realtime réel et une sémantique cross-ledger/provider justifient le contrat ; aucune persistence Store par défaut.
|
|
||||||
|
- [ ] **TODO** — maintenir la matrice d’admission HTTP/WS/gRPC/provider lors de toute nouvelle famille D1 : plusieurs sources ne convergent vers un même struct que si elles satisfont la même sémantique sans perte.
|
||||||
|
- [X] **TODO `0.3.9`** — matrice RAW Transaction unique produite et consolidée dans `docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md`, avec séparation possibilités/support/preuve, capabilities orthogonales aux producteurs, provenance/déduplication/continuité et handoffs désormais scindés `0.3.11`–`0.3.14` Worker live / `0.3.16` Backfill historique.
|
||||||
|
- [X] **TODO Helius `0.3.13`** — la voie Worker Helius `transactionSubscribe` réutilise la surface Transport et les profils Config Helius déjà existants avec `KSP_SECRET_HELIUS_API_KEY`; aucun nouveau profil, secret, SDK provider, tier codé ou second client parallèle n’a été nécessaire. Le payload riche reste Transport-owned et la voie Worker hydrate via `getTransaction observed` tant que le RAW complet n’est pas prouvé directement.
|
||||||
|
- [X] **TODO réseau** — identité KSP canonique fixée à `mainnet` dans Config/Store/Transport/tests ; `mainnet-beta` reste uniquement un alias legacy/externe lorsque la frontière provider l'exige. Les données D1 RAW antérieures restent non autoritaires pendant cette phase et aucune compatibilité de base de test n'impose l'ancien libellé.
|
||||||
|
- [X] `RawAccountState` + observation — contrat commun stabilisé en `0.3.1` avec bytes complets + slot, provenance séparée et enrichissements source-specific optionnels ; la persistence PostgreSQL physique est complétée en `0.3.4` avec les quatre capabilities account et la conformance RAW 10/10.
|
||||||
|
- [ ] **TODO** — statut/commitment transactionnel restant : `0.3.5` couvre uniquement le fait passif d’exécution `slot + signature + outcome`; réauditer séparément `signatureSubscribe` et `getSignatureStatuses` lorsqu’un consumer de commitment/snapshot réel apparaît, sans fusionner snapshot, transition et execution update dans un modèle Option-soup.
|
||||||
|
- [ ] **IDEA** — logs realtime enrichis : `logsSubscribe` alimente déjà la projection minimale `TransactionExecutionEvent`, mais un éventuel `TransactionLogEvent` portant les lignes de log reste différé dans `docs/IDEAS.md` jusqu’à démonstration d’un consumer et de bornes explicites. `logMessages` reste dans `RawTransaction` jusqu’à STRUCTURAL ; le wake-up post-commit reste distinct et Store API-owned conformément à `KSP-NOTIFY-*`.
|
||||||
|
- [X] slot/root/slotsUpdates — `0.3.5` stabilise `SlotLifecycleEvent` pour l’intersection réellement partagée, sans persistence Store par défaut et sans promettre l’ordre/complétude du flux.
|
||||||
|
- [ ] **TODO** — vote realtime : reste hors Interface tant qu’aucun consumer transversal et aucune sémantique provider-neutral suffisamment précise ne justifient un contrat partagé.
|
||||||
- [ ] **IDEA** — `RawBlock` : ne rouvrir que si une information block-level non reconstructible devient nécessaire ; `getBlock` doit d’abord être traité comme source de `RawTransaction`, pas comme invitation à recopier le ledger en blocs.
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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.
|
||||||
@@ -116,15 +133,16 @@ RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
|||||||
- [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.
|
- [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.
|
- [ ] **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.
|
- [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.
|
- [ ] **IDEA** — réauditer la structure de processing/decode/materialization historique kbot2/kbot3 lors de l’ouverture de D2/D3 ; conserver l’isolation instruction/CPI et les statuts terminal/versionnés, sans reprendre automatiquement le schéma SQL historique.
|
||||||
|
|
||||||
## Série STRUCTURAL suivante
|
## Série STRUCTURAL suivante
|
||||||
|
|
||||||
- [ ] Définir la persistence STRUCTURAL canonique Solana générique pour les familles réellement décomposables.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] Vérifier avant extension si d'autres familles D1 possèdent une vraie décomposition STRUCTURAL utile ; ne pas créer de niveau vide par convention.
|
||||||
- [ ] Ajouter replay/backfill RAW -> STRUCTURAL avec processing versionné.
|
- [ ] Ajouter replay/backfill RAW -> STRUCTURAL avec processing versionné.
|
||||||
- [ ] Ajouter worker/service STRUCTURAL et l'application de contrôle/inspection utile.
|
- [ ] 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
|
## Séries DECODED/DOMAIN/EXECUTION — progression verticale
|
||||||
|
|
||||||
|
|||||||
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-store-desk.json
Normal file
51
config/composite.ksp-app-store-desk.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "mainnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "devnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "mainnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "supertrace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "store",
|
||||||
|
"file_id": "cfg.std.store",
|
||||||
|
"profile_id": "testnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"profile_id": "mainnet",
|
"profile_id": "mainnet",
|
||||||
"network": "mainnet-beta",
|
"network": "mainnet",
|
||||||
"backend": "postgres",
|
"backend": "postgres",
|
||||||
"postgres": {
|
"postgres": {
|
||||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
"name": "mainnet_public",
|
"name": "mainnet_public",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
"connect_timeout_ms": 5000,
|
"connect_timeout_ms": 5000,
|
||||||
"request_timeout_ms": 15000,
|
"request_timeout_ms": 15000,
|
||||||
"max_idle_connections_per_host": 8,
|
"max_idle_connections_per_host": 8,
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"name": "mainnet_private",
|
"name": "mainnet_private",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "private-provider",
|
"provider": "private-provider",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
|
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
|
||||||
"connect_timeout_ms": 5000,
|
"connect_timeout_ms": 5000,
|
||||||
"request_timeout_ms": 10000,
|
"request_timeout_ms": 10000,
|
||||||
@@ -85,15 +85,15 @@
|
|||||||
"name": "mainnet_public_ws",
|
"name": "mainnet_public_ws",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mainnet_helius_ws",
|
"name": "mainnet_helius_ws",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "helius",
|
"provider": "helius",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"kind": "helius_laserstream",
|
"kind": "helius_laserstream",
|
||||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
||||||
"session": {
|
"session": {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"recycle_timeout_ms": 5000
|
"recycle_timeout_ms": 5000
|
||||||
},
|
},
|
||||||
"tls": {
|
"tls": {
|
||||||
"mode": "verify_full"
|
"mode": "disabled"
|
||||||
},
|
},
|
||||||
"bootstrap": {
|
"bootstrap": {
|
||||||
"schema_autocreate": true,
|
"schema_autocreate": true,
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"profile_id": "mainnet",
|
"profile_id": "mainnet",
|
||||||
"network": "mainnet-beta",
|
"network": "mainnet",
|
||||||
"backend": "postgres",
|
"backend": "postgres",
|
||||||
"postgres": {
|
"postgres": {
|
||||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"recycle_timeout_ms": 5000
|
"recycle_timeout_ms": 5000
|
||||||
},
|
},
|
||||||
"tls": {
|
"tls": {
|
||||||
"mode": "verify_full"
|
"mode": "disabled"
|
||||||
},
|
},
|
||||||
"bootstrap": {
|
"bootstrap": {
|
||||||
"schema_autocreate": true,
|
"schema_autocreate": true,
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"recycle_timeout_ms": 5000
|
"recycle_timeout_ms": 5000
|
||||||
},
|
},
|
||||||
"tls": {
|
"tls": {
|
||||||
"mode": "verify_full"
|
"mode": "disabled"
|
||||||
},
|
},
|
||||||
"bootstrap": {
|
"bootstrap": {
|
||||||
"schema_autocreate": true,
|
"schema_autocreate": true,
|
||||||
|
|||||||
@@ -144,8 +144,8 @@
|
|||||||
"name": "solana_mainnet_public",
|
"name": "solana_mainnet_public",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
"connect_timeout_ms": 5000,
|
"connect_timeout_ms": 5000,
|
||||||
"request_timeout_ms": 15000,
|
"request_timeout_ms": 15000,
|
||||||
"max_idle_connections_per_host": 8,
|
"max_idle_connections_per_host": 8,
|
||||||
@@ -172,9 +172,104 @@
|
|||||||
"name": "solana_mainnet_public_ws",
|
"name": "solana_mainnet_public_ws",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet_backfill_pool",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "backfill_pool",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "backfill_solana_public",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "publicnode_solana_mainnet_http",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "publicnode",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"url": "https://solana-rpc.publicnode.com",
|
||||||
|
"connect_timeout_ms": 5000,
|
||||||
|
"request_timeout_ms": 15000,
|
||||||
|
"max_idle_connections_per_host": 8,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": "backfill_pool",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "backfill_publicnode",
|
||||||
|
"enabled": true,
|
||||||
|
"request_kinds": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"priority": 100,
|
||||||
|
"limits": {
|
||||||
|
"requests_per_second": 5,
|
||||||
|
"burst_capacity": 10,
|
||||||
|
"max_concurrent_requests": 8,
|
||||||
|
"pause_after_rate_limit_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ws_endpoints": [
|
||||||
|
{
|
||||||
|
"name": "solana_mainnet_public_ws",
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "solana-public",
|
||||||
|
"cluster": "mainnet",
|
||||||
|
"kind": "solana_standard",
|
||||||
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -185,8 +280,8 @@
|
|||||||
"name": "solana_mainnet_public",
|
"name": "solana_mainnet_public",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet.solana.com}",
|
||||||
"connect_timeout_ms": 5000,
|
"connect_timeout_ms": 5000,
|
||||||
"request_timeout_ms": 15000,
|
"request_timeout_ms": 15000,
|
||||||
"max_idle_connections_per_host": 8,
|
"max_idle_connections_per_host": 8,
|
||||||
@@ -213,9 +308,9 @@
|
|||||||
"name": "solana_mainnet_public_ws",
|
"name": "solana_mainnet_public_ws",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "solana-public",
|
"provider": "solana-public",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"kind": "solana_standard",
|
"kind": "solana_standard",
|
||||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet.solana.com}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grpc_endpoints": [
|
"grpc_endpoints": [
|
||||||
@@ -223,7 +318,7 @@
|
|||||||
"name": "publicnode_solana_mainnet_yellowstone",
|
"name": "publicnode_solana_mainnet_yellowstone",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"provider": "publicnode",
|
"provider": "publicnode",
|
||||||
"cluster": "mainnet-beta",
|
"cluster": "mainnet",
|
||||||
"protocol": "solana_yellowstone",
|
"protocol": "solana_yellowstone",
|
||||||
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
||||||
"secret_metadata": [
|
"secret_metadata": [
|
||||||
|
|||||||
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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
72
crates/ksp-app-backfill-desk/tauri.conf.json
Normal file
72
crates/ksp-app-backfill-desk/tauri.conf.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"$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-store-desk.json": "config/composite.ksp-app-store-desk.json",
|
||||||
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
|
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
|
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||||
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json",
|
||||||
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json": "config/std.store.json",
|
||||||
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
|
"../../config/std.wallet.json": "config/std.wallet.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
308
crates/ksp-app-backfill-desk/tests/config_composition.rs
Normal file
308
crates/ksp-app-backfill-desk/tests/config_composition.rs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/config_composition.rs
|
||||||
|
// version: 7
|
||||||
|
|
||||||
|
//! Config composite, Transport readiness and Store-network composition contracts for Backfill Desk through pre.006.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn workspace_engine() -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentEngine> {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
return match registry {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_component(composite: &ksp_config_lib::ResolvedConfigComposite, component_id: &str, file_id: &str, profile_id: &str) {
|
||||||
|
let component = composite.component(component_id);
|
||||||
|
assert!(component.is_some(), "missing component {component_id}");
|
||||||
|
if let std::option::Option::Some(component) = component {
|
||||||
|
assert_eq!(component.resolved().file_id().as_str(), file_id);
|
||||||
|
assert_eq!(component.resolved().profile_id(), profile_id);
|
||||||
|
assert_eq!(component.resolved().selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_devnet_profile_selects_trace_logging_transport_and_store() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
assert!(file_id.is_ok(), "Backfill Desk composite file_id should be valid: {file_id:?}");
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::Some("devnet"));
|
||||||
|
assert!(composite.is_ok(), "committed Backfill Desk devnet profile should resolve: {composite:?}");
|
||||||
|
if let std::result::Result::Ok(composite) = composite {
|
||||||
|
assert_eq!(composite.profile_id(), "devnet");
|
||||||
|
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, "supertrace");
|
||||||
|
assert_component(&composite, "transport", ksp_config_lib::FILE_ID_STD_TRANSPORT, "devnet_public");
|
||||||
|
assert_component(&composite, "store", ksp_config_lib::FILE_ID_STD_STORE, "devnet");
|
||||||
|
let logging = composite.component("logging");
|
||||||
|
assert!(logging.is_some(), "Backfill Desk composite should expose Logging");
|
||||||
|
if let std::option::Option::Some(logging) = logging {
|
||||||
|
assert_eq!(logging.resolved().profile().get("default_filter").and_then(serde_json::Value::as_str), std::option::Option::Some("trace"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_named_profiles_keep_transport_and_store_network_selection_paired() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for (profile_id, logging_profile, transport_profile, store_profile) in
|
||||||
|
[("mainnet", "supertrace", "mainnet_backfill_pool", "mainnet"), ("testnet", "console_info", "publicnode_testnet", "testnet")]
|
||||||
|
{
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::Some(profile_id));
|
||||||
|
assert!(composite.is_ok(), "Backfill Desk composite profile {profile_id} should resolve: {composite:?}");
|
||||||
|
if let std::result::Result::Ok(composite) = composite {
|
||||||
|
assert_eq!(composite.profile_id(), profile_id);
|
||||||
|
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, logging_profile);
|
||||||
|
assert_component(&composite, "transport", ksp_config_lib::FILE_ID_STD_TRANSPORT, transport_profile);
|
||||||
|
assert_component(&composite, "store", ksp_config_lib::FILE_ID_STD_STORE, store_profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_default_composite_builds_http_pool_for_both_backfill_rpc_methods() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::Some("devnet"));
|
||||||
|
assert!(composite.is_ok(), "committed Backfill Desk devnet profile should resolve: {composite:?}");
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let transport = composite.component("transport");
|
||||||
|
assert!(transport.is_some(), "Backfill Desk composite should expose Transport");
|
||||||
|
let transport = match transport {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||||
|
assert!(environment.is_ok(), "Config environment should load for committed public devnet profile");
|
||||||
|
let environment = match environment {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let resolved = engine.resolve_transport_config_profile(transport.resolved(), &environment);
|
||||||
|
assert!(resolved.is_ok(), "composite-selected Transport should map: {resolved:?}");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.profile_id(), "devnet_public");
|
||||||
|
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::Composite);
|
||||||
|
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings());
|
||||||
|
assert!(pool.is_ok(), "composite-selected Transport settings should construct an HTTP pool");
|
||||||
|
let pool = match pool {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let snapshot = pool.snapshot();
|
||||||
|
let mut clusters = snapshot
|
||||||
|
.endpoints()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| {
|
||||||
|
if endpoint.enabled() {
|
||||||
|
return std::option::Option::Some(endpoint.cluster().to_owned());
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
clusters.sort();
|
||||||
|
clusters.dedup();
|
||||||
|
assert_eq!(clusters, vec!["devnet".to_owned()]);
|
||||||
|
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
|
||||||
|
for method in ["getSignaturesForAddress", "getTransaction"] {
|
||||||
|
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method);
|
||||||
|
assert!(descriptor.is_some(), "required Transport method {method} should exist");
|
||||||
|
if let std::option::Option::Some(descriptor) = descriptor {
|
||||||
|
let selection = pool.select_for_method(&role, descriptor);
|
||||||
|
assert!(selection.is_ok(), "role default should route {method}: {selection:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn declared_http_networks(profile: &ksp_config_lib::ResolvedConfigProfile) -> std::vec::Vec<String> {
|
||||||
|
let endpoints = profile.profile().get("endpoints").and_then(serde_json::Value::as_array);
|
||||||
|
let endpoints = match endpoints {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
let mut networks = endpoints
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| {
|
||||||
|
let enabled = endpoint.get("enabled").and_then(serde_json::Value::as_bool);
|
||||||
|
if enabled != std::option::Option::Some(true) {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return endpoint.get("cluster").and_then(serde_json::Value::as_str).map(str::to_owned);
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
networks.sort();
|
||||||
|
networks.dedup();
|
||||||
|
return networks;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_all_composite_profiles_keep_declared_http_and_store_networks_identical_without_external_secrets() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for profile_id in ["devnet", "mainnet", "testnet"] {
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::Some(profile_id));
|
||||||
|
assert!(composite.is_ok(), "Backfill Desk composite profile {profile_id} should resolve: {composite:?}");
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let transport = composite.component("transport");
|
||||||
|
let store = composite.component("store");
|
||||||
|
assert!(transport.is_some(), "profile {profile_id} should expose Transport");
|
||||||
|
assert!(store.is_some(), "profile {profile_id} should expose Store");
|
||||||
|
let (transport, store) = match (transport, store) {
|
||||||
|
(std::option::Option::Some(transport), std::option::Option::Some(store)) => (transport, store),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let networks = declared_http_networks(transport.resolved());
|
||||||
|
assert_eq!(networks.len(), 1, "profile {profile_id} must declare exactly one enabled HTTP network");
|
||||||
|
let store_network = store.resolved().profile().get("network").and_then(serde_json::Value::as_str);
|
||||||
|
assert!(store_network.is_some(), "profile {profile_id} Store should declare one logical network");
|
||||||
|
if let (std::option::Option::Some(transport_network), std::option::Option::Some(store_network)) = (networks.first(), store_network) {
|
||||||
|
assert_eq!(transport_network.as_str(), store_network, "profile {profile_id} Store/Transport network mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_default_mainnet_profile_exposes_pool_and_targeted_http_routes_without_secret_provider_data() {
|
||||||
|
let engine = workspace_engine();
|
||||||
|
assert!(engine.is_ok(), "workspace Config engine should be constructible: {engine:?}");
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let composite = engine.load_resolved_composite(&file_id, std::option::Option::None);
|
||||||
|
assert!(composite.is_ok(), "default Backfill Desk composite should resolve: {composite:?}");
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(composite.profile_id(), "mainnet");
|
||||||
|
assert_component(&composite, "logging", ksp_config_lib::FILE_ID_STD_LOGGING, "supertrace");
|
||||||
|
assert_component(&composite, "transport", ksp_config_lib::FILE_ID_STD_TRANSPORT, "mainnet_backfill_pool");
|
||||||
|
assert_component(&composite, "store", ksp_config_lib::FILE_ID_STD_STORE, "mainnet");
|
||||||
|
let transport = match composite.component("transport") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let profile = transport.resolved().profile();
|
||||||
|
let endpoints = profile.get("endpoints").and_then(serde_json::Value::as_array);
|
||||||
|
assert!(endpoints.is_some(), "mainnet Backfill Transport profile should expose HTTP endpoints");
|
||||||
|
let endpoints = match endpoints {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let providers = endpoints
|
||||||
|
.iter()
|
||||||
|
.filter_map(|endpoint| return endpoint.get("provider").and_then(serde_json::Value::as_str))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(providers, std::collections::BTreeSet::from(["publicnode", "solana-public"]));
|
||||||
|
let roles = endpoints
|
||||||
|
.iter()
|
||||||
|
.flat_map(|endpoint| return endpoint.get("roles").and_then(serde_json::Value::as_array).into_iter().flatten())
|
||||||
|
.filter_map(|role| return role.get("role").and_then(serde_json::Value::as_str))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert!(roles.contains("backfill_pool"));
|
||||||
|
assert!(roles.contains("backfill_publicnode"));
|
||||||
|
assert!(roles.contains("backfill_solana_public"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_composite_profile_and_component_inventory_is_exact() {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let source = std::fs::read_to_string(workspace.join("config/composite.ksp-app-backfill-desk.json"));
|
||||||
|
assert!(source.is_ok(), "Backfill Desk composite should be readable");
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let document = serde_json::from_str::<serde_json::Value>(source.as_str());
|
||||||
|
assert!(document.is_ok(), "Backfill Desk composite should remain valid JSON");
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(document.get("default_profile").and_then(serde_json::Value::as_str), std::option::Option::Some("mainnet"));
|
||||||
|
let profiles = document.get("profiles").and_then(serde_json::Value::as_array);
|
||||||
|
assert!(profiles.is_some(), "Backfill Desk composite should expose profiles");
|
||||||
|
let profiles = match profiles {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let profile_ids = profiles
|
||||||
|
.iter()
|
||||||
|
.filter_map(|profile| return profile.get("profile_id").and_then(serde_json::Value::as_str))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(profile_ids, std::collections::BTreeSet::from(["devnet", "mainnet", "testnet"]));
|
||||||
|
for profile in profiles {
|
||||||
|
let documents = profile.get("documents").and_then(serde_json::Value::as_array);
|
||||||
|
assert!(documents.is_some(), "every Backfill Desk profile should expose component documents");
|
||||||
|
let documents = match documents {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let component_ids = documents
|
||||||
|
.iter()
|
||||||
|
.filter_map(|document| return document.get("component_id").and_then(serde_json::Value::as_str))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(component_ids, std::collections::BTreeSet::from(["logging", "store", "transport"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
78
crates/ksp-app-backfill-desk/tests/dependency_boundary.rs
Normal file
78
crates/ksp-app-backfill-desk/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/dependency_boundary.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Final dependency-boundary checks for the KSP Backfill desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn app_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(path: &std::path::Path) -> String {
|
||||||
|
let source = std::fs::read_to_string(path);
|
||||||
|
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_manifest_ksp_dependency_surface_is_exact_and_backend_neutral() {
|
||||||
|
let manifest = read_text(app_root().join("Cargo.toml").as_path());
|
||||||
|
let dependency_section = manifest.split("[dependencies]").nth(1).and_then(|tail| return tail.split("[dev-dependencies]").next());
|
||||||
|
assert!(dependency_section.is_some(), "Backfill Desk manifest should expose a dependency section");
|
||||||
|
let dependency_section = match dependency_section {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let ksp_dependencies = dependency_section
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let name = line.split('=').next().map(str::trim);
|
||||||
|
return match name {
|
||||||
|
std::option::Option::Some(value) if value.starts_with("ksp-") => std::option::Option::Some(value),
|
||||||
|
_ => std::option::Option::None,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
ksp_dependencies,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"ksp-config-lib",
|
||||||
|
"ksp-core-lib",
|
||||||
|
"ksp-job-api",
|
||||||
|
"ksp-job-backfill-lib",
|
||||||
|
"ksp-logging-lib",
|
||||||
|
"ksp-onchain-transport-lib",
|
||||||
|
"ksp-store-lib",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
for forbidden in ["ksp-store-api", "ksp-store-postgres-lib", "tokio-postgres", "reqwest", "tonic", "yellowstone-grpc-proto", "mpl-token-metadata"] {
|
||||||
|
assert!(!dependency_section.contains(forbidden), "Backfill Desk manifest owns forbidden dependency {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_production_sources_do_not_cross_into_physical_store_or_provider_clients() {
|
||||||
|
let source_root = app_root().join("src");
|
||||||
|
let entries = std::fs::read_dir(source_root.as_path());
|
||||||
|
assert!(entries.is_ok(), "Backfill Desk source directory should be readable");
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("rs") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source = read_text(path.as_path());
|
||||||
|
for forbidden in ["ksp_store_api", "ksp_store_postgres_lib", "tokio_postgres", "reqwest::", "tonic::", "yellowstone_grpc_proto"] {
|
||||||
|
assert!(!source.contains(forbidden), "{} crosses forbidden dependency boundary via {forbidden}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
465
crates/ksp-app-backfill-desk/tests/desktop_contract.rs
Normal file
465
crates/ksp-app-backfill-desk/tests/desktop_contract.rs
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/desktop_contract.rs
|
||||||
|
// version: 17
|
||||||
|
|
||||||
|
//! 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(), 15);
|
||||||
|
for required in [
|
||||||
|
"../../config/composite.ksp-app-backfill-desk.json",
|
||||||
|
"../../config/composite.ksp-app-solprices-desk.json",
|
||||||
|
"../../config/composite.ksp-app-store-desk.json",
|
||||||
|
"../../config/composite.ksp-app-wallet-desk.json",
|
||||||
|
"../../config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json",
|
||||||
|
"../../config/std.transport.json",
|
||||||
|
"../../config/std.wallet.json",
|
||||||
|
"../../config/schemas/composite.schema.json",
|
||||||
|
"../../config/schemas/std.logging.schema.json",
|
||||||
|
"../../config/schemas/std.offchain_transport.schema.json",
|
||||||
|
"../../config/schemas/std.store.schema.json",
|
||||||
|
"../../config/schemas/std.transport.schema.json",
|
||||||
|
"../../config/schemas/std.wallet.schema.json",
|
||||||
|
] {
|
||||||
|
assert!(resources.contains_key(required), "missing packaged Config resource {required}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri.contains("prepare_packaged_runtime"));
|
||||||
|
assert!(tauri.contains("resource_dir"));
|
||||||
|
assert!(tauri.contains("set_current_dir"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_bootstrap_keeps_logging_composition_separate_from_transport_runtime() {
|
||||||
|
let root = app_root();
|
||||||
|
let bootstrap = read_text(root.join("src/bootstrap.rs").as_path());
|
||||||
|
assert!(bootstrap.contains("FILE_ID_COMPOSITE_KSP_APP_BACKFILL_DESK"));
|
||||||
|
assert!(bootstrap.contains("COMPOSITE_COMPONENT_ID_LOGGING"));
|
||||||
|
assert!(bootstrap.contains("COMPOSITE_COMPONENT_ID_TRANSPORT"));
|
||||||
|
assert!(bootstrap.contains("COMPOSITE_COMPONENT_ID_STORE"));
|
||||||
|
assert!(bootstrap.contains("LogFilterLevel::Trace"));
|
||||||
|
for forbidden in ["HttpTransportPool", "Store::open", "BackfillJobRuntime", "BackfillRequest"] {
|
||||||
|
assert!(!bootstrap.contains(forbidden), "bootstrap.rs absorbs another runtime responsibility: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_transport_runtime_builds_pool_and_validates_both_required_rpc_methods() {
|
||||||
|
let root = app_root();
|
||||||
|
let transport = read_text(root.join("src/transport_runtime.rs").as_path());
|
||||||
|
assert!(transport.contains("HttpTransportPool::new"));
|
||||||
|
assert!(transport.contains("getSignaturesForAddress"));
|
||||||
|
assert!(transport.contains("getTransaction"));
|
||||||
|
assert!(transport.contains("select_for_method"));
|
||||||
|
assert!(transport.contains("configured_networks"));
|
||||||
|
assert!(transport.contains("compatible_backfill_http_routes"));
|
||||||
|
assert!(transport.contains("endpoint.provider()"));
|
||||||
|
for forbidden in ["url()", "Store::open", "BackfillJobRuntime", "BackfillRequest"] {
|
||||||
|
assert!(!transport.contains(forbidden), "pre.004 opens or projects forbidden surface {forbidden}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri.contains("backfill_options"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_store_readiness_proves_network_before_open_and_closes_on_main_window_exit() {
|
||||||
|
let root = app_root();
|
||||||
|
let store = read_text(root.join("src/store_runtime.rs").as_path());
|
||||||
|
let coherence = store.find("validate_network_coherence(store_network.as_str(), transport_network.as_str())");
|
||||||
|
let open = store.find("ksp_store_lib::Store::open");
|
||||||
|
assert!(coherence.is_some());
|
||||||
|
assert!(open.is_some());
|
||||||
|
if let (std::option::Option::Some(coherence), std::option::Option::Some(open)) = (coherence, open) {
|
||||||
|
assert!(coherence < open, "Store network coherence must be proven before physical Store open");
|
||||||
|
}
|
||||||
|
assert!(store.contains("store.health().await"));
|
||||||
|
assert!(store.contains("store.close().await"));
|
||||||
|
assert!(store.contains("StoreHealthState::Ready"));
|
||||||
|
assert!(store.contains("StoreHealthState::NotReady"));
|
||||||
|
assert!(!store.contains("BackfillJobRuntime"));
|
||||||
|
assert!(!store.contains("BackfillRequest"));
|
||||||
|
let app_state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
assert!(app_state.contains("initialize_store"));
|
||||||
|
assert!(app_state.contains("composition_ready"));
|
||||||
|
assert!(app_state.contains("close_store"));
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
for required in ["on_window_event", "WindowEvent::CloseRequested", "prevent_close", "async_runtime::spawn", "close_store", "app_handle.exit(0)"] {
|
||||||
|
assert!(tauri.contains(required), "missing bounded Store shutdown lifecycle marker {required}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_mainnet_default_and_http_route_selector_are_explicit_without_start_runtime() {
|
||||||
|
let root = app_root();
|
||||||
|
let workspace = root.join("../..");
|
||||||
|
let composite = read_text(workspace.join("config/composite.ksp-app-backfill-desk.json").as_path());
|
||||||
|
assert!(composite.contains("\"default_profile\": \"mainnet\""));
|
||||||
|
assert!(composite.contains("\"profile_id\": \"mainnet_backfill_pool\""));
|
||||||
|
let transport = read_text(workspace.join("config/std.transport.json").as_path());
|
||||||
|
for required in ["mainnet_backfill_pool", "backfill_pool", "backfill_publicnode", "backfill_solana_public", "solana-rpc.publicnode.com"] {
|
||||||
|
assert!(transport.contains(required), "missing Backfill HTTP route marker {required}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(frontend.contains("Backfill Desk HTTP route selection changed"));
|
||||||
|
assert!(frontend.contains("backfill_options"));
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
assert!(html.contains("backfillHttpRoute"));
|
||||||
|
for forbidden in ["grpc", "websocket"] {
|
||||||
|
assert!(!frontend.contains(forbidden), "pre.006 frontend opens deferred runtime marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_campaign_dto_and_backend_mapping_remain_the_authoritative_start_input() {
|
||||||
|
let root = app_root();
|
||||||
|
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||||
|
assert!(manifest.contains("ksp-job-api = { path = \"../ksp-job-api\" }"));
|
||||||
|
assert!(manifest.contains("ksp-job-backfill-lib = { path = \"../ksp-job-backfill-lib\" }"));
|
||||||
|
let dto = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
for required in [
|
||||||
|
"BackfillStartRequestDto",
|
||||||
|
"BackfillRequestPreviewDto",
|
||||||
|
"MAX_BACKFILL_CANDIDATES",
|
||||||
|
"MAX_BACKFILL_HYDRATION_CONCURRENCY",
|
||||||
|
"MAX_BACKFILL_PAGES",
|
||||||
|
"MAX_BACKFILL_PAGE_SIZE",
|
||||||
|
] {
|
||||||
|
assert!(dto.contains(required), "missing pre.007 DTO/request bound marker {required}");
|
||||||
|
}
|
||||||
|
let mapping = read_text(root.join("src/backfill_request.rs").as_path());
|
||||||
|
for required in ["BackfillRequest::new", "HttpRoleName::new", "latest_address", "before_address", "after_address", "explicit_signatures"] {
|
||||||
|
assert!(mapping.contains(required), "missing strict Backfill request mapping marker {required}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri.contains("backfill_validate_request"));
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
for required in [
|
||||||
|
"backfillCampaignForm",
|
||||||
|
"backfillScopeKind",
|
||||||
|
"backfillCommitment",
|
||||||
|
"backfillAddress",
|
||||||
|
"backfillAnchorSignature",
|
||||||
|
"backfillExplicitSignatures",
|
||||||
|
"backfillMinContextSlot",
|
||||||
|
"validateBackfillRequest",
|
||||||
|
"Valider la requête",
|
||||||
|
] {
|
||||||
|
assert!(html.contains(required), "missing campaign form marker {required}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(frontend.contains("backfill_validate_request"));
|
||||||
|
assert!(frontend.contains("Backfill Desk campaign validation requested"));
|
||||||
|
assert!(frontend.contains("Aucun job n'a été démarré"));
|
||||||
|
assert!(frontend.contains("backfill_validate_request"));
|
||||||
|
assert!(tauri.contains("backfill_validate_request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_start_installs_handle_before_non_blocking_spawn_and_keeps_single_run_backend_owned() {
|
||||||
|
let root = app_root();
|
||||||
|
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(state.contains("BackfillJobRuntime::new(mapped)"));
|
||||||
|
let handle_index = state.find("let handle = runtime.handle();");
|
||||||
|
let install_index = state.find("self.backfill_runs.install(job_id.clone(), handle, retained_request)");
|
||||||
|
assert!(handle_index.is_some());
|
||||||
|
assert!(install_index.is_some());
|
||||||
|
if let (std::option::Option::Some(handle_index), std::option::Option::Some(install_index)) = (handle_index, install_index) {
|
||||||
|
assert!(handle_index < install_index);
|
||||||
|
}
|
||||||
|
assert!(run.contains("ERROR_CODE_BACKFILL_RUN_ACTIVE"));
|
||||||
|
assert!(run.contains("std::sync::Mutex<std::option::Option<ActiveBackfillRun>>"));
|
||||||
|
assert!(tauri.contains("backfill_start"));
|
||||||
|
let prepare_index = tauri.find("state.prepare_backfill_start(request)");
|
||||||
|
assert!(prepare_index.is_some());
|
||||||
|
if let std::option::Option::Some(prepare_index) = prepare_index {
|
||||||
|
let start_tail = &tauri[prepare_index..];
|
||||||
|
let spawn_index = start_tail.find("tauri::async_runtime::spawn(async move");
|
||||||
|
assert!(spawn_index.is_some());
|
||||||
|
}
|
||||||
|
assert!(html.contains("startBackfillRequest"));
|
||||||
|
assert!(html.contains("Démarrer le backfill"));
|
||||||
|
assert!(frontend.contains("Backfill Desk campaign Start requested"));
|
||||||
|
assert!(frontend.contains("backfill_start"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_latest_value_monitoring_uses_snapshot_source_event_bridge_and_resynchronization_command() {
|
||||||
|
let root = app_root();
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
let status = read_text(root.join("src/backfill_status.rs").as_path());
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(run.contains("last_terminal"));
|
||||||
|
assert!(run.contains("current_notification"));
|
||||||
|
assert!(status.contains("BackfillRunStatusDto"));
|
||||||
|
assert!(status.contains("checkpoint_present"));
|
||||||
|
assert!(status.contains("contiguous_completed"));
|
||||||
|
assert!(tauri.contains("backfill_status"));
|
||||||
|
assert!(tauri.contains("wait_for_change"));
|
||||||
|
assert!(tauri.contains("ksp-backfill-status") || tauri.contains("BACKFILL_STATUS_EVENT_NAME"));
|
||||||
|
assert!(frontend.contains("listen<BackfillRunStatusDto>"));
|
||||||
|
assert!(frontend.contains("syncBackfillStatus"));
|
||||||
|
assert!(!frontend.contains("parseBackfillLog"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_cancel_is_targeted_idempotent_and_shutdown_requests_cooperative_cancellation_before_exit() {
|
||||||
|
let root = app_root();
|
||||||
|
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(run.contains("current.job_id.as_str() != job_id"));
|
||||||
|
assert!(run.contains("current.handle.cancel()"));
|
||||||
|
assert!(run.contains("ERROR_CODE_BACKFILL_RUN_MISMATCH"));
|
||||||
|
assert!(state.contains("cancel_backfill"));
|
||||||
|
assert!(state.contains("cancel_active_backfill_for_shutdown"));
|
||||||
|
assert!(tauri.contains("backfill_cancel"));
|
||||||
|
assert!(tauri.contains("cancel_active_backfill_for_shutdown"));
|
||||||
|
let shutdown_cancel = tauri.find("cancel_active_backfill_for_shutdown");
|
||||||
|
let shutdown_exit = tauri.find("app_handle.exit(0)");
|
||||||
|
assert!(shutdown_cancel.is_some());
|
||||||
|
assert!(shutdown_exit.is_some());
|
||||||
|
if let (std::option::Option::Some(cancel), std::option::Option::Some(exit)) = (shutdown_cancel, shutdown_exit) {
|
||||||
|
assert!(cancel < exit);
|
||||||
|
}
|
||||||
|
assert!(html.contains("cancelBackfillRun"));
|
||||||
|
assert!(html.contains("Annuler"));
|
||||||
|
assert!(frontend.contains("backfill_cancel"));
|
||||||
|
assert!(frontend.contains("{ jobId: status.jobId }"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_011_resume_reissues_rust_only_checkpoint_for_new_backend_job_without_ipc_checkpoint_material() {
|
||||||
|
let root = app_root();
|
||||||
|
let workspace = root.join("../..");
|
||||||
|
let checkpoint = read_text(workspace.join("crates/ksp-job-backfill-lib/src/checkpoint.rs").as_path());
|
||||||
|
assert!(checkpoint.contains("reissue_for_job"));
|
||||||
|
let request = read_text(workspace.join("crates/ksp-job-backfill-lib/src/request.rs").as_path());
|
||||||
|
assert!(request.contains("pub fn resume_for_job"));
|
||||||
|
assert!(request.contains("validate_request_checkpoint"));
|
||||||
|
assert!(request.contains("checkpoint.reissue_for_job(job_id.clone())"));
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
for required in ["TerminalBackfillRun", "request: ksp_job_backfill_lib::BackfillRequest", "snapshot().checkpoint()", "resume_for_job(job_id, checkpoint)"] {
|
||||||
|
assert!(run.contains(required), "missing Rust-only Resume ownership marker {required}");
|
||||||
|
}
|
||||||
|
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
for required in ["prepare_backfill_resume", "options.composition_ready", "store_network_matches", "role_available"] {
|
||||||
|
assert!(state.contains(required), "missing Resume admission marker {required}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri.contains("backfill_resume"));
|
||||||
|
assert!(tauri.contains("spawn_backfill_launch"));
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(frontend.contains("resumeBackfillRun"));
|
||||||
|
assert!(frontend.contains("checkpointPresent"));
|
||||||
|
assert!(frontend.contains("backfill_resume"));
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
assert!(html.contains("resumeBackfillRun"));
|
||||||
|
assert!(html.contains("Reprendre"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_012_free_address_autocomplete_is_html_datalist_derived_from_core_registry() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
assert!(dto.contains("ksp_core_lib::entries()"));
|
||||||
|
assert!(dto.contains("ProgramIdAutocompleteOptionDto"));
|
||||||
|
assert!(dto.contains("program_id_autocomplete_options"));
|
||||||
|
let html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
assert!(html.contains("list=\"backfillProgramIds\""));
|
||||||
|
assert!(html.contains("<datalist id=\"backfillProgramIds\"></datalist>"));
|
||||||
|
assert!(html.contains("toute adresse Solana valide reste acceptée"));
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for required in [
|
||||||
|
"renderProgramIdAutocomplete(options.programIdOptions)",
|
||||||
|
"option.value = program.programId",
|
||||||
|
"option.dataset.code = program.code",
|
||||||
|
"option.dataset.domain = program.domain",
|
||||||
|
"registryMatch: selected !== null",
|
||||||
|
"Backfill Desk Program ID autocomplete dataset rendered",
|
||||||
|
] {
|
||||||
|
assert!(frontend.contains(required), "missing pre.012 autocomplete marker {required}");
|
||||||
|
}
|
||||||
|
for forbidden in
|
||||||
|
["AddressLookupTab1e1111111111111111111111111", "ComputeBudget111111111111111111111111111111", "Vote111111111111111111111111111111111111111"]
|
||||||
|
{
|
||||||
|
assert!(!frontend.contains(forbidden), "frontend hardcodes canonical Program ID {forbidden}");
|
||||||
|
assert!(!html.contains(forbidden), "HTML hardcodes canonical Program ID {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_final_v1_command_and_frontend_control_inventory_is_exact() {
|
||||||
|
let root = app_root();
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let commands = [
|
||||||
|
"backfill_cancel",
|
||||||
|
"backfill_options",
|
||||||
|
"backfill_resume",
|
||||||
|
"backfill_start",
|
||||||
|
"backfill_status",
|
||||||
|
"backfill_validate_request",
|
||||||
|
"emit_frontend_log",
|
||||||
|
"get_runtime_status",
|
||||||
|
"splash_frontend_ready",
|
||||||
|
];
|
||||||
|
for command in commands {
|
||||||
|
assert!(tauri.contains(format!("fn {command}").as_str()), "missing final Tauri command {command}");
|
||||||
|
}
|
||||||
|
assert_eq!(tauri.matches("#[tauri::command]").count(), commands.len());
|
||||||
|
for forbidden in ["backfill_reset", "backfill_delete", "backfill_retry", "backfill_schedule", "backfill_history"] {
|
||||||
|
assert!(!tauri.contains(forbidden), "unexpected V1 Tauri command marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
for required in ["startBackfillRequest", "cancelBackfillRun", "resumeBackfillRun", "refreshBackfillStatus", "backfillProgramIds"] {
|
||||||
|
assert!(frontend.contains(required), "missing final frontend control {required}");
|
||||||
|
}
|
||||||
|
}
|
||||||
395
crates/ksp-app-backfill-desk/tests/desktop_security.rs
Normal file
395
crates/ksp-app-backfill-desk/tests/desktop_security.rs
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/desktop_security.rs
|
||||||
|
// version: 16
|
||||||
|
|
||||||
|
//! Security and dependency-boundary checks for the Backfill Desk scaffold.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn app_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn struct_source<'a>(source: &'a str, marker: &str) -> &'a str {
|
||||||
|
let start = match source.find(marker) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return "",
|
||||||
|
};
|
||||||
|
let tail = &source[start..];
|
||||||
|
let end = match tail.find("\n}") {
|
||||||
|
std::option::Option::Some(value) => value + 2,
|
||||||
|
std::option::Option::None => return "",
|
||||||
|
};
|
||||||
|
return &tail[..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files(directory: &std::path::Path, extension: &str, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||||
|
let entries = std::fs::read_dir(directory);
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_files(path.as_path(), extension, files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some(extension) {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(path: &std::path::Path) -> String {
|
||||||
|
let source = std::fs::read_to_string(path);
|
||||||
|
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_surface_remains_core_plus_tracing_while_backfill_runtime_stays_backend_only() {
|
||||||
|
let root = app_root();
|
||||||
|
let capability = read_text(root.join("capabilities/default.json").as_path());
|
||||||
|
assert!(capability.contains("\"core:default\""));
|
||||||
|
assert!(capability.contains("\"tracing:default\""));
|
||||||
|
for forbidden in ["dialog:", "fs:", "http:", "shell:"] {
|
||||||
|
assert!(!capability.contains(forbidden));
|
||||||
|
}
|
||||||
|
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||||
|
assert!(manifest.contains("tauri-plugin-tracing.workspace = true"));
|
||||||
|
assert!(manifest.contains("ksp-job-api = { path = \"../ksp-job-api\" }"));
|
||||||
|
assert!(manifest.contains("ksp-job-backfill-lib = { path = \"../ksp-job-backfill-lib\" }"));
|
||||||
|
assert!(manifest.contains("ksp-onchain-transport-lib = { path = \"../ksp-onchain-transport-lib\" }"));
|
||||||
|
assert!(manifest.contains("ksp-store-lib = { path = \"../ksp-store-lib\" }"));
|
||||||
|
for forbidden in ["ksp-store-api", "ksp-store-postgres-lib", "reqwest", "tokio-postgres", "tonic", "yellowstone-grpc"] {
|
||||||
|
assert!(!manifest.contains(forbidden), "current Backfill Desk opens a forbidden direct dependency: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_frontend_has_no_network_persistence_or_native_dialog_surface() {
|
||||||
|
let root = app_root();
|
||||||
|
let mut typescript = std::vec::Vec::new();
|
||||||
|
collect_files(root.join("frontend/ts").as_path(), "ts", &mut typescript);
|
||||||
|
assert!(!typescript.is_empty());
|
||||||
|
for path in typescript {
|
||||||
|
let source = read_text(path.as_path());
|
||||||
|
for forbidden in ["fetch(", "XMLHttpRequest", "localStorage", "sessionStorage", "window.alert(", "window.confirm(", "window.prompt("] {
|
||||||
|
assert!(!source.contains(forbidden), "{} contains forbidden scaffold surface {forbidden}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_tauri_commands_remain_centralized() {
|
||||||
|
let root = app_root();
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_files(root.join("src").as_path(), "rs", &mut rust_files);
|
||||||
|
let mut command_count = 0usize;
|
||||||
|
for path in rust_files {
|
||||||
|
let source = read_text(path.as_path());
|
||||||
|
let count = source.matches("#[tauri::command]").count();
|
||||||
|
if path.file_name().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("tauri.rs") {
|
||||||
|
command_count += count;
|
||||||
|
} else {
|
||||||
|
assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(command_count, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_frontend_instrumentation_avoids_business_or_secret_payloads() {
|
||||||
|
let root = app_root();
|
||||||
|
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for required in [
|
||||||
|
"Backfill Desk frontend control clicked",
|
||||||
|
"Backfill Desk navigation tab clicked",
|
||||||
|
"Backfill Desk runtime status refresh button clicked",
|
||||||
|
"Frontend IPC command requested",
|
||||||
|
] {
|
||||||
|
if required == "Frontend IPC command requested" {
|
||||||
|
let invoke = read_text(root.join("frontend/ts/invoke.ts").as_path());
|
||||||
|
assert!(invoke.contains(required));
|
||||||
|
} else {
|
||||||
|
assert!(main.contains(required));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"JSON.stringify(status)",
|
||||||
|
"apiKey",
|
||||||
|
"authorization",
|
||||||
|
"database_url",
|
||||||
|
"campaign validation requested\", {\n address",
|
||||||
|
"campaign validation requested\", {\n anchorSignature",
|
||||||
|
"campaign validation requested\", {\n explicitSignatures",
|
||||||
|
"campaign validation requested\", {\n minContextSlot",
|
||||||
|
] {
|
||||||
|
assert!(!main.contains(forbidden), "frontend tracing includes forbidden request payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_store_runtime_remains_backend_neutral_after_job_mapping_opens() {
|
||||||
|
let root = app_root();
|
||||||
|
let bootstrap = read_text(root.join("src/bootstrap.rs").as_path());
|
||||||
|
assert!(bootstrap.contains("LogFilterLevel::Trace"));
|
||||||
|
assert!(!bootstrap.contains("HttpTransportPool"));
|
||||||
|
assert!(!bootstrap.contains("Store::open"));
|
||||||
|
let store = read_text(root.join("src/store_runtime.rs").as_path());
|
||||||
|
assert!(store.contains("ksp_store_lib::Store::open"));
|
||||||
|
assert!(store.contains("store.health().await"));
|
||||||
|
assert!(store.contains("store.close().await"));
|
||||||
|
for forbidden in ["ksp_job_", "BackfillRequest", "connection_uri", "postgres", "database_url", "provider()", "endpoint_url"] {
|
||||||
|
assert!(!store.contains(forbidden), "Store runtime leaks or absorbs another responsibility: {forbidden}");
|
||||||
|
}
|
||||||
|
let dto = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
assert!(dto.contains("BackfillDeskOptionsDto"));
|
||||||
|
for forbidden in ["endpoint_url", "pub(crate) url", "pub(crate) credential", "pub(crate) token", "connection_uri"] {
|
||||||
|
assert!(!dto.contains(forbidden), "readiness options DTO source contains forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_frontend_receives_only_safe_http_route_metadata_and_no_endpoint_material() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
assert!(dto.contains("BackfillHttpRouteOptionDto"));
|
||||||
|
assert!(dto.contains("pub(crate) providers"));
|
||||||
|
assert!(dto.contains("pub(crate) role"));
|
||||||
|
for forbidden in ["endpoint_url", "pub(crate) url", "pub(crate) credential", "pub(crate) api_key", "pub(crate) authorization"] {
|
||||||
|
assert!(!dto.contains(forbidden), "route DTO leaks forbidden endpoint material marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for forbidden in ["http://", "https://", "apiKey", "authorization", "token"] {
|
||||||
|
assert!(!frontend.contains(forbidden), "frontend embeds forbidden provider material marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_request_mapping_keeps_network_endpoint_and_payload_secrets_backend_owned() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
let request_source = struct_source(dto.as_str(), "pub(crate) struct BackfillStartRequestDto");
|
||||||
|
assert!(!request_source.is_empty());
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) network:",
|
||||||
|
"pub(crate) provider",
|
||||||
|
"pub(crate) endpoint",
|
||||||
|
"pub(crate) url",
|
||||||
|
"pub(crate) job_id",
|
||||||
|
"pub(crate) credential",
|
||||||
|
"pub(crate) token",
|
||||||
|
] {
|
||||||
|
assert!(!request_source.contains(forbidden), "frontend request DTO owns forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
let mapping = read_text(root.join("src/backfill_request.rs").as_path());
|
||||||
|
assert!(mapping.contains("options.store_network.as_deref()"));
|
||||||
|
assert!(mapping.contains("options.http_routes.iter().any"));
|
||||||
|
assert!(mapping.contains("BackfillRequest::new"));
|
||||||
|
assert!(!mapping.contains("HttpTransportPool::new"));
|
||||||
|
assert!(!mapping.contains("Store::open"));
|
||||||
|
let state = read_text(root.join("src/app_state.rs").as_path());
|
||||||
|
assert!(state.contains("backfill-desk-validation"));
|
||||||
|
assert!(state.contains("validated Backfill Desk campaign request without starting a Job"));
|
||||||
|
for forbidden in [
|
||||||
|
"preview.address",
|
||||||
|
"preview.anchor_signature",
|
||||||
|
"preview.explicit_signatures",
|
||||||
|
"request.address.as_str()",
|
||||||
|
"request.anchor_signature.as_str()",
|
||||||
|
] {
|
||||||
|
assert!(!state.contains(forbidden), "request validation logging includes forbidden payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_start_surface_keeps_payload_and_physical_resources_backend_owned() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
let response_source = struct_source(dto.as_str(), "pub(crate) struct BackfillStartResponseDto");
|
||||||
|
assert!(!response_source.is_empty());
|
||||||
|
assert!(response_source.contains("pub(crate) job_id: String"));
|
||||||
|
assert!(response_source.contains("pub(crate) state: String"));
|
||||||
|
for forbidden in ["address", "signature", "provider", "endpoint", "url", "credential", "token", "network"] {
|
||||||
|
assert!(!response_source.contains(forbidden), "Start acknowledgement leaks forbidden marker {forbidden}");
|
||||||
|
}
|
||||||
|
let run = read_text(root.join("src/backfill_run.rs").as_path());
|
||||||
|
assert!(run.contains("BackfillJobHandle"));
|
||||||
|
assert!(run.contains("ERROR_CODE_BACKFILL_RUN_ACTIVE"));
|
||||||
|
assert!(!run.contains("reqwest"));
|
||||||
|
assert!(!run.contains("tokio_postgres"));
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
let start_marker = frontend.find("Backfill Desk campaign Start requested");
|
||||||
|
assert!(start_marker.is_some());
|
||||||
|
let start_source = match start_marker {
|
||||||
|
std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 420, frontend.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
for forbidden in ["address:", "anchorSignature:", "explicitSignatures:", "minContextSlot:"] {
|
||||||
|
assert!(!start_source.contains(forbidden), "Start tracing includes forbidden request payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_monitoring_projection_exposes_counters_and_codes_without_checkpoint_or_business_payloads() {
|
||||||
|
let root = app_root();
|
||||||
|
let status = read_text(root.join("src/backfill_status.rs").as_path());
|
||||||
|
let dto = struct_source(status.as_str(), "BackfillRunStatusDto");
|
||||||
|
for required in ["checkpoint_present", "contiguous_completed", "failure_code", "failure_domain", "sequence", "scope_kind"] {
|
||||||
|
assert!(dto.contains(required), "monitoring DTO missing required safe marker {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) address:",
|
||||||
|
"pub(crate) signature:",
|
||||||
|
"pub(crate) endpoint:",
|
||||||
|
"pub(crate) provider:",
|
||||||
|
"pub(crate) credential:",
|
||||||
|
"pub(crate) token:",
|
||||||
|
"pub(crate) checkpoint:",
|
||||||
|
"pub(crate) payload:",
|
||||||
|
"pub(crate) raw_transaction:",
|
||||||
|
] {
|
||||||
|
assert!(!dto.contains(forbidden), "monitoring DTO leaks forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_cancel_surface_targets_backend_job_identity_without_business_payloads() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto_source = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
let dto = struct_source(dto_source.as_str(), "BackfillCancelResponseDto");
|
||||||
|
for required in ["accepted", "job_id", "state"] {
|
||||||
|
assert!(dto.contains(required), "Cancel acknowledgement missing safe field {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) address:",
|
||||||
|
"pub(crate) signature:",
|
||||||
|
"pub(crate) endpoint:",
|
||||||
|
"pub(crate) provider:",
|
||||||
|
"pub(crate) credential:",
|
||||||
|
"pub(crate) token:",
|
||||||
|
"pub(crate) checkpoint:",
|
||||||
|
"pub(crate) payload:",
|
||||||
|
] {
|
||||||
|
assert!(!dto.contains(forbidden), "Cancel acknowledgement leaks forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
let cancel_marker = frontend.find("Backfill Desk cooperative cancellation requested");
|
||||||
|
assert!(cancel_marker.is_some());
|
||||||
|
let cancel_source = match cancel_marker {
|
||||||
|
std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 700, frontend.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
assert!(cancel_source.contains("jobId: status.jobId"));
|
||||||
|
for forbidden in ["address:", "anchorSignature:", "explicitSignatures:", "minContextSlot:"] {
|
||||||
|
assert!(!cancel_source.contains(forbidden), "Cancel tracing includes forbidden campaign payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_011_resume_surface_exposes_only_new_job_acknowledgement_and_never_checkpoint_payload() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto_source = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
let dto = struct_source(dto_source.as_str(), "BackfillResumeResponseDto");
|
||||||
|
for required in ["accepted", "job_id", "state"] {
|
||||||
|
assert!(dto.contains(required), "Resume acknowledgement missing safe field {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) address:",
|
||||||
|
"pub(crate) signature:",
|
||||||
|
"pub(crate) endpoint:",
|
||||||
|
"pub(crate) provider:",
|
||||||
|
"pub(crate) credential:",
|
||||||
|
"pub(crate) token:",
|
||||||
|
"pub(crate) checkpoint:",
|
||||||
|
"pub(crate) payload:",
|
||||||
|
"pub(crate) network:",
|
||||||
|
] {
|
||||||
|
assert!(!dto.contains(forbidden), "Resume acknowledgement leaks forbidden field marker {forbidden}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
let resume_marker = tauri.find("fn backfill_resume");
|
||||||
|
assert!(resume_marker.is_some());
|
||||||
|
let resume_source = match resume_marker {
|
||||||
|
std::option::Option::Some(index) => &tauri[index..std::cmp::min(index + 1300, tauri.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
for forbidden in ["BackfillCheckpoint", "checkpoint:", "address:", "signature:", "provider:", "endpoint:"] {
|
||||||
|
assert!(!resume_source.contains(forbidden), "Resume IPC command leaks forbidden marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
let resume_marker = frontend.find("Backfill Desk in-session Resume requested");
|
||||||
|
assert!(resume_marker.is_some());
|
||||||
|
let resume_source = match resume_marker {
|
||||||
|
std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 1100, frontend.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
assert!(resume_source.contains("backfill_resume"));
|
||||||
|
for forbidden in ["checkpoint:", "address:", "anchorSignature:", "explicitSignatures:", "minContextSlot:"] {
|
||||||
|
assert!(!resume_source.contains(forbidden), "Resume frontend sends or logs forbidden payload marker {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_012_program_id_autocomplete_exposes_only_public_registry_metadata_and_no_browser_storage() {
|
||||||
|
let root = app_root();
|
||||||
|
let dto_source = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
let dto = struct_source(dto_source.as_str(), "ProgramIdAutocompleteOptionDto");
|
||||||
|
for required in ["code", "domain", "family", "name", "program_id", "protocol"] {
|
||||||
|
assert!(dto.contains(required), "Program ID autocomplete DTO missing safe registry field {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["url", "credential", "token", "secret", "endpoint", "connection", "private_key"] {
|
||||||
|
assert!(!dto.contains(forbidden), "Program ID autocomplete DTO leaks forbidden marker {forbidden}");
|
||||||
|
}
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for forbidden in ["localStorage", "sessionStorage", "indexedDB", "document.cookie"] {
|
||||||
|
assert!(!frontend.contains(forbidden), "frontend persists campaign/autocomplete material via {forbidden}");
|
||||||
|
}
|
||||||
|
let selection_marker = frontend.find("Backfill Desk address autocomplete selection evaluated");
|
||||||
|
assert!(selection_marker.is_some());
|
||||||
|
let selection_source = match selection_marker {
|
||||||
|
std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 320, frontend.len())],
|
||||||
|
std::option::Option::None => "",
|
||||||
|
};
|
||||||
|
assert!(selection_source.contains("programCode"));
|
||||||
|
assert!(selection_source.contains("registryMatch"));
|
||||||
|
assert!(!selection_source.contains("address.value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_final_frontend_and_ipc_surface_keeps_physical_and_checkpoint_material_backend_owned() {
|
||||||
|
let root = app_root();
|
||||||
|
let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
for forbidden in ["http://", "https://", "localStorage", "sessionStorage", "indexedDB", "document.cookie", "privateKey", "secretKey"] {
|
||||||
|
assert!(!frontend.contains(forbidden), "final frontend contains forbidden marker {forbidden}");
|
||||||
|
}
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
for forbidden in ["BackfillCheckpoint", "HttpTransportPool", "ksp_store_postgres_lib", "tokio_postgres", "reqwest", "tonic"] {
|
||||||
|
assert!(!tauri.contains(forbidden), "Tauri IPC shell owns forbidden physical/runtime marker {forbidden}");
|
||||||
|
}
|
||||||
|
let dto = read_text(root.join("src/dto_backfill.rs").as_path());
|
||||||
|
for marker in ["BackfillStartResponseDto", "BackfillCancelResponseDto", "BackfillResumeResponseDto"] {
|
||||||
|
let source = struct_source(dto.as_str(), marker);
|
||||||
|
assert!(!source.is_empty(), "missing DTO {marker}");
|
||||||
|
for forbidden in [
|
||||||
|
"pub(crate) checkpoint:",
|
||||||
|
"pub(crate) endpoint:",
|
||||||
|
"pub(crate) provider:",
|
||||||
|
"pub(crate) credential:",
|
||||||
|
"pub(crate) token:",
|
||||||
|
"pub(crate) payload:",
|
||||||
|
] {
|
||||||
|
assert!(!source.contains(forbidden), "DTO {marker} leaks forbidden field {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/ksp-app-backfill-desk/tests/public_api.rs
Normal file
14
crates/ksp-app-backfill-desk/tests/public_api.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/public_api.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Public API checks for the KSP Backfill desktop application library.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_entry_point_is_available_from_crate_root() {
|
||||||
|
let run_entry_point: fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()> = ksp_app_backfill_desk_lib::run;
|
||||||
|
assert_eq!(std::mem::size_of_val(&run_entry_point), std::mem::size_of::<fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()>>());
|
||||||
|
}
|
||||||
121
crates/ksp-app-backfill-desk/tests/release_completeness.rs
Normal file
121
crates/ksp-app-backfill-desk/tests/release_completeness.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/tests/release_completeness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Final V1 completeness canaries for the KSP Backfill desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn app_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(path: &std::path::Path) -> String {
|
||||||
|
let source = std::fs::read_to_string(path);
|
||||||
|
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_production_module_inventory_is_exact() {
|
||||||
|
let lib = read_text(app_root().join("src/lib.rs").as_path());
|
||||||
|
let modules = lib
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let line = line.trim();
|
||||||
|
if !line.starts_with("mod ") || !line.ends_with(';') {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return std::option::Option::Some(line.trim_start_matches("mod ").trim_end_matches(';'));
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
modules,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"app_state",
|
||||||
|
"backfill_request",
|
||||||
|
"backfill_run",
|
||||||
|
"backfill_status",
|
||||||
|
"bootstrap",
|
||||||
|
"constants",
|
||||||
|
"dto_backfill",
|
||||||
|
"dto_common",
|
||||||
|
"errors",
|
||||||
|
"frontend_logging",
|
||||||
|
"logging_runtime",
|
||||||
|
"splash",
|
||||||
|
"store_runtime",
|
||||||
|
"tauri",
|
||||||
|
"transport_runtime",
|
||||||
|
"tw_main",
|
||||||
|
"tw_splash",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_integration_suite_inventory_closes_v1_contracts() {
|
||||||
|
let tests_root = app_root().join("tests");
|
||||||
|
let entries = std::fs::read_dir(tests_root.as_path());
|
||||||
|
assert!(entries.is_ok(), "Backfill Desk tests directory should be readable");
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let files = entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("rs") {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return path.file_name().and_then(std::ffi::OsStr::to_str).map(str::to_owned);
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
files,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"config_composition.rs".to_owned(),
|
||||||
|
"dependency_boundary.rs".to_owned(),
|
||||||
|
"desktop_contract.rs".to_owned(),
|
||||||
|
"desktop_security.rs".to_owned(),
|
||||||
|
"public_api.rs".to_owned(),
|
||||||
|
"release_completeness.rs".to_owned(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_final_surface_contains_every_planned_v1_capability_without_deferred_extensions() {
|
||||||
|
let tauri = read_text(app_root().join("src/tauri.rs").as_path());
|
||||||
|
for required in ["backfill_start", "backfill_status", "backfill_cancel", "backfill_resume"] {
|
||||||
|
assert!(tauri.contains(required), "missing final V1 capability marker {required}");
|
||||||
|
}
|
||||||
|
let options = read_text(app_root().join("src/dto_common.rs").as_path());
|
||||||
|
assert!(options.contains("program_id_options"));
|
||||||
|
assert!(options.contains("program_id_autocomplete_options"));
|
||||||
|
let status = read_text(app_root().join("src/backfill_status.rs").as_path());
|
||||||
|
for required in ["checkpoint_present", "contiguous_completed", "failure_code", "failure_domain"] {
|
||||||
|
assert!(status.contains(required), "missing final monitoring marker {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["Grpc", "Yellowstone", "WebSocketBackfill", "scheduler", "job_history"] {
|
||||||
|
assert!(!tauri.contains(forbidden), "deferred capability leaked into V1 Tauri surface via {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_013_shared_autocomplete_dto_remains_exported_and_consumed_through_crate_root() {
|
||||||
|
let root = app_root();
|
||||||
|
let lib = read_text(root.join("src/lib.rs").as_path());
|
||||||
|
assert!(lib.contains("pub(crate) use self::dto_common::ProgramIdAutocompleteOptionDto;"));
|
||||||
|
let dto = read_text(root.join("src/dto_common.rs").as_path());
|
||||||
|
assert!(dto.contains("pub(crate) struct ProgramIdAutocompleteOptionDto"));
|
||||||
|
assert!(dto.contains("std::vec::Vec<crate::ProgramIdAutocompleteOptionDto>"));
|
||||||
|
assert!(dto.contains("crate::ProgramIdAutocompleteOptionDto {"));
|
||||||
|
let bare_construction = dto.matches("\n ProgramIdAutocompleteOptionDto {").count();
|
||||||
|
assert_eq!(bare_construction, 0, "shared autocomplete DTO must not bypass the crate-root facade");
|
||||||
|
}
|
||||||
31
crates/ksp-app-backfill-desk/tsconfig.json
Normal file
31
crates/ksp-app-backfill-desk/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client",
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"frontend",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
199
crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs
Normal file
199
crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
fn ready_options() -> crate::BackfillDeskOptionsDto {
|
||||||
|
let limits = crate::backfill_request_limits();
|
||||||
|
assert!(limits.is_ok());
|
||||||
|
let limits = match limits {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => crate::BackfillRequestLimitsDto {
|
||||||
|
default_hydration_concurrency: 1,
|
||||||
|
default_max_candidates: 1,
|
||||||
|
default_max_pages: 1,
|
||||||
|
default_page_size: 1,
|
||||||
|
max_hydration_concurrency: 1,
|
||||||
|
max_candidates: 1,
|
||||||
|
max_pages: 1,
|
||||||
|
max_page_size: 1,
|
||||||
|
max_signature_text_bytes: 88,
|
||||||
|
min_signature_text_bytes: 64,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::BackfillDeskOptionsDto {
|
||||||
|
commitments: crate::backfill_commitment_codes(),
|
||||||
|
composition_ready: true,
|
||||||
|
configured_networks: vec!["mainnet".to_owned()],
|
||||||
|
http_routes: vec![
|
||||||
|
crate::BackfillHttpRouteOptionDto {
|
||||||
|
pooled: true,
|
||||||
|
providers: vec!["publicnode".to_owned(), "solana-public".to_owned()],
|
||||||
|
role: "backfill_pool".to_owned(),
|
||||||
|
},
|
||||||
|
crate::BackfillHttpRouteOptionDto { pooled: false, providers: vec!["publicnode".to_owned()], role: "backfill_publicnode".to_owned() },
|
||||||
|
],
|
||||||
|
limits,
|
||||||
|
network_coherent: true,
|
||||||
|
program_id_options: crate::program_id_autocomplete_options(),
|
||||||
|
scope_kinds: crate::backfill_scope_kind_codes(),
|
||||||
|
store_diagnostic: std::option::Option::None,
|
||||||
|
store_network: std::option::Option::Some("mainnet".to_owned()),
|
||||||
|
store_ready: true,
|
||||||
|
transport_diagnostic: std::option::Option::None,
|
||||||
|
transport_ready: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(scope_kind: &str) -> crate::BackfillStartRequestDto {
|
||||||
|
return crate::BackfillStartRequestDto {
|
||||||
|
address: std::option::Option::Some("11111111111111111111111111111111".to_owned()),
|
||||||
|
anchor_signature: std::option::Option::None,
|
||||||
|
commitment: "finalized".to_owned(),
|
||||||
|
explicit_signatures: std::vec::Vec::new(),
|
||||||
|
hydration_concurrency: 4,
|
||||||
|
http_role: "backfill_pool".to_owned(),
|
||||||
|
max_candidates: 100,
|
||||||
|
max_pages: 10,
|
||||||
|
min_context_slot: std::option::Option::Some("123456".to_owned()),
|
||||||
|
page_size: 100,
|
||||||
|
scope_kind: scope_kind.to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_id() -> std::option::Option<ksp_job_api::JobId> {
|
||||||
|
let job_id = ksp_job_api::JobId::new("request-mapping-test");
|
||||||
|
assert!(job_id.is_ok());
|
||||||
|
return match job_id {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_address_maps_to_backend_network_role_commitment_and_bounds() {
|
||||||
|
let job_id = job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mapped = crate::map_backfill_request(request("latest_address"), &ready_options(), job_id);
|
||||||
|
assert!(mapped.is_ok());
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(mapped.network().as_str(), "mainnet");
|
||||||
|
assert_eq!(mapped.role().as_str(), "backfill_pool");
|
||||||
|
assert_eq!(mapped.commitment(), ksp_job_backfill_lib::BackfillCommitment::Finalized);
|
||||||
|
assert_eq!(mapped.scope().kind(), ksp_job_backfill_lib::BackfillScopeKind::LatestAddress);
|
||||||
|
assert_eq!(mapped.page_size(), 100);
|
||||||
|
assert_eq!(mapped.max_pages(), 10);
|
||||||
|
assert_eq!(mapped.max_candidates(), 100);
|
||||||
|
assert_eq!(mapped.hydration_concurrency(), 4);
|
||||||
|
assert_eq!(mapped.min_context_slot(), std::option::Option::Some(123456));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn before_and_after_scopes_require_an_exact_anchor_shape() {
|
||||||
|
for scope_kind in ["before_address", "after_address"] {
|
||||||
|
let job_id = job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mut input = request(scope_kind);
|
||||||
|
input.anchor_signature = std::option::Option::Some("1".repeat(64));
|
||||||
|
let mapped = crate::map_backfill_request(input, &ready_options(), job_id);
|
||||||
|
assert!(mapped.is_ok(), "scope {scope_kind} should map with a bounded anchor");
|
||||||
|
}
|
||||||
|
let job_id = job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let missing_anchor = crate::map_backfill_request(request("before_address"), &ready_options(), job_id);
|
||||||
|
assert!(missing_anchor.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_signatures_reject_address_context_and_deduplicate_in_job_contract() {
|
||||||
|
let request_job_id = job_id();
|
||||||
|
let request_job_id = match request_job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mut input = request("explicit_signatures");
|
||||||
|
input.address = std::option::Option::None;
|
||||||
|
input.min_context_slot = std::option::Option::None;
|
||||||
|
input.explicit_signatures = vec!["1".repeat(64), "1".repeat(64), "2".repeat(64)];
|
||||||
|
let mapped = crate::map_backfill_request(input, &ready_options(), request_job_id);
|
||||||
|
assert!(mapped.is_ok());
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let signatures = mapped.scope().signatures();
|
||||||
|
assert!(signatures.is_some());
|
||||||
|
if let std::option::Option::Some(signatures) = signatures {
|
||||||
|
assert_eq!(signatures.len(), 2);
|
||||||
|
}
|
||||||
|
let mut hostile = request("explicit_signatures");
|
||||||
|
hostile.address = std::option::Option::None;
|
||||||
|
hostile.min_context_slot = std::option::Option::None;
|
||||||
|
hostile.explicit_signatures = vec![" 1".to_owned()];
|
||||||
|
let hostile_job_id = job_id();
|
||||||
|
let hostile_job_id = match hostile_job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert!(crate::map_backfill_request(hostile, &ready_options(), hostile_job_id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_inventory_and_composition_are_revalidated_before_mapping() {
|
||||||
|
let first_job_id = job_id();
|
||||||
|
let first_job_id = match first_job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mut unknown_route = request("latest_address");
|
||||||
|
unknown_route.http_role = "provider-from-frontend".to_owned();
|
||||||
|
let rejected = crate::map_backfill_request(unknown_route, &ready_options(), first_job_id);
|
||||||
|
assert!(rejected.is_err());
|
||||||
|
let mut unavailable = ready_options();
|
||||||
|
unavailable.composition_ready = false;
|
||||||
|
let second_job_id = job_id();
|
||||||
|
let second_job_id = match second_job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert!(crate::map_backfill_request(request("latest_address"), &unavailable, second_job_id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_preview_never_contains_address_or_signature_values() {
|
||||||
|
let job_id = job_id();
|
||||||
|
let job_id = match job_id {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let mapped = crate::map_backfill_request(request("latest_address"), &ready_options(), job_id);
|
||||||
|
assert!(mapped.is_ok());
|
||||||
|
let mapped = match mapped {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let preview = crate::project_backfill_request(&mapped);
|
||||||
|
assert!(preview.is_ok());
|
||||||
|
let preview = match preview {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let serialized = serde_json::to_string(&preview);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
assert!(!serialized.contains("11111111111111111111111111111111"));
|
||||||
|
assert!(!serialized.contains(&"1".repeat(64)));
|
||||||
|
assert!(serialized.contains("mainnet"));
|
||||||
|
assert!(serialized.contains("backfill_pool"));
|
||||||
|
}
|
||||||
|
}
|
||||||
117
crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
Normal file
117
crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_run_ids_are_backend_owned_bounded_and_unique_in_session() {
|
||||||
|
let state = crate::BackfillRunState::new();
|
||||||
|
let first = state.next_job_id();
|
||||||
|
let second = state.next_job_id();
|
||||||
|
assert!(first.is_ok());
|
||||||
|
assert!(second.is_ok());
|
||||||
|
let first = match first {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let second = match second {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert!(first.as_str().starts_with("backfill-desk-"));
|
||||||
|
assert!(second.as_str().starts_with("backfill-desk-"));
|
||||||
|
assert!(first.as_str().len() <= ksp_job_api::MAX_JOB_ID_BYTES);
|
||||||
|
assert!(second.as_str().len() <= ksp_job_api::MAX_JOB_ID_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancellable_runtime(
|
||||||
|
job_id: &str,
|
||||||
|
) -> std::option::Option<(ksp_job_api::JobId, ksp_job_backfill_lib::BackfillRequest, ksp_job_backfill_lib::BackfillJobRuntime)> {
|
||||||
|
let job_id = match ksp_job_api::JobId::new(job_id) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let network = match ksp_store_lib::RawNetworkId::new("mainnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let request = ksp_job_backfill_lib::BackfillRequest::new(
|
||||||
|
job_id.clone(),
|
||||||
|
network,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("backfill_pool"),
|
||||||
|
ksp_job_backfill_lib::BackfillCommitment::Finalized,
|
||||||
|
ksp_job_backfill_lib::BackfillScope::latest_address(ksp_core_lib::Pubkey::new_from_array([1_u8; 32])),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let request = match request {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let retained_request = request.clone();
|
||||||
|
let runtime = match ksp_job_backfill_lib::BackfillJobRuntime::new(request) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some((job_id, retained_request, runtime));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn targeted_cancel_is_idempotent_and_rejects_stale_job_identity() {
|
||||||
|
let state = crate::BackfillRunState::new();
|
||||||
|
let runtime = cancellable_runtime("backfill-desk-41");
|
||||||
|
assert!(runtime.is_some());
|
||||||
|
let (job_id, request, runtime) = match runtime {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let installed = state.install(job_id.clone(), runtime.handle(), request);
|
||||||
|
assert!(installed.is_ok());
|
||||||
|
let first = state.cancel(job_id.as_str());
|
||||||
|
assert!(first.is_ok());
|
||||||
|
if let std::result::Result::Ok(first) = first {
|
||||||
|
assert!(first.accepted);
|
||||||
|
assert_eq!(first.state, "cancelling");
|
||||||
|
}
|
||||||
|
let repeated = state.cancel(job_id.as_str());
|
||||||
|
assert!(repeated.is_ok());
|
||||||
|
if let std::result::Result::Ok(repeated) = repeated {
|
||||||
|
assert!(!repeated.accepted);
|
||||||
|
assert_eq!(repeated.state, "cancelling");
|
||||||
|
}
|
||||||
|
let stale = state.cancel("backfill-desk-40");
|
||||||
|
assert!(stale.is_err());
|
||||||
|
if let std::result::Result::Err(error) = stale {
|
||||||
|
assert_eq!(error.code().domain(), crate::ERROR_CODE_BACKFILL_RUN_MISMATCH.domain());
|
||||||
|
assert_eq!(error.code().code(), crate::ERROR_CODE_BACKFILL_RUN_MISMATCH.code());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_requires_terminal_checkpoint_and_rejects_active_run() {
|
||||||
|
let state = crate::BackfillRunState::new();
|
||||||
|
let next = match ksp_job_api::JobId::new("backfill-desk-50") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let unavailable = state.resume_request(next.clone());
|
||||||
|
assert!(unavailable.is_err());
|
||||||
|
if let std::result::Result::Err(error) = unavailable {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
let runtime = cancellable_runtime("backfill-desk-49");
|
||||||
|
assert!(runtime.is_some());
|
||||||
|
let (job_id, request, runtime) = match runtime {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let installed = state.install(job_id, runtime.handle(), request);
|
||||||
|
assert!(installed.is_ok());
|
||||||
|
let active = state.resume_request(next);
|
||||||
|
assert!(active.is_err());
|
||||||
|
if let std::result::Result::Err(error) = active {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_RUN_ACTIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
crates/ksp-app-backfill-desk/unit_tests/backfill_status.rs
Normal file
76
crates/ksp-app-backfill-desk/unit_tests/backfill_status.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_status.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
fn initial_notification() -> std::option::Option<ksp_job_api::JobNotification<ksp_job_backfill_lib::BackfillJobSnapshot>> {
|
||||||
|
let signature = match ksp_job_backfill_lib::BackfillSignature::new("1".repeat(ksp_job_backfill_lib::MIN_BACKFILL_SIGNATURE_TEXT_BYTES)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let scope = match ksp_job_backfill_lib::BackfillScope::explicit_signatures(vec![signature]) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let job_id = match ksp_job_api::JobId::new("backfill-desk-monitor-test") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let network = match ksp_store_lib::RawNetworkId::new("mainnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let request = ksp_job_backfill_lib::BackfillRequest::new(
|
||||||
|
job_id,
|
||||||
|
network,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("backfill_pool"),
|
||||||
|
ksp_job_backfill_lib::BackfillCommitment::Confirmed,
|
||||||
|
scope,
|
||||||
|
100,
|
||||||
|
10,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let request = match request {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let runtime = match ksp_job_backfill_lib::BackfillJobRuntime::new(request) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let source = runtime.handle().snapshots();
|
||||||
|
return std::option::Option::Some(<ksp_job_backfill_lib::BackfillSnapshotSource as ksp_job_api::JobSnapshotSource>::current(&source));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_value_projection_is_safe_complete_and_uses_decimal_sequence_text() {
|
||||||
|
let notification = match initial_notification() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let status = crate::project_backfill_status(¬ification);
|
||||||
|
assert!(status.is_ok());
|
||||||
|
let status = match status {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(status.active);
|
||||||
|
assert!(!status.terminal);
|
||||||
|
assert_eq!(status.job_id, "backfill-desk-monitor-test");
|
||||||
|
assert_eq!(status.job_kind, ksp_job_backfill_lib::BACKFILL_JOB_KIND_CODE);
|
||||||
|
assert_eq!(status.sequence, "0");
|
||||||
|
assert_eq!(status.state, "created");
|
||||||
|
assert_eq!(status.phase, "created");
|
||||||
|
assert_eq!(status.scope_kind, "explicit_signatures");
|
||||||
|
assert!(!status.checkpoint_present);
|
||||||
|
assert_eq!(status.candidates_selected, 0);
|
||||||
|
assert_eq!(status.failure_code, std::option::Option::None);
|
||||||
|
assert_eq!(status.failure_domain, std::option::Option::None);
|
||||||
|
let serialized = serde_json::to_string(&status);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
for forbidden in ["address", "signature\"", "endpoint", "credential", "token", "postgres"] {
|
||||||
|
assert!(!serialized.contains(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
Normal file
75
crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_limits_are_derived_from_job_owned_public_bounds() {
|
||||||
|
let limits = crate::backfill_request_limits();
|
||||||
|
assert!(limits.is_ok());
|
||||||
|
let limits = match limits {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(usize::try_from(limits.max_page_size), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_PAGE_SIZE));
|
||||||
|
assert_eq!(usize::try_from(limits.max_pages), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_PAGES));
|
||||||
|
assert_eq!(usize::try_from(limits.max_candidates), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_CANDIDATES));
|
||||||
|
assert_eq!(usize::try_from(limits.max_hydration_concurrency), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_HYDRATION_CONCURRENCY));
|
||||||
|
assert_eq!(usize::try_from(limits.min_signature_text_bytes), std::result::Result::Ok(ksp_job_backfill_lib::MIN_BACKFILL_SIGNATURE_TEXT_BYTES));
|
||||||
|
assert_eq!(usize::try_from(limits.max_signature_text_bytes), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_SIGNATURE_TEXT_BYTES));
|
||||||
|
assert!(limits.default_page_size <= limits.max_page_size);
|
||||||
|
assert!(limits.default_max_pages <= limits.max_pages);
|
||||||
|
assert!(limits.default_max_candidates <= limits.max_candidates);
|
||||||
|
assert!(limits.default_hydration_concurrency <= limits.max_hydration_concurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_options_expose_exact_current_commitments_and_http_scopes() {
|
||||||
|
assert_eq!(crate::backfill_commitment_codes(), vec!["finalized".to_owned(), "confirmed".to_owned()]);
|
||||||
|
assert_eq!(
|
||||||
|
crate::backfill_scope_kind_codes(),
|
||||||
|
vec!["latest_address".to_owned(), "before_address".to_owned(), "after_address".to_owned(), "explicit_signatures".to_owned()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_acknowledgement_contains_only_backend_job_identity_and_initial_state() {
|
||||||
|
let response = crate::BackfillStartResponseDto { job_id: "backfill-desk-1".to_owned(), state: "created".to_owned() };
|
||||||
|
let serialized = serde_json::to_string(&response);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
assert!(serialized.contains("backfill-desk-1"));
|
||||||
|
assert!(serialized.contains("created"));
|
||||||
|
for forbidden in ["address", "signature", "provider", "endpoint", "credential", "token"] {
|
||||||
|
assert!(!serialized.contains(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_acknowledgement_contains_only_backend_job_identity_acceptance_and_state() {
|
||||||
|
let response = crate::BackfillCancelResponseDto { accepted: true, job_id: "backfill-desk-1".to_owned(), state: "cancelling".to_owned() };
|
||||||
|
let serialized = serde_json::to_string(&response);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
assert!(serialized.contains("backfill-desk-1"));
|
||||||
|
assert!(serialized.contains("cancelling"));
|
||||||
|
assert!(serialized.contains("accepted"));
|
||||||
|
for forbidden in ["address", "signature", "provider", "endpoint", "credential", "token", "checkpoint", "payload"] {
|
||||||
|
assert!(!serialized.contains(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_acknowledgement_contains_only_new_backend_job_identity_acceptance_and_state() {
|
||||||
|
let response = crate::BackfillResumeResponseDto { accepted: true, job_id: "backfill-desk-2".to_owned(), state: "created".to_owned() };
|
||||||
|
let serialized = serde_json::to_string(&response);
|
||||||
|
assert!(serialized.is_ok());
|
||||||
|
if let std::result::Result::Ok(serialized) = serialized {
|
||||||
|
assert!(serialized.contains("backfill-desk-2"));
|
||||||
|
assert!(serialized.contains("created"));
|
||||||
|
assert!(serialized.contains("accepted"));
|
||||||
|
for forbidden in ["address", "signature", "provider", "endpoint", "credential", "token", "checkpoint", "payload"] {
|
||||||
|
assert!(!serialized.contains(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/ksp-app-backfill-desk/unit_tests/dto_common.rs
Normal file
67
crates/ksp-app-backfill-desk/unit_tests/dto_common.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/dto_common.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_error_projection_excludes_arbitrary_context_and_source_values() {
|
||||||
|
let source = std::io::Error::other("secret-source");
|
||||||
|
let error = ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "failed"), "safe message")
|
||||||
|
.with_context("secret", "hidden")
|
||||||
|
.with_source(source);
|
||||||
|
let dto = crate::CommandErrorDto::from_error(&error);
|
||||||
|
assert_eq!(dto.domain, "test");
|
||||||
|
assert_eq!(dto.code, "failed");
|
||||||
|
assert_eq!(dto.message, "safe message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_options_projection_contains_only_safe_transport_metadata() {
|
||||||
|
let limits = crate::backfill_request_limits();
|
||||||
|
assert!(limits.is_ok());
|
||||||
|
let limits = match limits {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let dto = crate::BackfillDeskOptionsDto {
|
||||||
|
commitments: crate::backfill_commitment_codes(),
|
||||||
|
http_routes: vec![crate::BackfillHttpRouteOptionDto { pooled: false, providers: vec!["solana-public".to_owned()], role: "default".to_owned() }],
|
||||||
|
limits,
|
||||||
|
composition_ready: true,
|
||||||
|
configured_networks: vec!["devnet".to_owned()],
|
||||||
|
network_coherent: true,
|
||||||
|
program_id_options: crate::program_id_autocomplete_options(),
|
||||||
|
scope_kinds: crate::backfill_scope_kind_codes(),
|
||||||
|
store_diagnostic: std::option::Option::None,
|
||||||
|
store_network: std::option::Option::Some("devnet".to_owned()),
|
||||||
|
store_ready: true,
|
||||||
|
transport_diagnostic: std::option::Option::None,
|
||||||
|
transport_ready: true,
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(dto);
|
||||||
|
assert!(value.is_ok());
|
||||||
|
if let std::result::Result::Ok(value) = value {
|
||||||
|
let serialized = value.to_string();
|
||||||
|
assert!(serialized.contains("default"));
|
||||||
|
assert!(serialized.contains("devnet"));
|
||||||
|
assert!(serialized.contains("solana-public"));
|
||||||
|
assert!(!serialized.contains("url"));
|
||||||
|
assert!(!serialized.contains("endpoint"));
|
||||||
|
assert!(!serialized.contains("postgres"));
|
||||||
|
assert!(!serialized.contains("connection"));
|
||||||
|
assert!(!serialized.contains("database"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn program_id_autocomplete_dataset_is_derived_exactly_from_core_registry() {
|
||||||
|
let options = crate::program_id_autocomplete_options();
|
||||||
|
let entries = ksp_core_lib::entries();
|
||||||
|
assert_eq!(options.len(), entries.len());
|
||||||
|
for (option, entry) in options.iter().zip(entries.iter()) {
|
||||||
|
assert_eq!(option.code, entry.code());
|
||||||
|
assert_eq!(option.name, entry.name());
|
||||||
|
assert_eq!(option.program_id, entry.program_id());
|
||||||
|
assert_eq!(option.domain, entry.domain());
|
||||||
|
assert_eq!(option.family, entry.family());
|
||||||
|
assert_eq!(option.protocol, entry.protocol());
|
||||||
|
}
|
||||||
|
}
|
||||||
40
crates/ksp-app-backfill-desk/unit_tests/frontend_logging.rs
Normal file
40
crates/ksp-app-backfill-desk/unit_tests/frontend_logging.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/frontend_logging.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_log_levels_are_explicit_and_case_insensitive() {
|
||||||
|
assert!(matches!(super::parse_level(" TRACE "), std::result::Result::Ok(super::FrontendLogLevel::Trace)));
|
||||||
|
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(super::FrontendLogLevel::Debug)));
|
||||||
|
assert!(matches!(super::parse_level("Info"), std::result::Result::Ok(super::FrontendLogLevel::Info)));
|
||||||
|
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(super::FrontendLogLevel::Warn)));
|
||||||
|
assert!(matches!(super::parse_level("ERROR"), std::result::Result::Ok(super::FrontendLogLevel::Error)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_frontend_log_level_is_rejected() {
|
||||||
|
let result = super::parse_level("notice");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let error = match result {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_log_targets_are_whitelisted() {
|
||||||
|
assert!(matches!(super::parse_target("frontend"), std::result::Result::Ok(super::FrontendLogTarget::Frontend)));
|
||||||
|
assert!(matches!(super::parse_target(" MAIN "), std::result::Result::Ok(super::FrontendLogTarget::Main)));
|
||||||
|
assert!(matches!(super::parse_target("Splash"), std::result::Result::Ok(super::FrontendLogTarget::Splash)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrary_frontend_log_target_is_rejected() {
|
||||||
|
let result = super::parse_target("ksp-app-backfill-desk.test.logging");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let error = match result {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID);
|
||||||
|
}
|
||||||
36
crates/ksp-app-backfill-desk/unit_tests/splash.rs
Normal file
36
crates/ksp-app-backfill-desk/unit_tests/splash.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/splash.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_splash_settings_are_short_and_ordered() {
|
||||||
|
let settings = crate::SplashSettings::fallback();
|
||||||
|
assert_eq!(settings.fade_in_ms(), 300);
|
||||||
|
assert_eq!(settings.minimum_ms(), 1200);
|
||||||
|
assert_eq!(settings.fade_out_ms(), 300);
|
||||||
|
assert_eq!(settings.expected_backend_lifecycle_ms(), 1800);
|
||||||
|
assert_eq!(settings.fade_in_source(), "fallback");
|
||||||
|
assert_eq!(settings.minimum_source(), "fallback");
|
||||||
|
assert_eq!(settings.fade_out_source(), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splash_order_contract_keeps_action_message_status_and_duration() {
|
||||||
|
let order =
|
||||||
|
crate::SplashOrderDto::new("add_message", std::option::Option::Some("ready"), std::option::Option::Some("success"), std::option::Option::Some(300));
|
||||||
|
assert_eq!(order.action, "add_message");
|
||||||
|
assert_eq!(order.message.as_deref(), std::option::Option::Some("ready"));
|
||||||
|
assert_eq!(order.status.as_deref(), std::option::Option::Some("success"));
|
||||||
|
assert_eq!(order.duration_ms, std::option::Option::Some(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splash_duration_parser_rejects_invalid_or_unbounded_values() {
|
||||||
|
let malformed = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "invalid", 60_000);
|
||||||
|
let oversized_minimum = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "60001", 60_000);
|
||||||
|
let oversized_fade_in = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_IN_MS", "10001", 10_000);
|
||||||
|
let oversized_fade_out = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_OUT_MS", "10001", 10_000);
|
||||||
|
assert!(malformed.is_err());
|
||||||
|
assert!(oversized_minimum.is_err());
|
||||||
|
assert!(oversized_fade_in.is_err());
|
||||||
|
assert!(oversized_fade_out.is_err());
|
||||||
|
}
|
||||||
18
crates/ksp-app-backfill-desk/unit_tests/store_runtime.rs
Normal file
18
crates/ksp-app-backfill-desk/unit_tests/store_runtime.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/store_runtime.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_health_mapping_is_explicit_and_conservative() {
|
||||||
|
assert!(super::store_health_ready(ksp_store_lib::StoreHealthState::Ready));
|
||||||
|
assert!(!super::store_health_ready(ksp_store_lib::StoreHealthState::NotReady));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_transport_network_coherence_requires_exact_logical_identity() {
|
||||||
|
assert!(super::validate_network_coherence("devnet", "devnet").is_ok());
|
||||||
|
let mismatch = super::validate_network_coherence("mainnet", "devnet");
|
||||||
|
assert!(mismatch.is_err());
|
||||||
|
if let std::result::Result::Err(error) = mismatch {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_STORE_NETWORK_MISMATCH);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/ksp-app-backfill-desk/unit_tests/transport_runtime.rs
Normal file
133
crates/ksp-app-backfill-desk/unit_tests/transport_runtime.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/unit_tests/transport_runtime.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
fn endpoint(
|
||||||
|
name: &str,
|
||||||
|
cluster: &str,
|
||||||
|
role_name: &str,
|
||||||
|
request_kinds: std::vec::Vec<ksp_onchain_transport_lib::HttpRequestKind>,
|
||||||
|
) -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpEndpointSettings> {
|
||||||
|
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse(format!("https://{name}.example.invalid"));
|
||||||
|
let url = match url {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new(role_name),
|
||||||
|
true,
|
||||||
|
request_kinds,
|
||||||
|
0,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||||
|
name,
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::HttpProviderName::new("fixture"),
|
||||||
|
ksp_onchain_transport_lib::HttpClusterName::new(cluster),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
std::option::Option::Some(1),
|
||||||
|
vec![role],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool(endpoints: std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointSettings>) -> ksp_core_lib::Result<ksp_onchain_transport_lib::HttpTransportPool> {
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||||
|
endpoints,
|
||||||
|
ksp_onchain_transport_lib::HttpRetrySettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10)),
|
||||||
|
);
|
||||||
|
return ksp_onchain_transport_lib::HttpTransportPool::new(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_routes_require_both_backfill_rpc_methods() {
|
||||||
|
let signatures = ksp_onchain_transport_lib::find_http_rpc_method("getSignaturesForAddress");
|
||||||
|
let transaction = ksp_onchain_transport_lib::find_http_rpc_method("getTransaction");
|
||||||
|
assert!(signatures.is_some());
|
||||||
|
assert!(transaction.is_some());
|
||||||
|
let signatures = match signatures {
|
||||||
|
std::option::Option::Some(value) => ksp_onchain_transport_lib::HttpRequestKind::new(value.request_kind()),
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let transaction = match transaction {
|
||||||
|
std::option::Option::Some(value) => ksp_onchain_transport_lib::HttpRequestKind::new(value.request_kind()),
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let complete = endpoint("complete", "devnet", "complete", vec![signatures.clone(), transaction]);
|
||||||
|
let complete = match complete {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let partial = endpoint("partial", "devnet", "partial", vec![signatures]);
|
||||||
|
let partial = match partial {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let runtime_pool = pool(vec![complete, partial]);
|
||||||
|
assert!(runtime_pool.is_ok());
|
||||||
|
let runtime_pool = match runtime_pool {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let snapshot = runtime_pool.snapshot();
|
||||||
|
let roles = super::compatible_backfill_http_routes(&runtime_pool, &snapshot);
|
||||||
|
assert!(roles.is_ok());
|
||||||
|
if let std::result::Result::Ok(roles) = roles {
|
||||||
|
assert_eq!(roles.len(), 1);
|
||||||
|
if let std::option::Option::Some(route) = roles.first() {
|
||||||
|
assert_eq!(route.role, "complete");
|
||||||
|
assert_eq!(route.providers, vec!["fixture".to_owned()]);
|
||||||
|
assert!(!route.pooled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_networks_are_safe_sorted_and_deduplicated() {
|
||||||
|
let one = endpoint("one", "testnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let two = endpoint("two", "devnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let three = endpoint("three", "devnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let (one, two, three) = match (one, two, three) {
|
||||||
|
(std::result::Result::Ok(one), std::result::Result::Ok(two), std::result::Result::Ok(three)) => (one, two, three),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let runtime_pool = pool(vec![one, two, three]);
|
||||||
|
assert!(runtime_pool.is_ok());
|
||||||
|
if let std::result::Result::Ok(runtime_pool) = runtime_pool {
|
||||||
|
let values = super::configured_networks(&runtime_pool.snapshot());
|
||||||
|
assert_eq!(values, vec!["devnet".to_owned(), "testnet".to_owned()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coherent_network_requires_exactly_one_enabled_http_cluster() {
|
||||||
|
let one = endpoint("one", "devnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let one = match one {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let one_pool = pool(vec![one]);
|
||||||
|
assert!(one_pool.is_ok());
|
||||||
|
if let std::result::Result::Ok(one_pool) = one_pool {
|
||||||
|
let runtime = crate::TransportRuntime { pool: one_pool, profile_id: "fixture".to_owned() };
|
||||||
|
assert_eq!(runtime.coherent_network(), std::option::Option::Some("devnet".to_owned()));
|
||||||
|
}
|
||||||
|
let two = endpoint("two", "devnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let three = endpoint("three", "testnet", "default", vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()]);
|
||||||
|
let (two, three) = match (two, three) {
|
||||||
|
(std::result::Result::Ok(two), std::result::Result::Ok(three)) => (two, three),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let multi_pool = pool(vec![two, three]);
|
||||||
|
assert!(multi_pool.is_ok());
|
||||||
|
if let std::result::Result::Ok(multi_pool) = multi_pool {
|
||||||
|
let runtime = crate::TransportRuntime { pool: multi_pool, profile_id: "fixture".to_owned() };
|
||||||
|
assert_eq!(runtime.coherent_network(), std::option::Option::None);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
crates/ksp-app-backfill-desk/vite.config.ts
Normal file
72
crates/ksp-app-backfill-desk/vite.config.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// file: crates/ksp-app-backfill-desk/vite.config.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { NodePackageImporter } from "sass-embedded";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { defineConfig, normalizePath } from "vite";
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
||||||
|
const frontendDist = normalizePath(resolve(appRoot, "../../../builds/khadhroony-solana-project/ksp-app-backfill-desk/dist"));
|
||||||
|
const devHost = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
root: frontendRoot,
|
||||||
|
publicDir: false,
|
||||||
|
input: {
|
||||||
|
main: normalizePath(resolve(frontendRoot, "main.html")),
|
||||||
|
splash: normalizePath(resolve(frontendRoot, "splash.html")),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: frontendDist,
|
||||||
|
emptyOutDir: true,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "js/[name]-[hash].js",
|
||||||
|
chunkFileNames: "js/chunks/[name]-[hash].js",
|
||||||
|
assetFileNames: assetInfo => {
|
||||||
|
const originalName = assetInfo.names[0] ?? "";
|
||||||
|
const extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
if (extension === "css") {
|
||||||
|
return "css/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
if (["eot", "otf", "ttf", "woff", "woff2"].includes(extension)) {
|
||||||
|
return "fonts/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(extension)) {
|
||||||
|
return "imgs/[name][extname]";
|
||||||
|
}
|
||||||
|
return "otherassets/[name][extname]";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
quietDeps: true,
|
||||||
|
silenceDeprecations: ["import", "color-functions", "global-builtin"],
|
||||||
|
verbose: false,
|
||||||
|
importers: [new NodePackageImporter()],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 1436,
|
||||||
|
strictPort: true,
|
||||||
|
host: devHost || false,
|
||||||
|
ws: {
|
||||||
|
protocol: "ws",
|
||||||
|
host: devHost || "localhost",
|
||||||
|
port: 1437,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
ignored: ["**/src/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
||||||
<!-- version: 3 -->
|
<!-- version: 4 -->
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="splash-container" style="opacity: 0;">
|
<div id="splash-container" style="opacity: 0;">
|
||||||
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Config Desk">
|
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Config Desk">
|
||||||
<div id="app-name">Config Desk</div>
|
<div id="app-name">Config</div>
|
||||||
<div id="debug-info" aria-live="off" hidden></div>
|
<div id="debug-info" aria-live="off" hidden></div>
|
||||||
<div id="messages-container" aria-live="polite"></div>
|
<div id="messages-container" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,19 +52,21 @@
|
|||||||
"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-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
|
||||||
|
"../../config/composite.ksp-app-store-desk.json": "config/composite.ksp-app-store-desk.json",
|
||||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
"../../config/std.logging.json": "config/std.logging.json",
|
|
||||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
|
||||||
"../../config/std.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/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json",
|
||||||
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json": "config/std.store.json",
|
||||||
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
|
"../../config/std.wallet.json": "config/std.wallet.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||||
// version: 11
|
// version: 13
|
||||||
|
|
||||||
//! Desktop build/shell contract audits for Config Desk.
|
//! Desktop build/shell contract audits for Config Desk.
|
||||||
|
|
||||||
@@ -110,11 +110,19 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab
|
|||||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||||
if let std::option::Option::Some(resources) = resources {
|
if let std::option::Option::Some(resources) = resources {
|
||||||
assert_eq!(resources.len(), 13);
|
assert_eq!(resources.len(), 15);
|
||||||
|
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!(
|
assert_eq!(
|
||||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/composite.ksp-app-store-desk.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/composite.ksp-app-store-desk.json"),
|
||||||
|
);
|
||||||
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
|
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-app-solprices-desk/frontend/splash.html -->
|
<!-- file: crates/ksp-app-solprices-desk/frontend/splash.html -->
|
||||||
<!-- version: 2 -->
|
<!-- version: 3 -->
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="splash-container" style="opacity: 0;">
|
<div id="splash-container" style="opacity: 0;">
|
||||||
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP SOL Prices Desk">
|
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP SOL Prices Desk">
|
||||||
<div id="app-name">SOL Prices Desk</div>
|
<div id="app-name">SOL Prices</div>
|
||||||
<div id="debug-info" aria-live="off" hidden></div>
|
<div id="debug-info" aria-live="off" hidden></div>
|
||||||
<div id="messages-container" aria-live="polite"></div>
|
<div id="messages-container" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,19 +52,21 @@
|
|||||||
"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-solprices-desk.json": "config/composite.ksp-app-solprices-desk.json",
|
||||||
|
"../../config/composite.ksp-app-store-desk.json": "config/composite.ksp-app-store-desk.json",
|
||||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
"../../config/std.logging.json": "config/std.logging.json",
|
|
||||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
|
||||||
"../../config/std.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/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json",
|
||||||
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json": "config/std.store.json",
|
||||||
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
|
"../../config/std.wallet.json": "config/std.wallet.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
|
||||||
// version: 10
|
// version: 12
|
||||||
|
|
||||||
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
|
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
|
||||||
|
|
||||||
@@ -85,17 +85,25 @@ fn pre_002_package_is_mixed_lib_bin_and_frontend_is_scaffold_only() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pre_004_packaging_contains_current_thirteen_config_resources() {
|
fn v0_3_8_pre_006_packaging_contains_current_fifteen_config_resources() {
|
||||||
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 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());
|
assert!(resources.is_some());
|
||||||
if let std::option::Option::Some(resources) = resources {
|
if let std::option::Option::Some(resources) = resources {
|
||||||
assert_eq!(resources.len(), 13);
|
assert_eq!(resources.len(), 15);
|
||||||
|
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!(
|
assert_eq!(
|
||||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/composite.ksp-app-store-desk.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/composite.ksp-app-store-desk.json"),
|
||||||
|
);
|
||||||
assert!(resources.contains_key("../../config/std.offchain_transport.json"));
|
assert!(resources.contains_key("../../config/std.offchain_transport.json"));
|
||||||
assert!(resources.contains_key("../../config/schemas/std.offchain_transport.schema.json"));
|
assert!(resources.contains_key("../../config/schemas/std.offchain_transport.schema.json"));
|
||||||
assert!(resources.contains_key("../../config/std.store.json"));
|
assert!(resources.contains_key("../../config/std.store.json"));
|
||||||
|
|||||||
41
crates/ksp-app-store-desk/Cargo.toml
Normal file
41
crates/ksp-app-store-desk/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# file: crates/ksp-app-store-desk/Cargo.toml
|
||||||
|
# version: 2
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-app-store-desk"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ksp_app_store_desk_lib"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ksp-app-store-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-store-lib = { path = "../ksp-store-lib" }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
tauri.workspace = true
|
||||||
|
tauri-plugin-tracing.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["sync", "time"] }
|
||||||
|
ts-rs.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
106
crates/ksp-app-store-desk/README.md
Normal file
106
crates/ksp-app-store-desk/README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<!-- file: crates/ksp-app-store-desk/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# `ksp-app-store-desk`
|
||||||
|
|
||||||
|
`ksp-app-store-desk` est l’application desktop spécialisée d’inspection read-only du Store KSP.
|
||||||
|
|
||||||
|
Elle reste une couche de composition Tauri : Config sélectionne le profil Store et Logging, `ksp-store-lib` possède la façade backend-neutral et les contrats d’inspection, et le backend physique reste entièrement masqué à l’application comme au frontend.
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
```text
|
||||||
|
package : ksp-app-store-desk
|
||||||
|
lib : ksp_app_store_desk_lib
|
||||||
|
bin : ksp-app-store-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
Le binaire est un launcher mince. La bibliothèque applicative possède le bootstrap, le lifecycle Store, les DTOs Tauri, les commands, les projections de détail bornées et le bridge de tracing frontend.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> composite, profils, secrets et runtime packagé
|
||||||
|
ksp-core-lib -> erreurs et primitives communes
|
||||||
|
ksp-store-lib -> façade Store backend-neutral et contrats RAW/inspection
|
||||||
|
ksp-logging-lib -> logging/tracing applicatif
|
||||||
|
ksp-app-store-desk -> orchestration Tauri + projections sûres read-only
|
||||||
|
```
|
||||||
|
|
||||||
|
L’application ne dépend pas directement de `ksp-store-api`, `ksp-store-postgres-lib`, `tokio-postgres` ou d’un autre backend physique. Elle ne possède aucun SQL.
|
||||||
|
|
||||||
|
## Vues
|
||||||
|
|
||||||
|
La surface utilisateur comprend :
|
||||||
|
|
||||||
|
- **Overview** : état du runtime Store, health portable, réseau, backend logique, migration et compteurs de pool sûrs ;
|
||||||
|
- **RAW Transactions** : inspection DataTables server-side, filtres de slot et direction, état de rétention et détail explicite ;
|
||||||
|
- **RAW Accounts** : inspection DataTables server-side, filtre pubkey exact, plage de slots, direction et détail explicite ;
|
||||||
|
- **Diagnostics** : projection sûre du shell, de Config et du runtime Store.
|
||||||
|
|
||||||
|
Les détails Transaction et Account contiennent également leurs observations dans des DataTables server-side dédiées lorsque ces observations existent.
|
||||||
|
|
||||||
|
## Pagination et inspection
|
||||||
|
|
||||||
|
Store Desk utilise la surface d’inspection random-access de `ksp-store-lib` : `offset + limit` avec counts exacts. DataTables est l’unique propriétaire de la pagination visible et les tailles admises sont `25`, `50` et `100`.
|
||||||
|
|
||||||
|
Cette voie d’inspection humaine ne remplace pas la pagination cursor/keyset du Store. Les workers, jobs et autres consumers machine peuvent continuer à utiliser `RawPageCursor` / `RawPageRequest`; Store Desk ne consomme pas ces cursors.
|
||||||
|
|
||||||
|
Le frontend conserve `draw` localement et ne transmet ni search globale DataTables, ni ordre de colonnes arbitraire, ni cursor, ni réseau physique au Store.
|
||||||
|
|
||||||
|
## Rows, détails et observations
|
||||||
|
|
||||||
|
Les rows de table ne transportent jamais les bytes RAW complets :
|
||||||
|
|
||||||
|
- Transaction : identité, slot, format, hash, taille éventuelle et état de rétention ;
|
||||||
|
- Account : pubkey, slot, state hash, owner, lamports, executable, rent epoch et longueur de data ;
|
||||||
|
- Observation : provenance d’acquisition sûre et metadata bornées, sans source payload bytes.
|
||||||
|
|
||||||
|
Un détail est chargé explicitement pour une seule entité. Les previews payload Transaction et data Account sont limitées à **512 bytes**, rendues en hexadécimal et accompagnées de la taille exacte et d’un indicateur de troncature.
|
||||||
|
|
||||||
|
Pour une Transaction, les états `Full`, `Archived` et `Purged` restent distincts. Un état `Purged` est représenté via son tombstone ; Store Desk n’offre aucune action d’archive, purge ou force-rehydrate.
|
||||||
|
|
||||||
|
## Valeurs longues et copie
|
||||||
|
|
||||||
|
Les signatures, hashes, pubkeys, owners et autres identifiants longs sont tronqués visuellement au centre tout en restant disponibles via tooltip et bouton de copie. La copie n’ajoute aucune capability Tauri : elle utilise le WebView et le tracing ne transporte que l’identifiant logique du champ, jamais la valeur copiée.
|
||||||
|
|
||||||
|
## Config et réseaux
|
||||||
|
|
||||||
|
Le composite dédié est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.composite.ksp-app-store-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
Les profils committed couvrent `mainnet`, `devnet` et `testnet`. Mainnet est le profil par défaut. Le composite contient uniquement Logging + Store ; aucun Transport n’est ouvert par l’application.
|
||||||
|
|
||||||
|
Le frontend ne reçoit jamais URI Store, credential, secret Config, SQL, handle pool ou type backend physique.
|
||||||
|
|
||||||
|
## Sécurité et ownership
|
||||||
|
|
||||||
|
Store Desk est read-only par contrat applicatif : aucun command Tauri d’écriture Store n’est exposé. Les capabilities guest restent limitées à `core:default` et `tracing:default`.
|
||||||
|
|
||||||
|
Le frontend ne possède ni accès réseau navigateur direct, ni persistence navigateur des données RAW, ni accès filesystem, ni dialogue natif pour contourner les façades KSP.
|
||||||
|
|
||||||
|
Les comptes et counts `u64` susceptibles d’excéder le domaine entier sûr JavaScript traversent IPC sous forme décimale et sont vérifiés avant conversion côté TypeScript.
|
||||||
|
|
||||||
|
## Ports et développement
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vite HTTP : 1438
|
||||||
|
Vite WS : 1439
|
||||||
|
```
|
||||||
|
|
||||||
|
Lancement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-store-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Build production :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-store-desk && cargo tauri build)
|
||||||
|
```
|
||||||
|
|
||||||
|
Voir également [`USAGE.md`](USAGE.md), [`../ksp-store-lib/README.md`](../ksp-store-lib/README.md), [`../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md`](../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md) et [`../../docs/validation/025-V0_3_8_STORE_DESK.md`](../../docs/validation/025-V0_3_8_STORE_DESK.md).
|
||||||
149
crates/ksp-app-store-desk/USAGE.md
Normal file
149
crates/ksp-app-store-desk/USAGE.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<!-- file: crates/ksp-app-store-desk/USAGE.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Utilisation de `ksp-app-store-desk`
|
||||||
|
|
||||||
|
## 1. Lancement
|
||||||
|
|
||||||
|
Depuis la racine du workspace :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-store-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le runtime charge le composite Store Desk via `ksp-config-lib`, initialise Logging puis ouvre le Store sélectionné via `ksp-store-lib`.
|
||||||
|
|
||||||
|
## 2. Profil et readiness
|
||||||
|
|
||||||
|
Le composite `cfg.composite.ksp-app-store-desk` sélectionne un profil Logging et un profil Store. Mainnet est le profil par défaut ; Devnet et Testnet sont également définis dans la configuration committed.
|
||||||
|
|
||||||
|
La vue **Overview** expose notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Store open
|
||||||
|
health state
|
||||||
|
profile
|
||||||
|
network
|
||||||
|
backend kind
|
||||||
|
migration version / pending migrations
|
||||||
|
pool size / capacity / available / waiting
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces informations sont des projections portables. Elles ne contiennent jamais URI PostgreSQL, credential, secret ou handle physique.
|
||||||
|
|
||||||
|
## 3. RAW Transactions
|
||||||
|
|
||||||
|
La vue **RAW Transactions** charge les données côté serveur via la façade d’inspection Store.
|
||||||
|
|
||||||
|
Filtres disponibles :
|
||||||
|
|
||||||
|
```text
|
||||||
|
slot minimum
|
||||||
|
slot maximum
|
||||||
|
direction ascending / descending
|
||||||
|
```
|
||||||
|
|
||||||
|
La table affiche des summaries sans payload RAW complet. Les signatures et hashes longs restent copiables sans que leur valeur soit journalisée.
|
||||||
|
|
||||||
|
Le bouton de détail recharge explicitement une seule Transaction. Le modal affiche les metadata canoniques, l’état de rétention et, lorsqu’un payload est encore retenu, un preview hexadécimal limité à 512 bytes avec taille exacte et indicateur de troncature.
|
||||||
|
|
||||||
|
Un état `Purged` est présenté à partir du tombstone disponible. Aucune opération d’archive, purge ou force-rehydrate n’est proposée par l’application.
|
||||||
|
|
||||||
|
## 4. RAW Accounts
|
||||||
|
|
||||||
|
La vue **RAW Accounts** utilise le même modèle d’inspection server-side.
|
||||||
|
|
||||||
|
Filtres disponibles :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pubkey exacte
|
||||||
|
slot minimum
|
||||||
|
slot maximum
|
||||||
|
direction ascending / descending
|
||||||
|
```
|
||||||
|
|
||||||
|
La table ne transporte pas les bytes Account. Elle expose seulement les metadata utiles : pubkey, slot, state hash, owner, lamports, executable, rent epoch et longueur de data.
|
||||||
|
|
||||||
|
Le détail recharge exactement l’identité `(pubkey, slot, state_hash)` puis affiche un preview hexadécimal de data limité à 512 bytes, la longueur exacte et l’indicateur de troncature.
|
||||||
|
|
||||||
|
## 5. Observations
|
||||||
|
|
||||||
|
Les modals Transaction et Account contiennent une table **Observations** associée à l’entité actuellement ouverte.
|
||||||
|
|
||||||
|
La table est server-side et expose uniquement de la provenance sûre : provider logique, protocole, méthode/origine d’acquisition, timestamps, endpoint/session/filter/commitment lorsqu’ils existent, hash/taille de source et metadata Account optionnelles admises.
|
||||||
|
|
||||||
|
Les bytes du payload source ne traversent jamais IPC.
|
||||||
|
|
||||||
|
## 6. Pagination DataTables
|
||||||
|
|
||||||
|
Les tables Transactions, Accounts et Observations utilisent DataTables comme unique pager visible.
|
||||||
|
|
||||||
|
Tailles disponibles :
|
||||||
|
|
||||||
|
```text
|
||||||
|
25
|
||||||
|
50
|
||||||
|
100
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend mappe `start` vers l’offset Store et `length` vers la limite. Les counts exacts restent des chaînes décimales jusqu’à vérification du domaine `Number.MAX_SAFE_INTEGER`.
|
||||||
|
|
||||||
|
La pagination d’inspection est distincte de la pagination cursor/keyset utilisée par les consumers machine du Store. Store Desk n’accepte ni cursor, ni search globale DataTables, ni tri arbitraire de colonnes.
|
||||||
|
|
||||||
|
## 7. Rafraîchissement et filtres
|
||||||
|
|
||||||
|
**Refresh** redessine la table concernée avec ses filtres Store explicites. Un changement de filtre repart de la première page.
|
||||||
|
|
||||||
|
Les valeurs de filtre sont validées côté Rust : slots décimaux, pubkey canonique et direction autorisée. Elles ne sont pas injectées dans les logs frontend.
|
||||||
|
|
||||||
|
## 8. Valeurs longues et copie
|
||||||
|
|
||||||
|
Les valeurs longues sont affichées sous forme tronquée au centre. Le survol permet de consulter la valeur complète et le bouton de copie copie la valeur actuellement affichée par le frontend.
|
||||||
|
|
||||||
|
Les événements de tracing de copie transportent uniquement un `fieldId` logique. Ils ne contiennent jamais signature, pubkey, owner, hash ou preview hex.
|
||||||
|
|
||||||
|
## 9. Read-only et frontières
|
||||||
|
|
||||||
|
Store Desk est un outil d’inspection. Il ne fournit aucune commande pour :
|
||||||
|
|
||||||
|
```text
|
||||||
|
écrire une entité RAW
|
||||||
|
ajouter une observation
|
||||||
|
archiver
|
||||||
|
purger
|
||||||
|
force-rehydrate
|
||||||
|
démarrer un backfill
|
||||||
|
démarrer un worker
|
||||||
|
modifier la configuration Store
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces responsabilités appartiennent aux composants KSP propriétaires.
|
||||||
|
|
||||||
|
Le frontend ne reçoit jamais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
URI Store
|
||||||
|
credentials/secrets
|
||||||
|
SQL
|
||||||
|
handles backend/pool
|
||||||
|
payload RAW complet
|
||||||
|
data Account complète
|
||||||
|
source payload bytes
|
||||||
|
contexte d’erreur arbitraire
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Fermeture
|
||||||
|
|
||||||
|
La fermeture de la fenêtre principale bloque les nouvelles admissions puis ferme le Store de manière bornée avant de terminer le process.
|
||||||
|
|
||||||
|
Le shutdown applicatif n’effectue aucune mutation métier de rétention ou de données.
|
||||||
|
|
||||||
|
## 11. Build production
|
||||||
|
|
||||||
|
Depuis la racine du workspace :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd crates/ksp-app-store-desk && cargo tauri build)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le build frontend passe par TypeScript puis Vite avant le build Tauri. Les resources Config enregistrées sont packagées selon le contrat commun des applications Desk KSP.
|
||||||
12
crates/ksp-app-store-desk/build.rs
Normal file
12
crates/ksp-app-store-desk/build.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// file: crates/ksp-app-store-desk/build.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Build script for the KSP Store desktop application.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
13
crates/ksp-app-store-desk/capabilities/default.json
Normal file
13
crates/ksp-app-store-desk/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Minimal Store Desk desktop capability set.",
|
||||||
|
"windows": [
|
||||||
|
"splash",
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"tracing:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
crates/ksp-app-store-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
BIN
crates/ksp-app-store-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
Binary file not shown.
25
crates/ksp-app-store-desk/frontend/fonts/README.md
Normal file
25
crates/ksp-app-store-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-store-desk/frontend/imgs/logo.png
Normal file
BIN
crates/ksp-app-store-desk/frontend/imgs/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/ksp-app-store-desk/frontend/imgs/splash.png
Normal file
BIN
crates/ksp-app-store-desk/frontend/imgs/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
338
crates/ksp-app-store-desk/frontend/main.html
Normal file
338
crates/ksp-app-store-desk/frontend/main.html
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
<!-- file: crates/ksp-app-store-desk/frontend/main.html -->
|
||||||
|
<!-- version: 6 -->
|
||||||
|
<!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>Store Desk — Overview</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">Store 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">Overview</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 Store Desk">
|
||||||
|
<button class="nav-link text-start active" type="button" data-view="overview">
|
||||||
|
<i class="fa-solid fa-gauge-high me-2" aria-hidden="true"></i>Overview
|
||||||
|
</button>
|
||||||
|
<button class="nav-link text-start" type="button" data-view="transactions">
|
||||||
|
<i class="fa-solid fa-receipt me-2" aria-hidden="true"></i>RAW Transactions
|
||||||
|
</button>
|
||||||
|
<button class="nav-link text-start" type="button" data-view="accounts">
|
||||||
|
<i class="fa-solid fa-box-archive me-2" aria-hidden="true"></i>RAW Accounts
|
||||||
|
</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="overview">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Overview</h1>
|
||||||
|
<p class="text-body-secondary mb-0">État backend-neutral du Store sélectionné par le composite Config. Aucune donnée RAW complète n'est chargée ici.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span id="appVersionBadge" class="badge text-bg-secondary">—</span>
|
||||||
|
<button id="refreshOverview" class="btn btn-outline-primary" type="button">
|
||||||
|
<i class="fa-solid fa-arrows-rotate me-1" aria-hidden="true"></i>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="card h-100 shadow-sm"><div class="card-body"><span class="text-body-secondary small">Store profile</span><div id="overviewStoreProfile" class="fw-semibold mt-2">—</div></div></div></div>
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="card h-100 shadow-sm"><div class="card-body"><span class="text-body-secondary small">Target</span><div id="overviewStoreTarget" class="fw-semibold mt-2">—</div></div></div></div>
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="card h-100 shadow-sm"><div class="card-body"><span class="text-body-secondary small">Health</span><div id="overviewStoreHealth" class="fw-semibold mt-2">—</div></div></div></div>
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="card h-100 shadow-sm"><div class="card-body"><span class="text-body-secondary small">Pool</span><div id="overviewStorePool" class="fw-semibold mt-2">—</div></div></div></div>
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="card h-100 shadow-sm"><div class="card-body"><span class="text-body-secondary small">Migration</span><div id="overviewStoreMigration" class="fw-semibold mt-2">—</div></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div id="overviewStoreDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-view-panel="transactions" hidden>
|
||||||
|
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">RAW Transactions</h1>
|
||||||
|
<p class="text-body-secondary mb-0">Inspection Store random-access ; DataTables reste l'unique propriétaire de la pagination visible.</p>
|
||||||
|
</div>
|
||||||
|
<button id="refreshTransactions" class="btn btn-outline-primary" type="button">
|
||||||
|
<i class="fa-solid fa-rotate me-2" aria-hidden="true"></i>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="transactionSlotMin">Slot minimum</label>
|
||||||
|
<input id="transactionSlotMin" class="form-control font-monospace" type="text" inputmode="numeric" autocomplete="off" placeholder="optionnel">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="transactionSlotMax">Slot maximum</label>
|
||||||
|
<input id="transactionSlotMax" class="form-control font-monospace" type="text" inputmode="numeric" autocomplete="off" placeholder="optionnel">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="transactionDirection">Direction canonique</label>
|
||||||
|
<select id="transactionDirection" class="form-select">
|
||||||
|
<option value="descending" selected>Descending</option>
|
||||||
|
<option value="ascending">Ascending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="transactionsQueryError" class="alert alert-danger" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div class="table-responsive app-store-table-container">
|
||||||
|
<table id="rawTransactionsTable" class="table table-hover align-middle mb-0 app-store-table">
|
||||||
|
<caption class="visually-hidden">Inspection server-side des transactions RAW.</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Signature</th>
|
||||||
|
<th>Slot</th>
|
||||||
|
<th>Block time</th>
|
||||||
|
<th>Format ID</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Payload size</th>
|
||||||
|
<th>Content hash</th>
|
||||||
|
<th>Retention</th>
|
||||||
|
<th>Détail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-view-panel="accounts" hidden>
|
||||||
|
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">RAW Accounts</h1>
|
||||||
|
<p class="text-body-secondary mb-0">Inspection Store random-access ; DataTables reste l'unique propriétaire de la pagination visible.</p>
|
||||||
|
</div>
|
||||||
|
<button id="refreshAccounts" class="btn btn-outline-primary" type="button">
|
||||||
|
<i class="fa-solid fa-rotate me-2" aria-hidden="true"></i>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<label class="form-label" for="accountPubkey">Pubkey exacte</label>
|
||||||
|
<input id="accountPubkey" class="form-control font-monospace" type="text" autocomplete="off" placeholder="optionnel">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-lg-2">
|
||||||
|
<label class="form-label" for="accountSlotMin">Slot minimum</label>
|
||||||
|
<input id="accountSlotMin" class="form-control font-monospace" type="text" inputmode="numeric" autocomplete="off" placeholder="optionnel">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-lg-2">
|
||||||
|
<label class="form-label" for="accountSlotMax">Slot maximum</label>
|
||||||
|
<input id="accountSlotMax" class="form-control font-monospace" type="text" inputmode="numeric" autocomplete="off" placeholder="optionnel">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-lg-3">
|
||||||
|
<label class="form-label" for="accountDirection">Direction canonique</label>
|
||||||
|
<select id="accountDirection" class="form-select">
|
||||||
|
<option value="descending" selected>Descending</option>
|
||||||
|
<option value="ascending">Ascending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accountsQueryError" class="alert alert-danger" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div class="table-responsive app-store-table-container">
|
||||||
|
<table id="rawAccountsTable" class="table table-hover align-middle mb-0 app-store-table">
|
||||||
|
<caption class="visually-hidden">Inspection server-side des états RAW Account.</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pubkey</th>
|
||||||
|
<th>Slot</th>
|
||||||
|
<th>State hash</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Lamports</th>
|
||||||
|
<th>Executable</th>
|
||||||
|
<th>Rent epoch</th>
|
||||||
|
<th>Data length</th>
|
||||||
|
<th>Détail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</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 Config / Logging / Store. Les URI et détails physiques sensibles restent hors IPC.</p>
|
||||||
|
</div>
|
||||||
|
<button id="refreshDiagnostics" class="btn btn-outline-primary" type="button">
|
||||||
|
<i class="fa-solid fa-arrows-rotate me-1" 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>
|
||||||
|
<dt class="col-sm-5">Store profile</dt><dd id="runtimeStoreProfile" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Store target</dt><dd id="runtimeStoreTarget" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Store health</dt><dd id="runtimeStoreHealth" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Migration</dt><dd id="runtimeStoreMigration" class="col-sm-7">—</dd>
|
||||||
|
<dt class="col-sm-5">Pool</dt><dd id="runtimeStorePool" 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 id="runtimeStoreDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="transactionDetailModal" class="modal fade" tabindex="-1" aria-labelledby="transactionDetailTitle" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="transactionDetailTitle" class="modal-title h5">RAW Transaction detail</h2>
|
||||||
|
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Fermer"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="transactionDetailError" class="alert alert-danger" role="status" aria-live="polite" hidden></div>
|
||||||
|
<dl class="row app-runtime-list mb-4">
|
||||||
|
<dt class="col-sm-3">Signature</dt><dd id="transactionDetailSignature" class="col-sm-9 text-break">—</dd>
|
||||||
|
<dt class="col-sm-3">Slot</dt><dd id="transactionDetailSlot" class="col-sm-9">—</dd>
|
||||||
|
<dt class="col-sm-3">Block time</dt><dd id="transactionDetailBlockTime" class="col-sm-9">—</dd>
|
||||||
|
<dt class="col-sm-3">Format</dt><dd id="transactionDetailFormat" class="col-sm-9">—</dd>
|
||||||
|
<dt class="col-sm-3">Content hash</dt><dd id="transactionDetailContentHash" class="col-sm-9 text-break">—</dd>
|
||||||
|
<dt class="col-sm-3">Retention</dt><dd id="transactionDetailRetention" class="col-sm-9">—</dd>
|
||||||
|
<dt class="col-sm-3">Payload size</dt><dd id="transactionDetailPayloadSize" class="col-sm-9">—</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
|
||||||
|
<h3 class="h6 mb-0">Payload preview hex</h3>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button id="transactionDetailPayloadPreviewCopy" class="btn btn-sm btn-outline-secondary" type="button" title="Copier le preview hex affiché" aria-label="Copier le preview hex affiché" hidden>
|
||||||
|
<i class="fa-regular fa-copy" aria-hidden="true"></i>
|
||||||
|
<span>Copier</span>
|
||||||
|
</button>
|
||||||
|
<span id="transactionDetailPreviewTruncated" class="badge text-bg-warning" hidden>Preview tronquée à 512 bytes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre id="transactionDetailPayloadPreview" class="small bg-body-tertiary border rounded p-3 overflow-auto text-break mb-0">—</pre>
|
||||||
|
<section class="border-top mt-4 pt-4" aria-labelledby="transactionObservationsTitle">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 id="transactionObservationsTitle" class="h6 mb-1">Observations d’acquisition</h3>
|
||||||
|
<p class="small text-body-secondary mb-0">Provenance sûre uniquement ; aucun payload source n’est chargé.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="transactionObservationsError" class="alert alert-danger" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="transactionObservationsTable" class="table table-hover align-middle mb-0 app-store-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Received</th><th>Observed</th><th>Provider</th><th>Protocol</th><th>Method</th><th>Origin</th><th>Endpoint</th><th>Commitment</th><th>Session</th><th>Filter</th><th>Source hash</th><th>Source bytes</th><th>Observation key</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="accountDetailModal" class="modal fade" tabindex="-1" aria-labelledby="accountDetailTitle" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="accountDetailTitle" class="modal-title h5">RAW Account detail</h2>
|
||||||
|
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Fermer"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="accountDetailError" class="alert alert-danger" role="status" aria-live="polite" hidden></div>
|
||||||
|
<dl class="row app-runtime-list mb-4">
|
||||||
|
<dt class="col-sm-3">Pubkey</dt><dd id="accountDetailPubkey" class="col-sm-9 text-break">—</dd>
|
||||||
|
<dt class="col-sm-3">Slot</dt><dd id="accountDetailSlot" class="col-sm-9">—</dd>
|
||||||
|
<dt class="col-sm-3">State hash</dt><dd id="accountDetailStateHash" class="col-sm-9 text-break">—</dd>
|
||||||
|
<dt class="col-sm-3">Owner</dt><dd id="accountDetailOwner" class="col-sm-9 text-break">—</dd>
|
||||||
|
<dt class="col-sm-3">Lamports</dt><dd id="accountDetailLamports" class="col-sm-9">—</dd>
|
||||||
|
<dt class="col-sm-3">Executable</dt><dd id="accountDetailExecutable" class="col-sm-9">—</dd>
|
||||||
|
<dt class="col-sm-3">Rent epoch</dt><dd id="accountDetailRentEpoch" class="col-sm-9">—</dd>
|
||||||
|
<dt class="col-sm-3">Data length</dt><dd id="accountDetailDataLength" class="col-sm-9">—</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
|
||||||
|
<h3 class="h6 mb-0">Account data preview hex</h3>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button id="accountDetailDataPreviewCopy" class="btn btn-sm btn-outline-secondary" type="button" title="Copier le preview hex affiché" aria-label="Copier le preview hex affiché" hidden>
|
||||||
|
<i class="fa-regular fa-copy" aria-hidden="true"></i>
|
||||||
|
<span>Copier</span>
|
||||||
|
</button>
|
||||||
|
<span id="accountDetailPreviewTruncated" class="badge text-bg-warning" hidden>Preview tronquée à 512 bytes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre id="accountDetailDataPreview" class="small bg-body-tertiary border rounded p-3 overflow-auto text-break mb-0">—</pre>
|
||||||
|
<section class="border-top mt-4 pt-4" aria-labelledby="accountObservationsTitle">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 id="accountObservationsTitle" class="h6 mb-1">Observations d’acquisition</h3>
|
||||||
|
<p class="small text-body-secondary mb-0">Provenance sûre et métadonnées Yellowstone optionnelles ; aucun byte Account n’est chargé.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accountObservationsError" class="alert alert-danger" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="accountObservationsTable" class="table table-hover align-middle mb-0 app-store-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Received</th><th>Observed</th><th>Provider</th><th>Protocol</th><th>Method</th><th>Origin</th><th>Endpoint</th><th>Commitment</th><th>Session</th><th>Filter</th><th>Source hash</th><th>Source bytes</th><th>Observation key</th><th>Startup</th><th>Write version</th><th>Tx signature</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
117
crates/ksp-app-store-desk/frontend/sass/_app.scss
Normal file
117
crates/ksp-app-store-desk/frontend/sass/_app.scss
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// file: crates/ksp-app-store-desk/frontend/sass/_app.scss
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
$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-scrollable {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-card {
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
width: $app-sidebar-width;
|
||||||
|
min-width: $app-sidebar-width;
|
||||||
|
max-width: $app-sidebar-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-scroll,
|
||||||
|
.app-content {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar .nav-link {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-runtime-list dd,
|
||||||
|
.app-store-table td:first-child,
|
||||||
|
.app-store-table td:nth-child(2) {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-store-table-container {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.app-copyable-long-text {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: .375rem;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-copyable-long-text__value {
|
||||||
|
cursor: help;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 24ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-copyable-long-text__copy {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
line-height: 1;
|
||||||
|
padding: .25rem .375rem;
|
||||||
|
}
|
||||||
160
crates/ksp-app-store-desk/frontend/sass/_bootswatch.scss
Normal file
160
crates/ksp-app-store-desk/frontend/sass/_bootswatch.scss
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// file: crates/ksp-app-store-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-store-desk/frontend/sass/_fontawesome.scss
Normal file
19
crates/ksp-app-store-desk/frontend/sass/_fontawesome.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// file: crates/ksp-app-store-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-store-desk/frontend/sass/_simplebar.scss
Normal file
248
crates/ksp-app-store-desk/frontend/sass/_simplebar.scss
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
// file: crates/ksp-app-store-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-store-desk/frontend/sass/_variables.scss
Normal file
95
crates/ksp-app-store-desk/frontend/sass/_variables.scss
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// file: crates/ksp-app-store-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-store-desk/frontend/sass/main.scss
Normal file
10
crates/ksp-app-store-desk/frontend/sass/main.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// file: crates/ksp-app-store-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-store-desk/frontend/sass/splash.scss
Normal file
137
crates/ksp-app-store-desk/frontend/sass/splash.scss
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// file: crates/ksp-app-store-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-store-desk/frontend/splash.html
Normal file
24
crates/ksp-app-store-desk/frontend/splash.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- file: crates/ksp-app-store-desk/frontend/splash.html -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<link rel="icon" href="imgs/logo.png">
|
||||||
|
<link rel="stylesheet" href="sass/splash.scss">
|
||||||
|
<title>KSP Store Desk — Chargement</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="splash-container" style="opacity: 0;">
|
||||||
|
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Store Desk">
|
||||||
|
<div id="app-name">Store</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-store-desk/frontend/ts/frontend_log.ts
Normal file
110
crates/ksp-app-store-desk/frontend/ts/frontend_log.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// file: crates/ksp-app-store-desk/frontend/ts/frontend_log.ts
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { FrontendLogPayloadDto } from "./bindings/ksp_app_store_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-store-desk/frontend/ts/invoke.ts
Normal file
17
crates/ksp-app-store-desk/frontend/ts/invoke.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// file: crates/ksp-app-store-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1030
crates/ksp-app-store-desk/frontend/ts/main.ts
Normal file
1030
crates/ksp-app-store-desk/frontend/ts/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user