commit d3179eec10fa50d2b85bcfa51a6cf087d1320ff7
parent 82f50e70d55f538b32f08e70e8bb381a9273538d
Author: triesap <tyson@radroots.org>
Date: Tue, 21 Apr 2026 19:16:13 +0000
state: add pack day host handoff contract
- add typed pack day host handoff kind and status enums in shared models
- add in-memory host handoff request and projection beside pack day export state
- reset stale host handoff state when the pack day window or export state changes
- cover the contract with shared model and state tests
Diffstat:
2 files changed, 410 insertions(+), 24 deletions(-)
diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs
@@ -1609,6 +1609,33 @@ impl PackDayExportArtifactKind {
}
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PackDayHostHandoffKind {
+ RevealBundle,
+ OpenPackSheet,
+}
+
+impl PackDayHostHandoffKind {
+ pub const fn all_v1() -> [Self; 2] {
+ [Self::RevealBundle, Self::OpenPackSheet]
+ }
+
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::RevealBundle => "reveal_bundle",
+ Self::OpenPackSheet => "open_pack_sheet",
+ }
+ }
+
+ pub const fn artifact_kind(self) -> Option<PackDayExportArtifactKind> {
+ match self {
+ Self::RevealBundle => None,
+ Self::OpenPackSheet => Some(PackDayExportArtifactKind::PackSheet),
+ }
+ }
+}
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PackDayExportStatus {
@@ -1630,6 +1657,27 @@ impl PackDayExportStatus {
}
}
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PackDayHostHandoffStatus {
+ #[default]
+ Idle,
+ Running,
+ Succeeded,
+ Failed,
+}
+
+impl PackDayHostHandoffStatus {
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::Idle => "idle",
+ Self::Running => "running",
+ Self::Succeeded => "succeeded",
+ Self::Failed => "failed",
+ }
+ }
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PackDayOutputOrderState {
@@ -2333,19 +2381,19 @@ mod tests {
OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, OrderRecoveryProjection,
OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary,
OrdersScreenQueryState, PackDayExportArtifact, PackDayExportArtifactKind,
- PackDayExportBundle, PackDayExportStatus, PackDayOutputCustomerOrder,
- PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal,
- PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow,
- PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState,
- ParseStartupSignerSourceError, PersonalEntryProjection, PersonalEntryState,
- PersonalSection, PickupLocationId, ProductAttentionState, ProductAvailabilityState,
- ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, ProductPricePresentation,
- ProductPublishBlocker, ProductStatus, ProductStockState, ProductStockSummary,
- ProductsFilter, ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort,
- RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, RecoveryState,
- ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId,
- ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface,
- ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection,
+ PackDayExportBundle, PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus,
+ PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry,
+ PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow,
+ PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow,
+ PackDayScreenQueryState, ParseStartupSignerSourceError, PersonalEntryProjection,
+ PersonalEntryState, PersonalSection, PickupLocationId, ProductAttentionState,
+ ProductAvailabilityState, ProductAvailabilitySummary, ProductEditorDraft, ProductListRow,
+ ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductStockState,
+ ProductStockSummary, ProductsFilter, ProductsListProjection, ProductsListRow,
+ ProductsListSummary, ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId,
+ RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection,
+ ReminderId, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection,
+ ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection,
SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection,
ShellSection, StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind,
TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary,
@@ -2942,7 +2990,7 @@ mod tests {
}
#[test]
- fn pack_day_export_artifact_contract_is_frozen_for_v1() {
+ fn pack_day_export_and_host_handoff_contracts_are_frozen_for_v1() {
assert_eq!(
PackDayExportArtifactKind::all_v1(),
[
@@ -2971,6 +3019,36 @@ mod tests {
assert_eq!(PackDayExportStatus::Running.storage_key(), "running");
assert_eq!(PackDayExportStatus::Succeeded.storage_key(), "succeeded");
assert_eq!(PackDayExportStatus::Failed.storage_key(), "failed");
+ assert_eq!(
+ PackDayHostHandoffKind::all_v1(),
+ [
+ PackDayHostHandoffKind::RevealBundle,
+ PackDayHostHandoffKind::OpenPackSheet,
+ ]
+ );
+ assert_eq!(
+ PackDayHostHandoffKind::RevealBundle.storage_key(),
+ "reveal_bundle"
+ );
+ assert_eq!(
+ PackDayHostHandoffKind::OpenPackSheet.storage_key(),
+ "open_pack_sheet"
+ );
+ assert_eq!(PackDayHostHandoffKind::RevealBundle.artifact_kind(), None);
+ assert_eq!(
+ PackDayHostHandoffKind::OpenPackSheet.artifact_kind(),
+ Some(PackDayExportArtifactKind::PackSheet)
+ );
+ assert_eq!(
+ PackDayHostHandoffStatus::default(),
+ PackDayHostHandoffStatus::Idle
+ );
+ assert_eq!(PackDayHostHandoffStatus::Running.storage_key(), "running");
+ assert_eq!(
+ PackDayHostHandoffStatus::Succeeded.storage_key(),
+ "succeeded"
+ );
+ assert_eq!(PackDayHostHandoffStatus::Failed.storage_key(), "failed");
}
#[test]
diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs
@@ -15,11 +15,12 @@ use radroots_app_models::{
FarmSetupReadiness, FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase,
LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection,
OrdersScreenQueryState, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus,
- PackDayProjection, PackDayScreenQueryState, PersonalEntryProjection, ProductEditorDraft,
- ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort,
- RecoveryQueueProjection, ReminderFeedProjection, ReminderLogProjection,
- SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection,
- ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind,
+ PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayProjection, PackDayScreenQueryState,
+ PersonalEntryProjection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter,
+ ProductsListProjection, ProductsSort, RecoveryQueueProjection, ReminderFeedProjection,
+ ReminderLogProjection, SelectedSurfaceProjection, SettingsAccountProjection,
+ SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask,
+ TodaySetupTaskKind,
};
use radroots_app_sync::{
AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, SyncConflict,
@@ -309,12 +310,14 @@ pub struct PackDayScreenProjection {
pub query: PackDayScreenQueryState,
pub projection: PackDayProjection,
pub export: PackDayExportProjection,
+ pub host_handoff: PackDayHostHandoffProjection,
}
impl PackDayScreenProjection {
fn select_fulfillment_window(&mut self, fulfillment_window_id: Option<FulfillmentWindowId>) {
if self.query.fulfillment_window_id != fulfillment_window_id {
self.export = PackDayExportProjection::default();
+ self.host_handoff = PackDayHostHandoffProjection::default();
}
self.query.fulfillment_window_id = fulfillment_window_id;
}
@@ -332,14 +335,22 @@ impl PackDayScreenProjection {
if previous_window_id != next_window_id {
self.export = PackDayExportProjection::default();
+ self.host_handoff = PackDayHostHandoffProjection::default();
}
self.projection = projection;
}
fn replace_export(&mut self, export: PackDayExportProjection) {
+ if self.export != export {
+ self.host_handoff = PackDayHostHandoffProjection::default();
+ }
self.export = export;
}
+
+ fn replace_host_handoff(&mut self, host_handoff: PackDayHostHandoffProjection) {
+ self.host_handoff = host_handoff;
+ }
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -394,6 +405,56 @@ impl PackDayExportProjection {
}
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PackDayHostHandoffRequest {
+ pub fulfillment_window_id: FulfillmentWindowId,
+ pub kind: PackDayHostHandoffKind,
+ pub bundle_directory: String,
+}
+
+impl PackDayHostHandoffRequest {
+ pub fn for_bundle(kind: PackDayHostHandoffKind, bundle: &PackDayExportBundle) -> Self {
+ Self {
+ fulfillment_window_id: bundle.fulfillment_window_id,
+ kind,
+ bundle_directory: bundle.bundle_directory.clone(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct PackDayHostHandoffProjection {
+ pub status: PackDayHostHandoffStatus,
+ pub request: Option<PackDayHostHandoffRequest>,
+ pub error_message: Option<String>,
+}
+
+impl PackDayHostHandoffProjection {
+ pub fn running(request: PackDayHostHandoffRequest) -> Self {
+ Self {
+ status: PackDayHostHandoffStatus::Running,
+ request: Some(request),
+ error_message: None,
+ }
+ }
+
+ pub fn succeeded(request: PackDayHostHandoffRequest) -> Self {
+ Self {
+ status: PackDayHostHandoffStatus::Succeeded,
+ request: Some(request),
+ error_message: None,
+ }
+ }
+
+ pub fn failed(request: PackDayHostHandoffRequest, message: impl Into<String>) -> Self {
+ Self {
+ status: PackDayHostHandoffStatus::Failed,
+ request: Some(request),
+ error_message: Some(message.into()),
+ }
+ }
+}
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum FarmSetupFlowStage {
#[default]
@@ -845,6 +906,13 @@ pub enum AppStateCommand {
message: String,
},
ResetPackDayExport,
+ BeginPackDayHostHandoff(PackDayHostHandoffRequest),
+ SucceedPackDayHostHandoff(PackDayHostHandoffRequest),
+ FailPackDayHostHandoff {
+ request: PackDayHostHandoffRequest,
+ message: String,
+ },
+ ResetPackDayHostHandoff,
OpenNewProductEditor,
OpenExistingProductEditor {
product_id: ProductId,
@@ -989,6 +1057,28 @@ impl AppStateCommand {
Self::ResetPackDayExport
}
+ pub fn begin_pack_day_host_handoff(request: PackDayHostHandoffRequest) -> Self {
+ Self::BeginPackDayHostHandoff(request)
+ }
+
+ pub fn succeed_pack_day_host_handoff(request: PackDayHostHandoffRequest) -> Self {
+ Self::SucceedPackDayHostHandoff(request)
+ }
+
+ pub fn fail_pack_day_host_handoff(
+ request: PackDayHostHandoffRequest,
+ message: impl Into<String>,
+ ) -> Self {
+ Self::FailPackDayHostHandoff {
+ request,
+ message: message.into(),
+ }
+ }
+
+ pub const fn reset_pack_day_host_handoff() -> Self {
+ Self::ResetPackDayHostHandoff
+ }
+
pub const fn open_new_product_editor() -> Self {
Self::OpenNewProductEditor
}
@@ -1498,6 +1588,26 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap
.pack_day
.replace_export(PackDayExportProjection::default());
}
+ AppStateCommand::BeginPackDayHostHandoff(request) => {
+ projection
+ .pack_day
+ .replace_host_handoff(PackDayHostHandoffProjection::running(request));
+ }
+ AppStateCommand::SucceedPackDayHostHandoff(request) => {
+ projection
+ .pack_day
+ .replace_host_handoff(PackDayHostHandoffProjection::succeeded(request));
+ }
+ AppStateCommand::FailPackDayHostHandoff { request, message } => {
+ projection
+ .pack_day
+ .replace_host_handoff(PackDayHostHandoffProjection::failed(request, message));
+ }
+ AppStateCommand::ResetPackDayHostHandoff => {
+ projection
+ .pack_day
+ .replace_host_handoff(PackDayHostHandoffProjection::default());
+ }
AppStateCommand::OpenNewProductEditor => {
projection
.products
@@ -1831,9 +1941,10 @@ mod tests {
AppProjection, AppShellProjection, AppStateCommand, AppStateRepository,
AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute,
InMemoryAppStateRepository, OrdersScreenProjection, PackDayExportProjection,
- PackDayExportRequest, PackDayScreenProjection, PersistedAppState, ProductEditorState,
- ProductsScreenProjection, ProductsScreenQueryState, SettingsPreference,
- derive_sync_projection, derive_sync_run_status,
+ PackDayExportRequest, PackDayHostHandoffProjection, PackDayHostHandoffRequest,
+ PackDayScreenProjection, PersistedAppState, ProductEditorState, ProductsScreenProjection,
+ ProductsScreenQueryState, SettingsPreference, derive_sync_projection,
+ derive_sync_run_status,
};
use radroots_app_models::{
AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate,
@@ -1842,7 +1953,8 @@ mod tests {
LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId,
OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow,
OrdersListSummary, OrdersScreenQueryState, PackDayExportArtifact,
- PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus, PackDayPackListRow,
+ PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus,
+ PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow,
PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState,
PersonalEntryState, PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker,
ProductsFilter, ProductsListProjection, ProductsSort, ReminderDeliveryState,
@@ -1893,6 +2005,14 @@ mod tests {
PackDayExportRequest::for_fulfillment_window(fulfillment_window_id)
}
+ fn sample_pack_day_host_handoff_request(
+ fulfillment_window_id: FulfillmentWindowId,
+ kind: PackDayHostHandoffKind,
+ ) -> PackDayHostHandoffRequest {
+ let bundle = sample_pack_day_export_bundle(fulfillment_window_id);
+ PackDayHostHandoffRequest::for_bundle(kind, &bundle)
+ }
+
fn sample_pack_day_export_bundle(
fulfillment_window_id: FulfillmentWindowId,
) -> PackDayExportBundle {
@@ -2265,7 +2385,7 @@ mod tests {
}
#[test]
- fn pack_day_export_projection_defaults_to_idle() {
+ fn pack_day_export_and_host_handoff_projections_default_to_idle() {
assert_eq!(
PackDayScreenProjection::default().export,
PackDayExportProjection {
@@ -2275,6 +2395,14 @@ mod tests {
error_message: None,
}
);
+ assert_eq!(
+ PackDayScreenProjection::default().host_handoff,
+ PackDayHostHandoffProjection {
+ status: PackDayHostHandoffStatus::Idle,
+ request: None,
+ error_message: None,
+ }
+ );
}
#[test]
@@ -2333,6 +2461,64 @@ mod tests {
}
#[test]
+ fn pack_day_host_handoff_state_is_restart_ephemeral_and_skips_persistence() {
+ let mut store =
+ AppStateStore::load(FailingRepository).expect("failing repository should still load");
+ let fulfillment_window_id = FulfillmentWindowId::new();
+ let request = sample_pack_day_host_handoff_request(
+ fulfillment_window_id,
+ PackDayHostHandoffKind::RevealBundle,
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::begin_pack_day_host_handoff(
+ request.clone(),
+ )),
+ Ok(true)
+ );
+ assert_eq!(
+ store.pack_day_projection().host_handoff,
+ PackDayHostHandoffProjection::running(request.clone())
+ );
+ assert_eq!(
+ store.persisted_state().seller.pack_day_query,
+ PackDayScreenQueryState::default()
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::succeed_pack_day_host_handoff(
+ request.clone(),
+ )),
+ Ok(true)
+ );
+ assert_eq!(
+ store.pack_day_projection().host_handoff,
+ PackDayHostHandoffProjection::succeeded(request.clone())
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::fail_pack_day_host_handoff(
+ request.clone(),
+ "finder unavailable",
+ )),
+ Ok(true)
+ );
+ assert_eq!(
+ store.pack_day_projection().host_handoff,
+ PackDayHostHandoffProjection::failed(request, "finder unavailable")
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::reset_pack_day_host_handoff()),
+ Ok(true)
+ );
+ assert_eq!(
+ store.pack_day_projection().host_handoff,
+ PackDayHostHandoffProjection::default()
+ );
+ }
+
+ #[test]
fn changing_pack_day_window_clears_stale_export_state() {
let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
.expect("in-memory repository should load");
@@ -2368,6 +2554,128 @@ mod tests {
}
#[test]
+ fn changing_pack_day_window_clears_stale_host_handoff_state() {
+ let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
+ .expect("in-memory repository should load");
+ let fulfillment_window_id = FulfillmentWindowId::new();
+ let next_window_id = FulfillmentWindowId::new();
+ let request = sample_pack_day_host_handoff_request(
+ fulfillment_window_id,
+ PackDayHostHandoffKind::OpenPackSheet,
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::begin_pack_day_host_handoff(request)),
+ Ok(true)
+ );
+ assert_eq!(
+ store.pack_day_projection().host_handoff.status,
+ PackDayHostHandoffStatus::Running
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some(
+ next_window_id,
+ ))),
+ Ok(true)
+ );
+ assert_eq!(
+ store.pack_day_projection().query,
+ PackDayScreenQueryState {
+ fulfillment_window_id: Some(next_window_id),
+ }
+ );
+ assert_eq!(
+ store.pack_day_projection().host_handoff,
+ PackDayHostHandoffProjection::default()
+ );
+ }
+
+ #[test]
+ fn changing_pack_day_export_state_clears_stale_host_handoff_state() {
+ let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
+ .expect("in-memory repository should load");
+ let fulfillment_window_id = FulfillmentWindowId::new();
+ let export_request = sample_pack_day_export_request(fulfillment_window_id);
+ let host_handoff_request = sample_pack_day_host_handoff_request(
+ fulfillment_window_id,
+ PackDayHostHandoffKind::RevealBundle,
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::begin_pack_day_export(
+ export_request.clone(),
+ )),
+ Ok(true)
+ );
+ assert_eq!(
+ store.apply(AppStateCommand::begin_pack_day_host_handoff(
+ host_handoff_request,
+ )),
+ Ok(true)
+ );
+ assert_eq!(
+ store.pack_day_projection().host_handoff.status,
+ PackDayHostHandoffStatus::Running
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::succeed_pack_day_export(
+ export_request,
+ sample_pack_day_export_bundle(fulfillment_window_id),
+ )),
+ Ok(true)
+ );
+ assert_eq!(
+ store.pack_day_projection().host_handoff,
+ PackDayHostHandoffProjection::default()
+ );
+ }
+
+ #[test]
+ fn replacing_pack_day_projection_with_new_window_clears_stale_host_handoff_state() {
+ let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
+ .expect("in-memory repository should load");
+ let farm_id = FarmId::new();
+ let current_window_id = FulfillmentWindowId::new();
+ let next_window_id = FulfillmentWindowId::new();
+ let request = sample_pack_day_host_handoff_request(
+ current_window_id,
+ PackDayHostHandoffKind::OpenPackSheet,
+ );
+
+ assert_eq!(
+ store.apply(AppStateCommand::begin_pack_day_host_handoff(request)),
+ Ok(true)
+ );
+
+ let next_pack_day = PackDayProjection {
+ fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary {
+ fulfillment_window_id: next_window_id,
+ farm_id,
+ starts_at: "2026-04-25T16:00:00Z".to_owned(),
+ ends_at: "2026-04-25T19:00:00Z".to_owned(),
+ }),
+ totals_by_product: Vec::new(),
+ pack_list: Vec::new(),
+ pickup_roster: Vec::new(),
+ reminders: ReminderFeedProjection::default(),
+ };
+
+ assert_eq!(
+ store.apply(AppStateCommand::replace_pack_day_projection(
+ next_pack_day.clone(),
+ )),
+ Ok(true)
+ );
+ assert_eq!(store.pack_day_projection().projection, next_pack_day);
+ assert_eq!(
+ store.pack_day_projection().host_handoff,
+ PackDayHostHandoffProjection::default()
+ );
+ }
+
+ #[test]
fn startup_identity_choice_flow_is_explicit_and_in_memory_only() {
let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
.expect("in-memory repository should load");