diff --git a/Cargo.toml b/Cargo.toml
index 21860b2..de8c16d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,12 +1,12 @@
# file: Cargo.toml
-# version: 433
+# version: 434
[workspace]
resolver = "3"
members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"]
[workspace.package]
-version = "0.3.7-pre.9.fix.2"
+version = "0.3.7-pre.10"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
diff --git a/crates/ksp-app-backfill-desk/frontend/main.html b/crates/ksp-app-backfill-desk/frontend/main.html
index cfd9011..da36f8a 100644
--- a/crates/ksp-app-backfill-desk/frontend/main.html
+++ b/crates/ksp-app-backfill-desk/frontend/main.html
@@ -1,5 +1,5 @@
-
+
@@ -134,11 +134,17 @@
+
- Job
- —
- Lifecycle
- —
diff --git a/crates/ksp-app-backfill-desk/frontend/splash.html b/crates/ksp-app-backfill-desk/frontend/splash.html
index 2b9258c..4b647d2 100644
--- a/crates/ksp-app-backfill-desk/frontend/splash.html
+++ b/crates/ksp-app-backfill-desk/frontend/splash.html
@@ -1,5 +1,5 @@
-
+
@@ -14,7 +14,7 @@

-
Backfill Desk
+
Backfill
diff --git a/crates/ksp-app-backfill-desk/frontend/ts/main.ts b/crates/ksp-app-backfill-desk/frontend/ts/main.ts
index 8135e74..17e89bd 100644
--- a/crates/ksp-app-backfill-desk/frontend/ts/main.ts
+++ b/crates/ksp-app-backfill-desk/frontend/ts/main.ts
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/frontend/ts/main.ts
-// version: 5
+// version: 6
import "bootstrap";
import ResizeObserver from "resize-observer-polyfill";
@@ -7,6 +7,7 @@ import "simplebar";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { BackfillRunStatusDto } from "./bindings/ksp_app_backfill_desk/backfill_status/BackfillRunStatusDto.ts";
+import type { BackfillCancelResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillCancelResponseDto.ts";
import type { BackfillRequestPreviewDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestPreviewDto.ts";
import type { BackfillStartRequestDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartRequestDto.ts";
import type { BackfillStartResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartResponseDto.ts";
@@ -39,6 +40,7 @@ const scopeLabels: Record = {
};
let compositionReadyForStart = false;
+let currentRunStatus: BackfillRunStatusDto | null = null;
let runStartAccepted = false;
@@ -451,10 +453,15 @@ function bindBackfillRouteSelection(): void {
}
-function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "event" | "startup" | "start" | "user"): void {
+function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "cancel" | "event" | "startup" | "start" | "user"): void {
const card = document.querySelector("#backfillRunStatusCard");
+ const cancelButton = document.querySelector("#cancelBackfillRun");
+ currentRunStatus = status;
if (!status) {
runStartAccepted = false;
+ if (cancelButton) {
+ cancelButton.disabled = true;
+ }
refreshStartButton();
if (card) {
card.hidden = true;
@@ -463,6 +470,9 @@ function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "e
return;
}
runStartAccepted = status.active;
+ if (cancelButton) {
+ cancelButton.disabled = !status.active;
+ }
refreshStartButton();
if (card) {
card.hidden = false;
@@ -499,7 +509,7 @@ function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "e
});
}
-async function syncBackfillStatus(source: "startup" | "start" | "user"): Promise {
+async function syncBackfillStatus(source: "cancel" | "startup" | "start" | "user"): Promise {
frontendDebug("main", "Backfill Desk latest-value status resynchronization started", { source });
const status = await invokeKsp("main", "backfill_status");
renderBackfillRunStatus(status, source);
@@ -517,7 +527,40 @@ async function bindBackfillStatusMonitoring(): Promise {
void syncBackfillStatus("user").catch(() => frontendWarn("main", "Backfill Desk latest-value status refresh failed"));
});
}
- frontendTrace("main", "Backfill Desk latest-value monitoring listener installed");
+ const cancelButton = document.querySelector("#cancelBackfillRun");
+ if (cancelButton) {
+ cancelButton.addEventListener("click", () => {
+ const status = currentRunStatus;
+ if (!status || !status.active) {
+ return;
+ }
+ cancelButton.disabled = true;
+ frontendDebug("main", "Backfill Desk cooperative cancellation requested", { jobId: status.jobId, state: status.state });
+ void invokeKsp("main", "backfill_cancel", { jobId: status.jobId })
+ .then(response => {
+ const feedback = document.querySelector("#backfillCancelFeedback");
+ if (feedback) {
+ feedback.hidden = false;
+ feedback.textContent = response.accepted
+ ? `Annulation coopérative acceptée pour ${response.jobId}.`
+ : `Aucune nouvelle annulation acceptée pour ${response.jobId} (${response.state}).`;
+ }
+ frontendInfo("main", "Backfill Desk cooperative cancellation response received", {
+ accepted: response.accepted,
+ jobId: response.jobId,
+ state: response.state,
+ });
+ return syncBackfillStatus("cancel");
+ })
+ .catch(() => {
+ frontendWarn("main", "Backfill Desk cooperative cancellation failed");
+ if (currentRunStatus?.active) {
+ cancelButton.disabled = false;
+ }
+ });
+ });
+ }
+ frontendTrace("main", "Backfill Desk latest-value monitoring and cancellation handlers installed");
}
function renderRuntimeStatus(status: ShellStatusDto): void {
diff --git a/crates/ksp-app-backfill-desk/src/app_state.rs b/crates/ksp-app-backfill-desk/src/app_state.rs
index 3fabfa3..eea2726 100644
--- a/crates/ksp-app-backfill-desk/src/app_state.rs
+++ b/crates/ksp-app-backfill-desk/src/app_state.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/src/app_state.rs
-// version: 7
+// version: 8
//! Shared backend state owned by the Backfill Desk Tauri application.
@@ -123,7 +123,7 @@ impl crate::AppState {
application_version: env!("CARGO_PKG_VERSION").to_owned(),
config_document_count: document_count,
fallback_logging_active: runtime.fallback_active,
- shell_phase: "pre.008-runtime-start".to_owned(),
+ shell_phase: "pre.010-cancel-races".to_owned(),
startup_diagnostic: runtime.startup_diagnostic.clone(),
});
}
@@ -277,6 +277,29 @@ impl crate::AppState {
};
}
+ /// Requests cooperative cancellation of one exact backend-issued Backfill Job identity.
+ pub(crate) fn cancel_backfill(&self, job_id: &str) -> ksp_core_lib::Result {
+ let cancelled = self.backfill_runs.cancel(job_id);
+ let cancelled = match cancelled {
+ std::result::Result::Ok(value) => value,
+ std::result::Result::Err(error) => return std::result::Result::Err(error),
+ };
+ ksp_logging_lib::debug!(
+ target: crate::TRACING_TARGET,
+ domain = crate::TRACING_DOMAIN_RUN,
+ accepted = cancelled.accepted,
+ job_id = cancelled.job_id.as_str(),
+ state = cancelled.state.as_str(),
+ "processed Backfill Desk cooperative cancellation request"
+ );
+ return std::result::Result::Ok(cancelled);
+ }
+
+ /// Requests best-effort cooperative cancellation of the active run during application shutdown.
+ pub(crate) fn cancel_active_backfill_for_shutdown(&self) -> ksp_core_lib::Result {
+ return self.backfill_runs.cancel_active_for_shutdown();
+ }
+
/// Executes one already-admitted Backfill launch and restores application resources after its terminal result.
pub(crate) async fn execute_backfill_run(&self, launch: crate::BackfillRunLaunch) -> ksp_core_lib::Result<()> {
let job_id = launch.job_id.clone();
diff --git a/crates/ksp-app-backfill-desk/src/backfill_run.rs b/crates/ksp-app-backfill-desk/src/backfill_run.rs
index 664616e..70e848e 100644
--- a/crates/ksp-app-backfill-desk/src/backfill_run.rs
+++ b/crates/ksp-app-backfill-desk/src/backfill_run.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/src/backfill_run.rs
-// version: 2
+// version: 3
//! Single-active-run admission and launch ownership for Backfill Desk.
@@ -130,6 +130,91 @@ impl crate::BackfillRunState {
return std::result::Result::Ok(cancellation_requested);
}
+ /// Requests cooperative cancellation for the exact backend-issued active Job identity.
+ pub(crate) fn cancel(&self, job_id: &str) -> ksp_core_lib::Result {
+ let active = self.active.lock();
+ let active = match active {
+ std::result::Result::Ok(value) => value,
+ std::result::Result::Err(_) => {
+ return std::result::Result::Err(ksp_core_lib::Error::new(
+ crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
+ "Backfill Desk active-run cancellation state lock is poisoned",
+ ));
+ },
+ };
+ if let std::option::Option::Some(current) = active.as_ref() {
+ if current.job_id.as_str() != job_id {
+ return std::result::Result::Err(ksp_core_lib::Error::new(
+ crate::ERROR_CODE_BACKFILL_RUN_MISMATCH,
+ "Backfill Desk cancellation target does not match the active Job",
+ ));
+ }
+ let source = current.handle.snapshots();
+ let notification = ::current(&source);
+ let state = notification.state();
+ if state.is_terminal() {
+ return std::result::Result::Ok(crate::BackfillCancelResponseDto {
+ accepted: false,
+ job_id: current.job_id.as_str().to_owned(),
+ state: state.code().to_owned(),
+ });
+ }
+ let accepted = current.handle.cancel();
+ let state_code = if current.handle.is_cancellation_requested() { "cancelling" } else { state.code() };
+ return std::result::Result::Ok(crate::BackfillCancelResponseDto {
+ accepted,
+ job_id: current.job_id.as_str().to_owned(),
+ state: state_code.to_owned(),
+ });
+ }
+ drop(active);
+ let terminal = self.last_terminal.lock();
+ let terminal = match terminal {
+ std::result::Result::Ok(value) => value,
+ std::result::Result::Err(_) => {
+ return std::result::Result::Err(ksp_core_lib::Error::new(
+ crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
+ "Backfill Desk terminal cancellation state lock is poisoned",
+ ));
+ },
+ };
+ if let std::option::Option::Some(notification) = terminal.as_ref() {
+ if notification.id().as_str() != job_id {
+ return std::result::Result::Err(ksp_core_lib::Error::new(
+ crate::ERROR_CODE_BACKFILL_RUN_MISMATCH,
+ "Backfill Desk cancellation target does not match the retained terminal Job",
+ ));
+ }
+ return std::result::Result::Ok(crate::BackfillCancelResponseDto {
+ accepted: false,
+ job_id: notification.id().as_str().to_owned(),
+ state: notification.state().code().to_owned(),
+ });
+ }
+ return std::result::Result::Err(ksp_core_lib::Error::new(
+ crate::ERROR_CODE_BACKFILL_RUN_NOT_ACTIVE,
+ "Backfill Desk has no active Backfill run to cancel",
+ ));
+ }
+
+ /// Requests best-effort cooperative cancellation of whichever run is active after shutdown wins admission.
+ pub(crate) fn cancel_active_for_shutdown(&self) -> ksp_core_lib::Result {
+ let active = self.active.lock();
+ let active = match active {
+ std::result::Result::Ok(value) => value,
+ std::result::Result::Err(_) => {
+ return std::result::Result::Err(ksp_core_lib::Error::new(
+ crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
+ "Backfill Desk shutdown cancellation state lock is poisoned",
+ ));
+ },
+ };
+ return match active.as_ref() {
+ std::option::Option::Some(current) => std::result::Result::Ok(current.handle.cancel()),
+ std::option::Option::None => std::result::Result::Ok(false),
+ };
+ }
+
/// Returns the complete current active snapshot or the retained latest terminal snapshot for resynchronization.
pub(crate) fn current_notification(
&self,
diff --git a/crates/ksp-app-backfill-desk/src/dto_backfill.rs b/crates/ksp-app-backfill-desk/src/dto_backfill.rs
index 2fff3dc..ac1588c 100644
--- a/crates/ksp-app-backfill-desk/src/dto_backfill.rs
+++ b/crates/ksp-app-backfill-desk/src/dto_backfill.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/src/dto_backfill.rs
-// version: 2
+// version: 3
//! Application-owned Backfill campaign DTOs and backend-derived request limits.
@@ -76,6 +76,19 @@ pub(crate) struct BackfillStartResponseDto {
pub(crate) state: String,
}
+/// Safe acknowledgement of one targeted cooperative cancellation request.
+#[derive(Clone, Debug, serde::Serialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillCancelResponseDto.ts")]
+pub(crate) struct BackfillCancelResponseDto {
+ /// Whether this call won the first pre-terminal cancellation request for the targeted run.
+ pub(crate) accepted: bool,
+ /// Backend-generated Job identifier that the cancellation request targeted.
+ pub(crate) job_id: String,
+ /// Safe current or cancellation-intent lifecycle code at command completion.
+ pub(crate) state: String,
+}
+
/// Safe projection proving that one app request mapped to the KSP Backfill contract.
///
/// Address and signature values are intentionally reduced to presence/count metadata. This DTO is
diff --git a/crates/ksp-app-backfill-desk/src/errors.rs b/crates/ksp-app-backfill-desk/src/errors.rs
index 847879c..0f765d0 100644
--- a/crates/ksp-app-backfill-desk/src/errors.rs
+++ b/crates/ksp-app-backfill-desk/src/errors.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/src/errors.rs
-// version: 7
+// version: 8
//! Application-local error codes for Backfill Desk composition and desktop runtime surfaces.
@@ -14,6 +14,10 @@ pub(crate) const ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY: ksp_core_lib::ErrorC
pub(crate) const ERROR_CODE_BACKFILL_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_request_invalid");
/// Backfill Desk refuses a concurrent Start while another Backfill run owns the single-run slot.
pub(crate) const ERROR_CODE_BACKFILL_RUN_ACTIVE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_run_active");
+/// Backfill Desk received a stale or mismatched Job identity for an active-run control operation.
+pub(crate) const ERROR_CODE_BACKFILL_RUN_MISMATCH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_run_mismatch");
+/// Backfill Desk has no active or retained matching run for the requested control operation.
+pub(crate) const ERROR_CODE_BACKFILL_RUN_NOT_ACTIVE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_run_not_active");
/// Backfill Desk composite configuration is missing or references an unexpected document.
pub(crate) const ERROR_CODE_CONFIG_COMPOSITE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "config_composite_invalid");
/// Frontend logging requested an unsupported level.
diff --git a/crates/ksp-app-backfill-desk/src/lib.rs b/crates/ksp-app-backfill-desk/src/lib.rs
index e61d945..389718b 100644
--- a/crates/ksp-app-backfill-desk/src/lib.rs
+++ b/crates/ksp-app-backfill-desk/src/lib.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/src/lib.rs
-// version: 8
+// version: 9
//! Tauri desktop application scaffold for controlling and inspecting KSP RAW backfill jobs.
@@ -92,6 +92,8 @@ pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
/// Owning target for splash-window frontend events.
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH;
+/// Safe acknowledgement returned by targeted cooperative cancellation.
+pub(crate) use self::dto_backfill::BackfillCancelResponseDto;
/// Backend-derived request bounds and Desk defaults for the campaign form.
pub(crate) use self::dto_backfill::BackfillRequestLimitsDto;
/// Safe request preview produced after strict backend mapping.
@@ -124,6 +126,10 @@ pub(crate) use self::errors::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY;
pub(crate) use self::errors::ERROR_CODE_BACKFILL_REQUEST_INVALID;
/// Backfill Desk refuses a concurrent Start while another Backfill run owns the single-run slot.
pub(crate) use self::errors::ERROR_CODE_BACKFILL_RUN_ACTIVE;
+/// Backfill Desk rejects stale or mismatched Job identity control requests.
+pub(crate) use self::errors::ERROR_CODE_BACKFILL_RUN_MISMATCH;
+/// Backfill Desk has no active or retained matching run for the requested control operation.
+pub(crate) use self::errors::ERROR_CODE_BACKFILL_RUN_NOT_ACTIVE;
/// Backfill Desk composite configuration is missing or references an unexpected document.
pub(crate) use self::errors::ERROR_CODE_CONFIG_COMPOSITE_INVALID;
/// Frontend logging requested an unsupported level.
diff --git a/crates/ksp-app-backfill-desk/src/tauri.rs b/crates/ksp-app-backfill-desk/src/tauri.rs
index 800d68f..3d8da4c 100644
--- a/crates/ksp-app-backfill-desk/src/tauri.rs
+++ b/crates/ksp-app-backfill-desk/src/tauri.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/src/tauri.rs
-// version: 7
+// version: 8
//! Tauri runtime assembly for the KSP Backfill desktop application.
@@ -77,6 +77,7 @@ fn configure_plugins(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder {
return builder.invoke_handler(tauri::generate_handler![
+ backfill_cancel,
backfill_options,
backfill_start,
backfill_status,
@@ -114,11 +115,26 @@ fn configure_window_events(builder: tauri::Builder) -> tauri::Builde
if !state.begin_shutdown() {
return;
}
- ksp_logging_lib::debug!(
- target: crate::TRACING_TARGET,
- domain = crate::TRACING_DOMAIN_WINDOWS,
- "Backfill Desk main-window close requested; starting bounded Store shutdown"
- );
+ let cancellation = state.cancel_active_backfill_for_shutdown();
+ match cancellation {
+ std::result::Result::Ok(accepted) => {
+ ksp_logging_lib::debug!(
+ target: crate::TRACING_TARGET,
+ domain = crate::TRACING_DOMAIN_WINDOWS,
+ cancellation_accepted = accepted,
+ "Backfill Desk main-window close requested; cooperative Backfill cancellation attempted before bounded Store shutdown"
+ );
+ },
+ std::result::Result::Err(error) => {
+ ksp_logging_lib::warn!(
+ target: crate::TRACING_TARGET,
+ domain = crate::TRACING_DOMAIN_WINDOWS,
+ error_domain = error.code().domain(),
+ error_code = error.code().code(),
+ "Backfill Desk could not request cooperative cancellation during shutdown; continuing bounded shutdown"
+ );
+ },
+ }
tauri::async_runtime::spawn(async move {
let state = app_handle.state::();
let closed = state.close_store().await;
@@ -158,6 +174,15 @@ fn project_command_error(command: &'static str, domain: &'static str, error: &ks
return crate::CommandErrorDto::from_error(error);
}
+#[tauri::command]
+fn backfill_cancel(job_id: String, state: tauri::State<'_, crate::AppState>) -> std::result::Result {
+ let result = state.cancel_backfill(job_id.as_str());
+ return match result {
+ std::result::Result::Ok(value) => std::result::Result::Ok(value),
+ std::result::Result::Err(error) => std::result::Result::Err(project_command_error("backfill_cancel", crate::TRACING_DOMAIN_RUN, &error)),
+ };
+}
+
#[tauri::command]
fn backfill_options(state: tauri::State<'_, crate::AppState>) -> std::result::Result {
let result = state.backfill_options();
diff --git a/crates/ksp-app-backfill-desk/tests/desktop_contract.rs b/crates/ksp-app-backfill-desk/tests/desktop_contract.rs
index 6ebecdc..5e4b56e 100644
--- a/crates/ksp-app-backfill-desk/tests/desktop_contract.rs
+++ b/crates/ksp-app-backfill-desk/tests/desktop_contract.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/tests/desktop_contract.rs
-// version: 11
+// version: 12
//! Structural desktop contract checks for the Backfill Desk scaffold.
@@ -346,3 +346,31 @@ fn pre_009_latest_value_monitoring_uses_snapshot_source_event_bridge_and_resynch
assert!(frontend.contains("syncBackfillStatus"));
assert!(!frontend.contains("parseBackfillLog"));
}
+
+#[test]
+fn pre_010_cancel_is_targeted_idempotent_and_shutdown_requests_cooperative_cancellation_before_exit() {
+ let root = app_root();
+ let state = read_text(root.join("src/app_state.rs").as_path());
+ let run = read_text(root.join("src/backfill_run.rs").as_path());
+ let tauri = read_text(root.join("src/tauri.rs").as_path());
+ let html = read_text(root.join("frontend/main.html").as_path());
+ let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
+ assert!(run.contains("current.job_id.as_str() != job_id"));
+ assert!(run.contains("current.handle.cancel()"));
+ assert!(run.contains("ERROR_CODE_BACKFILL_RUN_MISMATCH"));
+ assert!(state.contains("cancel_backfill"));
+ assert!(state.contains("cancel_active_backfill_for_shutdown"));
+ assert!(tauri.contains("backfill_cancel"));
+ assert!(tauri.contains("cancel_active_backfill_for_shutdown"));
+ let shutdown_cancel = tauri.find("cancel_active_backfill_for_shutdown");
+ let shutdown_exit = tauri.find("app_handle.exit(0)");
+ assert!(shutdown_cancel.is_some());
+ assert!(shutdown_exit.is_some());
+ if let (std::option::Option::Some(cancel), std::option::Option::Some(exit)) = (shutdown_cancel, shutdown_exit) {
+ assert!(cancel < exit);
+ }
+ assert!(html.contains("cancelBackfillRun"));
+ assert!(html.contains("Annuler"));
+ assert!(frontend.contains("backfill_cancel"));
+ assert!(frontend.contains("{ jobId: status.jobId }"));
+}
diff --git a/crates/ksp-app-backfill-desk/tests/desktop_security.rs b/crates/ksp-app-backfill-desk/tests/desktop_security.rs
index e9d02b3..e2898e3 100644
--- a/crates/ksp-app-backfill-desk/tests/desktop_security.rs
+++ b/crates/ksp-app-backfill-desk/tests/desktop_security.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/tests/desktop_security.rs
-// version: 12
+// version: 13
//! Security and dependency-boundary checks for the Backfill Desk scaffold.
@@ -104,7 +104,7 @@ fn pre_002_tauri_commands_remain_centralized() {
assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display());
}
}
- assert_eq!(command_count, 7);
+ assert_eq!(command_count, 8);
}
#[test]
@@ -262,3 +262,36 @@ fn pre_009_monitoring_projection_exposes_counters_and_codes_without_checkpoint_o
assert!(!dto.contains(forbidden), "monitoring DTO leaks forbidden field marker {forbidden}");
}
}
+
+#[test]
+fn pre_010_cancel_surface_targets_backend_job_identity_without_business_payloads() {
+ let root = app_root();
+ let dto_source = read_text(root.join("src/dto_backfill.rs").as_path());
+ let dto = struct_source(dto_source.as_str(), "BackfillCancelResponseDto");
+ for required in ["accepted", "job_id", "state"] {
+ assert!(dto.contains(required), "Cancel acknowledgement missing safe field {required}");
+ }
+ for forbidden in [
+ "pub(crate) address:",
+ "pub(crate) signature:",
+ "pub(crate) endpoint:",
+ "pub(crate) provider:",
+ "pub(crate) credential:",
+ "pub(crate) token:",
+ "pub(crate) checkpoint:",
+ "pub(crate) payload:",
+ ] {
+ assert!(!dto.contains(forbidden), "Cancel acknowledgement leaks forbidden field marker {forbidden}");
+ }
+ let frontend = read_text(root.join("frontend/ts/main.ts").as_path());
+ let cancel_marker = frontend.find("Backfill Desk cooperative cancellation requested");
+ assert!(cancel_marker.is_some());
+ let cancel_source = match cancel_marker {
+ std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 700, frontend.len())],
+ std::option::Option::None => "",
+ };
+ assert!(cancel_source.contains("jobId: status.jobId"));
+ for forbidden in ["address:", "anchorSignature:", "explicitSignatures:", "minContextSlot:"] {
+ assert!(!cancel_source.contains(forbidden), "Cancel tracing includes forbidden campaign payload marker {forbidden}");
+ }
+}
diff --git a/crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs b/crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
index c71bf5a..da4ce5c 100644
--- a/crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
+++ b/crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
-// version: 1
+// version: 2
#[test]
fn generated_run_ids_are_backend_owned_bounded_and_unique_in_session() {
@@ -22,3 +22,66 @@ fn generated_run_ids_are_backend_owned_bounded_and_unique_in_session() {
assert!(first.as_str().len() <= ksp_job_api::MAX_JOB_ID_BYTES);
assert!(second.as_str().len() <= ksp_job_api::MAX_JOB_ID_BYTES);
}
+
+fn cancellable_runtime(job_id: &str) -> std::option::Option<(ksp_job_api::JobId, ksp_job_backfill_lib::BackfillJobRuntime)> {
+ let job_id = match ksp_job_api::JobId::new(job_id) {
+ std::result::Result::Ok(value) => value,
+ std::result::Result::Err(_) => return std::option::Option::None,
+ };
+ let network = match ksp_store_lib::RawNetworkId::new("mainnet-beta") {
+ std::result::Result::Ok(value) => value,
+ std::result::Result::Err(_) => return std::option::Option::None,
+ };
+ let request = ksp_job_backfill_lib::BackfillRequest::new(
+ job_id.clone(),
+ network,
+ ksp_onchain_transport_lib::HttpRoleName::new("backfill_pool"),
+ ksp_job_backfill_lib::BackfillCommitment::Finalized,
+ ksp_job_backfill_lib::BackfillScope::latest_address(ksp_core_lib::Pubkey::new_from_array([1_u8; 32])),
+ 1,
+ 1,
+ 1,
+ 1,
+ std::option::Option::None,
+ );
+ let request = match request {
+ std::result::Result::Ok(value) => value,
+ std::result::Result::Err(_) => return std::option::Option::None,
+ };
+ let runtime = match ksp_job_backfill_lib::BackfillJobRuntime::new(request) {
+ std::result::Result::Ok(value) => value,
+ std::result::Result::Err(_) => return std::option::Option::None,
+ };
+ return std::option::Option::Some((job_id, runtime));
+}
+
+#[test]
+fn targeted_cancel_is_idempotent_and_rejects_stale_job_identity() {
+ let state = crate::BackfillRunState::new();
+ let runtime = cancellable_runtime("backfill-desk-41");
+ assert!(runtime.is_some());
+ let (job_id, runtime) = match runtime {
+ std::option::Option::Some(value) => value,
+ std::option::Option::None => return,
+ };
+ let installed = state.install(job_id.clone(), runtime.handle());
+ assert!(installed.is_ok());
+ let first = state.cancel(job_id.as_str());
+ assert!(first.is_ok());
+ if let std::result::Result::Ok(first) = first {
+ assert!(first.accepted);
+ assert_eq!(first.state, "cancelling");
+ }
+ let repeated = state.cancel(job_id.as_str());
+ assert!(repeated.is_ok());
+ if let std::result::Result::Ok(repeated) = repeated {
+ assert!(!repeated.accepted);
+ assert_eq!(repeated.state, "cancelling");
+ }
+ let stale = state.cancel("backfill-desk-40");
+ assert!(stale.is_err());
+ if let std::result::Result::Err(error) = stale {
+ assert_eq!(error.code().domain(), crate::ERROR_CODE_BACKFILL_RUN_MISMATCH.domain());
+ assert_eq!(error.code().code(), crate::ERROR_CODE_BACKFILL_RUN_MISMATCH.code());
+ }
+}
diff --git a/crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs b/crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
index 3f738c2..4dd82d8 100644
--- a/crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
+++ b/crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
@@ -1,5 +1,5 @@
// file: crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
-// version: 2
+// version: 3
#[test]
fn request_limits_are_derived_from_job_owned_public_bounds() {
@@ -43,3 +43,18 @@ fn start_acknowledgement_contains_only_backend_job_identity_and_initial_state()
}
}
}
+
+#[test]
+fn cancel_acknowledgement_contains_only_backend_job_identity_acceptance_and_state() {
+ let response = crate::BackfillCancelResponseDto { accepted: true, job_id: "backfill-desk-1".to_owned(), state: "cancelling".to_owned() };
+ let serialized = serde_json::to_string(&response);
+ assert!(serialized.is_ok());
+ if let std::result::Result::Ok(serialized) = serialized {
+ assert!(serialized.contains("backfill-desk-1"));
+ assert!(serialized.contains("cancelling"));
+ assert!(serialized.contains("accepted"));
+ for forbidden in ["address", "signature", "provider", "endpoint", "credential", "token", "checkpoint", "payload"] {
+ assert!(!serialized.contains(forbidden));
+ }
+ }
+}
diff --git a/crates/ksp-app-config-desk/frontend/splash.html b/crates/ksp-app-config-desk/frontend/splash.html
index 8dbc18f..5ee8ac9 100644
--- a/crates/ksp-app-config-desk/frontend/splash.html
+++ b/crates/ksp-app-config-desk/frontend/splash.html
@@ -1,5 +1,5 @@
-
+
@@ -14,7 +14,7 @@

