From 7d0fe8ac5fea978e64d972a9c2701dd849fbe2df Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Thu, 27 Aug 2026 11:27:39 +0200 Subject: [PATCH] v0.2.12-pre.006 --- Cargo.toml | 4 +- .../ksp-app-solprices-desk/frontend/main.html | 16 +- .../frontend/ts/main.ts | 157 +++++++++- crates/ksp-app-solprices-desk/package.json | 2 +- .../ksp-app-solprices-desk/src/app_state.rs | 17 +- crates/ksp-app-solprices-desk/src/errors.rs | 8 +- crates/ksp-app-solprices-desk/src/lib.rs | 10 +- .../src/market_price_runtime.rs | 276 +++++++++++++++++- crates/ksp-app-solprices-desk/src/tauri.rs | 27 +- crates/ksp-app-solprices-desk/tauri.conf.json | 2 +- .../tests/desktop_contract.rs | 44 ++- .../tests/desktop_security.rs | 38 ++- .../unit_tests/market_price_runtime.rs | 50 +++- deltas/0.2.12/pre.006.md | 103 +++++++ .../plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md | 14 +- .../validation/015-V0_2_12_SOL_PRICES_DESK.md | 74 ++++- 16 files changed, 774 insertions(+), 68 deletions(-) create mode 100644 deltas/0.2.12/pre.006.md diff --git a/Cargo.toml b/Cargo.toml index b257fd7..432626d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 292 +# version: 293 [workspace] 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-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.12-pre.5.fix.1" +version = "0.2.12-pre.6" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-app-solprices-desk/frontend/main.html b/crates/ksp-app-solprices-desk/frontend/main.html index 5af3c32..36332b9 100644 --- a/crates/ksp-app-solprices-desk/frontend/main.html +++ b/crates/ksp-app-solprices-desk/frontend/main.html @@ -1,5 +1,5 @@ - + @@ -44,10 +44,19 @@
-
+

Prices

-

Inventaire provider-neutral du registry Off-chain. Chaque ligne peut être rafraîchie explicitement sans polling ni fallback automatique.

+

Inventaire provider-neutral du registry Off-chain. Rafraîchissement individuel, sélectionné ou global, sans polling ni fallback automatique.

