feat(kanban): upload pending command with retry

req: sync/004\nreq: sync/009\nreq: sync/010\nreq: sync/016\nreq: sync/017
This commit is contained in:
slhx agent
2026-07-13 16:32:44 +02:00
parent 687c64d078
commit 599d1b838d
6 changed files with 368 additions and 5 deletions
+35 -2
View File
@@ -9,13 +9,13 @@ use hemplate::Hemplate;
use hemx::{Html, IntoEffect};
use hemx_axum::{
interactions, runtime_js, runtime_js_path, sse, DispatchRegistry, DispatchRejection,
EffectResponse, InteractionRequest, PageRequest,
EffectResponse, InteractionRequest, PageRequest, PageResponse,
};
use hemx_kanban_example::ui::board::{self as board};
use hemx_kanban_example::ui::board_card as card_board;
use hemx_kanban_example::ui::{self, board as board_ui};
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
@@ -34,6 +34,8 @@ struct SyncState {
next_sequence: u64,
acknowledgements: BTreeMap<CommandId, SyncAcknowledgement>,
reconnects: BTreeMap<String, u64>,
fail_first_upload: bool,
transient_failures: BTreeSet<CommandId>,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -68,6 +70,7 @@ struct SyncAcknowledgement {
enum SyncRejection {
BadRequest(&'static str),
Conflict(&'static str),
Transient,
}
impl IntoResponse for SyncRejection {
@@ -75,6 +78,7 @@ impl IntoResponse for SyncRejection {
let (status, error) = match self {
Self::BadRequest(error) => (StatusCode::BAD_REQUEST, error),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Transient => (StatusCode::SERVICE_UNAVAILABLE, "transient sync failure"),
};
(status, Json(serde_json::json!({ "error": error }))).into_response()
}
@@ -99,6 +103,11 @@ struct AppShell {
body: Html,
}
#[derive(Hemplate)]
struct SyncShell {
runtime_src: &'static str,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct BoardColumns {
@@ -171,6 +180,7 @@ async fn main() {
}),
sync: Mutex::new(SyncState {
next_sequence: 1,
fail_first_upload: std::env::var_os("HEMX_KANBAN_FAIL_FIRST_SYNC").is_some(),
..SyncState::default()
}),
});
@@ -178,6 +188,8 @@ async fn main() {
let app = Router::new()
.route("/", get(home).post(interact))
.route("/events", get(events))
.route("/sync-demo", get(sync_demo))
.route("/sync.js", get(sync_js))
.route("/sync/commands", post(sync_command))
.route("/sync/acknowledgements", get(sync_acknowledgements))
.route(runtime_js_path(), get(runtime))
@@ -204,6 +216,24 @@ async fn runtime() -> impl IntoResponse {
runtime_js()
}
async fn sync_demo() -> impl IntoResponse {
PageResponse::full(
ui::page(&SyncShell {
runtime_src: runtime_js_path(),
})
.into_string(),
)
.title("hemx Kanban sync")
.fingerprint(ui::BUILD_FINGERPRINT)
}
async fn sync_js() -> impl IntoResponse {
(
[("content-type", "text/javascript; charset=utf-8")],
include_str!("../static/sync.js"),
)
}
async fn interact(
State(state): State<Arc<AppState>>,
request: InteractionRequest,
@@ -254,6 +284,9 @@ async fn sync_command(
}
return Ok(Json(existing.clone()));
}
if sync.fail_first_upload && sync.transient_failures.insert(command_id.clone()) {
return Err(SyncRejection::Transient);
}
let mut board = state.board.lock().unwrap();
let card = board