v0.2.12-pre.006

This commit is contained in:
2026-08-27 11:27:39 +02:00
parent a00e485ebb
commit 7d0fe8ac5f
16 changed files with 774 additions and 68 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 292 # version: 293
[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-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] 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] [workspace.package]
version = "0.2.12-pre.5.fix.1" version = "0.2.12-pre.6"
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"

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html --> <!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
<!-- version: 6 --> <!-- version: 7 -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
@@ -44,10 +44,19 @@
<div class="card app-shell-card mx-auto shadow-sm border-0"> <div class="card app-shell-card mx-auto shadow-sm border-0">
<div class="card-body p-4"> <div class="card-body p-4">
<section data-view-panel="prices"> <section data-view-panel="prices">
<div class="d-flex align-items-center justify-content-between mb-4"> <div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-4">
<div> <div>
<h1 class="h3 mb-1">Prices</h1> <h1 class="h3 mb-1">Prices</h1>
<p class="text-body-secondary mb-0">Inventaire provider-neutral du registry Off-chain. Chaque ligne peut être rafraîchie explicitement sans polling ni fallback automatique.</p> <p class="text-body-secondary mb-0">Inventaire provider-neutral du registry Off-chain. Rafraîchissement individuel, sélectionné ou global, sans polling ni fallback automatique.</p>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span id="selectedProviderCount" class="small text-body-secondary">0 selected</span>
<button id="refreshSelectedButton" class="btn btn-sm btn-outline-primary" type="button" disabled>
<i class="fa-solid fa-list-check me-1" aria-hidden="true"></i>Refresh selected
</button>
<button id="refreshAllButton" class="btn btn-sm btn-primary" type="button" disabled>
<i class="fa-solid fa-arrows-rotate me-1" aria-hidden="true"></i>Refresh all
</button>
</div> </div>
</div> </div>
<div id="pricesLoading" class="app-placeholder"> <div id="pricesLoading" class="app-placeholder">
@@ -69,6 +78,7 @@
<table id="marketPriceTable" class="table table-hover align-middle mb-0"> <table id="marketPriceTable" class="table table-hover align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th scope="col"><span class="visually-hidden">Select</span></th>
<th scope="col">Provider</th> <th scope="col">Provider</th>
<th scope="col">Pair</th> <th scope="col">Pair</th>
<th scope="col">Semantics</th> <th scope="col">Semantics</th>

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts // file: crates/ksp-app-solprices-desk/frontend/ts/main.ts
// version: 5 // version: 6
import "bootstrap"; import "bootstrap";
import ResizeObserver from "resize-observer-polyfill"; import ResizeObserver from "resize-observer-polyfill";
@@ -7,6 +7,8 @@ import "simplebar";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import type { MarketPriceRuntimeStatusDto } from "./bindings/ksp_app_solprices_desk/dto_common/MarketPriceRuntimeStatusDto.ts"; 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 { 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 { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
import { invokeKsp } from "./invoke"; import { invokeKsp } from "./invoke";
@@ -21,6 +23,7 @@ const viewTitles: Record<ViewId, string> = {
}; };
let currentMarketPriceRows: MarketPriceProviderRowDto[] = []; let currentMarketPriceRows: MarketPriceProviderRowDto[] = [];
const selectedProviderIds = new Set<string>();
function isViewId(value: string | undefined): value is ViewId { function isViewId(value: string | undefined): value is ViewId {
return value === "prices" || value === "diagnostics"; 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<HTMLButtonElement>("#refreshSelectedButton");
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
const selectedCountLabel = document.querySelector<HTMLElement>("#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<string, MarketPriceProviderRowDto>();
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<void> {
showPricesDiagnostic(message);
try {
await loadMarketPrices();
} catch {
frontendWarn("main", "SOL Prices Desk market-price refresh recovery reload failed");
}
}
async function refreshMarketPrice(providerId: string): Promise<void> { async function refreshMarketPrice(providerId: string): Promise<void> {
frontendDebug("main", "SOL Prices Desk market-price refresh control clicked", { providerId }); frontendDebug("main", "SOL Prices Desk market-price refresh control clicked", { providerId });
clearPricesDiagnostic(); clearPricesDiagnostic();
currentMarketPriceRows = currentMarketPriceRows.map(provider => { markProvidersLoading([providerId]);
return provider.providerId === providerId ? { ...provider, loading: true } : provider;
});
renderMarketPriceRows(currentMarketPriceRows);
try { try {
const refreshed = await invokeKsp<MarketPriceProviderRowDto>("main", "refresh_market_price", { providerId }); const refreshed = await invokeKsp<MarketPriceProviderRowDto>("main", "refresh_market_price", { providerId });
currentMarketPriceRows = currentMarketPriceRows.map(provider => { currentMarketPriceRows = currentMarketPriceRows.map(provider => {
@@ -94,15 +150,76 @@ async function refreshMarketPrice(providerId: string): Promise<void> {
frontendTrace("main", "SOL Prices Desk market-price refresh row applied", { providerId }); frontendTrace("main", "SOL Prices Desk market-price refresh row applied", { providerId });
} catch { } catch {
frontendWarn("main", "SOL Prices Desk market-price refresh failed", { providerId }); 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."); await recoverMarketPriceRows("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 });
}
} }
} }
async function refreshSelectedMarketPrices(): Promise<void> {
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<MarketPriceRefreshResultDto>("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<void> {
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<MarketPriceRefreshResultDto>("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 { function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td"); const cell = document.createElement("td");
const button = document.createElement("button"); 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"); frontendWarn("main", "SOL Prices Desk market-price table elements are missing");
return; return;
} }
currentMarketPriceRows = rows;
synchronizeSelectedProviders();
tableBody.replaceChildren(); tableBody.replaceChildren();
empty.hidden = rows.length !== 0; empty.hidden = rows.length !== 0;
tableContainer.hidden = rows.length === 0; tableContainer.hidden = rows.length === 0;
for (const provider of rows) { for (const provider of rows) {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.dataset.providerId = provider.providerId; row.dataset.providerId = provider.providerId;
appendSelectionCell(row, provider);
appendMarketPriceCell(row, provider.displayName); appendMarketPriceCell(row, provider.displayName);
appendMarketPriceCell(row, provider.pair); appendMarketPriceCell(row, provider.pair);
appendMarketPriceCell(row, provider.semantics); appendMarketPriceCell(row, provider.semantics);
@@ -150,9 +270,23 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
appendRefreshCell(row, provider); appendRefreshCell(row, provider);
tableBody.append(row); tableBody.append(row);
} }
synchronizeBatchControls();
frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", { rowCount: rows.length }); frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", { rowCount: rows.length });
} }
function bindMarketPriceControls(): void {
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
const allButton = document.querySelector<HTMLButtonElement>("#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 { function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void {
const values: Record<string, string> = { const values: Record<string, string> = {
runtimeCompositeProfile: status.activeCompositeProfile, runtimeCompositeProfile: status.activeCompositeProfile,
@@ -203,6 +337,7 @@ async function initializeMain(): Promise<void> {
const windowLabel = getCurrentWindow().label; const windowLabel = getCurrentWindow().label;
frontendInfo("main", "SOL Prices Desk main frontend loaded", { windowLabel }); frontendInfo("main", "SOL Prices Desk main frontend loaded", { windowLabel });
bindNavigation(); bindNavigation();
bindMarketPriceControls();
activateView("prices", "startup"); activateView("prices", "startup");
try { try {
await loadRuntimeStatus(); await loadRuntimeStatus();

View File

@@ -1,7 +1,7 @@
{ {
"name": "ksp-app-solprices-desk", "name": "ksp-app-solprices-desk",
"private": true, "private": true,
"version": "0.2.12-pre.5.fix.1", "version": "0.2.12-pre.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/app_state.rs // 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. //! 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; 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<crate::MarketPriceRefreshResultDto> {
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<crate::MarketPriceRefreshResultDto> {
return self.market_price_runtime.refresh_all().await;
}
/// Builds the safe runtime status exposed by the SOL Prices Desk shell. /// Builds the safe runtime status exposed by the SOL Prices Desk shell.
pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result<crate::MarketPriceRuntimeStatusDto> { pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result<crate::MarketPriceRuntimeStatusDto> {
let document_count = self.config_management.engine().registry().descriptors().count(); let document_count = self.config_management.engine().registry().descriptors().count();
@@ -116,7 +129,7 @@ impl crate::AppState {
fallback_logging_active: runtime.fallback_active, fallback_logging_active: runtime.fallback_active,
provider_count, provider_count,
ready_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(), startup_diagnostic: runtime.startup_diagnostic.clone(),
unavailable_provider_count, unavailable_provider_count,
}); });

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/errors.rs // 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. //! 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"); 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. /// 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"); 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. /// 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"); 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. /// A KSP desk splash environment duration is malformed or exceeds its safety bound.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/lib.rs // file: crates/ksp-app-solprices-desk/src/lib.rs
// version: 3 // version: 4
//! Tauri desktop application scaffold for provider-neutral SOL/USD price visualization. //! 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; 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. /// SOL Prices Desk could not install the managed Logging runtime or its safe fallback.
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED; 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. /// Splash readiness was invoked from a window other than the splash window.
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID; pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
/// A KSP desk splash environment duration is malformed or exceeds its safety bound. /// 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; pub(crate) use self::logging_runtime::launch_identity;
/// Safe provider-neutral market-price row exposed to the frontend. /// Safe provider-neutral market-price row exposed to the frontend.
pub(crate) use self::market_price_runtime::MarketPriceProviderRowDto; 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. /// Backend application runtime owning the Config-selected service and in-memory market-price presentation state.
pub(crate) use self::market_price_runtime::MarketPriceRuntime; pub(crate) use self::market_price_runtime::MarketPriceRuntime;
/// Resolved composite and Off-chain Transport configuration retained by SOL Prices Desk. /// Resolved composite and Off-chain Transport configuration retained by SOL Prices Desk.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/market_price_runtime.rs // 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. //! Provider-neutral market-price presentation runtime owned by SOL Prices Desk.
@@ -36,6 +36,35 @@ pub(crate) struct MarketPriceProviderRowDto {
pub(crate) semantics: String, 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<String>,
}
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<String> {
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<MarketPriceProviderRowDto>,
}
/// Backend application runtime that owns the Config-selected Off-chain service and current in-memory presentation state. /// Backend application runtime that owns the Config-selected Off-chain service and current in-memory presentation state.
pub(crate) struct MarketPriceRuntime { pub(crate) struct MarketPriceRuntime {
presentation: std::sync::Mutex<MarketPricePresentationState>, presentation: std::sync::Mutex<MarketPricePresentationState>,
@@ -128,7 +157,8 @@ impl crate::MarketPriceRuntime {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error), 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 { if let std::result::Result::Err(error) = loading {
return std::result::Result::Err(error); return std::result::Result::Err(error);
} }
@@ -142,7 +172,7 @@ impl crate::MarketPriceRuntime {
let outcome = match outcome { let outcome = match outcome {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => { 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 { if let std::result::Result::Err(clear_error) = cleared {
return std::result::Result::Err(clear_error); return std::result::Result::Err(clear_error);
} }
@@ -152,7 +182,13 @@ impl crate::MarketPriceRuntime {
let row = self.apply_outcome(&outcome); let row = self.apply_outcome(&outcome);
let row = match row { let row = match row {
std::result::Result::Ok(value) => value, 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!( ksp_logging_lib::debug!(
target: crate::TRACING_TARGET, target: crate::TRACING_TARGET,
@@ -165,6 +201,101 @@ impl crate::MarketPriceRuntime {
return std::result::Result::Ok(row); 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<String>) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
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<crate::MarketPriceRefreshResultDto> {
let registry = self.startup.resolved().service().registry();
let provider_ids = registry.entries().iter().map(|entry| return entry.descriptor().id().clone()).collect::<std::vec::Vec<_>>();
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<crate::MarketPriceProviderRowDto> { fn apply_outcome(&self, outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
let registry = self.startup.resolved().service().registry(); let registry = self.startup.resolved().service().registry();
let registry_entry = registry.entry(outcome.provider_id()); 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<crate::MarketPriceRefreshResultDto> {
let registry = self.startup.resolved().service().registry();
let presentation = lock_presentation(&self.presentation); let presentation = lock_presentation(&self.presentation);
let mut presentation = match presentation { let mut presentation = match presentation {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error), 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<u32> { fn count_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result<u32> {
let converted = u32::try_from(value); let converted = u32::try_from(value);
return match converted { return match converted {
@@ -282,6 +533,19 @@ fn lock_presentation(
}; };
} }
fn parse_provider_ids(provider_ids: std::vec::Vec<String>) -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderId>> {
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( fn project_registry_entry(
entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry, entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry,
presentation: &MarketPricePresentationEntry, presentation: &MarketPricePresentationEntry,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/tauri.rs // file: crates/ksp-app-solprices-desk/src/tauri.rs
// version: 4 // version: 5
//! Tauri runtime assembly for the KSP SOL prices desktop application. //! Tauri runtime assembly for the KSP SOL prices desktop application.
@@ -76,7 +76,9 @@ fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tau
emit_frontend_log, emit_frontend_log,
get_runtime_status, get_runtime_status,
list_market_prices, list_market_prices,
refresh_all_market_prices,
refresh_market_price, refresh_market_price,
refresh_market_prices,
splash_frontend_ready, splash_frontend_ready,
]); ]);
} }
@@ -128,6 +130,17 @@ fn list_market_prices(
}; };
} }
#[tauri::command]
async fn refresh_all_market_prices(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::MarketPriceRefreshResultDto, crate::CommandErrorDto> {
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] #[tauri::command]
async fn refresh_market_price( async fn refresh_market_price(
provider_id: String, 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<crate::MarketPriceRefreshResultDto, crate::CommandErrorDto> {
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] #[tauri::command]
async fn splash_frontend_ready( async fn splash_frontend_ready(
app: tauri::AppHandle, app: tauri::AppHandle,

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KSP SOL Prices Desk", "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", "identifier": "com.sasedev.ksp-app-solprices-desk",
"build": { "build": {
"beforeDevCommand": { "beforeDevCommand": {

View File

@@ -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: 5 // version: 6
//! 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`.
@@ -192,29 +192,21 @@ fn pre_002_fix_001_reuses_the_common_ksp_desktop_template() {
} }
#[test] #[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 root = app_root();
let manifest = read_text(root.join("Cargo.toml").as_path()); 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(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#));
assert!(!manifest.contains("reqwest")); 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()); 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("pub(crate) async fn refresh_one"));
assert!(runtime_rs.contains("service().refresh(&provider_id).await")); 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")); assert!(runtime_rs.contains("apply_outcome_to_state"));
let tauri_rs = read_text(root.join("src/tauri.rs").as_path()); let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri_rs.contains("refresh_market_price")); 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()); let main_html = read_text(root.join("frontend/main.html").as_path());
assert!(main_html.contains(r#"id="marketPriceTable""#)); assert!(main_html.contains(r#"id="marketPriceTable""#));
assert!(main_html.contains(">Action</th>")); assert!(main_html.contains(">Action</th>"));
assert!(main_html.contains("rafraîchie explicitement"));
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path()); 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(r#""refresh_market_price""#));
assert!(main_typescript.contains("SOL Prices Desk market-price refresh control clicked")); 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("parseFloat("));
assert!(!main_typescript.contains("Number(provider.price")); 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}");
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/tests/desktop_security.rs // file: crates/ksp-app-solprices-desk/tests/desktop_security.rs
// version: 5 // version: 6
//! Security, ownership and frontend instrumentation canaries for SOL Prices Desk. //! 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] #[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 root = app_root();
let mut rust_files = std::vec::Vec::new(); let mut rust_files = std::vec::Vec::new();
collect_files(root.join("src").as_path(), "rs", &mut rust_files); 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") { 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("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()); let tauri = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri.contains("refresh_market_price")); 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}"); 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}");
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs // file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
// version: 2 // version: 3
fn descriptor() -> std::option::Option<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> { fn descriptor() -> std::option::Option<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> {
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("test-provider"); 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(()); 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(());
}

103
deltas/0.2.12/pre.006.md Normal file
View File

@@ -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)
```

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md --> <!-- file: docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md -->
<!-- version: 11 --> <!-- version: 12 -->
# Plan `0.2.12` — SOL Prices Desk + intégration prix Wallet Desk # 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 | | `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 | | `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é | | `MarketPriceRefreshManyRequestDto` | provider_ids opaques bornés | Public input borné |
| `MarketPriceRefreshResultDto` | rows affectées + refreshed/classification | Public/safe | | `MarketPriceRefreshResultDto` | rows affectées + refreshed/classification | Public/safe |
| `CommandErrorDto` | domain/code/message borné sans context sensible | 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 | | `get_runtime_status` | aucun | `MarketPriceRuntimeStatusDto` | AppState/Config runtime | safe |
| `list_market_prices` | aucun | `Vec<MarketPriceProviderRowDto>` | MarketPriceRuntime + registry | safe | | `list_market_prices` | aucun | `Vec<MarketPriceProviderRowDto>` | 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_market_prices` | liste bornée `provider_id` | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_many | safe IDs |
| `refresh_all_market_prices` | aucun | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_all | safe | | `refresh_all_market_prices` | aucun | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_all | safe |
| `splash_frontend_ready` | fenêtre invoquante | ordre splash existant | Tauri window lifecycle | 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 ### `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`. 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 #### `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é. 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 ### `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 ### `pre.007` — UX et instrumentation SOL Prices Desk

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/015-V0_2_12_SOL_PRICES_DESK.md --> <!-- file: docs/validation/015-V0_2_12_SOL_PRICES_DESK.md -->
<!-- version: 10 --> <!-- version: 11 -->
# Validation `0.2.12` — SOL Prices Desk + intégration Wallet Desk # 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 | | splash Price Desk | lifecycle KSP commun | PASS |
| main shell Price Desk | header/navigation KSP | PASS | | main shell Price Desk | header/navigation KSP | PASS |
| table providers | registry provider-neutral, ordre stable | PENDING | | table providers | registry provider-neutral, ordre stable | PASS |
| prix exact | String issue de `MarketPriceDecimal` | PENDING | | prix exact | String issue de `MarketPriceDecimal` | PASS |
| availability/fraîcheur | état générique + timestamps réels | PENDING | | availability/fraîcheur | état générique + timestamps réels | PASS |
| refresh row | `MarketPriceService::refresh` | PENDING | | refresh row | `MarketPriceService::refresh` | PASS |
| refresh selected/many | `MarketPriceService::refresh_many` | PENDING | | refresh selected/many | `MarketPriceService::refresh_many` | PENDING |
| refresh all | `MarketPriceService::refresh_all` | 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 | | Wallet price panel | Dashboard compact provider-neutral | PENDING |
| auto-refresh | aucun scheduler/polling | OUT | | auto-refresh | aucun scheduler/polling | OUT |
| Wallet USD valuation | aucun prix canonique | 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 | | Contrat | Entrée/sortie sûre | Statut |
|----------------------------------------|--------------------------------------------------------------------|---------| |----------------------------------------|--------------------------------------------------------------------|---------|
| `MarketPriceRuntimeStatusDto` | profiles/counts sans secret | PENDING | | `MarketPriceRuntimeStatusDto` | profiles/counts sans secret | PASS |
| `MarketPriceProviderRowDto` | ID/display/pair/semantics/auth/availability/price/timestamps/retry | PENDING | | `MarketPriceProviderRowDto` | ID/display/pair/semantics/auth/availability/price/timestamps/retry | PASS |
| `MarketPriceRefreshRequestDto` | provider_id opaque seulement | PENDING | | single refresh direct | provider_id opaque seulement | PASS |
| `MarketPriceRefreshManyRequestDto` | liste bornée dIDs opaques | PENDING | | `MarketPriceRefreshManyRequestDto` | liste bornée dIDs opaques | PENDING |
| `MarketPriceRefreshResultDto` | rows/outcome classifiés | PENDING | | `MarketPriceRefreshResultDto` | rows/outcome classifiés | PENDING |
| runtime/list/refresh/many/all commands | aucun URL/header/API key/body | 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 | | provider-specific type dans IPC | interdit | OUT |
| prix `f64` comme vérité 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 | | Risque/canari | Attendu | Statut |
|--------------------------------|----------------------------------------------|---------| |--------------------------------|----------------------------------------------|---------|
| URL arbitraire depuis frontend | impossible par contrat command | PENDING | | URL arbitraire depuis frontend | impossible par contrat command | PASS |
| extraction API key/header | aucune projection/log | PENDING | | extraction API key/header | aucune projection/log | PASS |
| provider_id inconnu | erreur KSP sûre | PENDING | | provider_id inconnu | erreur KSP sûre | PASS |
| batch duplicate/oversized | rejet avant dispatch | PENDING | | batch duplicate/oversized | rejet avant dispatch | PENDING |
| refresh spam | limites/cooldown Off-chain toujours actifs | PENDING | | refresh spam | limites/cooldown Off-chain toujours actifs | PASS |
| stale observation | timestamps/état distingués | PENDING | | stale observation | timestamps/état distingués | PASS |
| double-click race | in-flight/order contrôlés | PENDING | | double-click race | in-flight/order contrôlés | PENDING |
| close pendant refresh | aucune persistance/sûreté frontend requise | PENDING | | close pendant refresh | aucune persistance/sûreté frontend requise | PENDING |
| CWD/resource confusion | canaris propres à chaque app | 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`. 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.