-
Config Desk
+
Config
diff --git a/crates/ksp-app-solprices-desk/frontend/splash.html b/crates/ksp-app-solprices-desk/frontend/splash.html
index e4b8204..8a30950 100644
--- a/crates/ksp-app-solprices-desk/frontend/splash.html
+++ b/crates/ksp-app-solprices-desk/frontend/splash.html
@@ -1,5 +1,5 @@
-
+
@@ -14,7 +14,7 @@

-
SOL Prices Desk
+
SOL Prices
diff --git a/crates/ksp-app-wallet-desk/frontend/splash.html b/crates/ksp-app-wallet-desk/frontend/splash.html
index 3ac05ee..7555c98 100644
--- a/crates/ksp-app-wallet-desk/frontend/splash.html
+++ b/crates/ksp-app-wallet-desk/frontend/splash.html
@@ -1,5 +1,5 @@
-
+
@@ -13,7 +13,7 @@

-
Wallet Desk
+
Wallet
diff --git a/deltas/0.3.7/pre.010.md b/deltas/0.3.7/pre.010.md
new file mode 100644
index 0000000..46308dc
--- /dev/null
+++ b/deltas/0.3.7/pre.010.md
@@ -0,0 +1,125 @@
+
+
+
+# Delta `0.3.7-pre.010` — Cancel ciblé et races terminales Backfill Desk
+
+## 1. Base requise
+
+```text
+0.3.7-pre.009-fix.002
+workspace.package.version = 0.3.7-pre.9.fix.2
+```
+
+## 2. Objectif
+
+Ouvrir le contrôle coopératif d'un run déjà admis sans modifier le contrat de campagne : Cancel ciblé par JobId backend, idempotence, protection contre les requêtes périmées, état cancelling observable et shutdown applicatif best-effort. Resume reste hors périmètre.
+
+## 3. Cancel ciblé
+
+La nouvelle commande `backfill_cancel(job_id)` accepte uniquement le JobId généré par le backend et déjà projeté par le monitoring. Le slot single-run vérifie l'identité avant d'appeler `BackfillJobHandle::cancel()`.
+
+Cette cible évite qu'un Cancel IPC retardé destiné au run N puisse annuler le run N+1 après une terminaison rapide et un nouveau Start.
+
+## 4. Idempotence et races
+
+Règles appliquées :
+
+- premier Cancel pré-terminal : `accepted=true` ;
+- répétition sur le même run : `accepted=false`, sans effet supplémentaire ;
+- terminal déjà gagné : `accepted=false` avec état terminal ;
+- JobId différent du run actif ou du terminal retenu : erreur stable `backfill_desk/backfill_run_mismatch` ;
+- absence de run actif/retenu correspondant : `backfill_desk/backfill_run_not_active` ;
+- Start continue d'être refusé tant que le slot actif existe, y compris pendant cancelling.
+
+Le DTO de réponse contient seulement `accepted`, `job_id` et `state`.
+
+## 5. Shutdown coopératif
+
+Le close de la fenêtre principale gagne d'abord le one-shot `begin_shutdown()`, ce qui bloque tout nouveau Start. Il demande ensuite best-effort l'annulation du run actif avant de poursuivre le shutdown Store/process.
+
+Cette séquence ne promet pas le drain crash-safe du travail déjà soumis. La sémantique de drain coopératif reste celle de `ksp-job-backfill-lib` : arrêt des nouvelles admissions et traitement du travail déjà engagé avant terminal lorsque le process reste vivant.
+
+## 6. Frontend
+
+La carte latest-value ajoute **Annuler**. Le bouton n'est disponible que pour un statut actif, transmet uniquement `jobId`, affiche l'acceptation sûre puis resynchronise immédiatement via `backfill_status`.
+
+Aucune adresse, signature, ancre, provider, endpoint, credential, checkpoint ou payload métier n'est envoyé ou journalisé par le chemin Cancel.
+
+## 7. Version
+
+```text
+workspace.package.version = 0.3.7-pre.10
+label = 0.3.7-pre.010
+```
+
+## 8. Fichiers ajoutés
+
+```text
+deltas/0.3.7/pre.010.md
+```
+
+## 9. Fichiers modifiés
+
+```text
+Cargo.toml
+crates/ksp-app-backfill-desk/frontend/main.html
+crates/ksp-app-backfill-desk/frontend/ts/main.ts
+crates/ksp-app-backfill-desk/src/app_state.rs
+crates/ksp-app-backfill-desk/src/backfill_run.rs
+crates/ksp-app-backfill-desk/src/dto_backfill.rs
+crates/ksp-app-backfill-desk/src/errors.rs
+crates/ksp-app-backfill-desk/src/lib.rs
+crates/ksp-app-backfill-desk/src/tauri.rs
+crates/ksp-app-backfill-desk/tests/desktop_contract.rs
+crates/ksp-app-backfill-desk/tests/desktop_security.rs
+crates/ksp-app-backfill-desk/unit_tests/backfill_run.rs
+crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs
+docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md
+docs/validation/024-V0_3_7_BACKFILL_DESK.md
+```
+
+## 10. Fichiers supprimés
+
+Aucun.
+
+## 11. Gate précédent acquis
+
+Le replay opérateur de `0.3.7-pre.009-fix.002` est intégralement propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, 49 tests unitaires Job Backfill et toutes les suites Backfill Desk passent.
+
+## 12. Validations exécutées dans l'environnement d'assemblage
+
+```text
+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.3.7
+```
+
+Contrôles ciblés :
+
+- Cancel ciblé par JobId backend et protection stale-run ;
+- répétition idempotente et terminal non réouvert ;
+- shutdown : annulation demandée après `begin_shutdown()` et avant `exit` ;
+- DTO Cancel limité aux métadonnées sûres ;
+- frontend sans payload de campagne sur le chemin Cancel ;
+- aucune dépendance/feature Cargo modifiée ;
+- syntaxe TypeScript par transpilation locale ;
+- contrôle différentiel des headers et reconstruction stricte par application du delta.
+
+## 13. Validations non exécutées dans l'environnement d'assemblage
+
+`cargo` et `rustfmt` ne sont pas disponibles dans l'environnement d'assemblage. Gate opérateur obligatoire avant `pre.011` :
+
+```text
+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.3.7
+cargo check --workspace
+cargo clippy --workspace --all-targets
+cargo test -p ksp-job-backfill-lib
+cargo test -p ksp-app-backfill-desk
+```
+
+Aucun `cargo tree` n'est requis pour cette tranche : dépendances et features Cargo sont inchangées.
+
+## 14. Suite
+
+`pre.011` ouvrira checkpoint/frontier et Resume in-session, sans durable checkpoint externe ni scheduler générique.
diff --git a/docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md b/docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md
index c34a7c6..ac62174 100644
--- a/docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md
+++ b/docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md
@@ -1,5 +1,5 @@
-
+
# Plan v0.3.7 — Backfill Desk
@@ -123,7 +123,7 @@ Commandes prévues :
backfill_options() -> BackfillDeskOptionsDto
backfill_status() -> BackfillRunStatusDto
backfill_start(request) -> BackfillRunStatusDto
-backfill_cancel() -> BackfillCancelResponseDto
+backfill_cancel(job_id) -> BackfillCancelResponseDto
backfill_resume() -> BackfillResumeResponseDto
backfill_reset() -> BackfillRunStatusDto
```
@@ -182,8 +182,9 @@ Règles :
- start atomique : refus si un run est `starting/running/cancelling` ;
- le handle est installé avant spawn afin que Cancel ne perde pas la course au démarrage ;
+- Cancel cible le `job_id` backend affiché par le monitoring afin qu'une requête IPC retardée ne puisse jamais annuler un run ultérieur ;
- Cancel répété est idempotent au niveau UX, mais le retour expose si la demande a été acceptée ;
-- un terminal publié gagne sur un Cancel tardif ;
+- un terminal publié gagne sur un Cancel tardif ; un Cancel visant l'ancien JobId après admission d'un nouveau run est rejeté comme mismatch ;
- la source latest-value est conservée jusqu'à projection du terminal ;
- reset/new campaign n'est autorisé qu'en absence de run actif ;
- fermeture de l'application déclenche une demande d'annulation coopérative best-effort puis laisse la destruction du process terminer les ressources ; aucune promesse de drain crash-safe n'est faite ;
@@ -275,7 +276,9 @@ Le backend émet `ksp-backfill-status` à partir de `JobSnapshotSource::wait_for
### pre.010 — Cancel et races terminales
-Cancel, état cancelling, idempotence UX, concurrence Start/Cancel/terminal, sémantique de drain Store et fermeture app.
+Ajouter `backfill_cancel(job_id)` avec `BackfillCancelResponseDto { accepted, job_id, state }`. Le JobId est celui généré par le backend et projeté par le monitoring ; il cible le slot actif pour empêcher un Cancel IPC retardé d'atteindre un run suivant. Le premier Cancel pré-terminal est accepté, les répétitions sont idempotentes et renvoient `accepted=false`, un terminal gagne sur un Cancel tardif et un JobId périmé est rejeté sans toucher au run courant.
+
+Le frontend expose **Annuler** uniquement pour un status actif, trace seulement JobId/état/acceptation et se resynchronise par `backfill_status`. L'état `cancelling` reste l'intention coopérative : le runtime arrête les nouvelles admissions mais draine le travail déjà soumis au Store. À la fermeture de la fenêtre principale, le one-shot shutdown bloque d'abord les nouveaux Starts, demande ensuite best-effort l'annulation du run actif, puis poursuit le shutdown Store/process sans promettre un drain crash-safe. Resume reste hors tranche.
### pre.011 — checkpoint/frontier et Resume
diff --git a/docs/validation/024-V0_3_7_BACKFILL_DESK.md b/docs/validation/024-V0_3_7_BACKFILL_DESK.md
index 862c344..1c6328e 100644
--- a/docs/validation/024-V0_3_7_BACKFILL_DESK.md
+++ b/docs/validation/024-V0_3_7_BACKFILL_DESK.md
@@ -1,5 +1,5 @@
-
+
# Validation v0.3.7 — Backfill Desk
@@ -480,3 +480,24 @@ Le correctif conserve le même niveau de sécurité mais cible désormais les d
- [X] version Cargo synchronisée en `0.3.7-pre.9.fix.2` ;
- [X] audits statiques Rust/Markdown rejoués dans l'environnement d'assemblage ;
- [ ] replay opérateur `cargo fmt/check/clippy/test` du fix à exécuter ; aucun `cargo tree` requis car dépendances/features inchangées.
+
+## 24. `pre.010` — Cancel ciblé, idempotence et races terminales
+
+Le replay opérateur de `pre.009-fix.002` ferme le monitoring latest-value : audits Rust/Markdown, `cargo check --workspace`, Clippy, les 49 tests unitaires `ksp-job-backfill-lib` et toutes les suites Backfill Desk passent. La surface `pre.010` peut donc ouvrir le contrôle coopératif sans rattrapage antérieur.
+
+`backfill_cancel(job_id)` cible explicitement le JobId backend actuellement observé. Cette cible empêche une requête Cancel IPC retardée, émise pour un run terminé, d'annuler un run ultérieur. Le slot single-run sérialise Start/Cancel/finish : le premier Cancel pré-terminal appelle `BackfillJobHandle::cancel()` et renvoie `accepted=true`; les répétitions renvoient `accepted=false`; un terminal déjà gagné reste terminal; un JobId qui ne correspond ni au run actif ni au terminal retenu est rejeté par un code `backfill_desk` stable.
+
+Le frontend ajoute un bouton **Annuler** au monitoring, transmet uniquement le JobId sûr, n'envoie aucun payload de campagne et se resynchronise immédiatement via `backfill_status`. La fermeture de la fenêtre principale gagne d'abord `begin_shutdown()`, ce qui interdit tout nouveau Start, puis demande best-effort l'annulation du run actif avant le shutdown Store et l'exit. Cette fermeture ne promet pas le drain crash-safe du travail déjà soumis ; le runtime Job conserve seul la sémantique de drain coopératif.
+
+### Gate statique local `pre.010`
+
+- [X] Cancel ciblé par JobId backend, sans identité fournie au Start ;
+- [X] premier Cancel accepté et répétitions idempotentes ;
+- [X] stale JobId rejeté avant action sur un nouveau run ;
+- [X] terminal tardif prioritaire sur un Cancel non accepté ;
+- [X] shutdown bloque Start puis tente l'annulation active avant exit ;
+- [X] DTO Cancel limité à `accepted`, `job_id`, `state` ;
+- [X] frontend Cancel sans adresse/signature/provider/endpoint/checkpoint/payload ;
+- [X] aucune dépendance/feature Cargo ajoutée ;
+- [X] Resume non ouvert ;
+- [ ] `cargo fmt/check/clippy/test` de `pre.010` à rejouer par l'opérateur.