diff --git a/kb-app-demo-desktop/Cargo.toml b/kb-app-demo-desktop/Cargo.toml index 459dd50..c432bad 100644 --- a/kb-app-demo-desktop/Cargo.toml +++ b/kb-app-demo-desktop/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-app-demo-desktop/Cargo.toml -# version: 1 +# version: 2 [package] name = "kb-app-demo-desktop" @@ -21,10 +21,14 @@ path = "src/main.rs" tauri-build.workspace = true [dependencies] +chrono.workspace = true fs2.workspace = true kb-config = { path = "../kb-config" } kb-core = { path = "../kb-core" } kb-logging = { path = "../kb-logging" } +kb-store = { path = "../kb-store" } +kb-pipeline = { path = "../kb-pipeline" } +kb-onchain-transport = { path = "../kb-onchain-transport" } serde.workspace = true serde_json.workspace = true rustls.workspace = true diff --git a/kb-app-demo-desktop/frontend/demo_backfill.html b/kb-app-demo-desktop/frontend/demo_backfill.html new file mode 100644 index 0000000..7c50b4c --- /dev/null +++ b/kb-app-demo-desktop/frontend/demo_backfill.html @@ -0,0 +1,188 @@ + + + + + + + + + Khadhroony Bot3 - Backfill HTTP + + + + +
+ +
+ +
+
+
+
+
+

Backfill transactionnel canonique

+

Les signatures sont découvertes avec getSignaturesForAddress, hydratées avec getTransaction, puis stockées dans les tables canoniques et d'observation. Une seule campagne peut fonctionner à la fois.

+
+
+ +
+
+

+ +

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+

+ +

+
+
+ + + +
+
+
+ +
+

+ +

+
+
+
+
+
+
Optionnelle pour « Avant, plus anciennes » : vide, la recherche commence aux transactions les plus récentes. Obligatoire pour « Après, plus récentes ».
+
+
+
+
+ +
+
+
+ +
+

+ +

+
+
+
+
+
+
Optionnelle pour « Avant, plus anciennes » : vide, la recherche commence aux transactions les plus récentes. Obligatoire pour « Après, plus récentes ».
+
+
+
+
+ +
+
+
+ +
+

+ +

+
+
+
+
+
+
Optionnelle pour « Avant, plus anciennes » : vide, la recherche commence aux transactions les plus récentes. Obligatoire pour « Après, plus récentes ».
+
+
+
+
+ +
+
+
+ +
+

+ +

+
+
+
+

Journal

+
+ +

Résumé JSON

