From 618f940310b7965af6ee73f5bcd7b7eb32481637 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 2 Sep 2026 19:50:01 +0200 Subject: [PATCH] v0.3.7-pre.011 --- Cargo.toml | 4 +- .../ksp-app-backfill-desk/frontend/main.html | 6 +- .../ksp-app-backfill-desk/frontend/ts/main.ts | 61 ++++++- crates/ksp-app-backfill-desk/package.json | 2 +- crates/ksp-app-backfill-desk/src/app_state.rs | 90 ++++++++++- .../ksp-app-backfill-desk/src/backfill_run.rs | 79 ++++++++- .../ksp-app-backfill-desk/src/dto_backfill.rs | 15 +- crates/ksp-app-backfill-desk/src/errors.rs | 4 +- crates/ksp-app-backfill-desk/src/lib.rs | 6 +- crates/ksp-app-backfill-desk/src/tauri.rs | 31 +++- .../tests/desktop_contract.rs | 34 +++- .../tests/desktop_security.rs | 48 +++++- .../unit_tests/backfill_run.rs | 40 ++++- .../unit_tests/dto_backfill.rs | 17 +- crates/ksp-job-backfill-lib/src/checkpoint.rs | 8 +- crates/ksp-job-backfill-lib/src/request.rs | 15 +- .../unit_tests/request.rs | 70 +++++++- deltas/0.3.7/pre.011.md | 153 ++++++++++++++++++ docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md | 12 +- docs/validation/024-V0_3_7_BACKFILL_DESK.md | 30 +++- 20 files changed, 682 insertions(+), 43 deletions(-) create mode 100644 deltas/0.3.7/pre.011.md diff --git a/Cargo.toml b/Cargo.toml index de8c16d..db92a8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 434 +# version: 435 [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.10" +version = "0.3.7-pre.11" 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 da36f8a..f9d95ee 100644 --- a/crates/ksp-app-backfill-desk/frontend/main.html +++ b/crates/ksp-app-backfill-desk/frontend/main.html @@ -1,5 +1,5 @@ - + @@ -138,6 +138,9 @@ + @@ -145,6 +148,7 @@
+
Job
Lifecycle
diff --git a/crates/ksp-app-backfill-desk/frontend/ts/main.ts b/crates/ksp-app-backfill-desk/frontend/ts/main.ts index 17e89bd..1f00563 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: 6 +// version: 7 import "bootstrap"; import ResizeObserver from "resize-observer-polyfill"; @@ -9,6 +9,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import type { BackfillRunStatusDto } from "./bindings/ksp_app_backfill_desk/backfill_status/BackfillRunStatusDto.ts"; import type { BackfillCancelResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillCancelResponseDto.ts"; import type { BackfillRequestPreviewDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestPreviewDto.ts"; +import type { BackfillResumeResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillResumeResponseDto.ts"; import type { BackfillStartRequestDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartRequestDto.ts"; import type { BackfillStartResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartResponseDto.ts"; import type { BackfillDeskOptionsDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillDeskOptionsDto.ts"; @@ -42,7 +43,7 @@ const scopeLabels: Record = { let compositionReadyForStart = false; let currentRunStatus: BackfillRunStatusDto | null = null; let runStartAccepted = false; - +let resumeInFlight = false; function isViewId(value: string | undefined): value is ViewId { return value === "backfill" || value === "diagnostics"; @@ -453,15 +454,20 @@ function bindBackfillRouteSelection(): void { } -function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "cancel" | "event" | "startup" | "start" | "user"): void { +function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "cancel" | "event" | "resume" | "startup" | "start" | "user"): void { const card = document.querySelector("#backfillRunStatusCard"); const cancelButton = document.querySelector("#cancelBackfillRun"); + const resumeButton = document.querySelector("#resumeBackfillRun"); currentRunStatus = status; if (!status) { runStartAccepted = false; if (cancelButton) { cancelButton.disabled = true; } + if (resumeButton) { + resumeButton.disabled = true; + } + resumeInFlight = false; refreshStartButton(); if (card) { card.hidden = true; @@ -473,6 +479,12 @@ function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "c if (cancelButton) { cancelButton.disabled = !status.active; } + if (status.active) { + resumeInFlight = false; + } + if (resumeButton) { + resumeButton.disabled = !status.terminal || !status.checkpointPresent || resumeInFlight; + } refreshStartButton(); if (card) { card.hidden = false; @@ -509,13 +521,46 @@ function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "c }); } -async function syncBackfillStatus(source: "cancel" | "startup" | "start" | "user"): Promise { +async function syncBackfillStatus(source: "cancel" | "resume" | "startup" | "start" | "user"): Promise { frontendDebug("main", "Backfill Desk latest-value status resynchronization started", { source }); const status = await invokeKsp("main", "backfill_status"); renderBackfillRunStatus(status, source); frontendDebug("main", "Backfill Desk latest-value status resynchronization completed", { source, statusPresent: status !== null }); } +async function resumeBackfillRun(): Promise { + const status = currentRunStatus; + if (!status || !status.terminal || !status.checkpointPresent || resumeInFlight) { + return; + } + const resumeButton = document.querySelector("#resumeBackfillRun"); + resumeInFlight = true; + if (resumeButton) { + resumeButton.disabled = true; + } + frontendDebug("main", "Backfill Desk in-session Resume requested", { jobId: status.jobId, state: status.state }); + try { + const response = await invokeKsp("main", "backfill_resume"); + const feedback = document.querySelector("#backfillResumeFeedback"); + if (feedback) { + feedback.hidden = false; + feedback.textContent = `Checkpoint repris dans ${response.jobId} (${response.state}).`; + } + frontendInfo("main", "Backfill Desk in-session Resume accepted", { + accepted: response.accepted, + jobId: response.jobId, + state: response.state, + }); + await syncBackfillStatus("resume"); + } catch (_caughtError) { + resumeInFlight = false; + if (resumeButton && currentRunStatus?.terminal && currentRunStatus.checkpointPresent) { + resumeButton.disabled = false; + } + frontendWarn("main", "Backfill Desk in-session Resume failed"); + } +} + async function bindBackfillStatusMonitoring(): Promise { await listen("ksp-backfill-status", event => { renderBackfillRunStatus(event.payload, "event"); @@ -560,7 +605,13 @@ async function bindBackfillStatusMonitoring(): Promise { }); }); } - frontendTrace("main", "Backfill Desk latest-value monitoring and cancellation handlers installed"); + const resumeButton = document.querySelector("#resumeBackfillRun"); + if (resumeButton) { + resumeButton.addEventListener("click", () => { + void resumeBackfillRun(); + }); + } + frontendTrace("main", "Backfill Desk latest-value monitoring, cancellation and Resume handlers installed"); } function renderRuntimeStatus(status: ShellStatusDto): void { diff --git a/crates/ksp-app-backfill-desk/package.json b/crates/ksp-app-backfill-desk/package.json index 2cec18b..50ad31e 100644 --- a/crates/ksp-app-backfill-desk/package.json +++ b/crates/ksp-app-backfill-desk/package.json @@ -1,7 +1,7 @@ { "name": "ksp-app-backfill-desk", "private": true, - "version": "0.3.7-pre.2", + "version": "0.3.7", "type": "module", "scripts": { "dev": "vite", diff --git a/crates/ksp-app-backfill-desk/src/app_state.rs b/crates/ksp-app-backfill-desk/src/app_state.rs index eea2726..e3cebe6 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: 8 +// version: 9 //! 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.010-cancel-races".to_owned(), + shell_phase: "pre.011-resume-checkpoint".to_owned(), startup_diagnostic: runtime.startup_diagnostic.clone(), }); } @@ -219,6 +219,7 @@ impl crate::AppState { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; + let retained_request = mapped.clone(); let runtime = ksp_job_backfill_lib::BackfillJobRuntime::new(mapped); let runtime = match runtime { std::result::Result::Ok(value) => value, @@ -226,7 +227,7 @@ impl crate::AppState { }; let handle = runtime.handle(); let snapshots = handle.snapshots(); - let installed = self.backfill_runs.install(job_id.clone(), handle); + let installed = self.backfill_runs.install(job_id.clone(), handle, retained_request); if let std::result::Result::Err(error) = installed { return std::result::Result::Err(error); } @@ -264,6 +265,89 @@ impl crate::AppState { return std::result::Result::Ok(crate::BackfillRunLaunch { job_id, runtime, snapshots, store, transport }); } + /// Prepares one in-session Resume from the retained Rust-only checkpoint using a new backend Job identity. + pub(crate) fn prepare_backfill_resume(&self) -> ksp_core_lib::Result { + if self.shutdown_started.load(std::sync::atomic::Ordering::Acquire) { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY, + "Backfill Desk cannot resume a Backfill run while application shutdown is in progress", + )); + } + let options = self.backfill_options(); + let options = match options { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if !options.composition_ready { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY, + "Backfill Desk cannot resume without ready Transport and Store composition", + )); + } + let job_id = self.backfill_runs.next_job_id(); + let job_id = match job_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mapped = self.backfill_runs.resume_request(job_id.clone()); + let mapped = match mapped { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let store_network_matches = options.store_network.as_deref() == std::option::Option::Some(mapped.network().as_str()); + let role_available = options.http_routes.iter().any(|route| return route.role == mapped.role().as_str()); + if !store_network_matches || !role_available { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY, + "Backfill Desk retained Resume request no longer matches current Transport/Store composition", + )); + } + let retained_request = mapped.clone(); + let runtime = ksp_job_backfill_lib::BackfillJobRuntime::new(mapped); + let runtime = match runtime { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let handle = runtime.handle(); + let snapshots = handle.snapshots(); + let installed = self.backfill_runs.install(job_id.clone(), handle, retained_request); + if let std::result::Result::Err(error) = installed { + return std::result::Result::Err(error); + } + let transport = self.transport_runtime.as_ref(); + let transport = match transport { + std::option::Option::Some(value) => value.pool(), + std::option::Option::None => { + let rollback = self.backfill_runs.rollback(&job_id); + if let std::result::Result::Err(error) = rollback { + return std::result::Result::Err(error); + } + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY, + "Backfill Desk cannot resume a Backfill run without a ready HTTP Transport pool", + )); + }, + }; + let store = self.store_startup.take_for_run(); + let store = match store { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let rollback = self.backfill_runs.rollback(&job_id); + if let std::result::Result::Err(rollback_error) = rollback { + return std::result::Result::Err(rollback_error); + } + return std::result::Result::Err(error); + }, + }; + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_RUN, + job_id = job_id.as_str(), + "admitted Backfill Desk in-session Resume with a reissued Rust-only checkpoint" + ); + return std::result::Result::Ok(crate::BackfillRunLaunch { job_id, runtime, snapshots, store, transport }); + } + /// Returns the current active or retained terminal Backfill status for frontend resynchronization. pub(crate) fn backfill_status(&self) -> ksp_core_lib::Result> { let notification = self.backfill_runs.current_notification(); diff --git a/crates/ksp-app-backfill-desk/src/backfill_run.rs b/crates/ksp-app-backfill-desk/src/backfill_run.rs index 70e848e..a19cc24 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: 3 +// version: 4 //! Single-active-run admission and launch ownership for Backfill Desk. @@ -28,7 +28,7 @@ impl crate::BackfillRunLaunch { /// Single-run control slot retained by the application while one Backfill runtime is active. pub(crate) struct BackfillRunState { active: std::sync::Mutex>, - last_terminal: std::sync::Mutex>>, + last_terminal: std::sync::Mutex>, next_sequence: std::sync::atomic::AtomicU64, } @@ -61,7 +61,12 @@ impl crate::BackfillRunState { } /// Installs one control handle atomically and rejects concurrent Starts while another run owns the slot. - pub(crate) fn install(&self, job_id: ksp_job_api::JobId, handle: ksp_job_backfill_lib::BackfillJobHandle) -> ksp_core_lib::Result<()> { + pub(crate) fn install( + &self, + job_id: ksp_job_api::JobId, + handle: ksp_job_backfill_lib::BackfillJobHandle, + request: ksp_job_backfill_lib::BackfillRequest, + ) -> ksp_core_lib::Result<()> { let active = self.active.lock(); let mut active = match active { std::result::Result::Ok(value) => value, @@ -78,7 +83,7 @@ impl crate::BackfillRunState { "Backfill Desk already has an active Backfill run", )); } - *active = std::option::Option::Some(ActiveBackfillRun { handle, job_id }); + *active = std::option::Option::Some(ActiveBackfillRun { handle, job_id, request }); return std::result::Result::Ok(()); } @@ -111,6 +116,7 @@ impl crate::BackfillRunState { }, }; let cancellation_requested = current.handle.is_cancellation_requested(); + let request = current.request.clone(); let source = current.handle.snapshots(); let notification = ::current(&source); if notification.state().is_terminal() { @@ -124,7 +130,7 @@ impl crate::BackfillRunState { )); }, }; - *terminal = std::option::Option::Some(notification); + *terminal = std::option::Option::Some(TerminalBackfillRun { notification, request }); } *active = std::option::Option::None; return std::result::Result::Ok(cancellation_requested); @@ -178,7 +184,8 @@ impl crate::BackfillRunState { )); }, }; - if let std::option::Option::Some(notification) = terminal.as_ref() { + if let std::option::Option::Some(terminal) = terminal.as_ref() { + let notification = &terminal.notification; if notification.id().as_str() != job_id { return std::result::Result::Err(ksp_core_lib::Error::new( crate::ERROR_CODE_BACKFILL_RUN_MISMATCH, @@ -245,7 +252,58 @@ impl crate::BackfillRunState { )); }, }; - return std::result::Result::Ok(terminal.clone()); + return std::result::Result::Ok(terminal.as_ref().map(|terminal| return terminal.notification.clone())); + } + + /// Rebuilds the last terminal request with its opaque checkpoint reissued onto one new Job identity. + pub(crate) fn resume_request(&self, job_id: ksp_job_api::JobId) -> ksp_core_lib::Result { + let active = self.active.lock(); + let active = match active { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_APP_STATE_LOCK_FAILED, + "Backfill Desk active-run resume state lock is poisoned", + )); + }, + }; + if active.is_some() { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_BACKFILL_RUN_ACTIVE, + "Backfill Desk cannot resume while another Backfill run owns the single-run slot", + )); + } + drop(active); + let terminal = self.last_terminal.lock(); + let terminal = match terminal { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_APP_STATE_LOCK_FAILED, + "Backfill Desk terminal resume state lock is poisoned", + )); + }, + }; + let terminal = match terminal.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE, + "Backfill Desk has no retained terminal run available for in-session Resume", + )); + }, + }; + let checkpoint = terminal.notification.snapshot().checkpoint(); + let checkpoint = match checkpoint { + std::option::Option::Some(value) => value.clone(), + std::option::Option::None => { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE, + "Backfill Desk retained terminal run has no safe checkpoint for in-session Resume", + )); + }, + }; + return terminal.request.resume_for_job(job_id, checkpoint); } /// Rolls back a just-installed admission when execution resources cannot be acquired before spawn. @@ -261,6 +319,13 @@ impl crate::BackfillRunState { struct ActiveBackfillRun { handle: ksp_job_backfill_lib::BackfillJobHandle, job_id: ksp_job_api::JobId, + request: ksp_job_backfill_lib::BackfillRequest, +} + +#[derive(Clone)] +struct TerminalBackfillRun { + notification: ksp_job_api::JobNotification, + request: ksp_job_backfill_lib::BackfillRequest, } #[cfg(test)] diff --git a/crates/ksp-app-backfill-desk/src/dto_backfill.rs b/crates/ksp-app-backfill-desk/src/dto_backfill.rs index ac1588c..39e042d 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: 3 +// version: 4 //! Application-owned Backfill campaign DTOs and backend-derived request limits. @@ -89,6 +89,19 @@ pub(crate) struct BackfillCancelResponseDto { pub(crate) state: String, } +/// Safe acknowledgement returned when one retained checkpoint is resumed into a new backend Job lifecycle. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillResumeResponseDto.ts")] +pub(crate) struct BackfillResumeResponseDto { + /// Whether the retained in-session checkpoint was accepted for a new run. + pub(crate) accepted: bool, + /// Newly allocated backend Job identifier owning the reissued checkpoint. + pub(crate) job_id: String, + /// Initial lifecycle state at the time Resume returns to the frontend. + pub(crate) state: String, +} + /// Safe projection proving that one app request mapped to the KSP Backfill contract. /// /// Address and signature values are intentionally reduced to presence/count metadata. This DTO is diff --git a/crates/ksp-app-backfill-desk/src/errors.rs b/crates/ksp-app-backfill-desk/src/errors.rs index 0f765d0..dbf9d64 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: 8 +// version: 9 //! Application-local error codes for Backfill Desk composition and desktop runtime surfaces. @@ -12,6 +12,8 @@ pub(crate) const ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY: ksp_core_lib::ErrorC ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_composition_not_ready"); /// Backfill Desk received a campaign field or logical route that cannot map to the Backfill contract. pub(crate) const ERROR_CODE_BACKFILL_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_request_invalid"); +/// Backfill Desk has no retained safe checkpoint/request pair available for in-session Resume. +pub(crate) const ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_resume_unavailable"); /// Backfill Desk refuses a concurrent Start while another Backfill run owns the single-run slot. pub(crate) const ERROR_CODE_BACKFILL_RUN_ACTIVE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_run_active"); /// Backfill Desk received a stale or mismatched Job identity for an active-run control operation. diff --git a/crates/ksp-app-backfill-desk/src/lib.rs b/crates/ksp-app-backfill-desk/src/lib.rs index 389718b..768191e 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: 9 +// version: 10 //! Tauri desktop application scaffold for controlling and inspecting KSP RAW backfill jobs. @@ -98,6 +98,8 @@ pub(crate) use self::dto_backfill::BackfillCancelResponseDto; pub(crate) use self::dto_backfill::BackfillRequestLimitsDto; /// Safe request preview produced after strict backend mapping. pub(crate) use self::dto_backfill::BackfillRequestPreviewDto; +/// Safe acknowledgement returned after in-session checkpoint Resume admission. +pub(crate) use self::dto_backfill::BackfillResumeResponseDto; /// App-owned campaign request received from the frontend. pub(crate) use self::dto_backfill::BackfillStartRequestDto; /// Safe immediate acknowledgement returned after one Backfill run is admitted and spawned. @@ -124,6 +126,8 @@ pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED; pub(crate) use self::errors::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY; /// Backfill Desk received a campaign field that cannot map to the Backfill contract. pub(crate) use self::errors::ERROR_CODE_BACKFILL_REQUEST_INVALID; +/// Backfill Desk has no retained safe checkpoint available for in-session Resume. +pub(crate) use self::errors::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE; /// Backfill Desk refuses a concurrent Start while another Backfill run owns the single-run slot. pub(crate) use self::errors::ERROR_CODE_BACKFILL_RUN_ACTIVE; /// Backfill Desk rejects stale or mismatched Job identity control requests. diff --git a/crates/ksp-app-backfill-desk/src/tauri.rs b/crates/ksp-app-backfill-desk/src/tauri.rs index 3d8da4c..7ec478f 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: 8 +// version: 9 //! Tauri runtime assembly for the KSP Backfill desktop application. @@ -79,6 +79,7 @@ fn configure_commands(builder: tauri::Builder) -> tauri::Builder, +) -> std::result::Result { + let launch = state.prepare_backfill_resume(); + let launch = match launch { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(project_command_error("backfill_resume", crate::TRACING_DOMAIN_RUN, &error)); + }, + }; + let response = crate::BackfillResumeResponseDto { + accepted: true, + job_id: launch.job_id.as_str().to_owned(), + state: ksp_job_api::JobState::Created.code().to_owned(), + }; + spawn_backfill_launch(app, launch); + return std::result::Result::Ok(response); +} + +fn spawn_backfill_launch(app: tauri::AppHandle, launch: crate::BackfillRunLaunch) { let monitor_app = app.clone(); let monitor_source = launch.snapshots.clone(); let _monitor_task = tauri::async_runtime::spawn(async move { @@ -224,7 +251,7 @@ fn backfill_start( ); } }); - return std::result::Result::Ok(response); + return; } async fn monitor_backfill_status(app: tauri::AppHandle, source: ksp_job_backfill_lib::BackfillSnapshotSource) { diff --git a/crates/ksp-app-backfill-desk/tests/desktop_contract.rs b/crates/ksp-app-backfill-desk/tests/desktop_contract.rs index 5e4b56e..49b0218 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: 12 +// version: 13 //! Structural desktop contract checks for the Backfill Desk scaffold. @@ -305,7 +305,7 @@ fn pre_008_start_installs_handle_before_non_blocking_spawn_and_keeps_single_run_ let frontend = read_text(root.join("frontend/ts/main.ts").as_path()); assert!(state.contains("BackfillJobRuntime::new(mapped)")); let handle_index = state.find("let handle = runtime.handle();"); - let install_index = state.find("self.backfill_runs.install(job_id.clone(), handle)"); + let install_index = state.find("self.backfill_runs.install(job_id.clone(), handle, retained_request)"); assert!(handle_index.is_some()); assert!(install_index.is_some()); if let (std::option::Option::Some(handle_index), std::option::Option::Some(install_index)) = (handle_index, install_index) { @@ -374,3 +374,33 @@ fn pre_010_cancel_is_targeted_idempotent_and_shutdown_requests_cooperative_cance assert!(frontend.contains("backfill_cancel")); assert!(frontend.contains("{ jobId: status.jobId }")); } + +#[test] +fn pre_011_resume_reissues_rust_only_checkpoint_for_new_backend_job_without_ipc_checkpoint_material() { + let root = app_root(); + let workspace = root.join("../.."); + let checkpoint = read_text(workspace.join("crates/ksp-job-backfill-lib/src/checkpoint.rs").as_path()); + assert!(checkpoint.contains("reissue_for_job")); + let request = read_text(workspace.join("crates/ksp-job-backfill-lib/src/request.rs").as_path()); + assert!(request.contains("pub fn resume_for_job")); + assert!(request.contains("validate_request_checkpoint")); + assert!(request.contains("checkpoint.reissue_for_job(job_id.clone())")); + let run = read_text(root.join("src/backfill_run.rs").as_path()); + for required in ["TerminalBackfillRun", "request: ksp_job_backfill_lib::BackfillRequest", "snapshot().checkpoint()", "resume_for_job(job_id, checkpoint)"] { + assert!(run.contains(required), "missing Rust-only Resume ownership marker {required}"); + } + let state = read_text(root.join("src/app_state.rs").as_path()); + for required in ["prepare_backfill_resume", "options.composition_ready", "store_network_matches", "role_available"] { + assert!(state.contains(required), "missing Resume admission marker {required}"); + } + let tauri = read_text(root.join("src/tauri.rs").as_path()); + assert!(tauri.contains("backfill_resume")); + assert!(tauri.contains("spawn_backfill_launch")); + let frontend = read_text(root.join("frontend/ts/main.ts").as_path()); + assert!(frontend.contains("resumeBackfillRun")); + assert!(frontend.contains("checkpointPresent")); + assert!(frontend.contains("backfill_resume")); + let html = read_text(root.join("frontend/main.html").as_path()); + assert!(html.contains("resumeBackfillRun")); + assert!(html.contains("Reprendre")); +} diff --git a/crates/ksp-app-backfill-desk/tests/desktop_security.rs b/crates/ksp-app-backfill-desk/tests/desktop_security.rs index e2898e3..e96a3ce 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: 13 +// version: 14 //! 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, 8); + assert_eq!(command_count, 9); } #[test] @@ -295,3 +295,47 @@ fn pre_010_cancel_surface_targets_backend_job_identity_without_business_payloads assert!(!cancel_source.contains(forbidden), "Cancel tracing includes forbidden campaign payload marker {forbidden}"); } } + +#[test] +fn pre_011_resume_surface_exposes_only_new_job_acknowledgement_and_never_checkpoint_payload() { + let root = app_root(); + let dto_source = read_text(root.join("src/dto_backfill.rs").as_path()); + let dto = struct_source(dto_source.as_str(), "BackfillResumeResponseDto"); + for required in ["accepted", "job_id", "state"] { + assert!(dto.contains(required), "Resume acknowledgement missing safe field {required}"); + } + for forbidden in [ + "pub(crate) address:", + "pub(crate) signature:", + "pub(crate) endpoint:", + "pub(crate) provider:", + "pub(crate) credential:", + "pub(crate) token:", + "pub(crate) checkpoint:", + "pub(crate) payload:", + "pub(crate) network:", + ] { + assert!(!dto.contains(forbidden), "Resume acknowledgement leaks forbidden field marker {forbidden}"); + } + let tauri = read_text(root.join("src/tauri.rs").as_path()); + let resume_marker = tauri.find("fn backfill_resume"); + assert!(resume_marker.is_some()); + let resume_source = match resume_marker { + std::option::Option::Some(index) => &tauri[index..std::cmp::min(index + 1300, tauri.len())], + std::option::Option::None => "", + }; + for forbidden in ["BackfillCheckpoint", "checkpoint:", "address:", "signature:", "provider:", "endpoint:"] { + assert!(!resume_source.contains(forbidden), "Resume IPC command leaks forbidden marker {forbidden}"); + } + let frontend = read_text(root.join("frontend/ts/main.ts").as_path()); + let resume_marker = frontend.find("Backfill Desk in-session Resume requested"); + assert!(resume_marker.is_some()); + let resume_source = match resume_marker { + std::option::Option::Some(index) => &frontend[index..std::cmp::min(index + 1100, frontend.len())], + std::option::Option::None => "", + }; + assert!(resume_source.contains("backfill_resume")); + for forbidden in ["checkpoint:", "address:", "anchorSignature:", "explicitSignatures:", "minContextSlot:"] { + assert!(!resume_source.contains(forbidden), "Resume frontend sends or logs forbidden payload marker {forbidden}"); + } +} 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 da4ce5c..7282ca1 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: 2 +// version: 3 #[test] fn generated_run_ids_are_backend_owned_bounded_and_unique_in_session() { @@ -23,7 +23,9 @@ fn generated_run_ids_are_backend_owned_bounded_and_unique_in_session() { 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)> { +fn cancellable_runtime( + job_id: &str, +) -> std::option::Option<(ksp_job_api::JobId, ksp_job_backfill_lib::BackfillRequest, ksp_job_backfill_lib::BackfillJobRuntime)> { let job_id = match ksp_job_api::JobId::new(job_id) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::option::Option::None, @@ -48,11 +50,12 @@ fn cancellable_runtime(job_id: &str) -> std::option::Option<(ksp_job_api::JobId, std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::option::Option::None, }; + let retained_request = request.clone(); let runtime = match ksp_job_backfill_lib::BackfillJobRuntime::new(request) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::option::Option::None, }; - return std::option::Option::Some((job_id, runtime)); + return std::option::Option::Some((job_id, retained_request, runtime)); } #[test] @@ -60,11 +63,11 @@ 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 { + let (job_id, request, runtime) = match runtime { std::option::Option::Some(value) => value, std::option::Option::None => return, }; - let installed = state.install(job_id.clone(), runtime.handle()); + let installed = state.install(job_id.clone(), runtime.handle(), request); assert!(installed.is_ok()); let first = state.cancel(job_id.as_str()); assert!(first.is_ok()); @@ -85,3 +88,30 @@ fn targeted_cancel_is_idempotent_and_rejects_stale_job_identity() { assert_eq!(error.code().code(), crate::ERROR_CODE_BACKFILL_RUN_MISMATCH.code()); } } + +#[test] +fn resume_requires_terminal_checkpoint_and_rejects_active_run() { + let state = crate::BackfillRunState::new(); + let next = match ksp_job_api::JobId::new("backfill-desk-50") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let unavailable = state.resume_request(next.clone()); + assert!(unavailable.is_err()); + if let std::result::Result::Err(error) = unavailable { + assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_RESUME_UNAVAILABLE); + } + let runtime = cancellable_runtime("backfill-desk-49"); + assert!(runtime.is_some()); + let (job_id, request, runtime) = match runtime { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let installed = state.install(job_id, runtime.handle(), request); + assert!(installed.is_ok()); + let active = state.resume_request(next); + assert!(active.is_err()); + if let std::result::Result::Err(error) = active { + assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_RUN_ACTIVE); + } +} 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 4dd82d8..e297839 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: 3 +// version: 4 #[test] fn request_limits_are_derived_from_job_owned_public_bounds() { @@ -58,3 +58,18 @@ fn cancel_acknowledgement_contains_only_backend_job_identity_acceptance_and_stat } } } + +#[test] +fn resume_acknowledgement_contains_only_new_backend_job_identity_acceptance_and_state() { + let response = crate::BackfillResumeResponseDto { accepted: true, job_id: "backfill-desk-2".to_owned(), state: "created".to_owned() }; + let serialized = serde_json::to_string(&response); + assert!(serialized.is_ok()); + if let std::result::Result::Ok(serialized) = serialized { + assert!(serialized.contains("backfill-desk-2")); + assert!(serialized.contains("created")); + assert!(serialized.contains("accepted")); + for forbidden in ["address", "signature", "provider", "endpoint", "credential", "token", "checkpoint", "payload"] { + assert!(!serialized.contains(forbidden)); + } + } +} diff --git a/crates/ksp-job-backfill-lib/src/checkpoint.rs b/crates/ksp-job-backfill-lib/src/checkpoint.rs index 40f2795..eaebd3f 100644 --- a/crates/ksp-job-backfill-lib/src/checkpoint.rs +++ b/crates/ksp-job-backfill-lib/src/checkpoint.rs @@ -1,5 +1,5 @@ // file: crates/ksp-job-backfill-lib/src/checkpoint.rs -// version: 2 +// version: 3 /// Opaque caller-owned checkpoint for one controlled Backfill resumption. /// @@ -43,6 +43,12 @@ impl crate::BackfillCheckpoint { return Self { job_id, scope_fingerprint, completed_prefix, resume_before }; } + /// Reissues this opaque frontier for a new caller-owned Job identity without changing scope or progress. + pub(crate) fn reissue_for_job(mut self, job_id: ksp_job_api::JobId) -> Self { + self.job_id = job_id; + return self; + } + /// Returns the internal exclusive `before` cursor used only by controlled Before resumption. pub(crate) const fn resume_before(&self) -> std::option::Option<&crate::BackfillSignature> { return self.resume_before.as_ref(); diff --git a/crates/ksp-job-backfill-lib/src/request.rs b/crates/ksp-job-backfill-lib/src/request.rs index 73ffaa1..6c972d4 100644 --- a/crates/ksp-job-backfill-lib/src/request.rs +++ b/crates/ksp-job-backfill-lib/src/request.rs @@ -1,5 +1,5 @@ // file: crates/ksp-job-backfill-lib/src/request.rs -// version: 5 +// version: 6 use sha2::Digest; // rust-rules: trait-import @@ -396,6 +396,19 @@ impl crate::BackfillRequest { return std::result::Result::Ok(self); } + /// Reissues a validated checkpoint onto a new Job lifecycle while preserving exact request semantics. + pub fn resume_for_job(&self, job_id: ksp_job_api::JobId, checkpoint: crate::BackfillCheckpoint) -> ksp_core_lib::Result { + let validation = crate::validate_request_checkpoint(&self.job_id, self.scope_fingerprint, self.scope.kind(), &checkpoint); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + let checkpoint = checkpoint.reissue_for_job(job_id.clone()); + let mut resumed = self.clone(); + resumed.job_id = job_id; + resumed.checkpoint = std::option::Option::None; + return resumed.with_checkpoint(checkpoint); + } + /// Returns the validated optional checkpoint supplied for controlled resumption. #[must_use] pub const fn checkpoint(&self) -> std::option::Option<&crate::BackfillCheckpoint> { diff --git a/crates/ksp-job-backfill-lib/unit_tests/request.rs b/crates/ksp-job-backfill-lib/unit_tests/request.rs index bcd1839..96b67cc 100644 --- a/crates/ksp-job-backfill-lib/unit_tests/request.rs +++ b/crates/ksp-job-backfill-lib/unit_tests/request.rs @@ -1,5 +1,5 @@ // file: crates/ksp-job-backfill-lib/unit_tests/request.rs -// version: 1 +// version: 2 fn signature(character: char) -> std::option::Option { return match crate::BackfillSignature::new(character.to_string().repeat(crate::MIN_BACKFILL_SIGNATURE_TEXT_BYTES)) { @@ -232,3 +232,71 @@ fn pre_005_scope_kind_and_anchor_are_distinct_semantics() { assert_ne!(before, after); return; } + +#[test] +fn pre_011_resume_reissues_checkpoint_for_new_job_without_changing_scope_semantics() { + let network = match network("mainnet-beta") { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let original_job = match job_id("backfill:resume-original") { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let resumed_job = match job_id("backfill:resume-next") { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let request = crate::BackfillRequest::new( + original_job.clone(), + network, + ksp_onchain_transport_lib::HttpRoleName::new("backfill_pool"), + crate::BackfillCommitment::Finalized, + crate::BackfillScope::before_address( + ksp_core_lib::Pubkey::new_from_array([11_u8; 32]), + match signature('8') { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }, + ), + 100, + 10, + 1000, + 4, + std::option::Option::Some(42), + ); + let request = match request { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let resume_before = match signature('9') { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let checkpoint = crate::BackfillCheckpoint::new(original_job, request.scope_fingerprint(), 7, std::option::Option::Some(resume_before.clone())); + let resumed = request.resume_for_job(resumed_job.clone(), checkpoint); + assert!(resumed.is_ok()); + let resumed = match resumed { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + assert_eq!(resumed.job_id(), &resumed_job); + assert_eq!(resumed.scope_fingerprint(), request.scope_fingerprint()); + assert_eq!(resumed.network(), request.network()); + assert_eq!(resumed.role(), request.role()); + assert_eq!(resumed.commitment(), request.commitment()); + assert_eq!(resumed.scope(), request.scope()); + assert_eq!(resumed.page_size(), request.page_size()); + assert_eq!(resumed.max_pages(), request.max_pages()); + assert_eq!(resumed.max_candidates(), request.max_candidates()); + assert_eq!(resumed.hydration_concurrency(), request.hydration_concurrency()); + assert_eq!(resumed.min_context_slot(), request.min_context_slot()); + let checkpoint = resumed.checkpoint(); + assert!(checkpoint.is_some()); + if let std::option::Option::Some(checkpoint) = checkpoint { + assert_eq!(checkpoint.job_id(), &resumed_job); + assert_eq!(checkpoint.scope_fingerprint(), request.scope_fingerprint()); + assert_eq!(checkpoint.completed_prefix(), 7); + assert_eq!(checkpoint.resume_before(), std::option::Option::Some(&resume_before)); + } +} diff --git a/deltas/0.3.7/pre.011.md b/deltas/0.3.7/pre.011.md new file mode 100644 index 0000000..c3b2b81 --- /dev/null +++ b/deltas/0.3.7/pre.011.md @@ -0,0 +1,153 @@ + + + +# Delta `0.3.7-pre.011` — checkpoint/frontier et Resume in-session + +## 1. Base requise + +```text +0.3.7-pre.010 +workspace.package.version = 0.3.7-pre.10 +``` + +## 2. Objectif + +Ouvrir Resume sans introduire de checkpoint durable ni affaiblir l'identité du checkpoint : conserver la requête terminale et son checkpoint uniquement dans Rust, réémettre le checkpoint vers un nouveau JobId backend après validation de son identité d'origine, puis relancer le même runtime/monitoring que Start. + +## 3. Reprise contrôlée côté Job Backfill + +`BackfillCheckpoint` reste opaque et lié au JobId qui l'a produit. `ksp-job-backfill-lib` ajoute `BackfillRequest::resume_for_job` : + +- le checkpoint est validé contre le JobId et le scope fingerprint de la requête terminale d'origine ; +- seul le JobId est réémis pour le nouveau lifecycle ; +- scope fingerprint, completed prefix et éventuel cursor `before` restent inchangés ; +- `BackfillRequest::with_checkpoint` revalide ensuite le checkpoint réémis contre la nouvelle requête. + +Aucun constructeur public de checkpoint, aucune sérialisation et aucun cursor exposé ne sont ajoutés. + +## 4. Ownership Rust-only côté Desk + +`BackfillRunState` retient désormais avec le dernier terminal la `BackfillRequest` exacte qui a produit ce terminal. Cette requête reste exclusivement backend et n'est jamais projetée vers IPC. + +`resume_request(new_job_id)` : + +- refuse Resume lorsqu'un run actif possède le slot ; +- refuse l'absence de terminal ou de checkpoint ; +- extrait le checkpoint uniquement depuis le snapshot terminal retenu ; +- appelle `BackfillRequest::resume_for_job` avec le nouveau JobId backend. + +## 5. Admission Resume + +`AppState::prepare_backfill_resume` : + +- refuse le shutdown en cours ; +- exige `composition_ready` ; +- alloue un nouveau JobId backend ; +- reconstruit la requête via le checkpoint Rust-only ; +- revalide réseau Store et rôle HTTP contre la composition courante ; +- construit `BackfillJobRuntime`, installe le handle single-run, acquiert Transport/Store puis retourne `BackfillRunLaunch`. + +Start et Resume partagent ensuite `spawn_backfill_launch`, donc le monitoring latest-value et la restitution du Store restent identiques. + +## 6. IPC et frontend + +Nouvelle commande : + +```text +backfill_resume() -> BackfillResumeResponseDto +``` + +`BackfillResumeResponseDto` contient uniquement : + +```text +accepted +job_id +state +``` + +Le frontend expose **Reprendre** seulement pour un snapshot terminal avec `checkpoint_present=true`. La commande Resume n'envoie aucun argument métier. Aucun checkpoint, adresse, signature, ancre, provider, endpoint, credential, network ou payload RAW ne traverse IPC. + +## 7. Version + +```text +workspace.package.version = 0.3.7-pre.11 +label = 0.3.7-pre.011 +``` + +## 8. Fichiers ajoutés + +```text +deltas/0.3.7/pre.011.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 +crates/ksp-job-backfill-lib/src/checkpoint.rs +crates/ksp-job-backfill-lib/src/request.rs +crates/ksp-job-backfill-lib/unit_tests/request.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.010` est intégralement propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, 49 tests unitaires Job Backfill et toutes les suites Backfill Desk passent. Le smoke Tauri Mainnet confirme aussi le Cancel ciblé, le passage en `cancelling`, la terminalisation puis la libération du slot avant shutdown. + +## 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 : + +- `resume_for_job` valide le checkpoint d'origine puis réémet uniquement le JobId ; +- scope fingerprint, frontier et cursor interne restent inchangés ; +- requête terminale/checkpoint restent Rust-only ; +- Resume refuse run actif ou checkpoint absent ; +- réseau Store + rôle HTTP revalidés avant admission ; +- DTO Resume limité aux métadonnées sûres ; +- frontend Resume sans payload de campagne/checkpoint ; +- `std.store.json` inchangé par le delta ; +- 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.012` : + +```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.012` terminera le frontend fonctionnel/polish, notamment l'autocomplete libre de Program IDs alimenté depuis `ksp-core-lib::entries()`/`ProgramIdEntry`, sans liste hardcodée ni checkpoint durable. 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 ac62174..c07f0be 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 @@ -192,9 +192,11 @@ Règles : ## 10. Reprise et checkpoint -V1 conserve le dernier `BackfillCheckpoint` uniquement dans Rust. L'UI voit `checkpoint_present` et `contiguous_completed`, jamais les octets/identité internes du checkpoint. +V1 conserve le dernier `BackfillCheckpoint` et la requête sémantique terminale uniquement dans Rust. L'UI voit `checkpoint_present` et `contiguous_completed`, jamais les octets/identité internes du checkpoint. -`Resume` reconstruit une requête sémantiquement identique avec un nouveau lifecycle contrôlé et réattache le checkpoint via `BackfillRequest::with_checkpoint`. Le backend doit préserver les éléments participant au scope fingerprint ; toute modification de scope/commitment/bornes sémantiques force une nouvelle campagne et invalide Resume. +Le checkpoint concret reste lié au JobId qui l'a produit. Pour créer un nouveau lifecycle sans affaiblir cette garantie, `ksp-job-backfill-lib` valide d'abord le checkpoint contre la requête terminale d'origine puis `BackfillRequest::resume_for_job` le réémet sur le nouveau JobId backend en conservant exactement scope fingerprint, frontier et cursor interne. La Desk ne fabrique ni ne modifie ces éléments. + +`Resume` exige en outre que la composition Transport/Store courante reste compatible avec le réseau et le rôle logique de la requête retenue. Toute modification de scope/commitment/bornes sémantiques appartient à une nouvelle campagne et n'utilise pas Resume. Aucun JSON, fichier ou table checkpoint n'est inventé. La reprise après crash/redémarrage est reportée à une évolution explicite du contrat Backfill si un besoin durable est démontré. @@ -282,7 +284,9 @@ Le frontend expose **Annuler** uniquement pour un status actif, trace seulement ### pre.011 — checkpoint/frontier et Resume -Projection sûre, conservation Rust-only du checkpoint, reprise in-session et invariants par scope. +Conserver avec le dernier terminal la requête KSP ayant produit le checkpoint, uniquement dans l'état Rust de la Desk. `ksp-job-backfill-lib` ajoute `BackfillRequest::resume_for_job` : l'opération valide le checkpoint contre son JobId et son scope fingerprint d'origine, puis réémet uniquement l'identité Job pour le nouveau lifecycle sans exposer ni modifier cursor/frontier. + +La commande `backfill_resume()` alloue un nouveau JobId backend, refuse un run actif ou l'absence de checkpoint, revalide la compatibilité réseau/rôle avec la composition courante, reconstruit `BackfillJobRuntime` et réutilise le même chemin de spawn/monitoring que Start. Le frontend expose **Reprendre** seulement sur un terminal avec `checkpoint_present`. Aucun checkpoint, adresse, signature ou paramètre de campagne ne traverse cette commande IPC. ### pre.012 — frontend fonctionnel et polish diff --git a/docs/validation/024-V0_3_7_BACKFILL_DESK.md b/docs/validation/024-V0_3_7_BACKFILL_DESK.md index 1c6328e..3496737 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 @@ -500,4 +500,30 @@ Le frontend ajoute un bouton **Annuler** au monitoring, transmet uniquement le J - [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. +- [X] replay opérateur `cargo fmt/check/clippy/test` de `pre.010` propre ; smoke Tauri Mainnet confirme Cancel ciblé, terminalisation puis shutdown Store. + +## 25. `pre.011` — checkpoint/frontier et Resume in-session + +Le replay opérateur de `pre.010` ferme le couloir Cancel : audits Rust/Markdown, `cargo check --workspace`, Clippy, les 49 tests unitaires `ksp-job-backfill-lib` et toutes les suites Backfill Desk passent. Le smoke Tauri Mainnet confirme également le chemin réel : Store ready, campagne HTTP active, Cancel ciblé accepté, transition `cancelling`, terminalisation du Job puis libération du slot avant shutdown. La phase `finished` observée est la phase terminale commune ; le lifecycle Job reste `cancelled`. + +`pre.011` ouvre la reprise in-session sans sérialisation du checkpoint. Le contrat existant lie volontairement `BackfillCheckpoint` au JobId qui l'a produit ; la Desk ne peut donc pas simplement attacher ce checkpoint à un nouveau Job. `ksp-job-backfill-lib` ajoute `BackfillRequest::resume_for_job` : le checkpoint est d'abord validé contre la requête d'origine puis réémis en interne pour le nouveau JobId, avec scope fingerprint, completed prefix et éventuel cursor Before inchangés. + +`BackfillRunState` retient maintenant la requête terminale avec la notification terminale, uniquement en mémoire Rust. `backfill_resume` n'accepte aucun argument frontend, alloue un nouveau JobId backend, exige un checkpoint présent, refuse tout run actif et revalide que réseau Store et rôle HTTP correspondent encore à la composition courante. La commande utilise le même chemin `spawn_backfill_launch` et le même monitoring latest-value que Start. + +Le frontend expose **Reprendre** uniquement pour un snapshot terminal avec `checkpoint_present=true`. `BackfillResumeResponseDto` contient uniquement `accepted`, `job_id` et `state`; aucun checkpoint, cursor, adresse, signature, provider, endpoint ou payload RAW ne traverse IPC. + +### Gate statique local `pre.011` + +- [X] checkpoint original validé avant réémission vers un nouveau JobId ; +- [X] scope fingerprint/frontier/cursor inchangés par `resume_for_job` ; +- [X] requête terminale + checkpoint retenus uniquement dans `BackfillRunState` Rust ; +- [X] Resume refusé sans terminal/checkpoint ou lorsqu'un run est déjà actif ; +- [X] compatibilité réseau Store + rôle HTTP revalidée avant reprise ; +- [X] nouveau JobId exclusivement backend ; +- [X] chemin de spawn/monitoring partagé avec Start ; +- [X] DTO Resume limité à `accepted`, `job_id`, `state` ; +- [X] frontend Resume sans checkpoint ni payload de campagne ; +- [X] aucun fichier/table/JSON de checkpoint durable ajouté ; +- [X] aucune dépendance/feature Cargo ajoutée ; +- [ ] `cargo fmt/check/clippy/test` de `pre.011` à rejouer par l'opérateur. +