+
+
+ 0 selected + +
@@ -69,6 +78,7 @@ + diff --git a/crates/ksp-app-solprices-desk/frontend/ts/main.ts b/crates/ksp-app-solprices-desk/frontend/ts/main.ts index acc47b2..b425fa2 100644 --- a/crates/ksp-app-solprices-desk/frontend/ts/main.ts +++ b/crates/ksp-app-solprices-desk/frontend/ts/main.ts @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/frontend/ts/main.ts -// version: 5 +// version: 6 import "bootstrap"; import ResizeObserver from "resize-observer-polyfill"; @@ -7,6 +7,8 @@ import "simplebar"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { MarketPriceRuntimeStatusDto } from "./bindings/ksp_app_solprices_desk/dto_common/MarketPriceRuntimeStatusDto.ts"; import type { MarketPriceProviderRowDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceProviderRowDto.ts"; +import type { MarketPriceRefreshManyRequestDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshManyRequestDto.ts"; +import type { MarketPriceRefreshResultDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshResultDto.ts"; import { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log"; import { invokeKsp } from "./invoke"; @@ -21,6 +23,7 @@ const viewTitles: Record = { }; let currentMarketPriceRows: MarketPriceProviderRowDto[] = []; +const selectedProviderIds = new Set(); function isViewId(value: string | undefined): value is ViewId { return value === "prices" || value === "diagnostics"; @@ -78,13 +81,66 @@ function showPricesDiagnostic(message: string): void { } } +function selectedProviderIdsInRowOrder(): string[] { + return currentMarketPriceRows.filter(provider => selectedProviderIds.has(provider.providerId)).map(provider => provider.providerId); +} + +function synchronizeSelectedProviders(): void { + const currentIds = new Set(currentMarketPriceRows.map(provider => provider.providerId)); + for (const providerId of selectedProviderIds) { + if (!currentIds.has(providerId)) { + selectedProviderIds.delete(providerId); + } + } +} + +function synchronizeBatchControls(): void { + const selectedCount = selectedProviderIdsInRowOrder().length; + const anyLoading = currentMarketPriceRows.some(provider => provider.loading); + const selectedButton = document.querySelector("#refreshSelectedButton"); + const allButton = document.querySelector("#refreshAllButton"); + const selectedCountLabel = document.querySelector("#selectedProviderCount"); + if (selectedButton) { + selectedButton.disabled = selectedCount === 0 || anyLoading; + } + if (allButton) { + allButton.disabled = currentMarketPriceRows.length === 0 || anyLoading; + } + if (selectedCountLabel) { + selectedCountLabel.textContent = `${selectedCount} selected`; + } +} + +function markProvidersLoading(providerIds: string[]): void { + const loadingIds = new Set(providerIds); + currentMarketPriceRows = currentMarketPriceRows.map(provider => { + return loadingIds.has(provider.providerId) ? { ...provider, loading: true } : provider; + }); + renderMarketPriceRows(currentMarketPriceRows); +} + +function applyRefreshResult(result: MarketPriceRefreshResultDto): void { + const refreshedRows = new Map(); + for (const provider of result.rows) { + refreshedRows.set(provider.providerId, provider); + } + currentMarketPriceRows = currentMarketPriceRows.map(provider => refreshedRows.get(provider.providerId) ?? provider); + renderMarketPriceRows(currentMarketPriceRows); +} + +async function recoverMarketPriceRows(message: string): Promise { + showPricesDiagnostic(message); + try { + await loadMarketPrices(); + } catch { + frontendWarn("main", "SOL Prices Desk market-price refresh recovery reload failed"); + } +} + async function refreshMarketPrice(providerId: string): Promise { frontendDebug("main", "SOL Prices Desk market-price refresh control clicked", { providerId }); clearPricesDiagnostic(); - currentMarketPriceRows = currentMarketPriceRows.map(provider => { - return provider.providerId === providerId ? { ...provider, loading: true } : provider; - }); - renderMarketPriceRows(currentMarketPriceRows); + markProvidersLoading([providerId]); try { const refreshed = await invokeKsp("main", "refresh_market_price", { providerId }); currentMarketPriceRows = currentMarketPriceRows.map(provider => { @@ -94,15 +150,76 @@ async function refreshMarketPrice(providerId: string): Promise { frontendTrace("main", "SOL Prices Desk market-price refresh row applied", { providerId }); } catch { frontendWarn("main", "SOL Prices Desk market-price refresh failed", { providerId }); - showPricesDiagnostic("Le provider sélectionné n'a pas pu être rafraîchi. L'état précédent est conservé lorsqu'il reste disponible."); - try { - await loadMarketPrices(); - } catch { - frontendWarn("main", "SOL Prices Desk market-price refresh recovery reload failed", { providerId }); - } + await recoverMarketPriceRows("Le provider sélectionné n'a pas pu être rafraîchi. L'état précédent est conservé lorsqu'il reste disponible."); } } +async function refreshSelectedMarketPrices(): Promise { + const providerIds = selectedProviderIdsInRowOrder(); + frontendDebug("main", "SOL Prices Desk selected market-price refresh control clicked", { selectedCount: providerIds.length }); + if (providerIds.length === 0) { + return; + } + clearPricesDiagnostic(); + markProvidersLoading(providerIds); + const request: MarketPriceRefreshManyRequestDto = { providerIds }; + try { + const result = await invokeKsp("main", "refresh_market_prices", { request }); + applyRefreshResult(result); + frontendTrace("main", "SOL Prices Desk selected market-price refresh result applied", { + requestedCount: result.requestedCount, + refreshedCount: result.refreshedCount, + }); + } catch { + frontendWarn("main", "SOL Prices Desk selected market-price refresh failed", { selectedCount: providerIds.length }); + await recoverMarketPriceRows("La sélection n'a pas pu être rafraîchie. Les observations précédentes restent visibles avec leur état réel."); + } +} + +async function refreshAllMarketPrices(): Promise { + frontendDebug("main", "SOL Prices Desk global market-price refresh control clicked", { providerCount: currentMarketPriceRows.length }); + if (currentMarketPriceRows.length === 0) { + return; + } + clearPricesDiagnostic(); + markProvidersLoading(currentMarketPriceRows.map(provider => provider.providerId)); + try { + const result = await invokeKsp("main", "refresh_all_market_prices"); + applyRefreshResult(result); + frontendTrace("main", "SOL Prices Desk global market-price refresh result applied", { + requestedCount: result.requestedCount, + refreshedCount: result.refreshedCount, + }); + } catch { + frontendWarn("main", "SOL Prices Desk global market-price refresh failed", { providerCount: currentMarketPriceRows.length }); + await recoverMarketPriceRows("Le refresh global n'a pas pu être terminé. Les observations précédentes restent visibles avec leur état réel."); + } +} + +function appendSelectionCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void { + const cell = document.createElement("td"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "form-check-input"; + checkbox.checked = selectedProviderIds.has(provider.providerId); + checkbox.disabled = provider.loading; + checkbox.setAttribute("aria-label", `Select ${provider.displayName}`); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + selectedProviderIds.add(provider.providerId); + } else { + selectedProviderIds.delete(provider.providerId); + } + frontendDebug("main", "SOL Prices Desk market-price selection control changed", { + providerId: provider.providerId, + selected: checkbox.checked, + }); + synchronizeBatchControls(); + }); + cell.append(checkbox); + row.append(cell); +} + function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void { const cell = document.createElement("td"); const button = document.createElement("button"); @@ -132,12 +249,15 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void { frontendWarn("main", "SOL Prices Desk market-price table elements are missing"); return; } + currentMarketPriceRows = rows; + synchronizeSelectedProviders(); tableBody.replaceChildren(); empty.hidden = rows.length !== 0; tableContainer.hidden = rows.length === 0; for (const provider of rows) { const row = document.createElement("tr"); row.dataset.providerId = provider.providerId; + appendSelectionCell(row, provider); appendMarketPriceCell(row, provider.displayName); appendMarketPriceCell(row, provider.pair); appendMarketPriceCell(row, provider.semantics); @@ -150,9 +270,23 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void { appendRefreshCell(row, provider); tableBody.append(row); } + synchronizeBatchControls(); frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", { rowCount: rows.length }); } +function bindMarketPriceControls(): void { + const selectedButton = document.querySelector("#refreshSelectedButton"); + const allButton = document.querySelector("#refreshAllButton"); + selectedButton?.addEventListener("click", () => { + void refreshSelectedMarketPrices(); + }); + allButton?.addEventListener("click", () => { + void refreshAllMarketPrices(); + }); + synchronizeBatchControls(); + frontendTrace("main", "SOL Prices Desk market-price batch controls installed"); +} + function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void { const values: Record = { runtimeCompositeProfile: status.activeCompositeProfile, @@ -203,6 +337,7 @@ async function initializeMain(): Promise { const windowLabel = getCurrentWindow().label; frontendInfo("main", "SOL Prices Desk main frontend loaded", { windowLabel }); bindNavigation(); + bindMarketPriceControls(); activateView("prices", "startup"); try { await loadRuntimeStatus(); diff --git a/crates/ksp-app-solprices-desk/package.json b/crates/ksp-app-solprices-desk/package.json index b4e81de..18ce02e 100644 --- a/crates/ksp-app-solprices-desk/package.json +++ b/crates/ksp-app-solprices-desk/package.json @@ -1,7 +1,7 @@ { "name": "ksp-app-solprices-desk", "private": true, - "version": "0.2.12-pre.5.fix.1", + "version": "0.2.12-pre.6", "type": "module", "scripts": { "dev": "vite", diff --git a/crates/ksp-app-solprices-desk/src/app_state.rs b/crates/ksp-app-solprices-desk/src/app_state.rs index d32b5c4..8d38873 100644 --- a/crates/ksp-app-solprices-desk/src/app_state.rs +++ b/crates/ksp-app-solprices-desk/src/app_state.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/src/app_state.rs -// version: 4 +// version: 5 //! Shared backend state owned by the SOL Prices Desk Tauri application. @@ -73,6 +73,19 @@ impl crate::AppState { return self.market_price_runtime.refresh_one(provider_id).await; } + /// Refreshes a selected provider set in deterministic request order and returns only safe updated rows/counts. + pub(crate) async fn refresh_market_prices( + &self, + request: crate::MarketPriceRefreshManyRequestDto, + ) -> ksp_core_lib::Result { + return self.market_price_runtime.refresh_many(request.into_provider_ids()).await; + } + + /// Refreshes every configured provider in the deterministic order owned by Off-chain Transport. + pub(crate) async fn refresh_all_market_prices(&self) -> ksp_core_lib::Result { + return self.market_price_runtime.refresh_all().await; + } + /// Builds the safe runtime status exposed by the SOL Prices Desk shell. pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result { let document_count = self.config_management.engine().registry().descriptors().count(); @@ -116,7 +129,7 @@ impl crate::AppState { fallback_logging_active: runtime.fallback_active, provider_count, ready_provider_count, - shell_phase: "pre.005-single-refresh".to_owned(), + shell_phase: "pre.006-multi-refresh".to_owned(), startup_diagnostic: runtime.startup_diagnostic.clone(), unavailable_provider_count, }); diff --git a/crates/ksp-app-solprices-desk/src/errors.rs b/crates/ksp-app-solprices-desk/src/errors.rs index fb1a356..205bf31 100644 --- a/crates/ksp-app-solprices-desk/src/errors.rs +++ b/crates/ksp-app-solprices-desk/src/errors.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/src/errors.rs -// version: 2 +// version: 3 //! Application-local error codes for SOL Prices Desk composition and desktop runtime surfaces. @@ -16,6 +16,12 @@ pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode ksp_core_lib::ErrorCode::new("solprices_desk", "frontend_log_target_invalid"); /// SOL Prices 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("solprices_desk", "logging_bootstrap_failed"); +/// One requested market-price provider already has an explicit refresh in flight. +pub(crate) const ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT: ksp_core_lib::ErrorCode = + ksp_core_lib::ErrorCode::new("solprices_desk", "market_price_refresh_conflict"); +/// A selected market-price refresh contains duplicate or unknown provider identifiers. +pub(crate) const ERROR_CODE_MARKET_PRICE_SELECTION_INVALID: ksp_core_lib::ErrorCode = + ksp_core_lib::ErrorCode::new("solprices_desk", "market_price_selection_invalid"); /// 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("solprices_desk", "splash_origin_invalid"); /// A KSP desk splash environment duration is malformed or exceeds its safety bound. diff --git a/crates/ksp-app-solprices-desk/src/lib.rs b/crates/ksp-app-solprices-desk/src/lib.rs index a844109..2a31017 100644 --- a/crates/ksp-app-solprices-desk/src/lib.rs +++ b/crates/ksp-app-solprices-desk/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/src/lib.rs -// version: 3 +// version: 4 //! Tauri desktop application scaffold for provider-neutral SOL/USD price visualization. @@ -74,6 +74,10 @@ pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID; pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID; /// SOL Prices Desk could not install the managed Logging runtime or its safe fallback. pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED; +/// One requested market-price provider already has an explicit refresh in flight. +pub(crate) use self::errors::ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT; +/// A selected market-price refresh contains duplicate or unknown provider identifiers. +pub(crate) use self::errors::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID; /// 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. @@ -92,6 +96,10 @@ pub(crate) use self::frontend_logging::emit_frontend_log_event; pub(crate) use self::logging_runtime::launch_identity; /// Safe provider-neutral market-price row exposed to the frontend. pub(crate) use self::market_price_runtime::MarketPriceProviderRowDto; +/// Bounded provider-neutral request used by the selected-provider refresh command. +pub(crate) use self::market_price_runtime::MarketPriceRefreshManyRequestDto; +/// Safe result returned by selected/global market-price refresh commands. +pub(crate) use self::market_price_runtime::MarketPriceRefreshResultDto; /// Backend application runtime owning the Config-selected service and in-memory market-price presentation state. pub(crate) use self::market_price_runtime::MarketPriceRuntime; /// Resolved composite and Off-chain Transport configuration retained by SOL Prices Desk. diff --git a/crates/ksp-app-solprices-desk/src/market_price_runtime.rs b/crates/ksp-app-solprices-desk/src/market_price_runtime.rs index 7c7b762..12b72e6 100644 --- a/crates/ksp-app-solprices-desk/src/market_price_runtime.rs +++ b/crates/ksp-app-solprices-desk/src/market_price_runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/src/market_price_runtime.rs -// version: 2 +// version: 3 //! Provider-neutral market-price presentation runtime owned by SOL Prices Desk. @@ -36,6 +36,35 @@ pub(crate) struct MarketPriceProviderRowDto { pub(crate) semantics: String, } +/// Bounded provider-neutral request used by the selected-provider refresh command. +#[derive(Debug, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshManyRequestDto.ts")] +pub(crate) struct MarketPriceRefreshManyRequestDto { + /// Opaque provider identifiers requested by the frontend in deterministic row order. + pub(crate) provider_ids: std::vec::Vec, +} + +impl crate::MarketPriceRefreshManyRequestDto { + /// Consumes the IPC DTO and returns the opaque provider identifiers for authoritative backend validation. + pub(crate) fn into_provider_ids(self) -> std::vec::Vec { + return self.provider_ids; + } +} + +/// Safe result of one selected/global refresh operation. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshResultDto.ts")] +pub(crate) struct MarketPriceRefreshResultDto { + /// Number of provider refreshes that produced a new normalized observation. + pub(crate) refreshed_count: u32, + /// Number of providers included in the validated operation. + pub(crate) requested_count: u32, + /// Updated provider-neutral rows in the exact dispatch/outcome order. + pub(crate) rows: std::vec::Vec, +} + /// Backend application runtime that owns the Config-selected Off-chain service and current in-memory presentation state. pub(crate) struct MarketPriceRuntime { presentation: std::sync::Mutex, @@ -128,7 +157,8 @@ impl crate::MarketPriceRuntime { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let loading = self.set_loading(&provider_id, true); + let provider_ids = [provider_id.clone()]; + let loading = self.begin_refresh(provider_ids.as_slice()); if let std::result::Result::Err(error) = loading { return std::result::Result::Err(error); } @@ -142,7 +172,7 @@ impl crate::MarketPriceRuntime { let outcome = match outcome { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { - let cleared = self.set_loading(&provider_id, false); + let cleared = self.clear_loading(provider_ids.as_slice()); if let std::result::Result::Err(clear_error) = cleared { return std::result::Result::Err(clear_error); } @@ -152,7 +182,13 @@ impl crate::MarketPriceRuntime { let row = self.apply_outcome(&outcome); let row = match row { std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error), + std::result::Result::Err(error) => { + let cleared = self.clear_loading(provider_ids.as_slice()); + if let std::result::Result::Err(clear_error) = cleared { + return std::result::Result::Err(clear_error); + } + return std::result::Result::Err(error); + }, }; ksp_logging_lib::debug!( target: crate::TRACING_TARGET, @@ -165,6 +201,101 @@ impl crate::MarketPriceRuntime { return std::result::Result::Ok(row); } + /// Refreshes a frontend-selected provider set in deterministic request order through the generic Off-chain service. + pub(crate) async fn refresh_many(&self, provider_ids: std::vec::Vec) -> ksp_core_lib::Result { + let provider_ids = parse_provider_ids(provider_ids); + let provider_ids = match provider_ids { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let loading = self.begin_refresh(provider_ids.as_slice()); + if let std::result::Result::Err(error) = loading { + return std::result::Result::Err(error); + } + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, + provider_count = provider_ids.len(), + "started SOL Prices Desk selected-provider market-price refresh" + ); + let outcomes = self.startup.resolved().service().refresh_many(provider_ids.as_slice()).await; + let outcomes = match outcomes { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let cleared = self.clear_loading(provider_ids.as_slice()); + if let std::result::Result::Err(clear_error) = cleared { + return std::result::Result::Err(clear_error); + } + return std::result::Result::Err(error); + }, + }; + let result = self.apply_outcomes(outcomes.as_slice()); + let result = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let cleared = self.clear_loading(provider_ids.as_slice()); + if let std::result::Result::Err(clear_error) = cleared { + return std::result::Result::Err(clear_error); + } + return std::result::Result::Err(error); + }, + }; + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, + provider_count = result.requested_count, + refreshed_count = result.refreshed_count, + "completed SOL Prices Desk selected-provider market-price refresh" + ); + return std::result::Result::Ok(result); + } + + /// Refreshes every configured provider through the generic Off-chain service in its stable provider order. + pub(crate) async fn refresh_all(&self) -> ksp_core_lib::Result { + let registry = self.startup.resolved().service().registry(); + let provider_ids = registry.entries().iter().map(|entry| return entry.descriptor().id().clone()).collect::>(); + let loading = self.begin_refresh(provider_ids.as_slice()); + if let std::result::Result::Err(error) = loading { + return std::result::Result::Err(error); + } + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, + provider_count = provider_ids.len(), + "started SOL Prices Desk global market-price refresh" + ); + let outcomes = self.startup.resolved().service().refresh_all().await; + let outcomes = match outcomes { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let cleared = self.clear_loading(provider_ids.as_slice()); + if let std::result::Result::Err(clear_error) = cleared { + return std::result::Result::Err(clear_error); + } + return std::result::Result::Err(error); + }, + }; + let result = self.apply_outcomes(outcomes.as_slice()); + let result = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let cleared = self.clear_loading(provider_ids.as_slice()); + if let std::result::Result::Err(clear_error) = cleared { + return std::result::Result::Err(clear_error); + } + return std::result::Result::Err(error); + }, + }; + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, + provider_count = result.requested_count, + refreshed_count = result.refreshed_count, + "completed SOL Prices Desk global market-price refresh" + ); + return std::result::Result::Ok(result); + } + fn apply_outcome(&self, outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome) -> ksp_core_lib::Result { let registry = self.startup.resolved().service().registry(); let registry_entry = registry.entry(outcome.provider_id()); @@ -196,13 +327,86 @@ impl crate::MarketPriceRuntime { }; } - fn set_loading(&self, provider_id: &ksp_offchain_transport_lib::MarketPriceProviderId, loading: bool) -> ksp_core_lib::Result<()> { + fn apply_outcomes(&self, outcomes: &[ksp_offchain_transport_lib::MarketPriceRefreshOutcome]) -> ksp_core_lib::Result { + let registry = self.startup.resolved().service().registry(); let presentation = lock_presentation(&self.presentation); let mut presentation = match presentation { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - return set_loading_in_state(&mut presentation, provider_id.as_str(), loading); + let mut rows = std::vec::Vec::with_capacity(outcomes.len()); + let mut refreshed_count = 0usize; + for outcome in outcomes { + let registry_entry = registry.entry(outcome.provider_id()); + let registry_entry = match registry_entry { + 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, + "SOL Prices Desk refresh outcome references an unknown registry provider", + ) + .with_context("provider_id", outcome.provider_id().as_str()), + ); + }, + }; + let applied = apply_outcome_to_state(&mut presentation, outcome); + if let std::result::Result::Err(error) = applied { + return std::result::Result::Err(error); + } + if outcome.refreshed() { + refreshed_count += 1; + } + let presentation_entry = presentation.entries.get(outcome.provider_id().as_str()); + let presentation_entry = match presentation_entry { + 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, + "SOL Prices Desk market-price runtime is missing refreshed provider state", + ) + .with_context("provider_id", outcome.provider_id().as_str()), + ); + }, + }; + rows.push(project_registry_entry(registry_entry, presentation_entry)); + } + let requested_count = count_to_u32(outcomes.len(), "refresh_requested_count"); + let requested_count = match requested_count { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let refreshed_count = count_to_u32(refreshed_count, "refresh_success_count"); + let refreshed_count = match refreshed_count { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::MarketPriceRefreshResultDto { refreshed_count, requested_count, rows }); + } + + fn begin_refresh(&self, provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId]) -> ksp_core_lib::Result<()> { + let presentation = lock_presentation(&self.presentation); + let mut presentation = match presentation { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return begin_refresh_in_state(&mut presentation, provider_ids); + } + + fn clear_loading(&self, provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId]) -> ksp_core_lib::Result<()> { + let presentation = lock_presentation(&self.presentation); + let mut presentation = match presentation { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + for provider_id in provider_ids { + let cleared = set_loading_in_state(&mut presentation, provider_id.as_str(), false); + if let std::result::Result::Err(error) = cleared { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(()); } } @@ -258,6 +462,53 @@ fn availability_code(availability: ksp_offchain_transport_lib::MarketPriceProvid }; } +fn begin_refresh_in_state( + presentation: &mut MarketPricePresentationState, + provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId], +) -> ksp_core_lib::Result<()> { + let mut seen = std::collections::BTreeSet::new(); + for provider_id in provider_ids { + if !seen.insert(provider_id.as_str().to_owned()) { + return std::result::Result::Err( + ksp_core_lib::Error::new( + crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID, + "SOL Prices Desk market-price refresh selection contains a duplicate provider", + ) + .with_context("provider_id", provider_id.as_str()), + ); + } + let entry = presentation.entries.get(provider_id.as_str()); + let entry = match entry { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + ksp_core_lib::Error::new( + crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID, + "SOL Prices Desk market-price refresh selection contains an unknown provider", + ) + .with_context("provider_id", provider_id.as_str()), + ); + }, + }; + if entry.loading { + return std::result::Result::Err( + ksp_core_lib::Error::new( + crate::ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT, + "SOL Prices Desk market-price provider already has a refresh in flight", + ) + .with_context("provider_id", provider_id.as_str()), + ); + } + } + for provider_id in provider_ids { + let updated = set_loading_in_state(presentation, provider_id.as_str(), true); + if let std::result::Result::Err(error) = updated { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(()); +} + fn count_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result { let converted = u32::try_from(value); return match converted { @@ -282,6 +533,19 @@ fn lock_presentation( }; } +fn parse_provider_ids(provider_ids: std::vec::Vec) -> ksp_core_lib::Result> { + let mut parsed = std::vec::Vec::with_capacity(provider_ids.len()); + for provider_id in provider_ids { + let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id); + let provider_id = match provider_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + parsed.push(provider_id); + } + return std::result::Result::Ok(parsed); +} + fn project_registry_entry( entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry, presentation: &MarketPricePresentationEntry, diff --git a/crates/ksp-app-solprices-desk/src/tauri.rs b/crates/ksp-app-solprices-desk/src/tauri.rs index 7cced74..cab2542 100644 --- a/crates/ksp-app-solprices-desk/src/tauri.rs +++ b/crates/ksp-app-solprices-desk/src/tauri.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/src/tauri.rs -// version: 4 +// version: 5 //! Tauri runtime assembly for the KSP SOL prices desktop application. @@ -76,7 +76,9 @@ fn configure_commands(builder: tauri::Builder) -> tauri::Builder, +) -> std::result::Result { + let result = state.refresh_all_market_prices().await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)), + }; +} + #[tauri::command] async fn refresh_market_price( provider_id: String, @@ -140,6 +153,18 @@ async fn refresh_market_price( }; } +#[tauri::command] +async fn refresh_market_prices( + request: crate::MarketPriceRefreshManyRequestDto, + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let result = state.refresh_market_prices(request).await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)), + }; +} + #[tauri::command] async fn splash_frontend_ready( app: tauri::AppHandle, diff --git a/crates/ksp-app-solprices-desk/tauri.conf.json b/crates/ksp-app-solprices-desk/tauri.conf.json index b3a6b1f..f6b9581 100644 --- a/crates/ksp-app-solprices-desk/tauri.conf.json +++ b/crates/ksp-app-solprices-desk/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "KSP SOL Prices Desk", - "version": "0.2.12-pre.5.fix.1", + "version": "0.2.12-pre.6", "identifier": "com.sasedev.ksp-app-solprices-desk", "build": { "beforeDevCommand": { diff --git a/crates/ksp-app-solprices-desk/tests/desktop_contract.rs b/crates/ksp-app-solprices-desk/tests/desktop_contract.rs index 3fc716d..cab7d68 100644 --- a/crates/ksp-app-solprices-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-solprices-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs -// version: 5 +// version: 6 //! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`. @@ -192,29 +192,21 @@ fn pre_002_fix_001_reuses_the_common_ksp_desktop_template() { } #[test] -fn pre_005_market_price_runtime_adds_single_refresh_without_many_or_all_controls() { +fn pre_005_market_price_runtime_preserves_single_refresh_contract() { let root = app_root(); let manifest = read_text(root.join("Cargo.toml").as_path()); assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#)); assert!(!manifest.contains("reqwest")); - let lib_rs = read_text(root.join("src/lib.rs").as_path()); - assert!(lib_rs.contains("mod market_price_runtime;")); - assert!(lib_rs.contains("MarketPriceProviderRowDto")); - assert!(lib_rs.contains("MarketPriceRuntime")); let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path()); assert!(runtime_rs.contains("pub(crate) async fn refresh_one")); assert!(runtime_rs.contains("service().refresh(&provider_id).await")); - assert!(runtime_rs.contains("self.set_loading(&provider_id, true)")); + assert!(runtime_rs.contains("self.begin_refresh(provider_ids.as_slice())")); assert!(runtime_rs.contains("apply_outcome_to_state")); let tauri_rs = read_text(root.join("src/tauri.rs").as_path()); assert!(tauri_rs.contains("refresh_market_price")); - for forbidden in ["refresh_market_prices", "refresh_all_market_prices"] { - assert!(!tauri_rs.contains(forbidden)); - } let main_html = read_text(root.join("frontend/main.html").as_path()); assert!(main_html.contains(r#"id="marketPriceTable""#)); assert!(main_html.contains(">Action")); - assert!(main_html.contains("rafraîchie explicitement")); let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path()); assert!(main_typescript.contains(r#""refresh_market_price""#)); assert!(main_typescript.contains("SOL Prices Desk market-price refresh control clicked")); @@ -224,3 +216,33 @@ fn pre_005_market_price_runtime_adds_single_refresh_without_many_or_all_controls assert!(!main_typescript.contains("parseFloat(")); assert!(!main_typescript.contains("Number(provider.price")); } + +#[test] +fn pre_006_market_price_runtime_adds_selected_and_global_refresh_without_polling() { + let root = app_root(); + let lib_rs = read_text(root.join("src/lib.rs").as_path()); + assert!(lib_rs.contains("MarketPriceRefreshManyRequestDto")); + assert!(lib_rs.contains("MarketPriceRefreshResultDto")); + let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path()); + assert!(runtime_rs.contains("pub(crate) async fn refresh_many")); + assert!(runtime_rs.contains("service().refresh_many(provider_ids.as_slice()).await")); + assert!(runtime_rs.contains("pub(crate) async fn refresh_all")); + assert!(runtime_rs.contains("service().refresh_all().await")); + assert!(runtime_rs.contains("begin_refresh_in_state")); + let tauri_rs = read_text(root.join("src/tauri.rs").as_path()); + assert!(tauri_rs.contains("refresh_market_prices")); + assert!(tauri_rs.contains("refresh_all_market_prices")); + let main_html = read_text(root.join("frontend/main.html").as_path()); + assert!(main_html.contains(r#"id="refreshSelectedButton""#)); + assert!(main_html.contains(r#"id="refreshAllButton""#)); + assert!(main_html.contains(r#"id="selectedProviderCount""#)); + let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path()); + assert!(main_typescript.contains(r#""refresh_market_prices""#)); + assert!(main_typescript.contains(r#""refresh_all_market_prices""#)); + assert!(main_typescript.contains("selectedProviderIdsInRowOrder")); + assert!(main_typescript.contains("SOL Prices Desk selected market-price refresh control clicked")); + assert!(main_typescript.contains("SOL Prices Desk global market-price refresh control clicked")); + for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame("] { + assert!(!main_typescript.contains(forbidden), "pre.006 must not add consumer scheduling: {forbidden}"); + } +} diff --git a/crates/ksp-app-solprices-desk/tests/desktop_security.rs b/crates/ksp-app-solprices-desk/tests/desktop_security.rs index d41c22c..db89a80 100644 --- a/crates/ksp-app-solprices-desk/tests/desktop_security.rs +++ b/crates/ksp-app-solprices-desk/tests/desktop_security.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/tests/desktop_security.rs -// version: 5 +// version: 6 //! Security, ownership and frontend instrumentation canaries for SOL Prices Desk. @@ -69,7 +69,7 @@ fn pre_002_frontend_has_no_network_persistence_or_native_dialog_surface() { } #[test] -fn pre_005_tauri_commands_remain_centralized_and_only_single_refresh_is_present() { +fn pre_005_tauri_commands_remain_centralized_and_single_refresh_is_preserved() { let root = app_root(); let mut rust_files = std::vec::Vec::new(); collect_files(root.join("src").as_path(), "rs", &mut rust_files); @@ -85,10 +85,8 @@ fn pre_005_tauri_commands_remain_centralized_and_only_single_refresh_is_present( if path.file_name().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("tauri.rs") { assert!(!source.contains("tauri::generate_handler!"), "{} registers Tauri handlers outside tauri.rs", path.display()); } - assert!(!source.contains("refresh_market_prices"), "{} advances multi-refresh before pre.006", path.display()); - assert!(!source.contains("refresh_all_market_prices"), "{} advances global refresh before pre.006", path.display()); } - assert_eq!(command_count, 5); + assert!(command_count >= 5); let tauri = read_text(root.join("src/tauri.rs").as_path()); assert!(tauri.contains("refresh_market_price")); } @@ -114,3 +112,33 @@ fn pre_005_frontend_refresh_interactions_are_logged_without_business_values() { assert!(!main.contains(forbidden), "main frontend logging must not serialize business/secret values: {forbidden}"); } } + +#[test] +fn pre_006_selected_and_global_refresh_commands_are_centralized_and_bounded() { + let root = app_root(); + let tauri = read_text(root.join("src/tauri.rs").as_path()); + assert_eq!(tauri.matches("#[tauri::command]").count(), 7); + assert!(tauri.contains("refresh_market_price")); + assert!(tauri.contains("refresh_market_prices")); + assert!(tauri.contains("refresh_all_market_prices")); + let runtime = read_text(root.join("src/market_price_runtime.rs").as_path()); + assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT")); + assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_SELECTION_INVALID")); + assert!(runtime.contains("refresh_many(provider_ids.as_slice()).await")); + assert!(runtime.contains("refresh_all().await")); + assert!(!runtime.contains("tokio::time::sleep")); +} + +#[test] +fn pre_006_frontend_batch_controls_are_logged_without_price_or_provider_payloads() { + let root = app_root(); + let main = read_text(root.join("frontend/ts/main.ts").as_path()); + assert!(main.contains("SOL Prices Desk market-price selection control changed")); + assert!(main.contains("SOL Prices Desk selected market-price refresh control clicked")); + assert!(main.contains("SOL Prices Desk global market-price refresh control clicked")); + assert!(main.contains("requestedCount: result.requestedCount")); + assert!(main.contains("refreshedCount: result.refreshedCount")); + for forbidden in ["JSON.stringify(result)", "JSON.stringify(currentMarketPriceRows)", "price: provider.price", "apiKey", "authorization"] { + assert!(!main.contains(forbidden), "batch frontend logging must not serialize price/provider/secret values: {forbidden}"); + } +} diff --git a/crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs b/crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs index 7dafcd7..e361f6b 100644 --- a/crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs +++ b/crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs -// version: 2 +// version: 3 fn descriptor() -> std::option::Option { let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("test-provider"); @@ -230,3 +230,51 @@ async fn non_refresh_outcome_preserves_last_successful_observation() -> ksp_core } return std::result::Result::Ok(()); } + +#[test] +fn batch_loading_validation_rejects_duplicates_without_partial_state_mutation() -> ksp_core_lib::Result<()> { + let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut entries = std::collections::BTreeMap::new(); + entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None }); + let mut presentation = super::MarketPricePresentationState { entries }; + let result = super::begin_refresh_in_state(&mut presentation, &[provider_id.clone(), provider_id.clone()]); + assert!(result.is_err()); + let entry = presentation.entries.get(provider_id.as_str()); + assert!(entry.is_some()); + if let std::option::Option::Some(entry) = entry { + assert!(!entry.loading); + } + return std::result::Result::Ok(()); +} + +#[test] +fn batch_loading_validation_rejects_in_flight_provider_without_marking_other_rows() -> ksp_core_lib::Result<()> { + let coinpaprika = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let kraken = match ksp_offchain_transport_lib::MarketPriceProviderId::new("kraken") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut entries = std::collections::BTreeMap::new(); + entries.insert(coinpaprika.as_str().to_owned(), super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None }); + entries.insert(kraken.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::None }); + let mut presentation = super::MarketPricePresentationState { entries }; + let result = super::begin_refresh_in_state(&mut presentation, &[coinpaprika.clone(), kraken.clone()]); + assert!(result.is_err()); + let coinpaprika_entry = presentation.entries.get(coinpaprika.as_str()); + let kraken_entry = presentation.entries.get(kraken.as_str()); + assert!(coinpaprika_entry.is_some()); + assert!(kraken_entry.is_some()); + if let std::option::Option::Some(entry) = coinpaprika_entry { + assert!(!entry.loading); + } + if let std::option::Option::Some(entry) = kraken_entry { + assert!(entry.loading); + } + return std::result::Result::Ok(()); +} diff --git a/deltas/0.2.12/pre.006.md b/deltas/0.2.12/pre.006.md new file mode 100644 index 0000000..455f038 --- /dev/null +++ b/deltas/0.2.12/pre.006.md @@ -0,0 +1,103 @@ +# Delta `0.2.12-pre.006` — Refresh selected/many/all SOL/USD + +## 1. Identité + +```text +release : 0.2.12 +tranche : pre.006 +version Cargo : 0.2.12-pre.6 +base : 0.2.12-pre.5.fix.1 +``` + +Le gate opérateur de la base est propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, `cargo test -p ksp-app-solprices-desk` et `cargo test --workspace` passent. + +## 2. Scope réalisé + +`pre.006` complète les actions manuelles du SOL Prices Desk sans ajouter de scheduler consommateur : + +```text +refresh individuel conservé +refresh selected/many MarketPriceService::refresh_many +refresh all MarketPriceService::refresh_all +ordre selected ordre courant des rows / requête +ordre all ordre stable provider-id Off-chain +DTO batch input IDs opaques uniquement +DTO batch result rows + requested_count/refreshed_count +cooldown/retry registry Off-chain, aucune réinterprétation app +polling/auto-refresh non +fallback/consensus/moyenne non +``` + +## 3. In-flight et atomicité + +Avant chaque opération, le backend valide la sélection complète sous le mutex de présentation : + +- doublons interdits ; +- IDs inconnus interdits ; +- provider déjà `loading` interdit ; +- aucune ligne n'est marquée `loading` avant la fin de cette validation globale. + +Après validation, toutes les lignes concernées passent `loading = true`, puis le mutex est relâché avant l'appel async au service. En cas d'erreur structurelle du service, les flags `loading` sont nettoyés. Les outcomes réussis ou non éligibles sont appliqués dans l'ordre retourné par Off-chain Transport et conservent la dernière observation réussie lorsqu'aucune nouvelle observation n'est produite. + +## 4. Frontend et Logging + +La vue Prices ajoute : + +```text +checkbox par ligne +compteur selected +Refresh selected +Refresh all +``` + +Les IDs sélectionnés sont reconstruits dans l'ordre courant des rows avant l'IPC. Les contrôles batch sont désactivés lorsqu'une opération visible est déjà `loading`. + +Instrumentation : + +```text +changement de sélection debug +clic Refresh selected debug + selectedCount +clic Refresh all debug + providerCount +IPC request/completion debug / trace via invokeKsp +application résultat batch trace + requested/refreshed counts +échec warn +``` + +Aucun prix, payload provider, URL, header, credential ou DTO complet n'est sérialisé dans les logs. + +## 5. Canaris + +La tranche rend les canaris historiques évolutifs puis ajoute des preuves dédiées `pre.006` : + +- le refresh individuel reste présent sans interdire les surfaces futures de sa propre release ; +- `#[tauri::command]` et `generate_handler!` restent centralisés dans `tauri.rs` ; +- exactement sept commandes Tauri sont attendues dans le Desk à cette tranche ; +- `refresh_many` et `refresh_all` sont appelés directement sur le service générique ; +- doublons et conflits `in_flight` ne provoquent aucune mutation partielle ; +- aucun `setInterval`, `setTimeout`, `requestAnimationFrame` ou `tokio::time::sleep` n'est ajouté au chemin prix. + +## 6. Hors scope confirmé + +```text +DataTable/polish final pre.007 +hardening/package final pre.008 +Wallet Desk prices pre.009+ +auto-refresh OUT 0.2.12 +prix canonique/fallback/consensus OUT 0.2.12 +valorisation USD Wallet OUT 0.2.12 +``` + +## 7. Gate + +À exécuter par l'opérateur : + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-app-solprices-desk +cargo test --workspace +(cd crates/ksp-app-solprices-desk && cargo tauri dev) +``` diff --git a/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md b/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md index c756e87..caef359 100644 --- a/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md +++ b/docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.12` — SOL Prices Desk + intégration prix Wallet Desk @@ -268,7 +268,7 @@ Le prix exact traverse IPC comme chaîne décimale, jamais comme `f64`. Les time |------------------------------------|----------------------------------------------------------------------------------------------------------------|--------------------| | `MarketPriceRuntimeStatusDto` | app version, composite profile, offchain profile, provider counts, fallback logging | Public/safe | | `MarketPriceProviderRowDto` | provider_id, display_name, pair, semantics, auth_mode, availability, price?, timestamps?, retry_at?, refreshed | Public/safe | -| `MarketPriceRefreshRequestDto` | provider_id opaque | Public input borné | +| single refresh direct | `provider_id` opaque conservé directement depuis `pre.005` | Public input borné | | `MarketPriceRefreshManyRequestDto` | provider_ids opaques bornés | Public input borné | | `MarketPriceRefreshResultDto` | rows affectées + refreshed/classification | Public/safe | | `CommandErrorDto` | domain/code/message borné sans context sensible | Public/safe | @@ -291,7 +291,7 @@ Config effective secret values |-----------------------------|--------------------------------------|----------------------------------|----------------------------------|-----------------------| | `get_runtime_status` | aucun | `MarketPriceRuntimeStatusDto` | AppState/Config runtime | safe | | `list_market_prices` | aucun | `Vec` | MarketPriceRuntime + registry | safe | -| `refresh_market_price` | `provider_id` | `MarketPriceRefreshResultDto` | MarketPriceService::refresh | safe ID | +| `refresh_market_price` | `provider_id` | `MarketPriceProviderRowDto` | MarketPriceService::refresh | safe ID | | `refresh_market_prices` | liste bornée `provider_id` | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_many | safe IDs | | `refresh_all_market_prices` | aucun | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_all | safe | | `splash_frontend_ready` | fenêtre invoquante | ordre splash existant | Tauri window lifecycle | safe | @@ -514,21 +514,21 @@ Le gate de `pre.004-fix.001` a révélé que `desktop_contract.rs` comparait enc ### `pre.005` — Refresh individuel -**Statut : implémenté ; corrigé par `pre.005-fix.001`, gate opérateur à confirmer.** +**Statut : réalisé ; corrigé par `pre.005-fix.001`, gate opérateur Cargo/workspace intégralement PASS.** Branchement du refresh d'une ligne via `MarketPriceService::refresh`, projection de l'observation exacte, de la fraîcheur et de l'availability, gestion d'erreur sûre et maintien du dernier état applicatif uniquement en mémoire backend. Le backend marque la ligne `loading` sans conserver le mutex de présentation pendant l'`await`, puis applique l'outcome provider-neutral ; une tentative sans nouvelle observation conserve la dernière observation réussie. L'UI ajoute un bouton `Refresh` par ligne, journalise le clic en `debug`, et n'expose encore aucun refresh multiple/global avant `pre.006`. #### `pre.005-fix.001` — Canari de centralisation Tauri rendu structurel -**Statut : implémenté ; gate opérateur à confirmer.** +**Statut : réalisé ; gate opérateur Cargo/workspace intégralement PASS.** Le gate de `pre.005` confirme que le refresh individuel fonctionne et que le runtime Tauri démarre, mais révèle un faux positif dans `desktop_security.rs` : le canari assimilait toute occurrence du nom métier `refresh_market_price` hors `tauri.rs` à du plumbing de commande. Le fix conserve l'interdiction structurelle de `#[tauri::command]` hors `tauri.rs`, ajoute l'interdiction de `tauri::generate_handler!` hors `tauri.rs`, et autorise les méthodes applicatives homonymes dans `AppState`. Aucun comportement réseau, DTO, provider, row, frontend ou commande n'est modifié. ### `pre.006` — Refresh multiple et global -**Statut : planifié.** +**Statut : implémenté ; gate opérateur à confirmer.** -Ajout des refresh selected/many/all, suivi `in-flight`, ordre déterministe et projection des cooldown/retry deadlines sans scheduling consommateur. Aucun polling périodique, fallback automatique ou consensus provider n'est introduit. +Ajout de `refresh_market_prices` et `refresh_all_market_prices` sur les APIs génériques `MarketPriceService::refresh_many`/`refresh_all`. La sélection frontend est reconstruite dans l'ordre courant des rows, le backend valide atomiquement doublons/IDs inconnus/conflits `in_flight`, marque toutes les lignes concernées `loading`, relâche le mutex de présentation pendant l'`await`, puis applique les outcomes dans leur ordre déterministe. `MarketPriceRefreshManyRequestDto` transporte uniquement les IDs opaques et `MarketPriceRefreshResultDto` retourne les rows affectées avec `requested_count`/`refreshed_count`. Les cooldown/retry deadlines restent les projections Off-chain existantes ; aucun polling, sleep consommateur, fallback automatique ou consensus provider n'est introduit. ### `pre.007` — UX et instrumentation SOL Prices Desk diff --git a/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md b/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md index 51f58b3..af98836 100644 --- a/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md +++ b/docs/validation/015-V0_2_12_SOL_PRICES_DESK.md @@ -1,5 +1,5 @@ - + # Validation `0.2.12` — SOL Prices Desk + intégration Wallet Desk @@ -50,13 +50,13 @@ NOT RUN commande non exécutée dans le contexte indiqué |------------------------|-----------------------------------------|---------| | splash Price Desk | lifecycle KSP commun | PASS | | main shell Price Desk | header/navigation KSP | PASS | -| table providers | registry provider-neutral, ordre stable | PENDING | -| prix exact | String issue de `MarketPriceDecimal` | PENDING | -| availability/fraîcheur | état générique + timestamps réels | PENDING | -| refresh row | `MarketPriceService::refresh` | PENDING | +| table providers | registry provider-neutral, ordre stable | PASS | +| prix exact | String issue de `MarketPriceDecimal` | PASS | +| availability/fraîcheur | état générique + timestamps réels | PASS | +| refresh row | `MarketPriceService::refresh` | PASS | | refresh selected/many | `MarketPriceService::refresh_many` | PENDING | | refresh all | `MarketPriceService::refresh_all` | PENDING | -| Diagnostics | profils/counts sûrs seulement | PENDING | +| Diagnostics | profils/counts sûrs seulement | PASS | | Wallet price panel | Dashboard compact provider-neutral | PENDING | | auto-refresh | aucun scheduler/polling | OUT | | Wallet USD valuation | aucun prix canonique | OUT | @@ -65,13 +65,13 @@ NOT RUN commande non exécutée dans le contexte indiqué | Contrat | Entrée/sortie sûre | Statut | |----------------------------------------|--------------------------------------------------------------------|---------| -| `MarketPriceRuntimeStatusDto` | profiles/counts sans secret | PENDING | -| `MarketPriceProviderRowDto` | ID/display/pair/semantics/auth/availability/price/timestamps/retry | PENDING | -| `MarketPriceRefreshRequestDto` | provider_id opaque seulement | PENDING | +| `MarketPriceRuntimeStatusDto` | profiles/counts sans secret | PASS | +| `MarketPriceProviderRowDto` | ID/display/pair/semantics/auth/availability/price/timestamps/retry | PASS | +| single refresh direct | provider_id opaque seulement | PASS | | `MarketPriceRefreshManyRequestDto` | liste bornée d’IDs opaques | PENDING | | `MarketPriceRefreshResultDto` | rows/outcome classifiés | PENDING | | runtime/list/refresh/many/all commands | aucun URL/header/API key/body | PENDING | -| TS-RS exports | bindings générés non versionnés | PENDING | +| TS-RS exports | bindings générés non versionnés | PASS | | provider-specific type dans IPC | interdit | OUT | | prix `f64` comme vérité IPC | interdit | OUT | @@ -110,12 +110,12 @@ NOT RUN commande non exécutée dans le contexte indiqué | Risque/canari | Attendu | Statut | |--------------------------------|----------------------------------------------|---------| -| URL arbitraire depuis frontend | impossible par contrat command | PENDING | -| extraction API key/header | aucune projection/log | PENDING | -| provider_id inconnu | erreur KSP sûre | PENDING | +| URL arbitraire depuis frontend | impossible par contrat command | PASS | +| extraction API key/header | aucune projection/log | PASS | +| provider_id inconnu | erreur KSP sûre | PASS | | batch duplicate/oversized | rejet avant dispatch | PENDING | -| refresh spam | limites/cooldown Off-chain toujours actifs | PENDING | -| stale observation | timestamps/état distingués | PENDING | +| refresh spam | limites/cooldown Off-chain toujours actifs | PASS | +| stale observation | timestamps/état distingués | PASS | | double-click race | in-flight/order contrôlés | PENDING | | close pendant refresh | aucune persistance/sûreté frontend requise | PENDING | | CWD/resource confusion | canaris propres à chaque app | PENDING | @@ -486,3 +486,47 @@ runtime prix / frontend / providers inchangés ``` La version technique devient `0.2.12-pre.5.fix.1`. Le gate attendu doit confirmer le test ciblé puis le workspace complet avant d'ouvrir `pre.006`. + +## 22. Gate `0.2.12-pre.006` + +Le gate opérateur de `pre.005-fix.001` fourni le 2026-08-27 confirme la fermeture de la tranche précédente : + +```text +cargo fmt --all PASS +python3 scripts/audit_rust_workspace_rules.py PASS +python3 scripts/audit_markdown_tables.py ... deltas/0.2.12 PASS +cargo check --workspace PASS +cargo clippy --workspace --all-targets PASS +cargo test -p ksp-app-solprices-desk PASS +cargo test --workspace PASS +``` + +`pre.006` matérialise ensuite le batch manuel provider-neutral : + +```text +MarketPriceRefreshManyRequestDto implémenté, IDs opaques uniquement +MarketPriceRefreshResultDto implémenté, rows + requested/refreshed counts +refresh_market_prices implémenté via MarketPriceService::refresh_many +refresh_all_market_prices implémenté via MarketPriceService::refresh_all +ordre selected ordre courant des rows / requête conservé +ordre all ordre stable provider-id du service +in-flight backend validation atomique avant mutation +doublon / ID inconnu rejet sûr avant état loading partiel +mutex présentation pendant await non +cooldown / retry_at projection Off-chain existante +Refresh selected / Refresh all frontend implémentés +logging clic sélection/selected/all debug, compte/ID opaque seulement +logging résultat batch trace, requested/refreshed counts seulement +polling / setInterval / scheduler / sleep consommateur absent +fallback / consensus / moyenne absent +``` + +Validations sandbox après matérialisation : + +```text +python3 scripts/audit_rust_workspace_rules.py PASS +python3 scripts/audit_markdown_tables.py README.md ... deltas PASS +cargo fmt/check/clippy/test NOT RUN — cargo indisponible dans le sandbox +``` + +Les lignes `refresh selected/many`, `refresh all`, `MarketPriceRefreshManyRequestDto`, `MarketPriceRefreshResultDto`, ordre many, batch duplicate/oversized et double-click race de la matrice générale restent `PENDING` jusqu'au gate opérateur de cette tranche.
Select Provider Pair Semantics