+ +
+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/kb-app-demo-desktop/frontend/main.html b/kb-app-demo-desktop/frontend/main.html index 3c1e11f..815ba02 100644 --- a/kb-app-demo-desktop/frontend/main.html +++ b/kb-app-demo-desktop/frontend/main.html @@ -21,7 +21,9 @@ - + diff --git a/kb-app-demo-desktop/frontend/ts/demo_backfill.ts b/kb-app-demo-desktop/frontend/ts/demo_backfill.ts new file mode 100644 index 0000000..049175e --- /dev/null +++ b/kb-app-demo-desktop/frontend/ts/demo_backfill.ts @@ -0,0 +1,267 @@ +// file: kb-app-demo-desktop/frontend/ts/demo_backfill.ts +// version: 5 + +import * as bootstrap from "bootstrap"; +import "simplebar"; +import ResizeObserver from "resize-observer-polyfill"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log"; +import type { DemoBackfillOptionsPayload } from "./bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillOptionsPayload.ts"; +import type { DemoBackfillProgressPayload } from "./bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillProgressPayload.ts"; +import type { DemoBackfillRequest } from "./bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillRequest.ts"; +import type { DemoBackfillSummaryPayload } from "./bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillSummaryPayload.ts"; + +(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; +(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; + +const logLines: string[] = []; +const maximumLogLines = 1000; +let running = false; + +function element(selector: string): T { + const value = document.querySelector(selector); + if (!value) { + throw new Error(`Missing UI element: ${selector}`); + } + return value; +} + +function inputValue(selector: string): string { + return element(selector).value.trim(); +} + +function integerValue(selector: string): number { + const parsed = Number.parseInt(inputValue(selector), 10); + if (!Number.isFinite(parsed)) { + throw new Error(`Valeur numérique invalide pour ${selector}`); + } + return parsed; +} + +const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +function decodesToSolanaAddress(value: string): boolean { + if (value.length === 0) { + return false; + } + let numericValue = 0n; + for (const character of value) { + const digit = base58Alphabet.indexOf(character); + if (digit < 0) { + return false; + } + numericValue = numericValue * 58n + BigInt(digit); + } + let decodedNonZeroBytes = 0; + let remaining = numericValue; + while (remaining > 0n) { + decodedNonZeroBytes += 1; + remaining >>= 8n; + } + let leadingZeroBytes = 0; + for (const character of value) { + if (character !== "1") { + break; + } + leadingZeroBytes += 1; + } + return leadingZeroBytes + decodedNonZeroBytes === 32; +} + +function appendLog(payload: DemoBackfillProgressPayload | { timestamp: string; level: string; message: string }): void { + const progress = "completed" in payload && payload.completed !== null && payload.total !== null + ? ` [${payload.completed}/${payload.total}]` + : ""; + logLines.push(`${payload.timestamp} ${payload.level.toUpperCase()}${progress} ${payload.message}`); + while (logLines.length > maximumLogLines) { + logLines.shift(); + } + element("#backfillLogOutput").value = logLines.join("\n"); + element("#backfillLogOutput").scrollTop = element("#backfillLogOutput").scrollHeight; +} + +function setRunning(value: boolean): void { + running = value; + document.querySelectorAll(".backfill-start-button").forEach(button => { + button.disabled = value; + }); + element("#cancelBackfillButton").disabled = !value; + const badge = element("#backfillStatusBadge"); + badge.textContent = value ? "Backfill en cours" : "Prêt"; + badge.className = value ? "badge text-bg-warning" : "badge text-bg-success"; +} + +function commonRequest(mode: string): DemoBackfillRequest { + return { + role: inputValue("#backfillRoleSelect"), + commitment: inputValue("#backfillCommitmentSelect"), + mode, + signaturesText: null, + address: null, + anchorSignature: null, + direction: null, + limit: 1, + pageSize: integerValue("#backfillPageSizeInput"), + maxPages: integerValue("#backfillMaxPagesInput"), + maxConcurrentRequests: integerValue("#backfillConcurrencyInput"), + maxRetries: integerValue("#backfillRetriesInput"), + }; +} + +function buildRequest(mode: string): DemoBackfillRequest { + const request = commonRequest(mode); + if (mode === "explicit_signatures") { + request.signaturesText = element("#explicitSignaturesTextarea").value; + return request; + } + request.address = inputValue(`#${mode}AddressInput`); + const anchorSignature = inputValue(`#${mode}AnchorInput`); + request.anchorSignature = anchorSignature.length > 0 ? anchorSignature : null; + request.direction = inputValue(`#${mode}DirectionSelect`); + request.limit = integerValue(`#${mode}LimitInput`); + return request; +} + +function validationMessage(request: DemoBackfillRequest): string | null { + if (request.mode === "explicit_signatures") { + const signatureCount = request.signaturesText + ? request.signaturesText.split(/\r?\n/).filter(value => value.trim().length > 0).length + : 0; + if (signatureCount === 0) { + return "Ajouter au moins une signature explicite avant de lancer le backfill."; + } + return null; + } + if (!request.address || request.address.trim().length === 0) { + return "L’adresse est obligatoire pour ce mode de backfill."; + } + if (!decodesToSolanaAddress(request.address.trim())) { + return "L’adresse Solana doit être une valeur Base58 décodant exactement sur 32 octets."; + } + if (request.direction === "after" && (!request.anchorSignature || request.anchorSignature.trim().length === 0)) { + return "La signature d’ancrage est obligatoire pour rechercher des transactions plus récentes."; + } + return null; +} + +function reportValidationWarning(message: string): void { + element("#backfillSummaryOutput").value = JSON.stringify({ validation: message }, null, 2); + appendLog({ timestamp: new Date().toISOString(), level: "warn", message }); + frontendDebug("kb-app-demo-desktop.frontend.backfill", `Backfill request rejected locally: ${message}`); +} + + +function synchronizeAnchorRequirement(mode: "program" | "token" | "pool"): void { + const direction = element(`#${mode}DirectionSelect`).value; + const anchor = element(`#${mode}AnchorInput`); + const required = direction === "after"; + anchor.required = required; + anchor.placeholder = required + ? "Signature obligatoire pour rechercher après cette transaction" + : "Vide : commencer depuis les transactions les plus récentes"; +} + +async function executeBackfill(mode: string): Promise { + if (running) { + return; + } + try { + const request = buildRequest(mode); + const invalid = validationMessage(request); + if (invalid) { + reportValidationWarning(invalid); + return; + } + setRunning(true); + element("#backfillSummaryOutput").value = "Exécution en cours..."; + appendLog({ timestamp: new Date().toISOString(), level: "info", message: `Démarrage du mode ${mode}` }); + const summary = await invoke("demo_backfill_execute", { request }); + element("#backfillSummaryOutput").value = JSON.stringify(summary, null, 2); + appendLog({ + timestamp: new Date().toISOString(), + level: summary.cancelled ? "warn" : "info", + message: `Campagne terminée : completed=${summary.candidatesCompleted}, cancelled=${summary.candidatesCancelled}, notStarted=${summary.candidatesNotStarted}, inserted=${summary.canonicalInserted}, existing=${summary.existingSkipped}, missing=${summary.missing}, failed=${summary.failed}`, + }); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + element("#backfillSummaryOutput").value = JSON.stringify({ error: message }, null, 2); + appendLog({ timestamp: new Date().toISOString(), level: "error", message }); + frontendError("kb-app-demo-desktop.frontend.backfill", `Backfill failed: ${message}`); + } finally { + setRunning(false); + } +} + +async function cancelBackfill(): Promise { + try { + const accepted = await invoke("demo_backfill_cancel"); + appendLog({ + timestamp: new Date().toISOString(), + level: accepted ? "warn" : "info", + message: accepted ? "Demande d'arrêt envoyée." : "Aucune campagne active.", + }); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + frontendError("kb-app-demo-desktop.frontend.backfill", `Cancellation failed: ${message}`); + } +} + +async function loadOptions(): Promise { + const options = await invoke("demo_backfill_options"); + const roleSelect = element("#backfillRoleSelect"); + roleSelect.replaceChildren(); + for (const role of options.roles) { + const option = document.createElement("option"); + option.value = role.role; + option.textContent = `${role.role} — ${role.providers.join(", ")}`; + option.selected = role.role === options.defaultRole; + roleSelect.append(option); + } + if (options.roles.length === 0) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Aucun rôle compatible"; + roleSelect.append(option); + } + element("#backfillCommitmentSelect").value = options.defaultCommitment; + element("#backfillPageSizeInput").value = String(options.defaultPageSize); + element("#backfillMaxPagesInput").value = String(options.defaultMaxPages); + element("#backfillConcurrencyInput").value = String(options.defaultMaxConcurrentRequests); + element("#backfillRetriesInput").value = String(options.defaultMaxRetries); + setRunning(options.running); +} + +document.addEventListener("DOMContentLoaded", () => { + installFrontendConsoleBridge("kb-app-demo-desktop.frontend.backfill"); + frontendDebug("kb-app-demo-desktop.frontend.backfill", "backfill demo window loaded"); + document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(item => new bootstrap.Tooltip(item)); + for (const mode of ["program", "token", "pool"] as const) { + const direction = element(`#${mode}DirectionSelect`); + direction.addEventListener("change", () => synchronizeAnchorRequirement(mode)); + synchronizeAnchorRequirement(mode); + } + document.querySelectorAll(".backfill-start-button").forEach(button => { + button.addEventListener("click", () => { + const mode = button.dataset.backfillMode; + if (mode) { + void executeBackfill(mode); + } + }); + }); + element("#cancelBackfillButton").addEventListener("click", () => { + void cancelBackfill(); + }); + element("#clearBackfillLogButton").addEventListener("click", () => { + logLines.length = 0; + element("#backfillLogOutput").value = ""; + }); + void listen("demo-backfill-progress", event => { + appendLog(event.payload); + }); + void loadOptions().catch(caughtError => { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + appendLog({ timestamp: new Date().toISOString(), level: "error", message }); + frontendError("kb-app-demo-desktop.frontend.backfill", `Options loading failed: ${message}`); + }); +}); diff --git a/kb-app-demo-desktop/frontend/ts/main.ts b/kb-app-demo-desktop/frontend/ts/main.ts index 5f62656..19b7dae 100644 --- a/kb-app-demo-desktop/frontend/ts/main.ts +++ b/kb-app-demo-desktop/frontend/ts/main.ts @@ -36,3 +36,11 @@ document.addEventListener("DOMContentLoaded", async () => { await loadReadme(readmeContent); } }); + +const openDemoBackfillLink = document.querySelector("#openDemoBackfillLink"); +if (openDemoBackfillLink) { + openDemoBackfillLink.addEventListener("click", event => { + event.preventDefault(); + void invoke("open_demo_backfill_window"); + }); +} diff --git a/kb-app-demo-desktop/src/app_state.rs b/kb-app-demo-desktop/src/app_state.rs index ecb1b08..0125f83 100644 --- a/kb-app-demo-desktop/src/app_state.rs +++ b/kb-app-demo-desktop/src/app_state.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/app_state.rs -// version: 1 +// version: 2 //! Shared Tauri application state and startup initialization. @@ -9,6 +9,9 @@ pub(crate) struct AppState { app_config: kb_config::AppConfig, active_profile: kb_config::ProfileConfig, logging_guard: std::sync::Mutex, + http_pool: kb_onchain_transport::HttpEndpointPool, + demo_backfill_running: std::sync::atomic::AtomicBool, + demo_backfill_cancel_requested: std::sync::atomic::AtomicBool, } impl crate::AppState { @@ -28,11 +31,19 @@ impl crate::AppState { std::result::Result::Ok(guard) => guard, std::result::Result::Err(error) => return std::result::Result::Err(error), }; + let http_pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&active_profile) + { + std::result::Result::Ok(pool) => pool, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; return std::result::Result::Ok(crate::AppState { config_path: config_path.display().to_string(), app_config, active_profile, logging_guard: std::sync::Mutex::new(logging_guard), + http_pool, + demo_backfill_running: std::sync::atomic::AtomicBool::new(false), + demo_backfill_cancel_requested: std::sync::atomic::AtomicBool::new(false), }); } @@ -51,6 +62,21 @@ impl crate::AppState { return &self.active_profile; } + /// Returns the configured HTTP endpoint pool. + pub(crate) fn http_pool(&self) -> &kb_onchain_transport::HttpEndpointPool { + return &self.http_pool; + } + + /// Returns the single-campaign execution flag used by the backfill window. + pub(crate) fn demo_backfill_running(&self) -> &std::sync::atomic::AtomicBool { + return &self.demo_backfill_running; + } + + /// Returns the cooperative cancellation flag used by the backfill window. + pub(crate) fn demo_backfill_cancel_requested(&self) -> &std::sync::atomic::AtomicBool { + return &self.demo_backfill_cancel_requested; + } + /// Returns the number of logging routes held by the logging guard. pub(crate) fn logging_route_count(&self) -> usize { let lock_result = self.logging_guard.lock(); diff --git a/kb-app-demo-desktop/src/demo_backfill.rs b/kb-app-demo-desktop/src/demo_backfill.rs new file mode 100644 index 0000000..a6f61ce --- /dev/null +++ b/kb-app-demo-desktop/src/demo_backfill.rs @@ -0,0 +1,457 @@ +// file: kb-app-demo-desktop/src/demo_backfill.rs +// version: 10 + +//! Tauri commands and UI payloads for bounded HTTP transaction backfills. + +use tauri::Emitter; // rust-rules: trait-import +use ts_rs::TS; // rust-rules: derive-import + +/// One endpoint role capable of discovering and hydrating transaction history. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillRoleOption.ts" +)] +pub(crate) struct DemoBackfillRoleOption { + /// Stable endpoint role code. + pub(crate) role: std::string::String, + /// Providers exposing this role. + pub(crate) providers: std::vec::Vec, +} + +/// Initial options shown by the backfill demo. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillOptionsPayload.ts" +)] +pub(crate) struct DemoBackfillOptionsPayload { + /// Selectable endpoint roles. + pub(crate) roles: std::vec::Vec, + /// Preferred role when configured. + pub(crate) default_role: std::option::Option, + /// Default transaction commitment. + pub(crate) default_commitment: std::string::String, + /// Default history page size. + pub(crate) default_page_size: u16, + /// Default maximum number of history pages. + pub(crate) default_max_pages: u32, + /// Default hydration concurrency requested by the operator. + pub(crate) default_max_concurrent_requests: u32, + /// Default retries after the initial attempt. + pub(crate) default_max_retries: u32, + /// Whether one campaign is currently running. + pub(crate) running: bool, +} + +/// UI request for one bounded backfill campaign. +#[derive(Clone, Debug, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillRequest.ts" +)] +pub(crate) struct DemoBackfillRequest { + /// Endpoint role used for history and transaction requests. + pub(crate) role: std::string::String, + /// Commitment used by standard Solana HTTP requests. + pub(crate) commitment: std::string::String, + /// Source mode: explicit_signatures, program, token or pool. + pub(crate) mode: std::string::String, + /// Newline-separated signatures for explicit mode. + pub(crate) signatures_text: std::option::Option, + /// Program, token mint or pool address for address history modes. + pub(crate) address: std::option::Option, + /// Optional anchor transaction signature. It is required only for newer-history scans. + pub(crate) anchor_signature: std::option::Option, + /// Direction relative to the anchor, or from the latest entry when `before` has no anchor. + pub(crate) direction: std::option::Option, + /// Maximum number of address-history signatures to hydrate. + pub(crate) limit: u32, + /// Maximum RPC page size. + pub(crate) page_size: u16, + /// Maximum number of history pages inspected. + pub(crate) max_pages: u32, + /// Operator concurrency cap. + pub(crate) max_concurrent_requests: u32, + /// Retries after the initial HTTP attempt. + pub(crate) max_retries: u32, +} + +/// One progress event emitted to the backfill window. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillProgressPayload.ts" +)] +pub(crate) struct DemoBackfillProgressPayload { + /// RFC 3339 timestamp. + pub(crate) timestamp: std::string::String, + /// Stable severity code. + pub(crate) level: std::string::String, + /// Human-readable message. + pub(crate) message: std::string::String, + /// Completed candidate count when known. + #[ts(type = "number | null")] + pub(crate) completed: std::option::Option, + /// Total candidate count when known. + #[ts(type = "number | null")] + pub(crate) total: std::option::Option, +} + +/// Final UI-safe summary for one backfill campaign. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillSummaryPayload.ts" +)] +pub(crate) struct DemoBackfillSummaryPayload { + /// Unique capture session identifier. + pub(crate) capture_session_id: std::string::String, + /// Stable filter code. + pub(crate) filter_code: std::string::String, + /// Endpoint role. + pub(crate) role: std::string::String, + /// Provider used for hydration. + pub(crate) provider: std::string::String, + /// Endpoint code used for hydration. + pub(crate) endpoint_code: std::string::String, + /// Number of history pages fetched. + #[ts(type = "number")] + pub(crate) pages_fetched: u64, + /// Number of unique candidates selected. + #[ts(type = "number")] + pub(crate) candidates_selected: u64, + /// Number of candidates admitted to the bounded execution queue. + #[ts(type = "number")] + pub(crate) candidates_started: u64, + /// Number of candidates that reached a terminal outcome. + #[ts(type = "number")] + pub(crate) candidates_completed: u64, + /// Number of admitted candidates interrupted before a terminal outcome. + #[ts(type = "number")] + pub(crate) candidates_cancelled: u64, + /// Number of selected candidates never admitted after cancellation. + #[ts(type = "number")] + pub(crate) candidates_not_started: u64, + /// Number of transactions received and normalized. + #[ts(type = "number")] + pub(crate) transactions_received: u64, + /// Number of canonical rows inserted. + #[ts(type = "number")] + pub(crate) canonical_inserted: u64, + /// Number of canonical inserts skipped by idempotence. + #[ts(type = "number")] + pub(crate) canonical_skipped: u64, + /// Number of signatures skipped before hydration because they already existed. + #[ts(type = "number")] + pub(crate) existing_skipped: u64, + /// Number of missing transactions after retries. + #[ts(type = "number")] + pub(crate) missing: u64, + /// Number of failed candidates. + #[ts(type = "number")] + pub(crate) failed: u64, + /// Number of observation rows inserted. + #[ts(type = "number")] + pub(crate) observations_inserted: u64, + /// Number of source attempts. + #[ts(type = "number")] + pub(crate) attempts: u64, + /// Whether the operator cancelled the campaign. + pub(crate) cancelled: bool, + /// Optional cursor for continuing an older-history scan. + pub(crate) resume_before_signature: std::option::Option, + /// Campaign start time. + pub(crate) started_at: std::string::String, + /// Campaign completion time. + pub(crate) finished_at: std::string::String, +} + +pub(crate) struct DemoBackfillObserver<'a> { + pub(crate) app_handle: tauri::AppHandle, + pub(crate) cancel_requested: &'a std::sync::atomic::AtomicBool, +} + +impl kb_pipeline::BackfillObserver for crate::DemoBackfillObserver<'_> { + fn on_progress(&self, event: &kb_pipeline::BackfillProgressEvent) { + let payload = crate::DemoBackfillProgressPayload { + timestamp: event.timestamp.clone(), + level: event.level.code().to_string(), + message: event.message.clone(), + completed: event.completed, + total: event.total, + }; + let emit_result = + self.app_handle.emit_to("demo_backfill", "demo-backfill-progress", payload); + if let std::result::Result::Err(error) = emit_result { + tracing::warn!( + target: crate::TRACING_TARGET, + "cannot emit backfill progress: {error}" + ); + } + } + + fn is_cancelled(&self) -> bool { + return self.cancel_requested.load(std::sync::atomic::Ordering::Relaxed); + } +} + +pub(crate) struct DemoBackfillRunGuard<'a> { + pub(crate) running: &'a std::sync::atomic::AtomicBool, +} + +impl std::ops::Drop for crate::DemoBackfillRunGuard<'_> { + fn drop(&mut self) { + self.running.store(false, std::sync::atomic::Ordering::Release); + } +} + +pub(crate) fn build_role_options( + snapshots: std::vec::Vec, +) -> std::vec::Vec { + let mut role_methods = std::collections::BTreeMap::< + std::string::String, + ( + std::collections::BTreeSet, + std::collections::BTreeSet, + ), + >::new(); + for snapshot in snapshots { + for role in snapshot.roles { + if !role.enabled { + continue; + } + let entry = role_methods.entry(role.role).or_default(); + for request_kind in role.request_kinds { + entry.0.insert(request_kind); + } + entry.1.insert(snapshot.provider.clone()); + } + } + let mut output = std::vec::Vec::new(); + for (role, (request_kinds, providers)) in role_methods { + let supports_all = request_kinds.contains("*"); + if !supports_all + && (!request_kinds.contains("get_signatures_for_address") + || !request_kinds.contains("get_transaction")) + { + continue; + } + output.push(DemoBackfillRoleOption { + role, + providers: providers.into_iter().collect(), + }); + } + return output; +} + +pub(crate) fn build_demo_backfill_pipeline_request( + request: DemoBackfillRequest, +) -> std::result::Result { + let source_result = match request.mode.trim() { + "explicit_signatures" => explicit_source(request.signatures_text.as_deref()), + "program" => address_source( + kb_pipeline::BackfillAddressKind::Program, + request.address.as_deref(), + request.anchor_signature.as_deref(), + request.direction.as_deref(), + request.limit, + ), + "token" => address_source( + kb_pipeline::BackfillAddressKind::Token, + request.address.as_deref(), + request.anchor_signature.as_deref(), + request.direction.as_deref(), + request.limit, + ), + "pool" => address_source( + kb_pipeline::BackfillAddressKind::Pool, + request.address.as_deref(), + request.anchor_signature.as_deref(), + request.direction.as_deref(), + request.limit, + ), + _ => std::result::Result::Err("unsupported backfill mode".to_string()), + }; + let source = match source_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let pipeline_request = kb_pipeline::BackfillRequest { + role: request.role, + commitment: request.commitment, + source, + page_size: request.page_size, + max_pages: request.max_pages, + max_concurrent_requests: request.max_concurrent_requests, + max_retries: request.max_retries, + }; + let validation_result = pipeline_request.validate(); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error.to_string()); + } + return std::result::Result::Ok(pipeline_request); +} + +fn explicit_source( + signatures_text: std::option::Option<&str>, +) -> std::result::Result { + let text = match signatures_text { + std::option::Option::Some(value) => value, + std::option::Option::None => "", + }; + let signatures = text + .lines() + .map(str::trim) + .filter(|value| return !value.is_empty()) + .map(str::to_string) + .collect::>(); + if signatures.is_empty() { + return std::result::Result::Err( + "the signatures textarea must contain at least one signature".to_string(), + ); + } + return std::result::Result::Ok(kb_pipeline::BackfillSource::ExplicitSignatures(signatures)); +} + +fn address_source( + kind: kb_pipeline::BackfillAddressKind, + address: std::option::Option<&str>, + anchor_signature: std::option::Option<&str>, + direction: std::option::Option<&str>, + limit: u32, +) -> std::result::Result { + let address_text = match address { + std::option::Option::Some(value) => value, + std::option::Option::None => "", + }; + let address_value = address_text.trim(); + if address_value.is_empty() { + return std::result::Result::Err("address is required".to_string()); + } + let direction_text = match direction { + std::option::Option::Some(value) => value, + std::option::Option::None => "", + }; + let direction_value = match direction_text.trim() { + "before" => kb_pipeline::BackfillDirection::Before, + "after" => kb_pipeline::BackfillDirection::After, + _ => { + return std::result::Result::Err("direction must be before or after".to_string()); + }, + }; + let anchor_value = anchor_signature + .map(str::trim) + .filter(|value| return !value.is_empty()) + .map(str::to_string); + if direction_value == kb_pipeline::BackfillDirection::After && anchor_value.is_none() { + return std::result::Result::Err( + "anchor signature is required for newer-history backfill".to_string(), + ); + } + let limit_value = match usize::try_from(limit) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(format!("signature limit conversion failed: {error}")); + }, + }; + return std::result::Result::Ok(kb_pipeline::BackfillSource::AddressHistory { + kind, + address: address_value.to_string(), + anchor_signature: anchor_value, + direction: direction_value, + limit: limit_value, + }); +} + +pub(crate) fn demo_backfill_summary_payload( + summary: kb_pipeline::BackfillSummary, +) -> DemoBackfillSummaryPayload { + return crate::DemoBackfillSummaryPayload { + capture_session_id: summary.capture_session_id, + filter_code: summary.filter_code, + role: summary.role, + provider: summary.provider, + endpoint_code: summary.endpoint_code, + pages_fetched: summary.pages_fetched, + candidates_selected: summary.candidates_selected, + candidates_started: summary.candidates_started, + candidates_completed: summary.candidates_completed, + candidates_cancelled: summary.candidates_cancelled, + candidates_not_started: summary.candidates_not_started, + transactions_received: summary.transactions_received, + canonical_inserted: summary.canonical_inserted, + canonical_skipped: summary.canonical_skipped, + existing_skipped: summary.existing_skipped, + missing: summary.missing, + failed: summary.failed, + observations_inserted: summary.observations_inserted, + attempts: summary.attempts, + cancelled: summary.cancelled, + resume_before_signature: summary.resume_before_signature, + started_at: summary.started_at, + finished_at: summary.finished_at, + }; +} + +#[cfg(test)] +mod tests { + #[test] + fn explicit_source_splits_lines_and_ignores_empty_rows() { + let result = super::explicit_source(std::option::Option::Some(" first \n\n second\n")); + let source = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("source failed: {error}"), + }; + assert_eq!( + source, + kb_pipeline::BackfillSource::ExplicitSignatures(std::vec![ + "first".to_string(), + "second".to_string(), + ]) + ); + } + + #[test] + fn older_history_accepts_missing_anchor_and_uses_latest_filter() { + let source_result = super::address_source( + kb_pipeline::BackfillAddressKind::Program, + std::option::Option::Some("11111111111111111111111111111111"), + std::option::Option::Some(" "), + std::option::Option::Some("before"), + 100, + ); + let source = match source_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("source failed: {error}"), + }; + let request = kb_pipeline::BackfillRequest { + role: "history_backfill".to_string(), + commitment: "confirmed".to_string(), + source, + page_size: 100, + max_pages: 1, + max_concurrent_requests: 1, + max_retries: 0, + }; + assert!(request.validate().is_ok()); + assert_eq!(request.filter_code(), "program_latest"); + } + + #[test] + fn newer_history_rejects_missing_anchor() { + let source_result = super::address_source( + kb_pipeline::BackfillAddressKind::Program, + std::option::Option::Some("11111111111111111111111111111111"), + std::option::Option::None, + std::option::Option::Some("after"), + 100, + ); + assert!(source_result.is_err()); + } +} diff --git a/kb-app-demo-desktop/src/lib.rs b/kb-app-demo-desktop/src/lib.rs index 8234534..270fcd9 100644 --- a/kb-app-demo-desktop/src/lib.rs +++ b/kb-app-demo-desktop/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/lib.rs -// version: 3 +// version: 4 //! Tauri desktop demo application for `khadhroony-bot3`. @@ -9,6 +9,7 @@ mod app_state; mod constants; +mod demo_backfill; mod frontend_log; mod main_window; mod splash; @@ -19,6 +20,26 @@ pub use self::tauri::run; /// Shared application state managed by Tauri. pub(crate) use self::app_state::AppState; +/// Backfill observer forwarding pipeline progress to the Tauri window. +pub(crate) use self::demo_backfill::DemoBackfillObserver; +/// Initial options shown by the backfill window. +pub(crate) use self::demo_backfill::DemoBackfillOptionsPayload; +/// One progress event emitted to the backfill window. +pub(crate) use self::demo_backfill::DemoBackfillProgressPayload; +/// UI request for one bounded backfill campaign. +pub(crate) use self::demo_backfill::DemoBackfillRequest; +/// One endpoint role selectable by the backfill window. +pub(crate) use self::demo_backfill::DemoBackfillRoleOption; +/// Guard restoring the single-campaign flag. +pub(crate) use self::demo_backfill::DemoBackfillRunGuard; +/// Final UI-safe summary for one backfill campaign. +pub(crate) use self::demo_backfill::DemoBackfillSummaryPayload; +/// Builds one validated pipeline request from the UI contract. +pub(crate) use self::demo_backfill::build_demo_backfill_pipeline_request; +/// Builds selectable endpoint roles from the HTTP pool snapshot. +pub(crate) use self::demo_backfill::build_role_options; +/// Converts a pipeline summary to the UI-safe summary. +pub(crate) use self::demo_backfill::demo_backfill_summary_payload; /// Frontend logging payload. pub(crate) use self::frontend_log::FrontendLogPayload; /// Emits one normalized frontend log event. diff --git a/kb-app-demo-desktop/src/tauri.rs b/kb-app-demo-desktop/src/tauri.rs index 93e217e..bc976cb 100644 --- a/kb-app-demo-desktop/src/tauri.rs +++ b/kb-app-demo-desktop/src/tauri.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/tauri.rs -// version: 3 +// version: 4 //! Tauri runtime assembly and private command wrappers. @@ -7,16 +7,22 @@ use tauri::Manager; // rust-rules: trait-import /// Runs the desktop demo application. #[cfg_attr(mobile, tauri::mobile_entry_point)] -#[allow(clippy::question_mark_used)] pub fn run() -> kb_core::Result<()> { let rustls_result = install_default_rustls_provider(); if let std::result::Result::Err(error) = rustls_result { return std::result::Result::Err(error); } - let app_state = match crate::AppState::initialize() { - std::result::Result::Ok(state) => state, - std::result::Result::Err(error) => return std::result::Result::Err(error), - }; + let app_state_result = crate::AppState::initialize(); + let app_state; + if let std::result::Result::Ok(state) = app_state_result { + app_state = state; + } else if let std::result::Result::Err(error) = app_state_result { + return std::result::Result::Err(error); + } else { + return std::result::Result::Err(kb_core::Error::invalid_state( + "application state initialization produced no result", + )); + } tracing::info!( target: crate::TRACING_TARGET, config_path = app_state.config_path(), @@ -27,8 +33,14 @@ pub fn run() -> kb_core::Result<()> { let tracing_builder = tauri_plugin_tracing::Builder::new(); let mut builder = tauri::Builder::default(); builder = builder.manage(app_state); - builder = - builder.invoke_handler(tauri::generate_handler![emit_frontend_log, load_project_readme,]); + builder = builder.invoke_handler(tauri::generate_handler![ + emit_frontend_log, + load_project_readme, + open_demo_backfill_window, + demo_backfill_options, + demo_backfill_execute, + demo_backfill_cancel, + ]); builder = builder.plugin(tracing_builder.build::()); builder = builder.setup(|app| { let splash_window = match app.get_webview_window("splash") { @@ -124,3 +136,126 @@ fn emit_frontend_log(payload: crate::FrontendLogPayload) { fn load_project_readme() -> std::result::Result { return into_ipc_result(crate::load_project_readme()); } + +#[tauri::command] +fn demo_backfill_cancel(state: tauri::State<'_, crate::AppState>) -> bool { + let running = state.demo_backfill_running().load(std::sync::atomic::Ordering::Acquire); + state + .demo_backfill_cancel_requested() + .store(true, std::sync::atomic::Ordering::Release); + return running; +} + +#[tauri::command] +fn open_demo_backfill_window( + app_handle: tauri::AppHandle, +) -> std::result::Result<(), std::string::String> { + let existing_window = app_handle.get_webview_window("demo_backfill"); + if let std::option::Option::Some(window) = existing_window { + if let std::result::Result::Err(error) = window.show() { + return std::result::Result::Err(error.to_string()); + } + if let std::result::Result::Err(error) = window.set_focus() { + return std::result::Result::Err(error.to_string()); + } + return std::result::Result::Ok(()); + } + let build_result = tauri::WebviewWindowBuilder::new( + &app_handle, + "demo_backfill", + tauri::WebviewUrl::App("demo_backfill.html".into()), + ) + .title("Khadhroony Bot3 - Backfill HTTP") + .inner_size(1280.0, 860.0) + .min_inner_size(960.0, 620.0) + .resizable(true) + .visible(true) + .build(); + return match build_result { + std::result::Result::Ok(window) => match window.set_focus() { + std::result::Result::Ok(()) => std::result::Result::Ok(()), + std::result::Result::Err(error) => std::result::Result::Err(error.to_string()), + }, + std::result::Result::Err(error) => std::result::Result::Err(error.to_string()), + }; +} + +#[tauri::command] +fn demo_backfill_options( + state: tauri::State<'_, crate::AppState>, +) -> crate::DemoBackfillOptionsPayload { + let roles = crate::build_role_options(state.http_pool().snapshot()); + let default_role = if roles.iter().any(|item| return item.role == "history_backfill") { + std::option::Option::Some("history_backfill".to_string()) + } else { + roles.first().map(|item| return item.role.clone()) + }; + return crate::DemoBackfillOptionsPayload { + roles, + default_role, + default_commitment: "confirmed".to_string(), + default_page_size: 100, + default_max_pages: 20, + default_max_concurrent_requests: 4, + default_max_retries: 2, + running: state.demo_backfill_running().load(std::sync::atomic::Ordering::Acquire), + }; +} + +#[tauri::command] +async fn demo_backfill_execute( + app_handle: tauri::AppHandle, + state: tauri::State<'_, crate::AppState>, + request: crate::DemoBackfillRequest, +) -> std::result::Result { + let acquire_result = state.demo_backfill_running().compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ); + if acquire_result.is_err() { + return std::result::Result::Err("a backfill campaign is already running".to_string()); + } + let _run_guard = crate::DemoBackfillRunGuard { running: state.demo_backfill_running() }; + state + .demo_backfill_cancel_requested() + .store(false, std::sync::atomic::Ordering::Release); + let pipeline_request = match crate::build_demo_backfill_pipeline_request(request) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let profile = state.active_profile(); + let store_options = match kb_store::PostgresStoreOptions::new( + profile.database.postgres.url.clone(), + profile.database.postgres.max_connections, + profile.database.postgres.connect_timeout_ms, + false, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let store = match kb_store::PostgresStore::connect(store_options).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + if let std::result::Result::Err(error) = store.initialize_store_schema().await { + return std::result::Result::Err(error.to_string()); + } + let observer = crate::DemoBackfillObserver { + app_handle, + cancel_requested: state.demo_backfill_cancel_requested(), + }; + let summary = match kb_pipeline::execute_http_backfill( + state.http_pool(), + &store, + &pipeline_request, + &observer, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + return std::result::Result::Ok(crate::demo_backfill_summary_payload(summary)); +}