From 46f3a76f7de2d0d68a01b3831a26b6778deff559 Mon Sep 17 00:00:00 2001 From: binhan Date: Tue, 12 May 2026 16:19:01 +0700 Subject: [PATCH 01/45] test: add reservation lifecycle smoke guard --- mhm/src-tauri/src/services/booking/tests.rs | 209 ++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/mhm/src-tauri/src/services/booking/tests.rs b/mhm/src-tauri/src/services/booking/tests.rs index 170ef6e..0708e86 100644 --- a/mhm/src-tauri/src/services/booking/tests.rs +++ b/mhm/src-tauri/src/services/booking/tests.rs @@ -4508,6 +4508,215 @@ async fn reservation_command_idempotency_same_plain_key_across_commands_scopes_o assert!(origins.contains(&format!("{}:{}", cancel_ctx.command_name, plain_key))); } +#[tokio::test] +async fn reservation_lifecycle_smoke_covers_confirm_and_cancel_paths() { + let pool = test_pool().await; + seed_room(&pool, "R-SMOKE-CONFIRM") + .await + .expect("seed confirm room"); + seed_room(&pool, "R-SMOKE-CANCEL") + .await + .expect("seed cancel room"); + seed_pricing_rule(&pool, "standard", 600_000) + .await + .expect("seed pricing"); + + let today = Local::now().date_naive(); + let reservation_request = |room_id: &str, start_offset_days: i64| { + let check_in = today + Duration::days(start_offset_days); + let check_out = check_in + Duration::days(2); + CreateReservationRequest { + room_id: room_id.to_string(), + guest_name: format!("Smoke Guest {room_id}"), + guest_phone: Some("0900000137".to_string()), + guest_doc_number: Some(format!("DOC-{room_id}")), + check_in_date: check_in.format("%Y-%m-%d").to_string(), + check_out_date: check_out.format("%Y-%m-%d").to_string(), + nights: 2, + deposit_amount: Some(50_000), + source: Some("phone".to_string()), + notes: Some("reservation smoke".to_string()), + } + }; + + let create_confirm_ctx = crate::command_idempotency::WriteCommandContext::for_internal_test( + "req-smoke-reservation-create-confirm", + "idem-smoke-reservation-create-confirm", + "create_reservation", + ); + let created_for_confirm = reservation_lifecycle::create_reservation_idempotent( + &pool, + &create_confirm_ctx, + reservation_request("R-SMOKE-CONFIRM", 0), + ) + .await + .expect("reservation create succeeds for confirm branch"); + let confirm_booking_id = created_for_confirm.response["id"] + .as_str() + .expect("created reservation id") + .to_string(); + + assert_eq!( + created_for_confirm.response["status"], + serde_json::json!("booked") + ); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM rooms WHERE id = ?") + .bind("R-SMOKE-CONFIRM") + .fetch_one(&pool) + .await + .expect("confirm room status after create"), + "vacant" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM room_calendar WHERE booking_id = ? AND status = 'booked'", + ) + .bind(&confirm_booking_id) + .fetch_one(&pool) + .await + .expect("booked calendar rows after create"), + 2 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE booking_id = ? AND type = 'deposit'", + ) + .bind(&confirm_booking_id) + .fetch_one(&pool) + .await + .expect("reservation deposit total"), + 50_000 + ); + assert_single_outbox_event(&pool, &create_confirm_ctx, "booking.reservation_created").await; + + let confirm_ctx = crate::command_idempotency::WriteCommandContext::for_internal_test( + "req-smoke-reservation-confirm", + "idem-smoke-reservation-confirm", + "confirm_reservation", + ); + let confirmed = reservation_lifecycle::confirm_reservation_idempotent( + &pool, + &confirm_ctx, + &confirm_booking_id, + ) + .await + .expect("reservation confirm succeeds"); + let confirmed_nights = confirmed.response["nights"] + .as_i64() + .expect("confirmed reservation nights"); + let confirmed_total_price = confirmed.response["total_price"] + .as_i64() + .expect("confirmed reservation total price"); + + assert_eq!(confirmed.response["status"], serde_json::json!("active")); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM bookings WHERE id = ?") + .bind(&confirm_booking_id) + .fetch_one(&pool) + .await + .expect("confirmed booking status"), + "active" + ); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM rooms WHERE id = ?") + .bind("R-SMOKE-CONFIRM") + .fetch_one(&pool) + .await + .expect("confirm room status"), + "occupied" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM room_calendar WHERE booking_id = ? AND status = 'occupied'", + ) + .bind(&confirm_booking_id) + .fetch_one(&pool) + .await + .expect("occupied calendar rows after confirm"), + confirmed_nights + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE booking_id = ? AND type = 'charge'", + ) + .bind(&confirm_booking_id) + .fetch_one(&pool) + .await + .expect("reservation room charge total"), + confirmed_total_price + ); + assert_single_outbox_event(&pool, &confirm_ctx, "booking.reservation_confirmed").await; + + let create_cancel_ctx = crate::command_idempotency::WriteCommandContext::for_internal_test( + "req-smoke-reservation-create-cancel", + "idem-smoke-reservation-create-cancel", + "create_reservation", + ); + let created_for_cancel = reservation_lifecycle::create_reservation_idempotent( + &pool, + &create_cancel_ctx, + reservation_request("R-SMOKE-CANCEL", 0), + ) + .await + .expect("reservation create succeeds for cancel branch"); + let cancel_booking_id = created_for_cancel.response["id"] + .as_str() + .expect("created cancel reservation id") + .to_string(); + assert_single_outbox_event(&pool, &create_cancel_ctx, "booking.reservation_created").await; + + let cancel_ctx = crate::command_idempotency::WriteCommandContext::for_internal_test( + "req-smoke-reservation-cancel", + "idem-smoke-reservation-cancel", + "cancel_reservation", + ); + let cancelled = reservation_lifecycle::cancel_reservation_idempotent( + &pool, + &cancel_ctx, + &cancel_booking_id, + ) + .await + .expect("reservation cancel succeeds"); + + assert_eq!(cancelled.response["ok"], serde_json::json!(true)); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM bookings WHERE id = ?") + .bind(&cancel_booking_id) + .fetch_one(&pool) + .await + .expect("cancelled booking status"), + "cancelled" + ); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM rooms WHERE id = ?") + .bind("R-SMOKE-CANCEL") + .fetch_one(&pool) + .await + .expect("cancel room status"), + "vacant" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM room_calendar WHERE booking_id = ?") + .bind(&cancel_booking_id) + .fetch_one(&pool) + .await + .expect("cancelled calendar rows"), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE booking_id = ? AND type = 'cancellation_fee'", + ) + .bind(&cancel_booking_id) + .fetch_one(&pool) + .await + .expect("cancellation fee total"), + 50_000 + ); + assert_single_outbox_event(&pool, &cancel_ctx, "booking.reservation_cancelled").await; +} + #[tokio::test] async fn cancel_reservation_releases_calendar_and_keeps_fee_record() { let pool = test_pool().await; From 5289bc74ebbab95b97c94b92b1cb2c885eaabb03 Mon Sep 17 00:00:00 2001 From: binhan Date: Tue, 12 May 2026 16:49:59 +0700 Subject: [PATCH 02/45] test: add stay lifecycle smoke guard --- mhm/src-tauri/src/services/booking/tests.rs | 198 ++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/mhm/src-tauri/src/services/booking/tests.rs b/mhm/src-tauri/src/services/booking/tests.rs index 0708e86..745e4e2 100644 --- a/mhm/src-tauri/src/services/booking/tests.rs +++ b/mhm/src-tauri/src/services/booking/tests.rs @@ -5327,6 +5327,204 @@ async fn check_in_posts_charge_and_marks_room_occupied() { assert_eq!(calendar_days.0, 2); } +#[tokio::test] +async fn stay_lifecycle_smoke_covers_checkin_extend_and_checkout() { + let pool = test_pool().await; + seed_room(&pool, "R-SMOKE-STAY") + .await + .expect("seed stay room"); + seed_pricing_rule(&pool, "standard", 250_000) + .await + .expect("seed stay pricing"); + + let check_in_ctx = crate::command_idempotency::WriteCommandContext::for_internal_test( + "req-smoke-stay-checkin", + "idem-smoke-stay-checkin", + "check_in", + ); + let mut check_in_req = minimal_checkin_request("R-SMOKE-STAY"); + check_in_req.paid_amount = Some(50_000); + + let checked_in = stay_lifecycle::check_in_idempotent( + &pool, + &check_in_ctx, + check_in_req, + Some("user-smoke".to_string()), + ) + .await + .expect("stay check-in succeeds"); + let booking_id = checked_in.response["id"] + .as_str() + .expect("checked-in booking id") + .to_string(); + let initial_expected_checkout = checked_in.response["expected_checkout"] + .as_str() + .expect("initial expected checkout") + .to_string(); + + assert_eq!(checked_in.response["status"], serde_json::json!("active")); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM bookings WHERE id = ?") + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("active booking status"), + "active" + ); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM rooms WHERE id = ?") + .bind("R-SMOKE-STAY") + .fetch_one(&pool) + .await + .expect("occupied stay room"), + "occupied" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM room_calendar WHERE booking_id = ? AND status = 'occupied'", + ) + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("occupied calendar rows after check-in"), + 2 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE booking_id = ? AND type = 'charge'", + ) + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("check-in charge total"), + 500_000 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE booking_id = ? AND type = 'payment' AND note = 'Thanh toán khi check-in'", + ) + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("check-in payment total"), + 50_000 + ); + assert_single_outbox_event(&pool, &check_in_ctx, "booking.checked_in").await; + + let extend_ctx = crate::command_idempotency::WriteCommandContext::for_internal_test( + "req-smoke-stay-extend", + "idem-smoke-stay-extend", + "extend_stay", + ); + let extended = stay_lifecycle::extend_stay_idempotent(&pool, &extend_ctx, &booking_id) + .await + .expect("stay extend succeeds"); + let extended_expected_checkout = extended.response["expected_checkout"] + .as_str() + .expect("extended expected checkout"); + let initial_checkout = chrono::DateTime::parse_from_rfc3339(&initial_expected_checkout) + .expect("initial checkout parses"); + let extended_checkout = chrono::DateTime::parse_from_rfc3339(extended_expected_checkout) + .expect("extended checkout parses"); + + assert_eq!(extended.response["nights"], serde_json::json!(3)); + assert_eq!(extended.response["total_price"], serde_json::json!(750_000)); + assert_eq!(extended_checkout, initial_checkout + Duration::days(1)); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM room_calendar WHERE booking_id = ? AND status = 'occupied'", + ) + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("occupied calendar rows after extend"), + 3 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE booking_id = ? AND type = 'charge' AND note = 'Extended stay +1 night'", + ) + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("extension charge total"), + 250_000 + ); + assert_single_outbox_event(&pool, &extend_ctx, "booking.stay_extended").await; + + let check_out_ctx = crate::command_idempotency::WriteCommandContext::for_internal_test( + "req-smoke-stay-checkout", + "idem-smoke-stay-checkout", + "check_out", + ); + let checked_out = stay_lifecycle::check_out_idempotent( + &pool, + &check_out_ctx, + CheckOutRequest { + booking_id: booking_id.clone(), + settlement_mode: CheckoutSettlementMode::BookedNights, + final_total: 750_000, + }, + ) + .await + .expect("stay checkout succeeds"); + + assert_eq!(checked_out.response["ok"], serde_json::json!(true)); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM bookings WHERE id = ?") + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("checked-out booking status"), + "checked_out" + ); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM rooms WHERE id = ?") + .bind("R-SMOKE-STAY") + .fetch_one(&pool) + .await + .expect("room status after checkout"), + "cleaning" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM housekeeping WHERE room_id = ? AND status = 'needs_cleaning'", + ) + .bind("R-SMOKE-STAY") + .fetch_one(&pool) + .await + .expect("checkout housekeeping task"), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM room_calendar WHERE booking_id = ?") + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("calendar rows removed after checkout"), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE booking_id = ? AND type = 'payment' AND note = 'Thanh toán khi check-out'", + ) + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("checkout payment delta"), + 700_000 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT paid_amount FROM bookings WHERE id = ?") + .bind(&booking_id) + .fetch_one(&pool) + .await + .expect("paid amount after checkout"), + 750_000 + ); + assert_single_outbox_event(&pool, &check_out_ctx, "booking.checked_out").await; +} + #[tokio::test] async fn check_in_idempotent_retry_replays_and_does_not_duplicate_rows() { let pool = test_pool().await; From 5cd0e28bbb8405346cad8fa4cefeee3dd9c22548 Mon Sep 17 00:00:00 2001 From: binhan Date: Wed, 13 May 2026 08:36:44 +0700 Subject: [PATCH 03/45] docs: design frontend shell composition --- ...05-13-frontend-shell-composition-design.md | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-frontend-shell-composition-design.md diff --git a/docs/superpowers/specs/2026-05-13-frontend-shell-composition-design.md b/docs/superpowers/specs/2026-05-13-frontend-shell-composition-design.md new file mode 100644 index 0000000..efeae10 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-frontend-shell-composition-design.md @@ -0,0 +1,191 @@ +# Frontend Shell Composition Design + +Date: 2026-05-13 + +Issues: #138, #139 + +Planned PR title: `frontend: reduce App shell to composition` + +## Goal + +Reduce `mhm/src/App.tsx` to top-level composition by extracting runtime listeners and shell gates into named frontend units. The refactor should close both #138 and #139 without changing routing, auth behavior, store shape, backend command names, or PMS command semantics. + +This work intentionally excludes #140. It must not normalize raw `invoke` usage or introduce a broader command wrapper migration. + +## Scope + +In scope: + +- Extract runtime listeners from `App.tsx`. +- Extract bootstrap loading/onboarding behavior from `App.tsx`. +- Extract locked-app auth gating from `App.tsx`. +- Hide gateway/MCP shell UI and MCP toast listener from the normal app profile. +- Hide gateway/MCP settings entry/panel from the normal app profile if it remains reachable through the normal settings UI. +- Add a Vite build flag for experimental gateway/MCP UI, defaulting off. +- Preserve existing backup, crash reporting, app update, DB refresh, routing, and sheet behavior. + +Out of scope: + +- React Router changes. +- Auth store or hotel store shape changes. +- Backend command, payload, or PMS write semantics changes. +- Gateway/MCP backend changes. +- `invokeCommand` or raw `invoke` cleanup from #140. +- Visual redesign of the shell. + +## Architecture + +`App.tsx` becomes a composition root. It should wire providers and named units, not directly own bootstrap branches, auth branches, event listeners, gateway status calls, backup state machines, or crash recovery handlers. + +The intended structure is: + +```tsx + + {({ shellReady }) => ( + + {(appUpdate) => ( + + + + {(runtimeState) => ( + + )} + + + + )} + + )} + +``` + +The exact component names may vary during implementation, but the boundary should stay intact: gates decide whether the shell can render, runtime units own side effects, and the shell renders UI. + +## Components + +### `BootstrapGate` + +Owns the initial `get_bootstrap_status` command, loading state, bootstrap status state, and `hydrateFromBootstrap`. It renders: + +- loading screen while bootstrap status is pending, +- `OnboardingWizard` when setup is incomplete, +- children when setup is complete. + +Onboarding completion updates local bootstrap state and hydrates the current user, matching current behavior. + +### `AuthGate` + +Owns the locked-mode session check and login fallback. When `bootstrap.app_lock_enabled` is true, it calls `checkSession()` and renders `LoginScreen` until authenticated. When app lock is disabled, it renders children as today. + +This component does not change auth store state shape or login behavior. + +### `AppUpdateRuntime` + +Owns the `useAppUpdateController` call and the one-time silent update check after the shell is ready. This keeps update runtime behavior out of `App.tsx` while preserving the existing `AppUpdateProvider` contract for descendants. + +### `RuntimeListeners` + +Owns runtime side effects after gates clear: + +- `db-updated` listener refreshes rooms and stats. +- `backup-status` listener drives backup indicator and backup failure alert state. +- crash recovery checks pending reports once and exposes send, dismiss, and export handlers. +- gateway status checks run only when experimental gateway UI is enabled. +- `mcp_reservation_created` listener and toast run only when experimental gateway UI is enabled. + +Backup and crash reporting are normal profile runtime features, not experimental features. + +### `MainShell` + +Owns shell rendering only: + +- sidebar navigation, +- header, +- active page switch, +- user badge/logout, +- app update badge and restart modal, +- backup status indicator and failure alert, +- crash report prompt, +- check-in sheets, +- toaster. + +`MainShell` must not call `listen()` or runtime `invoke()` directly. + +### `runtimeProfile` + +Adds a small frontend profile module backed by a Vite constant: + +- `__EXPERIMENTAL_GATEWAY_UI__` +- default value: `false` +- enabling env: `CAPYINN_EXPERIMENTAL_GATEWAY_UI=1|true|yes|on` + +When false, the normal shell shows no gateway/MCP badge, does not call `gateway_get_status`, does not subscribe to `mcp_reservation_created`, and does not expose a gateway/MCP settings entry or panel. + +## Data Flow + +Bootstrap data flows from `BootstrapGate` into the gate tree. Auth readiness is still derived from the existing auth store and bootstrap status. Shell readiness remains equivalent to the current expression: + +```ts +!bootstrapLoading && +Boolean(bootstrap?.setup_completed) && +(!bootstrap?.app_lock_enabled || isAuthenticated) +``` + +Runtime state flows from `RuntimeListeners` to `MainShell` through a small render-prop or context boundary. The state should include only UI-facing runtime data and handlers, such as backup status, visible backup failure, crash prompt state, crash handlers, and optional gateway status. + +Page navigation remains the existing `useHotelStore().activeTab` switch. No route model changes are introduced. + +## Error Handling + +Diagnostics lookups continue to be non-blocking. Crash recovery failures must not prevent shell rendering. + +Backup failure behavior remains unchanged: failed jobs show a toast, display failed status, and show a persistent alert until dismissed or cleared by queue drain. + +Gateway status lookup failures remain non-fatal and should only set the optional gateway UI status to off when experimental UI is enabled. + +## Testing + +Validation for the implementation should include: + +```bash +cd mhm && npm test +cd mhm && npm run build +rg -n "gateway_get_status|mcp_reservation_created|backup-status|get_pending_crash_report" mhm/src/App.tsx +wc -l mhm/src/App.tsx +``` + +Expected results: + +- tests pass, +- build passes, +- `App.tsx` has no direct runtime listener setup, +- any remaining matches in `App.tsx` are limited to imported component names or explicit composition props, not event or command ownership, +- `App.tsx` is materially shorter and reads as composition. + +Focused test updates should cover: + +- app update flow still waits for shell readiness, +- backup status integration behavior remains unchanged, +- crash prompt behavior remains unchanged, +- onboarding and locked-login gates still render in the same conditions, +- normal profile hides gateway/MCP UI and does not call `gateway_get_status`, +- normal profile does not expose the gateway/MCP settings entry or panel, +- experimental profile can show gateway/MCP UI when the build flag is enabled. + +## Acceptance Criteria + +- `App.tsx` is a composition root, not a runtime controller. +- Bootstrap and auth/onboarding gates live in named units. +- Runtime listeners live outside `App.tsx`. +- Normal profile shows no gateway/MCP badge, toast, settings panel, or settings entry by default. +- Experimental gateway/MCP UI appears only behind the explicit Vite build flag. +- Listener behavior is preserved where still enabled. +- No #140 invoke-wrapper migration is included. +- Validation commands pass. From e1726eb232fd5631cefa85f6021a06ea2b3920c1 Mon Sep 17 00:00:00 2001 From: binhan Date: Wed, 13 May 2026 08:46:18 +0700 Subject: [PATCH 04/45] docs: clarify frontend shell composition design --- ...05-13-frontend-shell-composition-design.md | 89 ++++++++++--------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/docs/superpowers/specs/2026-05-13-frontend-shell-composition-design.md b/docs/superpowers/specs/2026-05-13-frontend-shell-composition-design.md index efeae10..8c5282c 100644 --- a/docs/superpowers/specs/2026-05-13-frontend-shell-composition-design.md +++ b/docs/superpowers/specs/2026-05-13-frontend-shell-composition-design.md @@ -20,7 +20,7 @@ In scope: - Extract bootstrap loading/onboarding behavior from `App.tsx`. - Extract locked-app auth gating from `App.tsx`. - Hide gateway/MCP shell UI and MCP toast listener from the normal app profile. -- Hide gateway/MCP settings entry/panel from the normal app profile if it remains reachable through the normal settings UI. +- Hide gateway/MCP settings entry/panel from the normal app profile. - Add a Vite build flag for experimental gateway/MCP UI, defaulting off. - Preserve existing backup, crash reporting, app update, DB refresh, routing, and sheet behavior. @@ -40,39 +40,30 @@ Out of scope: The intended structure is: ```tsx - - {({ shellReady }) => ( - - {(appUpdate) => ( - - - - {(runtimeState) => ( - - )} - + + + + + {({ bootstrap }) => ( + + - - )} - - )} - + )} + + + + ``` The exact component names may vary during implementation, but the boundary should stay intact: gates decide whether the shell can render, runtime units own side effects, and the shell renders UI. ## Components -### `BootstrapGate` +### `BootstrapStateProvider` and `BootstrapGate` -Owns the initial `get_bootstrap_status` command, loading state, bootstrap status state, and `hydrateFromBootstrap`. It renders: +The bootstrap boundary owns the initial `get_bootstrap_status` command, loading state, bootstrap status state, and `hydrateFromBootstrap`. + +`BootstrapGate` renders: - loading screen while bootstrap status is pending, - `OnboardingWizard` when setup is incomplete, @@ -82,23 +73,28 @@ Onboarding completion updates local bootstrap state and hydrates the current use ### `AuthGate` -Owns the locked-mode session check and login fallback. When `bootstrap.app_lock_enabled` is true, it calls `checkSession()` and renders `LoginScreen` until authenticated. When app lock is disabled, it renders children as today. +Owns the locked-mode session check and login fallback. It receives bootstrap status from the bootstrap boundary through props or context; it must not refetch bootstrap status. When `bootstrap.app_lock_enabled` is true, it calls `checkSession()` and renders `LoginScreen` until authenticated. When app lock is disabled, it renders children as today. This component does not change auth store state shape or login behavior. ### `AppUpdateRuntime` -Owns the `useAppUpdateController` call and the one-time silent update check after the shell is ready. This keeps update runtime behavior out of `App.tsx` while preserving the existing `AppUpdateProvider` contract for descendants. +Owns the `useAppUpdateController` call and the one-time silent update check after the shell is ready. It consumes shell readiness from the bootstrap boundary and wraps descendants with the existing `AppUpdateProvider`, so `MainShell` and settings descendants can continue to use the app update context. This keeps update runtime behavior out of `App.tsx` while preserving the existing provider contract. + +### `RuntimeStateProvider` -### `RuntimeListeners` +Owns runtime side effects and exposes UI-facing runtime state to `MainShell` through context or a render-prop boundary. It may mount before the shell gate renders so listener enablement must be controlled per listener, not by a single broad `shellReady` flag. -Owns runtime side effects after gates clear: +Per-listener enablement is the source of truth: -- `db-updated` listener refreshes rooms and stats. -- `backup-status` listener drives backup indicator and backup failure alert state. -- crash recovery checks pending reports once and exposes send, dismiss, and export handlers. -- gateway status checks run only when experimental gateway UI is enabled. -- `mcp_reservation_created` listener and toast run only when experimental gateway UI is enabled. +| Runtime concern | Enable condition | Reason | +| --- | --- | --- | +| `backup-status` listener | immediately while the app root is mounted | Preserves current unconditional listener behavior; the shell may render the indicator later, but events should still update runtime state. | +| `db-updated` listener | `isAuthenticated` | Matches current behavior and avoids store refresh work before an authenticated session exists. | +| crash recovery lookup and prompt state | `shellReady`, once per app mount | Matches current behavior; diagnostics must not block loading, onboarding, or login gates. | +| app update silent check | `shellReady`, once per app mount | Matches current behavior and keeps update checks out of pre-shell gates. | +| `gateway_get_status` | `isAuthenticated && experimentalGatewayUi` | Preserves the existing authenticated check when enabled while hiding gateway status from the normal profile. | +| `mcp_reservation_created` listener and toast | `isAuthenticated && experimentalGatewayUi` | Preserves the existing authenticated listener when enabled while hiding MCP toasts from the normal profile. | Backup and crash reporting are normal profile runtime features, not experimental features. @@ -110,13 +106,14 @@ Owns shell rendering only: - header, - active page switch, - user badge/logout, +- sidebar collapsed state, localStorage persistence, and resize handling, either directly or through a shell-local hook such as `useSidebarCollapse`, - app update badge and restart modal, - backup status indicator and failure alert, - crash report prompt, - check-in sheets, - toaster. -`MainShell` must not call `listen()` or runtime `invoke()` directly. +`MainShell` must not call `listen()` or runtime `invoke()` directly. UI-local effects such as sidebar resize handling are allowed in `MainShell` or its local hooks, but not in `App.tsx`. ### `runtimeProfile` @@ -126,11 +123,17 @@ Adds a small frontend profile module backed by a Vite constant: - default value: `false` - enabling env: `CAPYINN_EXPERIMENTAL_GATEWAY_UI=1|true|yes|on` +Implementation must update every existing global-define surface: + +- `mhm/vite.config.ts` +- `mhm/vitest.config.ts` +- `mhm/src/vite-env.d.ts` + When false, the normal shell shows no gateway/MCP badge, does not call `gateway_get_status`, does not subscribe to `mcp_reservation_created`, and does not expose a gateway/MCP settings entry or panel. ## Data Flow -Bootstrap data flows from `BootstrapGate` into the gate tree. Auth readiness is still derived from the existing auth store and bootstrap status. Shell readiness remains equivalent to the current expression: +Bootstrap data flows from the bootstrap boundary into `BootstrapGate`, `AuthGate`, `RuntimeStateProvider`, and `AppUpdateRuntime` through props or context. `AuthGate` consumes that existing bootstrap state; it must not issue a second bootstrap fetch. Auth readiness is still derived from the existing auth store and bootstrap status. Shell readiness remains equivalent to the current expression: ```ts !bootstrapLoading && @@ -138,7 +141,7 @@ Boolean(bootstrap?.setup_completed) && (!bootstrap?.app_lock_enabled || isAuthenticated) ``` -Runtime state flows from `RuntimeListeners` to `MainShell` through a small render-prop or context boundary. The state should include only UI-facing runtime data and handlers, such as backup status, visible backup failure, crash prompt state, crash handlers, and optional gateway status. +Runtime state flows from `RuntimeStateProvider` to `MainShell` through a small render-prop or context boundary. The state should include only UI-facing runtime data and handlers, such as backup status, visible backup failure, crash prompt state, crash handlers, and optional gateway status. Page navigation remains the existing `useHotelStore().activeTab` switch. No route model changes are introduced. @@ -158,6 +161,7 @@ Validation for the implementation should include: cd mhm && npm test cd mhm && npm run build rg -n "gateway_get_status|mcp_reservation_created|backup-status|get_pending_crash_report" mhm/src/App.tsx +rg -n "useEffect|useState|useRef|listen\\(|invoke\\(|localStorage|addEventListener" mhm/src/App.tsx wc -l mhm/src/App.tsx ``` @@ -166,7 +170,8 @@ Expected results: - tests pass, - build passes, - `App.tsx` has no direct runtime listener setup, -- any remaining matches in `App.tsx` are limited to imported component names or explicit composition props, not event or command ownership, +- `App.tsx` has no hook-owned runtime or UI controller logic, +- any remaining runtime-token matches in `App.tsx` are limited to imported component names or explicit composition props, not event or command ownership, - `App.tsx` is materially shorter and reads as composition. Focused test updates should cover: @@ -177,13 +182,17 @@ Focused test updates should cover: - onboarding and locked-login gates still render in the same conditions, - normal profile hides gateway/MCP UI and does not call `gateway_get_status`, - normal profile does not expose the gateway/MCP settings entry or panel, -- experimental profile can show gateway/MCP UI when the build flag is enabled. +- experimental profile can show gateway/MCP UI when the build flag is enabled, +- settings tests that currently navigate to `MCP Gateway` are updated to assert the normal hidden state and a separate experimental visible path, +- sidebar collapse persistence and resize behavior still work after moving UI-local effects out of `App.tsx`. ## Acceptance Criteria - `App.tsx` is a composition root, not a runtime controller. - Bootstrap and auth/onboarding gates live in named units. - Runtime listeners live outside `App.tsx`. +- Sidebar collapse localStorage and resize behavior live outside `App.tsx`. +- Runtime listener enablement follows the per-listener table above. - Normal profile shows no gateway/MCP badge, toast, settings panel, or settings entry by default. - Experimental gateway/MCP UI appears only behind the explicit Vite build flag. - Listener behavior is preserved where still enabled. From 1de4ed32250a9bce3b6ff699427364f18ffa1ea0 Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 08:21:57 +0700 Subject: [PATCH 05/45] docs: add frontend invoke wrapper design --- ...26-05-14-frontend-invoke-wrapper-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md diff --git a/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md b/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md new file mode 100644 index 0000000..9432d81 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md @@ -0,0 +1,187 @@ +# Frontend Invoke Wrapper Design + +Date: 2026-05-14 + +Issue: #140 + +Planned PR title: `frontend: normalize PMS invoke wrapper usage` + +## Goal + +Normalize obvious frontend PMS write calls through the existing Tauri invocation wrapper without changing backend command names, business request payloads, response shapes, or frontend store architecture. + +The work should make raw `invoke` usage easier to audit. PMS business writes should go through `invokeWriteCommand` when practical. Raw `invoke` may remain for system, runtime, export, diagnostics, gateway, bootstrap, and other non-PMS or low-risk calls when there is a clear reason. + +## Scope + +In scope: + +- Convert clear raw frontend PMS writes to `invokeWriteCommand`. +- Preserve business payload fields and command names at each converted call site. +- Keep `invokeWriteCommand` as the canonical place that adds `idempotencyKey`, optional `correlationId`, app-error normalization, and monitored command failure capture. +- Add lightweight guard coverage or documentation so remaining raw `invoke` calls are intentional and explainable. +- Update focused frontend tests for converted call sites where the existing test surface can verify wrapper usage and payload shape. +- Validate with `npm test`, `npm run build`, and an `rg` scan for remaining raw invokes. + +Out of scope: + +- Backend command renames. +- Backend request or response schema changes. +- Zustand or page architecture rewrites. +- A full migration of every read command. +- A full migration of writes that already use `invokeCommand` but may not safely accept an added idempotency field yet. +- Runtime/system invoke cleanup for crash reporting, gateway, bootstrap, update, backup, export, or diagnostics flows. + +## Current State + +`mhm/src/lib/invokeCommand.ts` already provides the intended boundary: + +- `invokeCommand` wraps Tauri `invoke`, merges an optional `correlationId`, normalizes app errors, and sends command failure monitoring for monitored commands. +- `invokeWriteCommand` calls `invokeCommand` after adding a command-scoped `idempotencyKey`. +- `createIdempotencyKey` formats keys as `command:`. + +Several frontend PMS writes already use `invokeWriteCommand`, including reservation confirmation/cancel, reservation create/modify, check-in, check-out, group checkout, group services, invoice generation, and CEO agent settings. + +The remaining raw `invoke` usage contains a mix of reads, system/runtime calls, exports, diagnostics, gateway calls, and a small number of obvious PMS writes. + +## Invocation Categories + +### PMS business writes + +PMS business writes are commands that mutate hotel operational state or persistent settings exposed as PMS configuration. These should use `invokeWriteCommand` when the backend command can safely accept the wrapper's added `idempotencyKey`. + +Batch 1 candidates: + +- `save_pricing_rule` in `mhm/src/pages/settings/PricingSection.tsx`. +- `save_settings` for `checkin_rules` in `mhm/src/pages/settings/CheckinRulesSection.tsx`. +- `save_settings` for `hotel_info` in `mhm/src/pages/settings/HotelInfoSection.tsx`. +- `update_housekeeping` in `mhm/src/stores/useHotelStore.ts`. + +These calls currently use raw `invoke` and are direct writes. The implementation must keep their existing business payload fields intact. + +### Read calls + +Read calls may remain raw `invoke` in this batch unless the file being changed benefits from using `invokeCommand` for local consistency. This avoids turning #140 Batch 1 into a broad frontend cleanup. + +Examples that can stay raw in this batch include dashboard reads, analytics reads, guest searches, availability checks, room detail reads, and other command calls that only retrieve data. + +### System and runtime calls + +System/runtime calls may remain raw `invoke` because they are not PMS business writes and often sit below or beside the business command boundary. + +Allowed examples include: + +- crash reporting lifecycle commands, +- JavaScript crash recording, +- pending crash report export and submission state, +- gateway status and key generation, +- bootstrap status, +- backup and CSV export commands, +- update/runtime support commands. + +This batch should make the reason for these remaining raw invokes clear through focused tests, a small classification helper used by tests, or concise local documentation. It should not force these calls through `invokeWriteCommand`. + +## Architecture + +The existing wrapper remains the only frontend abstraction: + +```ts +await invokeWriteCommand(commandName, businessArgs, options); +``` + +The implementation should not introduce a second command client, a command registry, or generated command API. The issue is a normalization pass, not a new architecture layer. + +Converted call sites should follow the existing local style: + +- import `invokeWriteCommand` from `@/lib/invokeCommand`; +- keep the command string unchanged; +- keep the business argument object unchanged except for the wrapper-added metadata; +- keep UI refresh and toast behavior in the same order; +- use `formatAppError(error)` when the edited file already uses app-error formatting or when the conversion makes normalized errors available without broad UI behavior changes. + +## Data Flow + +For converted writes, the data flow is: + +1. UI/store validates or prepares the same business fields it already sends today. +2. UI/store calls `invokeWriteCommand`. +3. `invokeWriteCommand` adds `idempotencyKey`. +4. `invokeCommand` optionally adds `correlationId`. +5. Tauri receives the same command name and the original business fields plus wrapper metadata. +6. Success handling, refreshes, and toasts continue as before. +7. Errors are normalized through `normalizeAppError` and thrown as `AppError` exceptions. + +No caller should manually create an idempotency key for the converted Batch 1 calls. Manual `createIdempotencyKey` usage should be left alone unless it is part of an explicitly converted call. + +## Error Handling + +Converted writes should route backend and Tauri failures through `invokeCommand` error normalization. This gives callers a normalized exception with: + +- app error code, +- message, +- kind, +- optional support id, +- optional correlation id, +- original cause. + +UI behavior should stay close to today. If a component currently displays a simple generic error toast and the conversion enables `formatAppError`, the implementation may improve that one local toast without changing surrounding flows. + +Command failure monitoring remains limited to commands listed in `mhm/src/lib/crashReporting/commandFailure.ts`. Batch 1 does not expand the monitored command list unless a converted command already has monitoring requirements in existing code. + +## GitNexus Guardrails + +Before editing any function, class, or method, implementation must run GitNexus impact analysis for the target symbol and report: + +- direct callers, +- affected processes, +- risk level. + +If impact analysis reports HIGH or CRITICAL risk, implementation must pause and warn before editing. + +Before committing implementation changes, implementation must run `gitnexus_detect_changes()` to verify the affected symbols and flows match the planned scope. + +## Testing + +Focused test updates should verify the converted write paths at the wrapper boundary where practical: + +- converted calls still send the same business fields, +- converted calls include a command-scoped `idempotencyKey`, +- existing success refresh/toast behavior remains intact, +- normalized errors are displayed through existing UI error paths where tests cover them. + +Validation commands: + +```bash +cd mhm && npm test +cd mhm && npm run build +rg -n "invoke<|invoke\\(" mhm/src +``` + +Expected raw invoke scan result: + +- no remaining raw `invoke` for the Batch 1 PMS write candidates, +- remaining raw invokes are reads or documented system/runtime/export/diagnostics/gateway/bootstrap calls, +- `mhm/src/lib/invokeCommand.ts` remains the low-level wrapper that directly calls Tauri `invoke`. + +## Acceptance Criteria + +- `save_pricing_rule`, settings saves for hotel info and check-in rules, and housekeeping updates no longer use raw frontend `invoke` if backend argument handling is compatible. +- Converted write payloads preserve their existing business fields. +- Raw `invoke` remains only where it is read-only or intentionally system/runtime/export/diagnostics/gateway/bootstrap oriented. +- No backend command names, backend payload semantics, response shapes, or Zustand architecture are changed. +- Tests and build pass. +- The final `rg` scan is reviewed and remaining raw invoke usage is explainable. + +## Risks And Mitigations + +Risk: adding `idempotencyKey` to a backend command that does not accept extra arguments could break the call. + +Mitigation: confirm compatibility before conversion. If a command cannot safely accept wrapper metadata without backend changes, leave it raw in Batch 1 and document it as a follow-up instead of expanding scope. + +Risk: converting too many call sites turns a narrow refactor into an architecture sweep. + +Mitigation: limit Batch 1 to obvious raw PMS writes and lightweight guard coverage. Defer writes currently using `invokeCommand` to a later batch. + +Risk: raw invoke scan still shows many matches. + +Mitigation: judge the scan by category, not by zero matches. Reads and system/runtime calls may remain raw under this design. From 1c50d21c2f4ea3943a8cba1e50f8021819f204f7 Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 08:26:31 +0700 Subject: [PATCH 06/45] docs: clarify frontend invoke wrapper spec --- ...26-05-14-frontend-invoke-wrapper-design.md | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md b/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md index 9432d81..edb293f 100644 --- a/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md +++ b/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md @@ -8,10 +8,12 @@ Planned PR title: `frontend: normalize PMS invoke wrapper usage` ## Goal -Normalize obvious frontend PMS write calls through the existing Tauri invocation wrapper without changing backend command names, business request payloads, response shapes, or frontend store architecture. +Normalize obvious frontend PMS write calls through the existing Tauri invocation wrapper without changing backend command names, business request fields, response shapes, or frontend store architecture. The work should make raw `invoke` usage easier to audit. PMS business writes should go through `invokeWriteCommand` when practical. Raw `invoke` may remain for system, runtime, export, diagnostics, gateway, bootstrap, and other non-PMS or low-risk calls when there is a clear reason. +Issue #140 says not to change request/response payloads. This spec interprets that as: do not change business request fields, command names, response shapes, or UI behavior. Converted writes may add only the wrapper metadata that `invokeWriteCommand` already owns, currently `idempotencyKey` and optional `correlationId`, and only after the compatibility checks below show the command can tolerate that metadata. If a command cannot tolerate wrapper metadata, it must stay raw in Batch 1 and be recorded as a follow-up rather than forcing a backend change into this frontend batch. + ## Scope In scope: @@ -19,7 +21,7 @@ In scope: - Convert clear raw frontend PMS writes to `invokeWriteCommand`. - Preserve business payload fields and command names at each converted call site. - Keep `invokeWriteCommand` as the canonical place that adds `idempotencyKey`, optional `correlationId`, app-error normalization, and monitored command failure capture. -- Add lightweight guard coverage or documentation so remaining raw `invoke` calls are intentional and explainable. +- Add a static frontend guardrail test so remaining raw `invoke` calls are intentional and explainable. - Update focused frontend tests for converted call sites where the existing test surface can verify wrapper usage and payload shape. - Validate with `npm test`, `npm run build`, and an `rg` scan for remaining raw invokes. @@ -42,8 +44,16 @@ Out of scope: Several frontend PMS writes already use `invokeWriteCommand`, including reservation confirmation/cancel, reservation create/modify, check-in, check-out, group checkout, group services, invoice generation, and CEO agent settings. +That list is descriptive, not scope expansion. Existing wrapper users such as CEO agent settings are not Batch 1 candidates unless they are already touched by the explicit tasks below. + The remaining raw `invoke` usage contains a mix of reads, system/runtime calls, exports, diagnostics, gateway calls, and a small number of obvious PMS writes. +## PMS Safety Boundary + +The frontend wrapper is not the full PMS command boundary described in `AGENTS.md`. It can supply or forward frontend metadata, but backend command handling is responsible for actor resolution, command name persistence, canonical payload hashing, timestamping, request context, authorization, locking, mutation, audit, outbox writes, and transactionality. + +For backend commands that already use `WriteCommandContext` or an equivalent backend command executor, `invokeWriteCommand` participates in that boundary by supplying an idempotency key and optional correlation id. For legacy backend commands that do not consume wrapper metadata, this batch may normalize the frontend call only if the command is invocation-compatible, but it must not claim the command is fully PMS-safety-compliant. Any missing backend command-boundary work is a follow-up outside #140 Batch 1. + ## Invocation Categories ### PMS business writes @@ -59,6 +69,22 @@ Batch 1 candidates: These calls currently use raw `invoke` and are direct writes. The implementation must keep their existing business payload fields intact. +Before converting any candidate, implementation must record compatibility evidence in the implementation notes or final summary: + +| Candidate | Business fields that must remain unchanged | Compatibility check | If incompatible | +| --- | --- | --- | --- | +| `save_pricing_rule` | `roomType`, `hourlyRate`, `overnightRate`, `dailyRate`, `earlyPct`, `latePct`, `weekendPct` | Inspect the Rust `#[tauri::command]` signature and run focused frontend tests proving the converted call routes those fields through `invokeWriteCommand`. The existing wrapper tests prove wrapper metadata is added before the low-level Tauri invoke. | Leave raw, keep the test/guard documenting why, and create a follow-up note for backend command-boundary support. | +| `save_settings` for `checkin_rules` | `key`, `value` | Inspect the Rust `save_settings` signature and run focused settings tests proving both fields route through `invokeWriteCommand`. The existing wrapper tests prove wrapper metadata is added before the low-level Tauri invoke. | Leave raw for this key and record why. | +| `save_settings` for `hotel_info` | `key`, `value` | Inspect the Rust `save_settings` signature and run focused settings tests proving both fields route through `invokeWriteCommand`. The existing wrapper tests prove wrapper metadata is added before the low-level Tauri invoke. | Leave raw for this key and record why. | +| `update_housekeeping` | `taskId`, `newStatus`, `note` | Inspect the Rust `update_housekeeping` signature and run focused store tests proving those fields route through `invokeWriteCommand`. The existing wrapper tests prove wrapper metadata is added before the low-level Tauri invoke. | Leave raw and record why. | + +The compatibility check has two levels: + +- Invocation compatibility: the call still succeeds with wrapper metadata in the command argument object. +- PMS safety completeness: the backend consumes and persists the metadata as part of an explicit command boundary. + +Batch 1 requires invocation compatibility for conversion. It does not require PMS safety completeness for legacy commands, but it must identify when that completeness is missing. + ### Read calls Read calls may remain raw `invoke` in this batch unless the file being changed benefits from using `invokeCommand` for local consistency. This avoids turning #140 Batch 1 into a broad frontend cleanup. @@ -79,7 +105,14 @@ Allowed examples include: - backup and CSV export commands, - update/runtime support commands. -This batch should make the reason for these remaining raw invokes clear through focused tests, a small classification helper used by tests, or concise local documentation. It should not force these calls through `invokeWriteCommand`. +This batch must add one required validation mechanism: a static guardrail test at `mhm/tests/frontend-invoke-wrapper-guardrails.test.ts`. + +The test should scan frontend source files for raw Tauri `invoke` calls and enforce two explicit lists: + +- `PMS_WRITE_COMMANDS_REQUIRING_WRAPPER`: Batch 1 commands that must not appear as raw `invoke`. +- `RAW_INVOKE_ALLOWED_COMMANDS`: read/system/runtime/export/diagnostics/gateway/bootstrap commands allowed to remain raw, each with an inline reason string in the test data. + +It should not force allowed calls through `invokeWriteCommand`. ## Architecture @@ -113,6 +146,8 @@ For converted writes, the data flow is: No caller should manually create an idempotency key for the converted Batch 1 calls. Manual `createIdempotencyKey` usage should be left alone unless it is part of an explicitly converted call. +For legacy backend commands, wrapper metadata may be accepted by the invocation layer without being consumed by backend safety tables. The implementation summary must distinguish those two cases. + ## Error Handling Converted writes should route backend and Tauri failures through `invokeCommand` error normalization. This gives callers a normalized exception with: @@ -149,6 +184,16 @@ Focused test updates should verify the converted write paths at the wrapper boun - existing success refresh/toast behavior remains intact, - normalized errors are displayed through existing UI error paths where tests cover them. +Per-candidate evidence: + +- `save_pricing_rule`: add or update a `PricingSection` test that performs a successful save and expects `invokeWriteCommand("save_pricing_rule", { ...business fields... })`; also assert raw `invoke` is not called with `save_pricing_rule`. +- `save_settings` for `hotel_info`: add or update a settings component test that clicks the hotel-info save path and expects `invokeWriteCommand("save_settings", { key: "hotel_info", value: ... })`. +- `save_settings` for `checkin_rules`: add or update a settings component test that clicks the check-in-rules save path and expects `invokeWriteCommand("save_settings", { key: "checkin_rules", value: ... })`. +- `update_housekeeping`: add or update a store test that expects `invokeWriteCommand("update_housekeeping", { taskId, newStatus, note })` and confirms raw `invoke` is not used for that write. +- Raw invoke guardrail: add `mhm/tests/frontend-invoke-wrapper-guardrails.test.ts` with explicit allow/deny command lists and reasons. + +Wrapper metadata evidence remains in `mhm/src/lib/invokeCommand.test.ts`; call-site tests should not duplicate the wrapper unit test by asserting the generated random idempotency value. + Validation commands: ```bash @@ -165,9 +210,15 @@ Expected raw invoke scan result: ## Acceptance Criteria -- `save_pricing_rule`, settings saves for hotel info and check-in rules, and housekeeping updates no longer use raw frontend `invoke` if backend argument handling is compatible. +- Each Batch 1 candidate has recorded compatibility evidence before conversion. +- If compatible, `save_pricing_rule` no longer uses raw frontend `invoke`, preserves `roomType`, `hourlyRate`, `overnightRate`, `dailyRate`, `earlyPct`, `latePct`, and `weekendPct`, and has focused test evidence for wrapper usage. +- If compatible, `save_settings` for `hotel_info` no longer uses raw frontend `invoke`, preserves `key` and `value`, and has focused test evidence for wrapper usage. +- If compatible, `save_settings` for `checkin_rules` no longer uses raw frontend `invoke`, preserves `key` and `value`, and has focused test evidence for wrapper usage. +- If compatible, `update_housekeeping` no longer uses raw frontend `invoke`, preserves `taskId`, `newStatus`, and `note`, and has focused test evidence for wrapper usage. +- Any incompatible candidate remains raw with a documented reason and follow-up; no backend command change is introduced to force compatibility. - Converted write payloads preserve their existing business fields. -- Raw `invoke` remains only where it is read-only or intentionally system/runtime/export/diagnostics/gateway/bootstrap oriented. +- `mhm/tests/frontend-invoke-wrapper-guardrails.test.ts` enforces forbidden raw PMS write commands and allowed raw read/system/runtime/export/diagnostics/gateway/bootstrap commands with reason strings. +- Raw `invoke` remains only where it is read-only or intentionally system/runtime/export/diagnostics/gateway/bootstrap oriented according to the guardrail test. - No backend command names, backend payload semantics, response shapes, or Zustand architecture are changed. - Tests and build pass. - The final `rg` scan is reviewed and remaining raw invoke usage is explainable. From bd1f0d6e1f6c8ec77d33c14325efe1e35301822f Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 09:25:17 +0700 Subject: [PATCH 07/45] test: cover pricing invoke wrapper --- .../pages/settings/PricingSection.test.tsx | 45 +++++++++++++++++-- mhm/src/pages/settings/PricingSection.tsx | 3 +- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/mhm/src/pages/settings/PricingSection.test.tsx b/mhm/src/pages/settings/PricingSection.test.tsx index 367460f..3cbc494 100644 --- a/mhm/src/pages/settings/PricingSection.test.tsx +++ b/mhm/src/pages/settings/PricingSection.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; const invoke = vi.hoisted(() => vi.fn()); +const invokeWriteCommand = vi.hoisted(() => vi.fn()); const toastError = vi.hoisted(() => vi.fn()); const toastSuccess = vi.hoisted(() => vi.fn()); @@ -11,6 +12,10 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke, })); +vi.mock("@/lib/invokeCommand", () => ({ + invokeWriteCommand, +})); + vi.mock("sonner", () => ({ toast: { error: toastError, @@ -60,13 +65,11 @@ import PricingSection from "./PricingSection"; describe("PricingSection", () => { beforeEach(() => { vi.clearAllMocks(); + invokeWriteCommand.mockResolvedValue(undefined); invoke.mockImplementation(async (command: string) => { if (command === "get_pricing_rules") { return []; } - if (command === "save_pricing_rule") { - return undefined; - } throw new Error(`Unexpected command: ${command}`); }); }); @@ -93,9 +96,45 @@ describe("PricingSection", () => { "save_pricing_rule", expect.anything(), ); + expect(invokeWriteCommand).not.toHaveBeenCalledWith( + "save_pricing_rule", + expect.anything(), + ); expect(toastError).toHaveBeenCalledWith( "hourly_rate must be a safe integer VND value", ); expect(toastSuccess).not.toHaveBeenCalled(); }); + + it("saves pricing rules through invokeWriteCommand", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_pricing_rules"); + }); + + fireEvent.change(screen.getByLabelText("Loại phòng"), { + target: { value: "standard" }, + }); + + await user.click(screen.getByRole("button", { name: "Thêm" })); + + await waitFor(() => { + expect(invokeWriteCommand).toHaveBeenCalledWith("save_pricing_rule", { + roomType: "standard", + hourlyRate: 80000, + overnightRate: 300000, + dailyRate: 400000, + earlyPct: 30, + latePct: 30, + weekendPct: 20, + }); + }); + expect(invoke).not.toHaveBeenCalledWith( + "save_pricing_rule", + expect.anything(), + ); + expect(toastSuccess).toHaveBeenCalledWith("Đã lưu bảng giá!"); + }); }); diff --git a/mhm/src/pages/settings/PricingSection.tsx b/mhm/src/pages/settings/PricingSection.tsx index 18e9a1f..8d91edc 100644 --- a/mhm/src/pages/settings/PricingSection.tsx +++ b/mhm/src/pages/settings/PricingSection.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { invokeWriteCommand } from "@/lib/invokeCommand"; import { assertNonNegativeMoneyVnd } from "@/lib/money"; import type { PricingRuleData } from "@/types"; @@ -43,7 +44,7 @@ export default function PricingSection() { const hourlyRate = assertNonNegativeMoneyVnd(form.hourly_rate, "hourly_rate"); const overnightRate = assertNonNegativeMoneyVnd(form.overnight_rate, "overnight_rate"); const dailyRate = assertNonNegativeMoneyVnd(form.daily_rate, "daily_rate"); - await invoke("save_pricing_rule", { + await invokeWriteCommand("save_pricing_rule", { roomType: form.room_type, hourlyRate, overnightRate, From 6d9d24847831b123b0062093dcecf274a8f0e297 Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 09:30:29 +0700 Subject: [PATCH 08/45] test: cover settings invoke wrapper --- .../pages/settings/CheckinRulesSection.tsx | 3 +- mhm/src/pages/settings/HotelInfoSection.tsx | 3 +- .../settings/SettingsWriteSections.test.tsx | 103 ++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 mhm/src/pages/settings/SettingsWriteSections.test.tsx diff --git a/mhm/src/pages/settings/CheckinRulesSection.tsx b/mhm/src/pages/settings/CheckinRulesSection.tsx index eb006fc..d48b370 100644 --- a/mhm/src/pages/settings/CheckinRulesSection.tsx +++ b/mhm/src/pages/settings/CheckinRulesSection.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { invokeWriteCommand } from "@/lib/invokeCommand"; function readSavedTime( value: Record, @@ -55,7 +56,7 @@ export default function CheckinRulesSection() { const handleSave = () => { const value = JSON.stringify({ checkin: checkinTime, checkout: checkoutTime }); - invoke("save_settings", { key: "checkin_rules", value }) + invokeWriteCommand("save_settings", { key: "checkin_rules", value }) .then(() => toast.success("Đã lưu quy tắc check-in!")) .catch(() => toast.error("Lỗi khi lưu!")); }; diff --git a/mhm/src/pages/settings/HotelInfoSection.tsx b/mhm/src/pages/settings/HotelInfoSection.tsx index 8e96a5e..4345512 100644 --- a/mhm/src/pages/settings/HotelInfoSection.tsx +++ b/mhm/src/pages/settings/HotelInfoSection.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { APP_NAME } from "@/lib/appIdentity"; +import { invokeWriteCommand } from "@/lib/invokeCommand"; export default function HotelInfoSection() { const [hotelName, setHotelName] = useState(APP_NAME); @@ -32,7 +33,7 @@ export default function HotelInfoSection() { const handleSave = () => { const value = JSON.stringify({ name: hotelName, address, phone, rating }); - invoke("save_settings", { key: "hotel_info", value }) + invokeWriteCommand("save_settings", { key: "hotel_info", value }) .then(() => toast.success("Đã lưu thông tin khách sạn!")) .catch(() => toast.error("Lỗi khi lưu!")); }; diff --git a/mhm/src/pages/settings/SettingsWriteSections.test.tsx b/mhm/src/pages/settings/SettingsWriteSections.test.tsx new file mode 100644 index 0000000..621723c --- /dev/null +++ b/mhm/src/pages/settings/SettingsWriteSections.test.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.hoisted(() => vi.fn()); +const invokeWriteCommand = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); +const toastSuccess = vi.hoisted(() => vi.fn()); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke, +})); + +vi.mock("@/lib/invokeCommand", () => ({ + invokeWriteCommand, +})); + +vi.mock("sonner", () => ({ + toast: { + error: toastError, + success: toastSuccess, + }, +})); + +import CheckinRulesSection from "./CheckinRulesSection"; +import HotelInfoSection from "./HotelInfoSection"; + +describe("settings write sections", () => { + beforeEach(() => { + vi.clearAllMocks(); + invokeWriteCommand.mockResolvedValue(undefined); + invoke.mockImplementation(async (command: string, args?: Record) => { + if (command !== "get_settings") { + throw new Error(`Unexpected raw invoke ${command}`); + } + + if (args?.key === "hotel_info") { + return JSON.stringify({ + name: "Grand Hotel", + address: "123 Main St", + phone: "0901234567", + rating: "4.8", + }); + } + + if (args?.key === "checkin_rules") { + return JSON.stringify({ + checkin: "15:30", + checkout: "11:15", + }); + } + + return null; + }); + }); + + it("saves hotel info through invokeWriteCommand", async () => { + const user = userEvent.setup(); + render(); + + const hotelNameInput = await screen.findByDisplayValue("Grand Hotel"); + await user.clear(hotelNameInput); + await user.type(hotelNameInput, "New Hotel"); + + await user.click(screen.getByRole("button", { name: "Lưu thay đổi" })); + + await waitFor(() => { + expect(invokeWriteCommand).toHaveBeenCalledWith("save_settings", { + key: "hotel_info", + value: JSON.stringify({ + name: "New Hotel", + address: "123 Main St", + phone: "0901234567", + rating: "4.8", + }), + }); + }); + expect(invoke).not.toHaveBeenCalledWith("save_settings", expect.anything()); + expect(toastSuccess).toHaveBeenCalledWith("Đã lưu thông tin khách sạn!"); + }); + + it("saves check-in rules through invokeWriteCommand", async () => { + const user = userEvent.setup(); + render(); + + const checkinInput = await screen.findByDisplayValue("15:30"); + const checkoutInput = await screen.findByDisplayValue("11:15"); + + fireEvent.change(checkinInput, { target: { value: "16:00" } }); + fireEvent.change(checkoutInput, { target: { value: "10:45" } }); + + await user.click(screen.getByRole("button", { name: "Lưu thay đổi" })); + + await waitFor(() => { + expect(invokeWriteCommand).toHaveBeenCalledWith("save_settings", { + key: "checkin_rules", + value: JSON.stringify({ checkin: "16:00", checkout: "10:45" }), + }); + }); + expect(invoke).not.toHaveBeenCalledWith("save_settings", expect.anything()); + expect(toastSuccess).toHaveBeenCalledWith("Đã lưu quy tắc check-in!"); + }); +}); From 39d0e4a048a013a287627748fd5152c51d19adee Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 09:39:03 +0700 Subject: [PATCH 09/45] test: cover housekeeping invoke wrapper --- mhm/src/stores/useHotelStore.test.ts | 18 ++++++++++++++++++ mhm/src/stores/useHotelStore.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mhm/src/stores/useHotelStore.test.ts b/mhm/src/stores/useHotelStore.test.ts index 88badfa..fe95ecd 100644 --- a/mhm/src/stores/useHotelStore.test.ts +++ b/mhm/src/stores/useHotelStore.test.ts @@ -35,6 +35,10 @@ describe("useHotelStore monitoring context", () => { return []; } + if (command === "get_housekeeping_tasks") { + return []; + } + if (command === "get_dashboard_stats") { return { total_rooms: 10, @@ -277,6 +281,20 @@ describe("useHotelStore monitoring context", () => { ); }); + it("routes updateHousekeeping through invokeWriteCommand", async () => { + await useHotelStore.getState().updateHousekeeping("task-1", "cleaning", "Started"); + + expect(invokeWriteCommand).toHaveBeenCalledWith("update_housekeeping", { + taskId: "task-1", + newStatus: "cleaning", + note: "Started", + }); + expect(invoke).not.toHaveBeenCalledWith( + "update_housekeeping", + expect.anything(), + ); + }); + it("rejects fractional checkIn paid_amount before invoking backend", async () => { await expect( useHotelStore.getState().checkIn( diff --git a/mhm/src/stores/useHotelStore.ts b/mhm/src/stores/useHotelStore.ts index 53f49b6..1519c81 100644 --- a/mhm/src/stores/useHotelStore.ts +++ b/mhm/src/stores/useHotelStore.ts @@ -213,7 +213,7 @@ export const useHotelStore = create((set, get) => { }, updateHousekeeping: async (taskId, status, note) => { - await invoke("update_housekeeping", { taskId, newStatus: status, note }); + await invokeWriteCommand("update_housekeeping", { taskId, newStatus: status, note }); await get().fetchHousekeeping(); await get().fetchRooms(); }, From 03f6f180cd9935765e5be5b3b7fc567cde920563 Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 09:45:14 +0700 Subject: [PATCH 10/45] test: guard frontend invoke wrapper usage --- ...frontend-invoke-wrapper-guardrails.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 mhm/tests/frontend-invoke-wrapper-guardrails.test.ts diff --git a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts new file mode 100644 index 0000000..d3f0852 --- /dev/null +++ b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts @@ -0,0 +1,126 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +type RawInvokeOccurrence = { + command: string; + file: string; + line: number; +}; + +const FRONTEND_SRC_ROOT = join(process.cwd(), "src"); + +const PMS_WRITE_COMMANDS_REQUIRING_WRAPPER = new Set([ + "save_pricing_rule", + "save_settings", + "update_housekeeping", +]); + +const RAW_INVOKE_ALLOWED_COMMANDS: Record = { + auto_assign_rooms: "read-style room assignment preview; no PMS mutation is committed", + backup_database: "system backup/export action, not a PMS business write wrapper target", + calculate_price_preview: "read-only pricing preview", + check_availability: "read-only reservation availability lookup", + complete_onboarding: "bootstrap setup command excluded from Batch 1 PMS wrapper scope", + export_bookings_csv: "system export action, not a PMS business write wrapper target", + export_crash_report: "diagnostics export action", + gateway_generate_key: "gateway runtime administration excluded from PMS write wrapper scope", + gateway_get_status: "gateway runtime status read", + generate_group_invoice: "read-only group invoice data generation; no invoice record is persisted", + get_all_bookings: "read-only booking list lookup", + get_all_groups: "read-only group list lookup", + get_all_guests: "read-only guest list lookup", + get_analytics: "read-only analytics lookup", + get_audit_logs: "read-only audit lookup", + get_bootstrap_status: "bootstrap runtime read", + get_crash_reporting_preference: "diagnostics preference read", + get_current_user: "auth session read", + get_dashboard_stats: "read-only dashboard stats lookup", + get_expenses: "read-only expense lookup", + get_guest_history: "read-only guest history lookup", + get_housekeeping_tasks: "read-only housekeeping task lookup", + get_pending_crash_report: "diagnostics recovery read", + get_pricing_rules: "read-only pricing rules lookup", + get_recent_activity: "read-only activity feed lookup", + get_room_detail: "read-only room detail lookup", + get_room_types: "read-only room type lookup", + get_rooms: "read-only room list lookup", + get_rooms_availability: "read-only room availability lookup", + get_settings: "read-only settings lookup", + get_stay_info_text: "read-only stay info lookup", + logout: "auth runtime action excluded from PMS write wrapper scope", + mark_crash_report_dismissed: "diagnostics lifecycle action", + mark_crash_report_send_failed: "diagnostics lifecycle action", + mark_crash_report_submitted: "diagnostics lifecycle action", + preview_checkout_settlement: "read-only checkout settlement preview", + record_js_crash: "diagnostics crash recording path", + search_guest_by_phone: "read-only guest search lookup", + set_crash_reporting_preference: "diagnostics preference action excluded from PMS wrapper scope", +}; + +function listSourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + const stats = statSync(path); + + if (stats.isDirectory()) { + if (entry === "__mocks__") { + return []; + } + return listSourceFiles(path); + } + + if (!/\.(ts|tsx)$/.test(entry) || /\.test\.(ts|tsx)$/.test(entry)) { + return []; + } + + return [path]; + }); +} + +function lineNumberForIndex(source: string, index: number): number { + return source.slice(0, index).split("\n").length; +} + +function findRawInvokeOccurrences(): RawInvokeOccurrence[] { + const invokePattern = /\binvoke(?:<[^>]+>)?\(\s*["']([^"']+)["']/g; + + return listSourceFiles(FRONTEND_SRC_ROOT).flatMap((file) => { + const source = readFileSync(file, "utf8"); + return Array.from(source.matchAll(invokePattern), (match) => ({ + command: match[1], + file: relative(process.cwd(), file), + line: lineNumberForIndex(source, match.index ?? 0), + })); + }); +} + +function formatOccurrences(occurrences: RawInvokeOccurrence[]): string { + return occurrences + .map(({ command, file, line }) => `${command} at ${file}:${line}`) + .join("\n"); +} + +describe("frontend invoke wrapper guardrails", () => { + it("keeps Batch 1 PMS writes out of raw Tauri invoke calls", () => { + const forbidden = findRawInvokeOccurrences().filter(({ command }) => + PMS_WRITE_COMMANDS_REQUIRING_WRAPPER.has(command), + ); + + expect(formatOccurrences(forbidden)).toBe(""); + }); + + it("keeps every remaining raw Tauri invoke explicitly categorized", () => { + for (const [command, reason] of Object.entries(RAW_INVOKE_ALLOWED_COMMANDS)) { + expect(reason, `${command} needs an allowlist reason`).toMatch(/\S.{10,}/); + } + + const unknown = findRawInvokeOccurrences().filter( + ({ command }) => + !PMS_WRITE_COMMANDS_REQUIRING_WRAPPER.has(command) && + !(command in RAW_INVOKE_ALLOWED_COMMANDS), + ); + + expect(formatOccurrences(unknown)).toBe(""); + }); +}); From f54e82f343689f069ec21cc9cacc5abbc345678c Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 09:48:29 +0700 Subject: [PATCH 11/45] test: tighten invoke wrapper guardrail --- mhm/tests/frontend-invoke-wrapper-guardrails.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts index d3f0852..8f8ecad 100644 --- a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts +++ b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts @@ -17,9 +17,7 @@ const PMS_WRITE_COMMANDS_REQUIRING_WRAPPER = new Set([ ]); const RAW_INVOKE_ALLOWED_COMMANDS: Record = { - auto_assign_rooms: "read-style room assignment preview; no PMS mutation is committed", backup_database: "system backup/export action, not a PMS business write wrapper target", - calculate_price_preview: "read-only pricing preview", check_availability: "read-only reservation availability lookup", complete_onboarding: "bootstrap setup command excluded from Batch 1 PMS wrapper scope", export_bookings_csv: "system export action, not a PMS business write wrapper target", @@ -111,8 +109,13 @@ describe("frontend invoke wrapper guardrails", () => { }); it("keeps every remaining raw Tauri invoke explicitly categorized", () => { + const rawCommands = new Set( + findRawInvokeOccurrences().map(({ command }) => command), + ); + for (const [command, reason] of Object.entries(RAW_INVOKE_ALLOWED_COMMANDS)) { expect(reason, `${command} needs an allowlist reason`).toMatch(/\S.{10,}/); + expect(rawCommands.has(command), `${command} is not a remaining raw invoke`).toBe(true); } const unknown = findRawInvokeOccurrences().filter( From c4e08291c8ed56af9cab2e6acf2ec8a31b076914 Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 09:53:32 +0700 Subject: [PATCH 12/45] test: harden invoke wrapper guardrail --- ...frontend-invoke-wrapper-guardrails.test.ts | 151 ++++++++++++++++-- 1 file changed, 139 insertions(+), 12 deletions(-) diff --git a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts index 8f8ecad..a3a7354 100644 --- a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts +++ b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts @@ -1,5 +1,6 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; +import * as ts from "typescript"; import { describe, expect, it } from "vitest"; type RawInvokeOccurrence = { @@ -56,6 +57,8 @@ const RAW_INVOKE_ALLOWED_COMMANDS: Record = { set_crash_reporting_preference: "diagnostics preference action excluded from PMS wrapper scope", }; +let rawInvokeOccurrencesCache: RawInvokeOccurrence[] | undefined; + function listSourceFiles(dir: string): string[] { return readdirSync(dir).flatMap((entry) => { const path = join(dir, entry); @@ -76,23 +79,126 @@ function listSourceFiles(dir: string): string[] { }); } -function lineNumberForIndex(source: string, index: number): number { - return source.slice(0, index).split("\n").length; +function sourceKindForFile(file: string): ts.ScriptKind { + return file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; } -function findRawInvokeOccurrences(): RawInvokeOccurrence[] { - const invokePattern = /\binvoke(?:<[^>]+>)?\(\s*["']([^"']+)["']/g; +function findTauriInvokeImports(sourceFile: ts.SourceFile): { + invokeLocals: Set; + namespaceLocals: Set; +} { + const invokeLocals = new Set(); + const namespaceLocals = new Set(); + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + if ( + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== "@tauri-apps/api/core" + ) { + continue; + } + + const importClause = statement.importClause; + const namedBindings = importClause?.namedBindings; + if (!namedBindings) { + continue; + } + + if (ts.isNamespaceImport(namedBindings)) { + namespaceLocals.add(namedBindings.name.text); + continue; + } + + for (const element of namedBindings.elements) { + const importedName = element.propertyName?.text ?? element.name.text; + if (importedName === "invoke") { + invokeLocals.add(element.name.text); + } + } + } + + return { invokeLocals, namespaceLocals }; +} + +function isImportedInvokeCall( + expression: ts.Expression, + invokeLocals: Set, + namespaceLocals: Set, +): boolean { + if (ts.isIdentifier(expression)) { + return invokeLocals.has(expression.text); + } + + return ( + ts.isPropertyAccessExpression(expression) && + expression.name.text === "invoke" && + ts.isIdentifier(expression.expression) && + namespaceLocals.has(expression.expression.text) + ); +} + +function stringCommandFromExpression(expression: ts.Expression): string | undefined { + if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) { + return expression.text; + } + return undefined; +} + +function findRawInvokeOccurrencesInSource( + file: string, + source: string, +): RawInvokeOccurrence[] { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + sourceKindForFile(file), + ); + const { invokeLocals, namespaceLocals } = findTauriInvokeImports(sourceFile); + const occurrences: RawInvokeOccurrence[] = []; + + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + isImportedInvokeCall(node.expression, invokeLocals, namespaceLocals) + ) { + const command = node.arguments[0] + ? stringCommandFromExpression(node.arguments[0]) + : undefined; + + if (command) { + occurrences.push({ + command, + file: relative(process.cwd(), file), + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return occurrences; +} + +function findRawInvokeOccurrences(): RawInvokeOccurrence[] { return listSourceFiles(FRONTEND_SRC_ROOT).flatMap((file) => { const source = readFileSync(file, "utf8"); - return Array.from(source.matchAll(invokePattern), (match) => ({ - command: match[1], - file: relative(process.cwd(), file), - line: lineNumberForIndex(source, match.index ?? 0), - })); + return findRawInvokeOccurrencesInSource(file, source); }); } +function getRawInvokeOccurrences(): RawInvokeOccurrence[] { + rawInvokeOccurrencesCache ??= findRawInvokeOccurrences(); + return rawInvokeOccurrencesCache; +} + function formatOccurrences(occurrences: RawInvokeOccurrence[]): string { return occurrences .map(({ command, file, line }) => `${command} at ${file}:${line}`) @@ -100,8 +206,29 @@ function formatOccurrences(occurrences: RawInvokeOccurrence[]): string { } describe("frontend invoke wrapper guardrails", () => { + it("detects aliased, namespaced, and typed raw Tauri invoke calls", () => { + const source = ` + import { invoke, invoke as tauriInvoke } from "@tauri-apps/api/core"; + import * as tauriCore from "@tauri-apps/api/core"; + + invoke>("save_pricing_rule"); + tauriInvoke("save_settings"); + tauriCore.invoke(\`update_housekeeping\`); + `; + + expect( + findRawInvokeOccurrencesInSource("src/example.ts", source).map( + ({ command }) => command, + ), + ).toEqual([ + "save_pricing_rule", + "save_settings", + "update_housekeeping", + ]); + }); + it("keeps Batch 1 PMS writes out of raw Tauri invoke calls", () => { - const forbidden = findRawInvokeOccurrences().filter(({ command }) => + const forbidden = getRawInvokeOccurrences().filter(({ command }) => PMS_WRITE_COMMANDS_REQUIRING_WRAPPER.has(command), ); @@ -110,7 +237,7 @@ describe("frontend invoke wrapper guardrails", () => { it("keeps every remaining raw Tauri invoke explicitly categorized", () => { const rawCommands = new Set( - findRawInvokeOccurrences().map(({ command }) => command), + getRawInvokeOccurrences().map(({ command }) => command), ); for (const [command, reason] of Object.entries(RAW_INVOKE_ALLOWED_COMMANDS)) { @@ -118,7 +245,7 @@ describe("frontend invoke wrapper guardrails", () => { expect(rawCommands.has(command), `${command} is not a remaining raw invoke`).toBe(true); } - const unknown = findRawInvokeOccurrences().filter( + const unknown = getRawInvokeOccurrences().filter( ({ command }) => !PMS_WRITE_COMMANDS_REQUIRING_WRAPPER.has(command) && !(command in RAW_INVOKE_ALLOWED_COMMANDS), From 089be4303ce6c97a4a5f02d7f6ea7f6cc5d82078 Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 09:59:44 +0700 Subject: [PATCH 13/45] test: align housekeeping invoke wrapper expectation --- mhm/tests/e2e/06-housekeeping.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/mhm/tests/e2e/06-housekeeping.test.tsx b/mhm/tests/e2e/06-housekeeping.test.tsx index d0f843e..a13211c 100644 --- a/mhm/tests/e2e/06-housekeeping.test.tsx +++ b/mhm/tests/e2e/06-housekeeping.test.tsx @@ -50,6 +50,7 @@ describe("06 — Housekeeping", () => { taskId: "hk-1", newStatus: "cleaning", note: "Started cleaning", + idempotencyKey: expect.stringMatching(/^update_housekeeping:/), }); }); From 7971de59972e78b53a288b81fe6176baf6ad7571 Mon Sep 17 00:00:00 2001 From: binhan Date: Thu, 14 May 2026 10:06:54 +0700 Subject: [PATCH 14/45] test: catch dynamic raw invoke calls --- ...frontend-invoke-wrapper-guardrails.test.ts | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts index a3a7354..6d8acca 100644 --- a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts +++ b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts @@ -4,7 +4,7 @@ import * as ts from "typescript"; import { describe, expect, it } from "vitest"; type RawInvokeOccurrence = { - command: string; + command: string | undefined; file: string; line: number; }; @@ -57,6 +57,10 @@ const RAW_INVOKE_ALLOWED_COMMANDS: Record = { set_crash_reporting_preference: "diagnostics preference action excluded from PMS wrapper scope", }; +const RAW_INVOKE_ALLOWED_NON_LITERAL_SITES: Record = { + "src/lib/invokeCommand.ts": "central invoke wrapper dispatch boundary", +}; + let rawInvokeOccurrencesCache: RawInvokeOccurrence[] | undefined; function listSourceFiles(dir: string): string[] { @@ -171,13 +175,11 @@ function findRawInvokeOccurrencesInSource( ? stringCommandFromExpression(node.arguments[0]) : undefined; - if (command) { - occurrences.push({ - command, - file: relative(process.cwd(), file), - line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, - }); - } + occurrences.push({ + command, + file: relative(process.cwd(), file), + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + }); } ts.forEachChild(node, visit); @@ -201,7 +203,10 @@ function getRawInvokeOccurrences(): RawInvokeOccurrence[] { function formatOccurrences(occurrences: RawInvokeOccurrence[]): string { return occurrences - .map(({ command, file, line }) => `${command} at ${file}:${line}`) + .map( + ({ command, file, line }) => + `${command ?? ""} at ${file}:${line}`, + ) .join("\n"); } @@ -227,9 +232,24 @@ describe("frontend invoke wrapper guardrails", () => { ]); }); + it("flags raw Tauri invoke calls without literal command names", () => { + const source = ` + import { invoke } from "@tauri-apps/api/core"; + + const command = "save_settings"; + invoke(command); + `; + + expect( + findRawInvokeOccurrencesInSource("src/example.ts", source).map( + ({ command }) => command ?? "", + ), + ).toEqual([""]); + }); + it("keeps Batch 1 PMS writes out of raw Tauri invoke calls", () => { const forbidden = getRawInvokeOccurrences().filter(({ command }) => - PMS_WRITE_COMMANDS_REQUIRING_WRAPPER.has(command), + command ? PMS_WRITE_COMMANDS_REQUIRING_WRAPPER.has(command) : false, ); expect(formatOccurrences(forbidden)).toBe(""); @@ -237,7 +257,9 @@ describe("frontend invoke wrapper guardrails", () => { it("keeps every remaining raw Tauri invoke explicitly categorized", () => { const rawCommands = new Set( - getRawInvokeOccurrences().map(({ command }) => command), + getRawInvokeOccurrences() + .map(({ command }) => command) + .filter((command): command is string => command !== undefined), ); for (const [command, reason] of Object.entries(RAW_INVOKE_ALLOWED_COMMANDS)) { @@ -245,10 +267,23 @@ describe("frontend invoke wrapper guardrails", () => { expect(rawCommands.has(command), `${command} is not a remaining raw invoke`).toBe(true); } - const unknown = getRawInvokeOccurrences().filter( - ({ command }) => - !PMS_WRITE_COMMANDS_REQUIRING_WRAPPER.has(command) && - !(command in RAW_INVOKE_ALLOWED_COMMANDS), + const nonLiteralSites = new Set( + getRawInvokeOccurrences() + .filter(({ command }) => command === undefined) + .map(({ file }) => file), + ); + for (const [file, reason] of Object.entries(RAW_INVOKE_ALLOWED_NON_LITERAL_SITES)) { + expect(reason, `${file} needs an allowlist reason`).toMatch(/\S.{10,}/); + expect(nonLiteralSites.has(file), `${file} is not a remaining non-literal raw invoke`).toBe( + true, + ); + } + + const unknown = getRawInvokeOccurrences().filter(({ command, file }) => + command === undefined + ? !(file in RAW_INVOKE_ALLOWED_NON_LITERAL_SITES) + : !PMS_WRITE_COMMANDS_REQUIRING_WRAPPER.has(command) && + !(command in RAW_INVOKE_ALLOWED_COMMANDS), ); expect(formatOccurrences(unknown)).toBe(""); From 1928234a181b8fe589d000e0ad2e60a03970c35d Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 07:57:35 +0700 Subject: [PATCH 15/45] docs: design experimental runtime gating --- ...5-15-experimental-runtime-gating-design.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-experimental-runtime-gating-design.md diff --git a/docs/superpowers/specs/2026-05-15-experimental-runtime-gating-design.md b/docs/superpowers/specs/2026-05-15-experimental-runtime-gating-design.md new file mode 100644 index 0000000..8398c1c --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-experimental-runtime-gating-design.md @@ -0,0 +1,227 @@ +# Experimental Runtime Gating Design + +Date: 2026-05-15 + +Issues: #141, #142 + +Planned PR title: `refactor: gate experimental gateway and agent runtime` + +## Goal + +Make gateway, MCP, agent, digest, Telegram, CEO, and OpenAI runtime surfaces disabled by default in the normal PMS profile. Normal PMS startup must not start experimental background tasks, require external runtime credentials, or expose gateway UI by accident. + +The implementation should combine #141 and #142 because both issues share the same runtime quarantine goal and the relevant startup paths are clear. The high-risk portion is agent supervisor reconciliation, so changes there must stay narrow and be verified carefully. + +## Scope + +In scope: + +- Add explicit experimental runtime opt-in helpers in `mhm/src-tauri/src/runtime_config.rs`. +- Gate gateway startup in `mhm/src-tauri/src/lib.rs`. +- Gate agent supervisor reconciliation in `mhm/src-tauri/src/agent/supervisor.rs`. +- Hide gateway/MCP UI in the normal frontend profile. +- Preserve existing gateway and agent code; do not delete experimental features. +- Audit direct SQL writes in `mhm/src-tauri/src/agent` and `mhm/src-tauri/src/gateway`. +- Refactor any production direct write from agent modules into PMS business tables if such a write bypasses the command/service boundary. +- Document the experimental disabled profile and the allowed agent-owned runtime tables. + +Out of scope: + +- New gateway, MCP, agent, digest, Telegram, CEO, or OpenAI capabilities. +- PMS command semantics changes. +- Replacing the command executor architecture. +- Removing existing gateway or agent modules. +- Gating unrelated runtime surfaces such as outbox dispatchers unless a tiny helper is required for naming consistency. +- Renaming `mhm/` or reorganizing unrelated docs. + +## Current State + +`mhm/src-tauri/src/lib.rs` currently starts the MCP gateway during app setup unless `CAPYINN_DISABLE_GATEWAY` is set. It also creates an `AgentSupervisor` and calls `agent::supervisor::reconcile_managed_supervisor` unless `CAPYINN_DISABLE_CEO_TELEGRAM` is set. + +`mhm/src/App.tsx` checks `gateway_get_status` after authentication and always renders a gateway badge that says either `MCP Gateway` or `Gateway Off`. `mhm/src/pages/settings/index.tsx` always includes the `MCP Gateway` settings section. + +The working tree already contains draft runtime flag helpers in `runtime_config.rs` and a supervisor test that expects agent workflows to stop when experimental agent runtime is absent. Implementation must preserve and complete those existing local changes rather than replacing them wholesale. + +## Approved Approach + +Use a single explicit experimental runtime gate and combine #141 plus #142 in one implementation. + +Environment flags: + +- `CAPYINN_EXPERIMENTAL_RUNTIME=true` enables every experimental runtime surface covered by this slice. +- `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true` enables only the gateway runtime surface. +- `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME=true` enables only the agent/digest/Telegram CEO runtime surface. +- `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME=true` is retained only as a shared helper for future peripheral runtime work; this issue does not consume it. + +Existing disable flags remain safety overrides: + +- `CAPYINN_DISABLE_GATEWAY=true` disables gateway even if gateway experimental runtime is enabled. +- `CAPYINN_DISABLE_CEO_TELEGRAM=true` disables agent supervisor workflows even if agent experimental runtime is enabled. + +The effective rule is positive opt-in first, disable override second. + +## Runtime Gate Design + +`runtime_config.rs` should own the flag parsing helpers. The normal default is false for all experimental helpers. + +Gateway startup: + +1. App setup initializes the database and core PMS state as before. +2. Gateway startup checks `experimental_gateway_runtime_enabled()`. +3. If gateway experimental runtime is false, do not call `gateway::start_gateway`. +4. If gateway experimental runtime is true but `CAPYINN_DISABLE_GATEWAY` is true, do not start the gateway. +5. Only if the experimental gate is true and the disable override is false should the gateway start. + +Agent startup and reconciliation: + +1. App setup still manages `AgentSupervisor` so commands have a stable state object. +2. Startup calls `reconcile_managed_supervisor`. +3. `reconcile_managed_supervisor` first checks `experimental_agent_runtime_enabled()`. +4. If false, it shuts down existing workflows and returns `Ok(())` without starting chat or digest tasks. +5. If true but `CAPYINN_DISABLE_CEO_TELEGRAM` is true, it shuts down existing workflows and returns `Ok(())`. +6. Only after both gates pass should it read Telegram/digest config and evaluate existing readiness gates. + +This preserves existing admin config commands. Changing CEO Telegram settings may update settings while experimental runtime is disabled, but it must not start background workflows until the process is launched with the explicit experimental agent flag. + +## Frontend UI Gate + +Normal PMS profile must not display gateway/MCP as an ordinary product surface. + +Frontend behavior: + +- `App.tsx` must not call `gateway_get_status` in the normal profile. +- `App.tsx` must not render a red `Gateway Off` badge in the normal profile. +- `mhm/src/pages/settings/index.tsx` must not render the `MCP Gateway` sidebar item in the normal profile. +- `GatewaySection` can keep its current behavior when rendered in an experimental profile. +- `gateway_get_status` should expose `experimental_enabled` for the experimental settings panel, while returning `running: false` and `port: null` when the gateway runtime is not enabled. + +The frontend should use one small profile helper, for example `mhm/src/lib/experimentalProfile.ts`, that parses `VITE_CAPYINN_EXPERIMENTAL_RUNTIME` and `VITE_CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME` with the same truthy values as the backend helper. Components should import that helper rather than scattering environment checks. + +## Agent And Gateway Write Audit + +Direct SQL writes in `mhm/src-tauri/src/agent` and `mhm/src-tauri/src/gateway` should be classified into three groups. + +Allowed agent-owned runtime state: + +- `agent_sessions` +- `agent_audit_events` +- `agent_memory_items` +- `agent_digest_runs` + +These tables are not PMS truth. They may remain in agent store/scheduler modules when they are narrow runtime metadata writes and do not mutate PMS business state. + +Command-boundary writes: + +- CEO cloud data opt-in. +- CEO Telegram config. +- CEO Telegram secret presence. +- CEO Telegram offset persistence. +- CEO digest config. +- CEO digest delivery chat ID. +- Gateway write tools that delegate to validated PMS commands or services. + +These should keep using `WriteCommandExecutor`, `WriteCommandContext`, lock keys, idempotency, and audit where already present. + +Forbidden production direct writes: + +- Any production write from `agent/` into PMS business tables such as `rooms`, `bookings`, `guests`, `transactions`, `folio_lines`, `invoices`, `room_calendar`, housekeeping tables, or other PMS truth tables. +- Any gateway or agent mutation exposed to external callers that bypasses the existing validated command/service boundary. + +If implementation finds a forbidden production direct write, it must refactor that path through the existing command/service boundary. If a SQL write is test-only fixture setup, it may remain in test scope and should be called out as test-only in the implementation evidence. + +Current audit expectation from exploration: + +- CEO chat production path dispatches read-only CEO tools. +- Local receptionist demo reads guest-facing PMS context and does not write PMS business state. +- Direct PMS writes seen under agent modules appear to be test fixtures. +- Gateway reservation write tools call `commands::do_create_reservation`, `commands::do_cancel_reservation`, and `commands::do_modify_reservation`. +- Gateway API key storage writes `gateway_api_keys`, which is gateway-owned experimental state, not PMS business truth. + +## Error Handling + +Runtime disabled states should be quiet and normal: + +- Startup should log that an experimental runtime is disabled by default, not emit an error. +- `gateway_get_status` should return a normal status object, not fail, when the runtime is disabled. +- Agent settings commands should still return their normal command results after config updates; reconcile should be a no-op shutdown when experimental agent runtime is disabled. +- If an experimental runtime is enabled but missing its existing readiness prerequisites, preserve the current gate behavior and errors. + +Disable override flags should remain explicit in logs so operators can distinguish "not opted in" from "opted in but force-disabled." + +## GitNexus Impact Notes + +GitNexus index was refreshed before exploration with `npx gitnexus analyze`. + +Pre-change impact findings: + +- `start_gateway`: LOW risk. One direct caller, `mhm/src-tauri/src/lib.rs::run`; one affected process, app startup. +- `gateway_get_status`: LOW risk. No indexed upstream callers. +- `gateway_generate_key`: LOW risk. No indexed upstream callers. +- `GatewaySection`: LOW risk. No indexed upstream callers. +- `SettingsPage`: LOW risk. No indexed upstream callers. +- `experimental_gateway_runtime_enabled`: LOW risk in current draft state. No upstream callers yet. +- `experimental_agent_runtime_enabled`: LOW risk in current draft state. No upstream callers yet. +- `reconcile_managed_supervisor`: HIGH risk. Direct callers include app startup and agent settings commands. Affected processes include `run`, `set_ceo_cloud_data_opt_in`, and `set_ceo_telegram_config`. + +Because `reconcile_managed_supervisor` is HIGH risk, implementation must keep the edit narrow, report the risk before editing, and cover startup/reconcile behavior with focused Rust tests. + +Before committing implementation changes, run `gitnexus_detect_changes()` and verify affected symbols and flows match the planned runtime-gating scope. + +## Testing + +Rust tests: + +- `runtime_config.rs` verifies all experimental flags are disabled by default. +- `runtime_config.rs` verifies the master experimental flag enables gateway, agent, and peripheral helpers. +- `runtime_config.rs` verifies individual gateway and agent flags only enable their matching surfaces. +- Gateway startup logic is covered either through extracted helper tests or focused status/startup-adjacent tests. +- `agent::supervisor` verifies disabled experimental agent runtime shuts down chat and digest workflows. +- `agent::supervisor` verifies `CAPYINN_DISABLE_CEO_TELEGRAM` still force-disables workflows when experimental agent runtime is enabled. + +Frontend tests: + +- Normal profile does not call `gateway_get_status`. +- Normal profile does not render the gateway badge. +- Normal profile does not render the `MCP Gateway` settings section. +- Experimental gateway profile renders the `MCP Gateway` settings section and preserves `GatewaySection` behavior. + +SQL audit validation: + +```bash +rg -n "sqlx::query|query_as|query_scalar|SqlitePool|Pool|Transaction<|execute\\(" mhm/src-tauri/src/agent mhm/src-tauri/src/gateway +rg -n "INSERT|UPDATE|DELETE|CREATE|DROP|ALTER" mhm/src-tauri/src/agent mhm/src-tauri/src/gateway +``` + +The implementation summary should classify every production write found by these scans as agent-owned runtime state, gateway-owned runtime state, command-boundary write, read-only query, or test fixture. Any production PMS business-table write found outside a command/service boundary must be fixed before completion. + +Validation commands: + +```bash +cd mhm && npm test +cd mhm/src-tauri && cargo test +rg -n "gateway" mhm/src mhm/src-tauri/src docs +rg -n "CAPYINN_EXPERIMENTAL|CAPYINN_DISABLE_GATEWAY|CAPYINN_DISABLE_CEO_TELEGRAM" mhm/src-tauri/src docs README.md +gitnexus_detect_changes() +``` + +## Acceptance Criteria + +- Gateway runtime does not start unless `CAPYINN_EXPERIMENTAL_RUNTIME` or `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME` is explicitly enabled. +- `CAPYINN_DISABLE_GATEWAY` still disables gateway even when experimental gateway runtime is enabled. +- Normal app profile does not call gateway status, show a gateway badge, or show the `MCP Gateway` settings entry. +- Agent chat and digest workflows do not start unless `CAPYINN_EXPERIMENTAL_RUNTIME` or `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME` is explicitly enabled. +- `CAPYINN_DISABLE_CEO_TELEGRAM` still disables agent workflows even when experimental agent runtime is enabled. +- Normal PMS operation requires no gateway, MCP, OpenAI, Telegram, CEO-agent, or digest config. +- Agent-owned runtime tables are documented as allowed experimental state. +- No production agent module directly mutates PMS business tables outside the validated command/service boundary. +- Gateway write tools continue delegating to existing validated command/service entry points. +- Core PMS frontend and Rust tests pass with experimental runtime disabled. + +## Non-Goals And Guardrails + +- Do not delete gateway or agent code. +- Do not add any new agent capability. +- Do not change PMS command semantics. +- Do not weaken idempotency, lock, audit, or outbox behavior for existing command/service writes. +- Do not convert agent-owned metadata tables into PMS command-boundary writes unless they mutate PMS truth. +- Do not combine this work with folder renames or unrelated frontend shell refactors. From 0ed3dfe85f252e84746ce44e142a2e62b19570d6 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 08:18:35 +0700 Subject: [PATCH 16/45] docs: address experimental runtime spec review --- ...5-15-experimental-runtime-gating-design.md | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-05-15-experimental-runtime-gating-design.md b/docs/superpowers/specs/2026-05-15-experimental-runtime-gating-design.md index 8398c1c..2af4809 100644 --- a/docs/superpowers/specs/2026-05-15-experimental-runtime-gating-design.md +++ b/docs/superpowers/specs/2026-05-15-experimental-runtime-gating-design.md @@ -8,7 +8,7 @@ Planned PR title: `refactor: gate experimental gateway and agent runtime` ## Goal -Make gateway, MCP, agent, digest, Telegram, CEO, and OpenAI runtime surfaces disabled by default in the normal PMS profile. Normal PMS startup must not start experimental background tasks, require external runtime credentials, or expose gateway UI by accident. +Make gateway, MCP, agent, digest, Telegram, CEO, and OpenAI runtime surfaces disabled by default in the normal PMS profile. For this slice, normal PMS startup must not start gateway or agent/digest/Telegram background tasks, require external gateway/agent credentials, or expose gateway/agent UI by accident. The implementation should combine #141 and #142 because both issues share the same runtime quarantine goal and the relevant startup paths are clear. The high-risk portion is agent supervisor reconciliation, so changes there must stay narrow and be verified carefully. @@ -19,7 +19,7 @@ In scope: - Add explicit experimental runtime opt-in helpers in `mhm/src-tauri/src/runtime_config.rs`. - Gate gateway startup in `mhm/src-tauri/src/lib.rs`. - Gate agent supervisor reconciliation in `mhm/src-tauri/src/agent/supervisor.rs`. -- Hide gateway/MCP UI in the normal frontend profile. +- Hide gateway/MCP/CEO Agent UI in the normal frontend profile. - Preserve existing gateway and agent code; do not delete experimental features. - Audit direct SQL writes in `mhm/src-tauri/src/agent` and `mhm/src-tauri/src/gateway`. - Refactor any production direct write from agent modules into PMS business tables if such a write bypasses the command/service boundary. @@ -31,14 +31,14 @@ Out of scope: - PMS command semantics changes. - Replacing the command executor architecture. - Removing existing gateway or agent modules. -- Gating unrelated runtime surfaces such as outbox dispatchers unless a tiny helper is required for naming consistency. +- Gating unrelated runtime surfaces such as outbox dispatchers unless a tiny helper is required for naming consistency. Outbox dispatchers remain a documented residual risk for the separate F5 issue. - Renaming `mhm/` or reorganizing unrelated docs. ## Current State `mhm/src-tauri/src/lib.rs` currently starts the MCP gateway during app setup unless `CAPYINN_DISABLE_GATEWAY` is set. It also creates an `AgentSupervisor` and calls `agent::supervisor::reconcile_managed_supervisor` unless `CAPYINN_DISABLE_CEO_TELEGRAM` is set. -`mhm/src/App.tsx` checks `gateway_get_status` after authentication and always renders a gateway badge that says either `MCP Gateway` or `Gateway Off`. `mhm/src/pages/settings/index.tsx` always includes the `MCP Gateway` settings section. +`mhm/src/App.tsx` checks `gateway_get_status` after authentication and always renders a gateway badge that says either `MCP Gateway` or `Gateway Off`. It also listens for `mcp_reservation_created` and shows an AI-agent toast. `mhm/src/pages/settings/index.tsx` always includes the `MCP Gateway` settings section and shows `CEO Agent` settings to admins. The working tree already contains draft runtime flag helpers in `runtime_config.rs` and a supervisor test that expects agent workflows to stop when experimental agent runtime is absent. Implementation must preserve and complete those existing local changes rather than replacing them wholesale. @@ -60,6 +60,22 @@ Existing disable flags remain safety overrides: The effective rule is positive opt-in first, disable override second. +## Runtime Status Source Of Truth + +Backend runtime config is the source of truth for frontend profile decisions. Do not use build-time `VITE_*` flags for this slice because packaged app UI could drift from backend process environment. + +Add a narrow Tauri command named `get_experimental_runtime_status` that reads backend runtime config and returns the effective frontend gates: + +- `experimental_runtime_enabled` +- `gateway_runtime_enabled` +- `agent_runtime_enabled` +- `gateway_disabled_by_override` +- `agent_disabled_by_override` + +`gateway_runtime_enabled` should be true only when the gateway experimental flag is enabled and `CAPYINN_DISABLE_GATEWAY` is false. `agent_runtime_enabled` should be true only when the agent experimental flag is enabled and `CAPYINN_DISABLE_CEO_TELEGRAM` is false. + +Frontend code should call this profile command once after authentication and use the returned booleans to decide whether to render gateway/MCP/CEO Agent surfaces. Calling this profile command in the normal profile is allowed because it does not start or expose an experimental runtime; it only reports whether the current process opted into one. + ## Runtime Gate Design `runtime_config.rs` should own the flag parsing helpers. The normal default is false for all experimental helpers. @@ -85,17 +101,20 @@ This preserves existing admin config commands. Changing CEO Telegram settings ma ## Frontend UI Gate -Normal PMS profile must not display gateway/MCP as an ordinary product surface. +Normal PMS profile must not display gateway/MCP/CEO Agent as ordinary product surfaces. Frontend behavior: - `App.tsx` must not call `gateway_get_status` in the normal profile. - `App.tsx` must not render a red `Gateway Off` badge in the normal profile. +- `App.tsx` must not subscribe to `mcp_reservation_created` or show AI-agent reservation toasts in the normal profile. - `mhm/src/pages/settings/index.tsx` must not render the `MCP Gateway` sidebar item in the normal profile. -- `GatewaySection` can keep its current behavior when rendered in an experimental profile. +- `mhm/src/pages/settings/index.tsx` must not render the `CEO Agent` sidebar item in the normal profile. +- `GatewaySection` can keep its current behavior when rendered in an experimental gateway profile. +- `CeoAgentSection` can keep its current behavior when rendered in an experimental agent profile. - `gateway_get_status` should expose `experimental_enabled` for the experimental settings panel, while returning `running: false` and `port: null` when the gateway runtime is not enabled. -The frontend should use one small profile helper, for example `mhm/src/lib/experimentalProfile.ts`, that parses `VITE_CAPYINN_EXPERIMENTAL_RUNTIME` and `VITE_CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME` with the same truthy values as the backend helper. Components should import that helper rather than scattering environment checks. +The frontend should use one small profile helper or hook, for example `mhm/src/lib/experimentalProfile.ts`, that consumes `get_experimental_runtime_status` and exposes typed booleans to components. Components should import that helper rather than scattering command calls or environment checks. ## Agent And Gateway Write Audit @@ -122,6 +141,12 @@ Command-boundary writes: These should keep using `WriteCommandExecutor`, `WriteCommandContext`, lock keys, idempotency, and audit where already present. +Gateway management writes: + +- `gateway_generate_key` should reject with a controlled error when effective gateway runtime is disabled. +- When effective gateway runtime is enabled, `gateway_generate_key` may continue writing `gateway_api_keys` as gateway-owned experimental state. +- `gateway_get_status` remains read-only and may return disabled status without error. + Forbidden production direct writes: - Any production write from `agent/` into PMS business tables such as `rooms`, `bookings`, `guests`, `transactions`, `folio_lines`, `invoices`, `room_calendar`, housekeeping tables, or other PMS truth tables. @@ -143,6 +168,8 @@ Runtime disabled states should be quiet and normal: - Startup should log that an experimental runtime is disabled by default, not emit an error. - `gateway_get_status` should return a normal status object, not fail, when the runtime is disabled. +- `gateway_generate_key` should fail closed with a controlled user-facing error when effective gateway runtime is disabled. +- `get_experimental_runtime_status` should always return a status object and should not require gateway, Telegram, OpenAI, or agent config. - Agent settings commands should still return their normal command results after config updates; reconcile should be a no-op shutdown when experimental agent runtime is disabled. - If an experimental runtime is enabled but missing its existing readiness prerequisites, preserve the current gate behavior and errors. @@ -174,7 +201,9 @@ Rust tests: - `runtime_config.rs` verifies all experimental flags are disabled by default. - `runtime_config.rs` verifies the master experimental flag enables gateway, agent, and peripheral helpers. - `runtime_config.rs` verifies individual gateway and agent flags only enable their matching surfaces. +- The experimental runtime status command reports effective gateway and agent gates, including disable overrides. - Gateway startup logic is covered either through extracted helper tests or focused status/startup-adjacent tests. +- `gateway_generate_key` fails closed when effective gateway runtime is disabled. - `agent::supervisor` verifies disabled experimental agent runtime shuts down chat and digest workflows. - `agent::supervisor` verifies `CAPYINN_DISABLE_CEO_TELEGRAM` still force-disables workflows when experimental agent runtime is enabled. @@ -182,8 +211,11 @@ Frontend tests: - Normal profile does not call `gateway_get_status`. - Normal profile does not render the gateway badge. +- Normal profile does not subscribe to `mcp_reservation_created` or show the AI-agent reservation toast. - Normal profile does not render the `MCP Gateway` settings section. +- Normal profile does not render the `CEO Agent` settings section. - Experimental gateway profile renders the `MCP Gateway` settings section and preserves `GatewaySection` behavior. +- Experimental agent profile renders the `CEO Agent` settings section and preserves `CeoAgentSection` behavior. SQL audit validation: @@ -208,9 +240,12 @@ gitnexus_detect_changes() - Gateway runtime does not start unless `CAPYINN_EXPERIMENTAL_RUNTIME` or `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME` is explicitly enabled. - `CAPYINN_DISABLE_GATEWAY` still disables gateway even when experimental gateway runtime is enabled. -- Normal app profile does not call gateway status, show a gateway badge, or show the `MCP Gateway` settings entry. +- `gateway_generate_key` rejects when effective gateway runtime is disabled. +- Frontend profile gates come from backend runtime status, not build-time `VITE_*` flags. +- Normal app profile does not call gateway status, show a gateway badge, subscribe to MCP reservation events, or show the `MCP Gateway` settings entry. - Agent chat and digest workflows do not start unless `CAPYINN_EXPERIMENTAL_RUNTIME` or `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME` is explicitly enabled. - `CAPYINN_DISABLE_CEO_TELEGRAM` still disables agent workflows even when experimental agent runtime is enabled. +- Normal app profile does not show the `CEO Agent` settings entry. - Normal PMS operation requires no gateway, MCP, OpenAI, Telegram, CEO-agent, or digest config. - Agent-owned runtime tables are documented as allowed experimental state. - No production agent module directly mutates PMS business tables outside the validated command/service boundary. From a3c9a2135d61a6b3ac07facba826253a7c1796d9 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 08:30:48 +0700 Subject: [PATCH 17/45] refactor: add effective experimental runtime flags --- mhm/src-tauri/src/runtime_config.rs | 119 ++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/mhm/src-tauri/src/runtime_config.rs b/mhm/src-tauri/src/runtime_config.rs index a487d75..535ea48 100644 --- a/mhm/src-tauri/src/runtime_config.rs +++ b/mhm/src-tauri/src/runtime_config.rs @@ -13,6 +13,38 @@ pub fn env_flag(name: &str) -> bool { ) } +pub fn experimental_runtime_enabled() -> bool { + env_flag("CAPYINN_EXPERIMENTAL_RUNTIME") +} + +pub fn experimental_gateway_runtime_enabled() -> bool { + experimental_runtime_enabled() || env_flag("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME") +} + +pub fn experimental_agent_runtime_enabled() -> bool { + experimental_runtime_enabled() || env_flag("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME") +} + +pub fn experimental_peripheral_runtime_enabled() -> bool { + experimental_runtime_enabled() || env_flag("CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME") +} + +pub fn gateway_runtime_disabled_by_override() -> bool { + env_flag("CAPYINN_DISABLE_GATEWAY") +} + +pub fn agent_runtime_disabled_by_override() -> bool { + env_flag("CAPYINN_DISABLE_CEO_TELEGRAM") +} + +pub fn effective_experimental_gateway_runtime_enabled() -> bool { + experimental_gateway_runtime_enabled() && !gateway_runtime_disabled_by_override() +} + +pub fn effective_experimental_agent_runtime_enabled() -> bool { + experimental_agent_runtime_enabled() && !agent_runtime_disabled_by_override() +} + pub fn runtime_root_override() -> Option { std::env::var_os("CAPYINN_RUNTIME_ROOT") .filter(|value| !value.is_empty()) @@ -67,6 +99,93 @@ mod tests { std::env::remove_var("CAPYINN_DISABLE_GATEWAY"); } + #[test] + fn experimental_runtime_flags_are_disabled_by_default() { + let _guard = env_lock().lock().unwrap(); + + for name in [ + "CAPYINN_EXPERIMENTAL_RUNTIME", + "CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", + "CAPYINN_EXPERIMENTAL_AGENT_RUNTIME", + "CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME", + ] { + std::env::remove_var(name); + } + + assert!(!experimental_runtime_enabled()); + assert!(!experimental_gateway_runtime_enabled()); + assert!(!experimental_agent_runtime_enabled()); + assert!(!experimental_peripheral_runtime_enabled()); + } + + #[test] + fn master_experimental_runtime_flag_enables_all_experimental_surfaces() { + let _guard = env_lock().lock().unwrap(); + + std::env::set_var("CAPYINN_EXPERIMENTAL_RUNTIME", "true"); + std::env::remove_var("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME"); + std::env::remove_var("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME"); + std::env::remove_var("CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME"); + + assert!(experimental_gateway_runtime_enabled()); + assert!(experimental_agent_runtime_enabled()); + assert!(experimental_peripheral_runtime_enabled()); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_RUNTIME"); + } + + #[test] + fn individual_experimental_runtime_flags_enable_only_matching_surfaces() { + let _guard = env_lock().lock().unwrap(); + + for name in [ + "CAPYINN_EXPERIMENTAL_RUNTIME", + "CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", + "CAPYINN_EXPERIMENTAL_AGENT_RUNTIME", + "CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME", + "CAPYINN_DISABLE_GATEWAY", + "CAPYINN_DISABLE_CEO_TELEGRAM", + ] { + std::env::remove_var(name); + } + + std::env::set_var("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", "true"); + assert!(experimental_gateway_runtime_enabled()); + assert!(!experimental_agent_runtime_enabled()); + assert!(!experimental_peripheral_runtime_enabled()); + assert!(effective_experimental_gateway_runtime_enabled()); + assert!(!effective_experimental_agent_runtime_enabled()); + std::env::remove_var("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME"); + + std::env::set_var("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME", "true"); + assert!(!experimental_gateway_runtime_enabled()); + assert!(experimental_agent_runtime_enabled()); + assert!(!experimental_peripheral_runtime_enabled()); + assert!(!effective_experimental_gateway_runtime_enabled()); + assert!(effective_experimental_agent_runtime_enabled()); + std::env::remove_var("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME"); + } + + #[test] + fn disable_flags_override_effective_experimental_runtime_flags() { + let _guard = env_lock().lock().unwrap(); + + std::env::set_var("CAPYINN_EXPERIMENTAL_RUNTIME", "true"); + std::env::set_var("CAPYINN_DISABLE_GATEWAY", "true"); + std::env::set_var("CAPYINN_DISABLE_CEO_TELEGRAM", "true"); + + assert!(experimental_gateway_runtime_enabled()); + assert!(experimental_agent_runtime_enabled()); + assert!(gateway_runtime_disabled_by_override()); + assert!(agent_runtime_disabled_by_override()); + assert!(!effective_experimental_gateway_runtime_enabled()); + assert!(!effective_experimental_agent_runtime_enabled()); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_RUNTIME"); + std::env::remove_var("CAPYINN_DISABLE_GATEWAY"); + std::env::remove_var("CAPYINN_DISABLE_CEO_TELEGRAM"); + } + #[test] fn test_now_parses_rfc3339_timestamp() { let _guard = env_lock().lock().unwrap(); From d4fbe7d82feb9143d1f6c5241fbf86e9399ecae3 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 08:42:35 +0700 Subject: [PATCH 18/45] refactor: gate gateway runtime by experimental profile --- mhm/src-tauri/src/lib.rs | 104 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/mhm/src-tauri/src/lib.rs b/mhm/src-tauri/src/lib.rs index c0b0ba7..f077335 100644 --- a/mhm/src-tauri/src/lib.rs +++ b/mhm/src-tauri/src/lib.rs @@ -128,6 +128,36 @@ fn updater_enabled() -> bool { ) } +fn gateway_runtime_effective_enabled() -> bool { + runtime_config::effective_experimental_gateway_runtime_enabled() +} + +fn agent_runtime_effective_enabled() -> bool { + runtime_config::effective_experimental_agent_runtime_enabled() +} + +fn experimental_runtime_status_value() -> serde_json::Value { + serde_json::json!({ + "experimental_runtime_enabled": runtime_config::experimental_runtime_enabled(), + "gateway_runtime_enabled": gateway_runtime_effective_enabled(), + "agent_runtime_enabled": agent_runtime_effective_enabled(), + "gateway_disabled_by_override": runtime_config::gateway_runtime_disabled_by_override(), + "agent_disabled_by_override": runtime_config::agent_runtime_disabled_by_override(), + }) +} + +fn gateway_management_disabled_error() -> String { + "MCP Gateway experimental runtime is disabled. Set CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true or CAPYINN_EXPERIMENTAL_RUNTIME=true to enable gateway management.".to_string() +} + +fn ensure_gateway_management_enabled() -> Result<(), String> { + if gateway_runtime_effective_enabled() { + Ok(()) + } else { + Err(gateway_management_disabled_error()) + } +} + fn spawn_crash_index_rebuild() { std::thread::spawn(|| { if let Err(error) = crash_index::rebuild_current_runtime_root() { @@ -182,7 +212,10 @@ pub fn run() { // axum server task gets cancelled when the runtime drops. let gateway_pool = pool.clone(); let gateway_handle = app.handle().clone(); - let gateway_runtime = if runtime_config::env_flag("CAPYINN_DISABLE_GATEWAY") { + let gateway_runtime = if !runtime_config::experimental_gateway_runtime_enabled() { + info!("MCP Gateway experimental runtime disabled by default"); + None + } else if runtime_config::gateway_runtime_disabled_by_override() { info!("MCP Gateway disabled by CAPYINN_DISABLE_GATEWAY"); None } else { @@ -341,6 +374,7 @@ pub fn run() { // MCP Gateway gateway_generate_key, gateway_get_status, + get_experimental_runtime_status, // Invoice PDF commands::invoices::generate_invoice, commands::invoices::get_invoice, @@ -399,12 +433,18 @@ pub fn run_proxy() { // ─── MCP Gateway Tauri Commands ─── +#[tauri::command] +fn get_experimental_runtime_status() -> serde_json::Value { + experimental_runtime_status_value() +} + #[tauri::command] async fn gateway_generate_key( state: tauri::State<'_, AppState>, label: Option, ) -> Result { commands::require_admin(&state)?; + ensure_gateway_management_enabled()?; let (key, hash) = gateway::auth::generate_api_key(); gateway::auth::store_api_key(&state.db, &hash, label.as_deref().unwrap_or("default")).await?; @@ -415,11 +455,17 @@ async fn gateway_generate_key( async fn gateway_get_status( state: tauri::State<'_, AppState>, ) -> Result { + let experimental_enabled = gateway_runtime_effective_enabled(); let has_keys = gateway::auth::has_api_keys(&state.db).await; - let port = gateway::live_port_from_lockfile(); + let port = if experimental_enabled { + gateway::live_port_from_lockfile() + } else { + None + }; Ok(serde_json::json!({ - "running": port.is_some(), + "experimental_enabled": experimental_enabled, + "running": experimental_enabled && port.is_some(), "port": port, "has_api_keys": has_keys, })) @@ -427,7 +473,10 @@ async fn gateway_get_status( #[cfg(test)] mod tests { - use super::updater_enabled_from_env; + use super::{ + experimental_runtime_status_value, gateway_management_disabled_error, + gateway_runtime_effective_enabled, updater_enabled_from_env, + }; #[test] fn updater_stays_disabled_for_plain_dev_runs() { @@ -443,4 +492,51 @@ mod tests { fn updater_stays_enabled_outside_dev_even_without_env_flag() { assert!(updater_enabled_from_env(false, false)); } + + #[test] + fn experimental_runtime_status_reports_effective_runtime_gates() { + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + + for name in [ + "CAPYINN_EXPERIMENTAL_RUNTIME", + "CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", + "CAPYINN_EXPERIMENTAL_AGENT_RUNTIME", + "CAPYINN_DISABLE_GATEWAY", + "CAPYINN_DISABLE_CEO_TELEGRAM", + ] { + std::env::remove_var(name); + } + + let disabled = experimental_runtime_status_value(); + assert_eq!(disabled["experimental_runtime_enabled"], false); + assert_eq!(disabled["gateway_runtime_enabled"], false); + assert_eq!(disabled["agent_runtime_enabled"], false); + + std::env::set_var("CAPYINN_EXPERIMENTAL_RUNTIME", "true"); + std::env::set_var("CAPYINN_DISABLE_GATEWAY", "true"); + + let gateway_disabled = experimental_runtime_status_value(); + assert_eq!(gateway_disabled["experimental_runtime_enabled"], true); + assert_eq!(gateway_disabled["gateway_runtime_enabled"], false); + assert_eq!(gateway_disabled["gateway_disabled_by_override"], true); + assert_eq!(gateway_disabled["agent_runtime_enabled"], true); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_RUNTIME"); + std::env::remove_var("CAPYINN_DISABLE_GATEWAY"); + } + + #[test] + fn gateway_management_error_is_returned_when_effective_gateway_runtime_is_disabled() { + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_RUNTIME"); + std::env::remove_var("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME"); + std::env::remove_var("CAPYINN_DISABLE_GATEWAY"); + + assert!(!gateway_runtime_effective_enabled()); + assert_eq!( + gateway_management_disabled_error(), + "MCP Gateway experimental runtime is disabled. Set CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true or CAPYINN_EXPERIMENTAL_RUNTIME=true to enable gateway management." + ); + } } From 867b069eb2c6295c32748c195c986c96dff3eaaa Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 08:52:44 +0700 Subject: [PATCH 19/45] refactor: gate agent supervisor by experimental profile --- mhm/src-tauri/src/agent/supervisor.rs | 68 ++++++++++++++++++++++++++- mhm/src-tauri/src/lib.rs | 4 +- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/mhm/src-tauri/src/agent/supervisor.rs b/mhm/src-tauri/src/agent/supervisor.rs index 8eb8195..7bcea6e 100644 --- a/mhm/src-tauri/src/agent/supervisor.rs +++ b/mhm/src-tauri/src/agent/supervisor.rs @@ -356,7 +356,12 @@ pub async fn reconcile_managed_supervisor( return Ok(()); }; - if crate::runtime_config::env_flag("CAPYINN_DISABLE_CEO_TELEGRAM") { + if !crate::runtime_config::experimental_agent_runtime_enabled() { + supervisor.shutdown().await; + return Ok(()); + } + + if crate::runtime_config::agent_runtime_disabled_by_override() { supervisor.shutdown().await; return Ok(()); } @@ -646,6 +651,67 @@ mod tests { } } + #[tokio::test] + async fn managed_supervisor_shuts_down_without_experimental_agent_runtime() { + use sqlx::sqlite::SqlitePoolOptions; + + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + std::env::remove_var("CAPYINN_EXPERIMENTAL_RUNTIME"); + std::env::remove_var("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME"); + std::env::remove_var("CAPYINN_DISABLE_CEO_TELEGRAM"); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect sqlite"); + let supervisor = AgentSupervisor::new_for_test(); + supervisor + .reconcile_for_test(true, true) + .await + .expect("precondition starts workflows"); + assert!(supervisor.is_chat_running_for_test()); + assert!(supervisor.is_digest_running_for_test()); + + reconcile_managed_supervisor(&pool, Some(&supervisor)) + .await + .expect("disabled experimental runtime reconciles"); + + assert!(!supervisor.is_chat_running_for_test()); + assert!(!supervisor.is_digest_running_for_test()); + } + + #[tokio::test] + async fn managed_supervisor_shuts_down_when_agent_runtime_is_force_disabled() { + use sqlx::sqlite::SqlitePoolOptions; + + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + std::env::set_var("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME", "true"); + std::env::set_var("CAPYINN_DISABLE_CEO_TELEGRAM", "true"); + std::env::remove_var("CAPYINN_EXPERIMENTAL_RUNTIME"); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect sqlite"); + let supervisor = AgentSupervisor::new_for_test(); + supervisor + .reconcile_for_test(true, true) + .await + .expect("precondition starts workflows"); + + reconcile_managed_supervisor(&pool, Some(&supervisor)) + .await + .expect("force-disabled experimental runtime reconciles"); + + assert!(!supervisor.is_chat_running_for_test()); + assert!(!supervisor.is_digest_running_for_test()); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME"); + std::env::remove_var("CAPYINN_DISABLE_CEO_TELEGRAM"); + } + #[tokio::test] async fn paired_chat_persists_delivery_chat_id_for_digest() { use crate::agent::digest::config::{ diff --git a/mhm/src-tauri/src/lib.rs b/mhm/src-tauri/src/lib.rs index f077335..e38c2b5 100644 --- a/mhm/src-tauri/src/lib.rs +++ b/mhm/src-tauri/src/lib.rs @@ -234,9 +234,7 @@ pub fn run() { }; let agent_supervisor = agent::supervisor::AgentSupervisor::new(pool.clone()); - if runtime_config::env_flag("CAPYINN_DISABLE_CEO_TELEGRAM") { - info!("CEO Telegram runtime disabled by CAPYINN_DISABLE_CEO_TELEGRAM"); - } else if let Err(error) = rt.block_on(agent::supervisor::reconcile_managed_supervisor( + if let Err(error) = rt.block_on(agent::supervisor::reconcile_managed_supervisor( &pool, Some(&agent_supervisor), )) { From e1d89b22b5a8ece53fce9293b2b88e5557dd5175 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 09:02:37 +0700 Subject: [PATCH 20/45] feat: add frontend experimental runtime profile --- mhm/src/__mocks__/tauri-core.ts | 7 +++ mhm/src/lib/experimentalProfile.test.ts | 41 +++++++++++++ mhm/src/lib/experimentalProfile.ts | 78 +++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 mhm/src/lib/experimentalProfile.test.ts create mode 100644 mhm/src/lib/experimentalProfile.ts diff --git a/mhm/src/__mocks__/tauri-core.ts b/mhm/src/__mocks__/tauri-core.ts index 370abe4..2994cc3 100644 --- a/mhm/src/__mocks__/tauri-core.ts +++ b/mhm/src/__mocks__/tauri-core.ts @@ -78,6 +78,13 @@ export const invoke = vi.fn(async (command: string, args?: Record { + beforeEach(() => { + clearMockResponses(); + }); + + it("normalizes backend experimental runtime status", async () => { + setMockResponse("get_experimental_runtime_status", () => ({ + experimental_runtime_enabled: true, + gateway_runtime_enabled: true, + agent_runtime_enabled: false, + gateway_disabled_by_override: false, + agent_disabled_by_override: true, + })); + + await expect(fetchExperimentalRuntimeStatus()).resolves.toEqual({ + experimentalRuntimeEnabled: true, + gatewayRuntimeEnabled: true, + agentRuntimeEnabled: false, + gatewayDisabledByOverride: false, + agentDisabledByOverride: true, + }); + }); + + it("falls back to disabled profile when status command fails", async () => { + setMockResponse("get_experimental_runtime_status", () => { + throw new Error("runtime status unavailable"); + }); + + await expect(fetchExperimentalRuntimeStatus()).resolves.toEqual( + DISABLED_EXPERIMENTAL_RUNTIME_STATUS, + ); + }); +}); diff --git a/mhm/src/lib/experimentalProfile.ts b/mhm/src/lib/experimentalProfile.ts new file mode 100644 index 0000000..3316de6 --- /dev/null +++ b/mhm/src/lib/experimentalProfile.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +type BackendExperimentalRuntimeStatus = { + experimental_runtime_enabled?: boolean; + gateway_runtime_enabled?: boolean; + agent_runtime_enabled?: boolean; + gateway_disabled_by_override?: boolean; + agent_disabled_by_override?: boolean; +}; + +export type ExperimentalRuntimeStatus = { + experimentalRuntimeEnabled: boolean; + gatewayRuntimeEnabled: boolean; + agentRuntimeEnabled: boolean; + gatewayDisabledByOverride: boolean; + agentDisabledByOverride: boolean; +}; + +export const DISABLED_EXPERIMENTAL_RUNTIME_STATUS: ExperimentalRuntimeStatus = { + experimentalRuntimeEnabled: false, + gatewayRuntimeEnabled: false, + agentRuntimeEnabled: false, + gatewayDisabledByOverride: false, + agentDisabledByOverride: false, +}; + +function normalizeExperimentalRuntimeStatus( + status: BackendExperimentalRuntimeStatus, +): ExperimentalRuntimeStatus { + return { + experimentalRuntimeEnabled: Boolean(status.experimental_runtime_enabled), + gatewayRuntimeEnabled: Boolean(status.gateway_runtime_enabled), + agentRuntimeEnabled: Boolean(status.agent_runtime_enabled), + gatewayDisabledByOverride: Boolean(status.gateway_disabled_by_override), + agentDisabledByOverride: Boolean(status.agent_disabled_by_override), + }; +} + +export async function fetchExperimentalRuntimeStatus(): Promise { + try { + const status = await invoke( + "get_experimental_runtime_status", + ); + return normalizeExperimentalRuntimeStatus(status); + } catch { + return DISABLED_EXPERIMENTAL_RUNTIME_STATUS; + } +} + +export function useExperimentalRuntimeStatus(enabled: boolean): ExperimentalRuntimeStatus { + const [status, setStatus] = useState( + DISABLED_EXPERIMENTAL_RUNTIME_STATUS, + ); + + useEffect(() => { + let cancelled = false; + + if (!enabled) { + setStatus(DISABLED_EXPERIMENTAL_RUNTIME_STATUS); + return () => { + cancelled = true; + }; + } + + fetchExperimentalRuntimeStatus().then((nextStatus) => { + if (!cancelled) { + setStatus(nextStatus); + } + }); + + return () => { + cancelled = true; + }; + }, [enabled]); + + return status; +} From 11a7ccbaee8ed5d1ff14afdc56343a4c878edb26 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 09:10:09 +0700 Subject: [PATCH 21/45] test: harden experimental runtime profile fallback --- mhm/src/lib/experimentalProfile.test.ts | 27 ++++++++++++ mhm/src/lib/experimentalProfile.ts | 57 +++++++++++++++++++------ 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/mhm/src/lib/experimentalProfile.test.ts b/mhm/src/lib/experimentalProfile.test.ts index 7a9c841..59a54f4 100644 --- a/mhm/src/lib/experimentalProfile.test.ts +++ b/mhm/src/lib/experimentalProfile.test.ts @@ -38,4 +38,31 @@ describe("experimentalProfile", () => { DISABLED_EXPERIMENTAL_RUNTIME_STATUS, ); }); + + it("falls back to disabled profile when backend status is missing fields", async () => { + setMockResponse("get_experimental_runtime_status", () => ({ + experimental_runtime_enabled: true, + gateway_runtime_enabled: true, + agent_runtime_enabled: false, + gateway_disabled_by_override: false, + })); + + await expect(fetchExperimentalRuntimeStatus()).resolves.toEqual( + DISABLED_EXPERIMENTAL_RUNTIME_STATUS, + ); + }); + + it("falls back to disabled profile when backend status uses non-boolean values", async () => { + setMockResponse("get_experimental_runtime_status", () => ({ + experimental_runtime_enabled: "true", + gateway_runtime_enabled: 1, + agent_runtime_enabled: false, + gateway_disabled_by_override: false, + agent_disabled_by_override: true, + })); + + await expect(fetchExperimentalRuntimeStatus()).resolves.toEqual( + DISABLED_EXPERIMENTAL_RUNTIME_STATUS, + ); + }); }); diff --git a/mhm/src/lib/experimentalProfile.ts b/mhm/src/lib/experimentalProfile.ts index 3316de6..820eb2a 100644 --- a/mhm/src/lib/experimentalProfile.ts +++ b/mhm/src/lib/experimentalProfile.ts @@ -2,13 +2,21 @@ import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; type BackendExperimentalRuntimeStatus = { - experimental_runtime_enabled?: boolean; - gateway_runtime_enabled?: boolean; - agent_runtime_enabled?: boolean; - gateway_disabled_by_override?: boolean; - agent_disabled_by_override?: boolean; + experimental_runtime_enabled: boolean; + gateway_runtime_enabled: boolean; + agent_runtime_enabled: boolean; + gateway_disabled_by_override: boolean; + agent_disabled_by_override: boolean; }; +const BACKEND_EXPERIMENTAL_RUNTIME_STATUS_KEYS = [ + "experimental_runtime_enabled", + "gateway_runtime_enabled", + "agent_runtime_enabled", + "gateway_disabled_by_override", + "agent_disabled_by_override", +] as const; + export type ExperimentalRuntimeStatus = { experimentalRuntimeEnabled: boolean; gatewayRuntimeEnabled: boolean; @@ -26,22 +34,43 @@ export const DISABLED_EXPERIMENTAL_RUNTIME_STATUS: ExperimentalRuntimeStatus = { }; function normalizeExperimentalRuntimeStatus( - status: BackendExperimentalRuntimeStatus, + status: unknown, ): ExperimentalRuntimeStatus { + if (!isBackendExperimentalRuntimeStatus(status)) { + return DISABLED_EXPERIMENTAL_RUNTIME_STATUS; + } + return { - experimentalRuntimeEnabled: Boolean(status.experimental_runtime_enabled), - gatewayRuntimeEnabled: Boolean(status.gateway_runtime_enabled), - agentRuntimeEnabled: Boolean(status.agent_runtime_enabled), - gatewayDisabledByOverride: Boolean(status.gateway_disabled_by_override), - agentDisabledByOverride: Boolean(status.agent_disabled_by_override), + experimentalRuntimeEnabled: status.experimental_runtime_enabled, + gatewayRuntimeEnabled: status.gateway_runtime_enabled, + agentRuntimeEnabled: status.agent_runtime_enabled, + gatewayDisabledByOverride: status.gateway_disabled_by_override, + agentDisabledByOverride: status.agent_disabled_by_override, }; } +function isBackendExperimentalRuntimeStatus( + status: unknown, +): status is BackendExperimentalRuntimeStatus { + if (!isRecord(status)) { + return false; + } + + return BACKEND_EXPERIMENTAL_RUNTIME_STATUS_KEYS.every( + (key) => typeof status[key] === "boolean", + ); +} + +function isRecord(status: unknown): status is Record { + return ( + typeof status === "object" && + status !== null + ); +} + export async function fetchExperimentalRuntimeStatus(): Promise { try { - const status = await invoke( - "get_experimental_runtime_status", - ); + const status = await invoke("get_experimental_runtime_status"); return normalizeExperimentalRuntimeStatus(status); } catch { return DISABLED_EXPERIMENTAL_RUNTIME_STATUS; From e4069e8d0cbd14ca7f90598ff827baa6fcb112c8 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 09:17:41 +0700 Subject: [PATCH 22/45] refactor: hide app gateway surfaces by runtime profile --- mhm/src/App.experimentalRuntime.test.tsx | 148 +++++++++++++++++++++++ mhm/src/App.tsx | 22 ++-- 2 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 mhm/src/App.experimentalRuntime.test.tsx diff --git a/mhm/src/App.experimentalRuntime.test.tsx b/mhm/src/App.experimentalRuntime.test.tsx new file mode 100644 index 0000000..4d208ab --- /dev/null +++ b/mhm/src/App.experimentalRuntime.test.tsx @@ -0,0 +1,148 @@ +import type { ButtonHTMLAttributes, HTMLAttributes } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import App from "./App"; +import { clearMockResponses, invoke, setMockResponses } from "./__mocks__/tauri-core"; +import { listen, resetEventMocks } from "./__mocks__/tauri-event"; +import { useAuthStore } from "./stores/useAuthStore"; +import { useHotelStore } from "./stores/useHotelStore"; + +vi.mock("./pages/Dashboard", () => ({ default: () =>
Dashboard page
})); +vi.mock("./pages/Rooms", () => ({ default: () =>
Rooms page
})); +vi.mock("./pages/Reservations", () => ({ default: () =>
Reservations page
})); +vi.mock("./pages/Guests", () => ({ default: () =>
Guests page
})); +vi.mock("./pages/Housekeeping", () => ({ default: () =>
Housekeeping page
})); +vi.mock("./pages/Analytics", () => ({ default: () =>
Analytics page
})); +vi.mock("./pages/settings", () => ({ default: () =>
Settings page
})); +vi.mock("./pages/NightAudit", () => ({ default: () =>
Night Audit page
})); +vi.mock("./pages/LoginScreen", () => ({ default: () =>
Login page
})); +vi.mock("./pages/onboarding", () => ({ default: () =>
Onboarding page
})); +vi.mock("./pages/GroupManagement", () => ({ default: () =>
Group page
})); +vi.mock("./components/CheckinSheet", () => ({ default: () => null })); +vi.mock("./components/GroupCheckinSheet", () => ({ default: () => null })); +vi.mock("./components/AppLogo", () => ({ default: () =>
Logo
})); +vi.mock("./hooks/useAppUpdateController", () => ({ + useAppUpdateController: () => ({ + supported: false, + phase: "idle", + currentVersion: "0.1.1", + availableVersion: null, + restartPromptOpen: false, + errorMessage: null, + canCheck: false, + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + dismissRestartPrompt: vi.fn(), + openRestartPrompt: vi.fn(), + confirmInstall: vi.fn(), + }), +})); +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children, ...props }: HTMLAttributes) => ( +
{children}
+ ), +})); +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), +})); +vi.mock("sonner", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn() }), + Toaster: () =>
, +})); + +function setupAuthenticatedShell( + runtimeStatus = { + experimental_runtime_enabled: false, + gateway_runtime_enabled: false, + agent_runtime_enabled: false, + gateway_disabled_by_override: false, + agent_disabled_by_override: false, + }, +) { + useAuthStore.setState({ + user: { id: "admin-1", name: "Owner", role: "admin", active: true, created_at: "" }, + isAuthenticated: true, + loading: false, + error: null, + }); + useHotelStore.setState({ + rooms: [], + stats: null, + roomDetail: null, + activeTab: "dashboard", + housekeepingTasks: [], + loading: false, + isCheckinOpen: false, + checkinRoomId: null, + isGroupCheckinOpen: false, + groups: [], + }); + setMockResponses({ + get_bootstrap_status: () => ({ + setup_completed: true, + app_lock_enabled: false, + current_user: { id: "admin-1", name: "Owner", role: "admin", active: true, created_at: "" }, + }), + get_experimental_runtime_status: () => runtimeStatus, + get_rooms: () => [], + get_dashboard_stats: () => ({ + total_rooms: 10, + occupied: 0, + vacant: 10, + cleaning: 0, + revenue_today: 0, + }), + }); +} + +describe("App experimental runtime gates", () => { + beforeEach(() => { + clearMockResponses(); + resetEventMocks(); + invoke.mockClear(); + vi.clearAllMocks(); + localStorage.setItem("sidebar-collapsed", "false"); + }); + + it("normal profile does not call gateway status, render gateway badge, or subscribe to MCP events", async () => { + setupAuthenticatedShell(); + + render(); + + await waitFor(() => { + expect(screen.getByText("Overview")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(invoke.mock.calls.some(([command]) => command === "get_experimental_runtime_status")).toBe(true); + }); + expect(invoke.mock.calls.some(([command]) => command === "gateway_get_status")).toBe(false); + expect(screen.queryByText(/Gateway/i)).not.toBeInTheDocument(); + expect(listen).not.toHaveBeenCalledWith("mcp_reservation_created", expect.any(Function)); + }); + + it("experimental gateway profile checks gateway status and subscribes to MCP events", async () => { + setupAuthenticatedShell({ + experimental_runtime_enabled: false, + gateway_runtime_enabled: true, + agent_runtime_enabled: false, + gateway_disabled_by_override: false, + agent_disabled_by_override: false, + }); + setMockResponses({ + gateway_get_status: () => ({ running: true, port: 61239, has_api_keys: true }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("● MCP Gateway")).toBeInTheDocument(); + }); + + expect(invoke.mock.calls.some(([command]) => command === "gateway_get_status")).toBe(true); + expect(listen).toHaveBeenCalledWith("mcp_reservation_created", expect.any(Function)); + }); +}); diff --git a/mhm/src/App.tsx b/mhm/src/App.tsx index 9770dd4..8438b13 100644 --- a/mhm/src/App.tsx +++ b/mhm/src/App.tsx @@ -32,6 +32,7 @@ import { APP_NAME } from "@/lib/appIdentity"; import { hasRemoteCrashReporting, submitCrashBundle } from "@/lib/crashReporting/sentry"; import type { CrashReportSummary } from "@/lib/crashReporting/types"; import { createDeferredCleanup } from "@/lib/deferredCleanup"; +import { useExperimentalRuntimeStatus } from "@/lib/experimentalProfile"; import { useAppUpdateController } from "@/hooks/useAppUpdateController"; import { Toaster, toast } from "sonner"; import { invoke } from "@tauri-apps/api/core"; @@ -105,6 +106,8 @@ export default function App() { !bootstrapLoading && Boolean(bootstrap?.setup_completed) && (!bootstrap?.app_lock_enabled || isAuthenticated); + const experimentalRuntime = useExperimentalRuntimeStatus(isAuthenticated); + const gatewayRuntimeEnabled = experimentalRuntime.gatewayRuntimeEnabled; const appUpdate = useAppUpdateController({ enabled: shellReady, supported: __UPDATER_ENABLED__, @@ -142,15 +145,18 @@ export default function App() { // Gateway status check useEffect(() => { - if (!isAuthenticated) return; + if (!isAuthenticated || !gatewayRuntimeEnabled) { + setGatewayRunning(false); + return; + } invoke<{ running: boolean }>("gateway_get_status") .then((s) => setGatewayRunning(s.running)) .catch(() => setGatewayRunning(false)); - }, [isAuthenticated]); + }, [gatewayRuntimeEnabled, isAuthenticated]); // MCP Gateway events: AI agent reservation notifications useEffect(() => { - if (!isAuthenticated) return; + if (!isAuthenticated || !gatewayRuntimeEnabled) return; const cleanup = createDeferredCleanup(listen<{ booking_id: string; room_id: string }>("mcp_reservation_created", (e) => { toast("🤖 AI Agent vừa tạo booking mới", { description: `Phòng ${e.payload.room_id} — ID: ${e.payload.booking_id}`, @@ -159,7 +165,7 @@ export default function App() { fetchStats(); })); return cleanup; - }, [isAuthenticated]); + }, [fetchRooms, fetchStats, gatewayRuntimeEnabled, isAuthenticated]); useEffect(() => { const cleanup = createDeferredCleanup( @@ -551,9 +557,11 @@ export default function App() { {user.role === 'admin' ? '👑 Admin' : '🏨 Lễ tân'} )} - setTab('settings' as any)}> - {gatewayRunning ? '● MCP Gateway' : '○ Gateway Off'} - + {gatewayRuntimeEnabled && ( + setTab('settings' as any)}> + {gatewayRunning ? '● MCP Gateway' : '○ Gateway Off'} + + )} ● Scanner Ready From c59e1b36ae03fddff6f1f8073f5c48b09e6e0bee Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 09:27:38 +0700 Subject: [PATCH 23/45] fix: ignore stale gateway status responses --- mhm/src/App.experimentalRuntime.test.tsx | 72 +++++++++++++++++++++++- mhm/src/App.tsx | 19 ++++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/mhm/src/App.experimentalRuntime.test.tsx b/mhm/src/App.experimentalRuntime.test.tsx index 4d208ab..ce3e258 100644 --- a/mhm/src/App.experimentalRuntime.test.tsx +++ b/mhm/src/App.experimentalRuntime.test.tsx @@ -1,10 +1,12 @@ import type { ButtonHTMLAttributes, HTMLAttributes } from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; import { clearMockResponses, invoke, setMockResponses } from "./__mocks__/tauri-core"; import { listen, resetEventMocks } from "./__mocks__/tauri-event"; +import * as experimentalProfile from "@/lib/experimentalProfile"; +import type { ExperimentalRuntimeStatus } from "@/lib/experimentalProfile"; import { useAuthStore } from "./stores/useAuthStore"; import { useHotelStore } from "./stores/useHotelStore"; @@ -145,4 +147,72 @@ describe("App experimental runtime gates", () => { expect(invoke.mock.calls.some(([command]) => command === "gateway_get_status")).toBe(true); expect(listen).toHaveBeenCalledWith("mcp_reservation_created", expect.any(Function)); }); + + it("ignores gateway status responses from a disabled profile generation", async () => { + setupAuthenticatedShell(); + + let runtimeStatus: ExperimentalRuntimeStatus = { + experimentalRuntimeEnabled: false, + gatewayRuntimeEnabled: true, + agentRuntimeEnabled: false, + gatewayDisabledByOverride: false, + agentDisabledByOverride: false, + }; + const runtimeSpy = vi + .spyOn(experimentalProfile, "useExperimentalRuntimeStatus") + .mockImplementation(() => runtimeStatus); + + let resolveFirstGatewayStatus: (status: { running: boolean }) => void = () => {}; + const firstGatewayStatus = new Promise<{ running: boolean }>((resolve) => { + resolveFirstGatewayStatus = resolve; + }); + const secondGatewayStatus = new Promise<{ running: boolean }>(() => {}); + let gatewayStatusCalls = 0; + + setMockResponses({ + gateway_get_status: () => { + gatewayStatusCalls += 1; + return gatewayStatusCalls === 1 ? firstGatewayStatus : secondGatewayStatus; + }, + }); + + const { rerender } = render(); + + await waitFor(() => { + expect(gatewayStatusCalls).toBe(1); + }); + + await act(async () => { + runtimeStatus = { + ...runtimeStatus, + gatewayRuntimeEnabled: false, + }; + rerender(); + }); + + await waitFor(() => { + expect(screen.queryByText(/Gateway/i)).not.toBeInTheDocument(); + }); + + await act(async () => { + resolveFirstGatewayStatus({ running: true }); + await Promise.resolve(); + }); + + await act(async () => { + runtimeStatus = { + ...runtimeStatus, + gatewayRuntimeEnabled: true, + }; + rerender(); + }); + + await waitFor(() => { + expect(gatewayStatusCalls).toBe(2); + }); + + expect(screen.getByText("○ Gateway Off")).toBeInTheDocument(); + expect(screen.queryByText("● MCP Gateway")).not.toBeInTheDocument(); + runtimeSpy.mockRestore(); + }); }); diff --git a/mhm/src/App.tsx b/mhm/src/App.tsx index 8438b13..990de1a 100644 --- a/mhm/src/App.tsx +++ b/mhm/src/App.tsx @@ -145,13 +145,26 @@ export default function App() { // Gateway status check useEffect(() => { + let cancelled = false; + if (!isAuthenticated || !gatewayRuntimeEnabled) { setGatewayRunning(false); - return; + return () => { + cancelled = true; + }; } + invoke<{ running: boolean }>("gateway_get_status") - .then((s) => setGatewayRunning(s.running)) - .catch(() => setGatewayRunning(false)); + .then((s) => { + if (!cancelled) setGatewayRunning(s.running); + }) + .catch(() => { + if (!cancelled) setGatewayRunning(false); + }); + + return () => { + cancelled = true; + }; }, [gatewayRuntimeEnabled, isAuthenticated]); // MCP Gateway events: AI agent reservation notifications From b4239e89bc940d55b749080786cfb9cb79d50621 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 09:36:07 +0700 Subject: [PATCH 24/45] refactor: hide experimental settings by runtime profile --- .../pages/settings/CeoAgentSection.test.tsx | 40 +++++++++++- .../SettingsExperimentalRuntime.test.tsx | 61 +++++++++++++++++++ mhm/src/pages/settings/index.tsx | 37 +++++++++-- 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 mhm/src/pages/settings/SettingsExperimentalRuntime.test.tsx diff --git a/mhm/src/pages/settings/CeoAgentSection.test.tsx b/mhm/src/pages/settings/CeoAgentSection.test.tsx index be0908f..fe14e68 100644 --- a/mhm/src/pages/settings/CeoAgentSection.test.tsx +++ b/mhm/src/pages/settings/CeoAgentSection.test.tsx @@ -2,7 +2,12 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { clearMockResponses, invoke, setMockResponses } from "@/__mocks__/tauri-core"; +import { + clearMockResponses, + invoke, + setMockResponse, + setMockResponses, +} from "@/__mocks__/tauri-core"; import { useAuthStore } from "@/stores/useAuthStore"; import CeoAgentSection from "./CeoAgentSection"; import SettingsPage from "./index"; @@ -97,6 +102,13 @@ function mockInitialState( }, ) { setMockResponses({ + get_experimental_runtime_status: () => ({ + experimental_runtime_enabled: true, + gateway_runtime_enabled: false, + agent_runtime_enabled: true, + gateway_disabled_by_override: false, + agent_disabled_by_override: false, + }), get_ceo_cloud_data_opt_in: () => true, get_ceo_telegram_config: () => config, get_ceo_telegram_gate_status: () => gate, @@ -733,7 +745,7 @@ describe("SettingsPage CEO Agent nav", () => { invoke.mockClear(); }); - it("shows CEO Agent in settings nav for admins and renders the section", async () => { + it("shows CEO Agent in settings nav for admins when agent runtime is experimental-enabled", async () => { const user = userEvent.setup(); useAuthStore.setState({ user: { id: "u1", name: "Admin", role: "admin", active: true, created_at: "" }, @@ -746,12 +758,34 @@ describe("SettingsPage CEO Agent nav", () => { render(); - const navButton = screen.getByRole("button", { name: "CEO Agent" }); + const navButton = await screen.findByRole("button", { name: "CEO Agent" }); await user.click(navButton); expect(await screen.findByText("CEO Telegram Chat")).toBeInTheDocument(); }); + it("hides CEO Agent in settings nav for admins when agent runtime is disabled", async () => { + useAuthStore.setState({ + user: { id: "u1", name: "Admin", role: "admin", active: true, created_at: "" }, + isAuthenticated: true, + loading: false, + error: null, + }); + setMockResponse("get_experimental_runtime_status", () => ({ + experimental_runtime_enabled: false, + gateway_runtime_enabled: false, + agent_runtime_enabled: false, + gateway_disabled_by_override: false, + agent_disabled_by_override: false, + })); + + render(); + + await waitFor(() => { + expect(screen.queryByRole("button", { name: "CEO Agent" })).not.toBeInTheDocument(); + }); + }); + it("does not show CEO Agent in settings nav for receptionist users", () => { useAuthStore.setState({ user: { id: "u2", name: "Reception", role: "receptionist", active: true, created_at: "" }, diff --git a/mhm/src/pages/settings/SettingsExperimentalRuntime.test.tsx b/mhm/src/pages/settings/SettingsExperimentalRuntime.test.tsx new file mode 100644 index 0000000..8083bb8 --- /dev/null +++ b/mhm/src/pages/settings/SettingsExperimentalRuntime.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { clearMockResponses, setMockResponse } from "@/__mocks__/tauri-core"; +import { useAuthStore } from "@/stores/useAuthStore"; +import SettingsPage from "./index"; + +function setAdmin() { + useAuthStore.setState({ + user: { id: "u1", name: "Admin", role: "admin", active: true, created_at: "" }, + isAuthenticated: true, + loading: false, + error: null, + }); +} + +function setRuntimeStatus(gateway: boolean, agent: boolean) { + setMockResponse("get_experimental_runtime_status", () => ({ + experimental_runtime_enabled: gateway || agent, + gateway_runtime_enabled: gateway, + agent_runtime_enabled: agent, + gateway_disabled_by_override: false, + agent_disabled_by_override: false, + })); +} + +describe("SettingsPage experimental runtime gates", () => { + beforeEach(() => { + clearMockResponses(); + setAdmin(); + }); + + it("hides MCP Gateway and CEO Agent in the normal profile", async () => { + setRuntimeStatus(false, false); + + render(); + + await waitFor(() => { + expect(screen.queryByRole("button", { name: "MCP Gateway" })).not.toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: "CEO Agent" })).not.toBeInTheDocument(); + }); + + it("shows MCP Gateway when gateway runtime is enabled", async () => { + setRuntimeStatus(true, false); + + render(); + + expect(await screen.findByRole("button", { name: "MCP Gateway" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "CEO Agent" })).not.toBeInTheDocument(); + }); + + it("shows CEO Agent when agent runtime is enabled", async () => { + setRuntimeStatus(false, true); + + render(); + + expect(await screen.findByRole("button", { name: "CEO Agent" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "MCP Gateway" })).not.toBeInTheDocument(); + }); +}); diff --git a/mhm/src/pages/settings/index.tsx b/mhm/src/pages/settings/index.tsx index dad4046..ee15858 100644 --- a/mhm/src/pages/settings/index.tsx +++ b/mhm/src/pages/settings/index.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { BedDouble, Bot, @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { Card } from "@/components/ui/card"; +import { useExperimentalRuntimeStatus } from "@/lib/experimentalProfile"; import { useAuthStore } from "@/stores/useAuthStore"; import AppearanceSection from "./AppearanceSection"; @@ -46,6 +47,7 @@ type SettingsSectionKey = export default function SettingsPage() { const { isAdmin } = useAuthStore(); const [activeSection, setActiveSection] = useState("hotel"); + const experimentalRuntime = useExperimentalRuntimeStatus(true); const sections = [ { key: "hotel" as const, label: "Hotel Info", icon: Building2 }, @@ -55,17 +57,38 @@ export default function SettingsPage() { { key: "appearance" as const, label: "Appearance", icon: Palette }, { key: "diagnostics" as const, label: "Diagnostics", icon: Database }, { key: "data" as const, label: "Data & Backup", icon: Database }, - { key: "gateway" as const, label: "MCP Gateway", icon: Wifi }, + ...(experimentalRuntime.gatewayRuntimeEnabled + ? [{ key: "gateway" as const, label: "MCP Gateway", icon: Wifi }] + : []), { key: "updates" as const, label: "Software Update", icon: RefreshCcw }, + ...(isAdmin() && experimentalRuntime.agentRuntimeEnabled + ? [{ key: "ceo-agent" as const, label: "CEO Agent", icon: Bot }] + : []), ...(isAdmin() ? [ - { key: "ceo-agent" as const, label: "CEO Agent", icon: Bot }, { key: "pricing" as const, label: "Pricing", icon: DollarSign }, { key: "users" as const, label: "Users", icon: Users }, ] : []), ]; + useEffect(() => { + if ( + (activeSection === "gateway" && !experimentalRuntime.gatewayRuntimeEnabled) || + ( + activeSection === "ceo-agent" && + (!isAdmin() || !experimentalRuntime.agentRuntimeEnabled) + ) + ) { + setActiveSection("hotel"); + } + }, [ + activeSection, + experimentalRuntime.agentRuntimeEnabled, + experimentalRuntime.gatewayRuntimeEnabled, + isAdmin, + ]); + return (
@@ -96,9 +119,13 @@ export default function SettingsPage() { {activeSection === "appearance" && } {activeSection === "diagnostics" && } {activeSection === "data" && } - {activeSection === "gateway" && } + {activeSection === "gateway" && experimentalRuntime.gatewayRuntimeEnabled && ( + + )} {activeSection === "updates" && } - {activeSection === "ceo-agent" && isAdmin() && } + {activeSection === "ceo-agent" && isAdmin() && experimentalRuntime.agentRuntimeEnabled && ( + + )} {activeSection === "pricing" && isAdmin() && } {activeSection === "users" && isAdmin() && } From 7e597b226f9ae691824e33a977ca13f7ac0ff43a Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 09:46:28 +0700 Subject: [PATCH 25/45] fix: reset hidden experimental settings sections --- .../pages/settings/CeoAgentSection.test.tsx | 42 ++++++++++++++++++- .../SettingsExperimentalRuntime.test.tsx | 8 +++- mhm/src/pages/settings/index.tsx | 21 ++++++---- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/mhm/src/pages/settings/CeoAgentSection.test.tsx b/mhm/src/pages/settings/CeoAgentSection.test.tsx index fe14e68..ea27550 100644 --- a/mhm/src/pages/settings/CeoAgentSection.test.tsx +++ b/mhm/src/pages/settings/CeoAgentSection.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -786,16 +786,54 @@ describe("SettingsPage CEO Agent nav", () => { }); }); - it("does not show CEO Agent in settings nav for receptionist users", () => { + it("resets CEO Agent section to Hotel Info when admin access is lost", async () => { + const user = userEvent.setup(); + useAuthStore.setState({ + user: { id: "u1", name: "Admin", role: "admin", active: true, created_at: "" }, + isAuthenticated: true, + loading: false, + error: null, + }); + mockInitialState(); + + render(); + + await user.click(await screen.findByRole("button", { name: "CEO Agent" })); + expect(await screen.findByText("CEO Telegram Chat")).toBeInTheDocument(); + + await act(async () => { + useAuthStore.setState({ + user: { id: "u2", name: "Reception", role: "receptionist", active: true, created_at: "" }, + isAuthenticated: true, + loading: false, + error: null, + }); + }); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Thông tin khách sạn" })).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: "CEO Agent" })).not.toBeInTheDocument(); + }); + + it("does not show CEO Agent in settings nav for receptionist users", async () => { useAuthStore.setState({ user: { id: "u2", name: "Reception", role: "receptionist", active: true, created_at: "" }, isAuthenticated: true, loading: false, error: null, }); + setMockResponse("get_experimental_runtime_status", () => ({ + experimental_runtime_enabled: true, + gateway_runtime_enabled: true, + agent_runtime_enabled: true, + gateway_disabled_by_override: false, + agent_disabled_by_override: false, + })); render(); + expect(await screen.findByRole("button", { name: "MCP Gateway" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "CEO Agent" })).not.toBeInTheDocument(); }); }); diff --git a/mhm/src/pages/settings/SettingsExperimentalRuntime.test.tsx b/mhm/src/pages/settings/SettingsExperimentalRuntime.test.tsx index 8083bb8..12c68a7 100644 --- a/mhm/src/pages/settings/SettingsExperimentalRuntime.test.tsx +++ b/mhm/src/pages/settings/SettingsExperimentalRuntime.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; -import { clearMockResponses, setMockResponse } from "@/__mocks__/tauri-core"; +import { clearMockResponses, invoke, setMockResponse } from "@/__mocks__/tauri-core"; import { useAuthStore } from "@/stores/useAuthStore"; import SettingsPage from "./index"; @@ -27,6 +27,7 @@ function setRuntimeStatus(gateway: boolean, agent: boolean) { describe("SettingsPage experimental runtime gates", () => { beforeEach(() => { clearMockResponses(); + invoke.mockClear(); setAdmin(); }); @@ -35,6 +36,11 @@ describe("SettingsPage experimental runtime gates", () => { render(); + await waitFor(() => { + expect( + invoke.mock.calls.some(([command]) => command === "get_experimental_runtime_status"), + ).toBe(true); + }); await waitFor(() => { expect(screen.queryByRole("button", { name: "MCP Gateway" })).not.toBeInTheDocument(); }); diff --git a/mhm/src/pages/settings/index.tsx b/mhm/src/pages/settings/index.tsx index ee15858..6b14a4a 100644 --- a/mhm/src/pages/settings/index.tsx +++ b/mhm/src/pages/settings/index.tsx @@ -48,6 +48,7 @@ export default function SettingsPage() { const { isAdmin } = useAuthStore(); const [activeSection, setActiveSection] = useState("hotel"); const experimentalRuntime = useExperimentalRuntimeStatus(true); + const isCurrentAdmin = isAdmin(); const sections = [ { key: "hotel" as const, label: "Hotel Info", icon: Building2 }, @@ -61,10 +62,10 @@ export default function SettingsPage() { ? [{ key: "gateway" as const, label: "MCP Gateway", icon: Wifi }] : []), { key: "updates" as const, label: "Software Update", icon: RefreshCcw }, - ...(isAdmin() && experimentalRuntime.agentRuntimeEnabled + ...(isCurrentAdmin && experimentalRuntime.agentRuntimeEnabled ? [{ key: "ceo-agent" as const, label: "CEO Agent", icon: Bot }] : []), - ...(isAdmin() + ...(isCurrentAdmin ? [ { key: "pricing" as const, label: "Pricing", icon: DollarSign }, { key: "users" as const, label: "Users", icon: Users }, @@ -77,7 +78,7 @@ export default function SettingsPage() { (activeSection === "gateway" && !experimentalRuntime.gatewayRuntimeEnabled) || ( activeSection === "ceo-agent" && - (!isAdmin() || !experimentalRuntime.agentRuntimeEnabled) + (!isCurrentAdmin || !experimentalRuntime.agentRuntimeEnabled) ) ) { setActiveSection("hotel"); @@ -86,7 +87,7 @@ export default function SettingsPage() { activeSection, experimentalRuntime.agentRuntimeEnabled, experimentalRuntime.gatewayRuntimeEnabled, - isAdmin, + isCurrentAdmin, ]); return ( @@ -123,11 +124,13 @@ export default function SettingsPage() { )} {activeSection === "updates" && } - {activeSection === "ceo-agent" && isAdmin() && experimentalRuntime.agentRuntimeEnabled && ( - - )} - {activeSection === "pricing" && isAdmin() && } - {activeSection === "users" && isAdmin() && } + {activeSection === "ceo-agent" && + isCurrentAdmin && + experimentalRuntime.agentRuntimeEnabled && ( + + )} + {activeSection === "pricing" && isCurrentAdmin && } + {activeSection === "users" && isCurrentAdmin && }
); From 84718c598dfd91c4fe0de1cb58255b5251c6ffd9 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 09:53:37 +0700 Subject: [PATCH 26/45] test: allow experimental runtime status invoke --- mhm/tests/frontend-invoke-wrapper-guardrails.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts index 6d8acca..194f13d 100644 --- a/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts +++ b/mhm/tests/frontend-invoke-wrapper-guardrails.test.ts @@ -36,6 +36,7 @@ const RAW_INVOKE_ALLOWED_COMMANDS: Record = { get_current_user: "auth session read", get_dashboard_stats: "read-only dashboard stats lookup", get_expenses: "read-only expense lookup", + get_experimental_runtime_status: "runtime profile read used to hide experimental surfaces", get_guest_history: "read-only guest history lookup", get_housekeeping_tasks: "read-only housekeeping task lookup", get_pending_crash_report: "diagnostics recovery read", From 2729514ac0eb624c45ac31c5b036eeb1b7cd2977 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 09:59:47 +0700 Subject: [PATCH 27/45] test: enable gateway profile in settings api key test --- mhm/tests/e2e/08-settings.test.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mhm/tests/e2e/08-settings.test.tsx b/mhm/tests/e2e/08-settings.test.tsx index 7eacf32..c6c9cd8 100644 --- a/mhm/tests/e2e/08-settings.test.tsx +++ b/mhm/tests/e2e/08-settings.test.tsx @@ -310,11 +310,18 @@ describe("08 — Settings", () => { it("disables API key generation for non-admin users", async () => { setAuthenticatedUser("receptionist"); + setMockResponse("get_experimental_runtime_status", () => ({ + experimental_runtime_enabled: true, + gateway_runtime_enabled: true, + agent_runtime_enabled: false, + gateway_disabled_by_override: false, + agent_disabled_by_override: false, + })); const user = userEvent.setup(); render(); - await user.click(screen.getByText("MCP Gateway")); + await user.click(await screen.findByText("MCP Gateway")); await waitFor(() => { expect(screen.getByRole("button", { name: "Tạo API Key" })).toBeDisabled(); From 65eb6a2a61691cd887430ec339e98a278c971a7b Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 10:10:15 +0700 Subject: [PATCH 28/45] chore: keep experimental runtime gates clippy-clean --- mhm/src-tauri/src/agent/supervisor.rs | 2 ++ mhm/src-tauri/src/runtime_config.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/mhm/src-tauri/src/agent/supervisor.rs b/mhm/src-tauri/src/agent/supervisor.rs index 7bcea6e..10a9ee0 100644 --- a/mhm/src-tauri/src/agent/supervisor.rs +++ b/mhm/src-tauri/src/agent/supervisor.rs @@ -652,6 +652,7 @@ mod tests { } #[tokio::test] + #[allow(clippy::await_holding_lock)] async fn managed_supervisor_shuts_down_without_experimental_agent_runtime() { use sqlx::sqlite::SqlitePoolOptions; @@ -682,6 +683,7 @@ mod tests { } #[tokio::test] + #[allow(clippy::await_holding_lock)] async fn managed_supervisor_shuts_down_when_agent_runtime_is_force_disabled() { use sqlx::sqlite::SqlitePoolOptions; diff --git a/mhm/src-tauri/src/runtime_config.rs b/mhm/src-tauri/src/runtime_config.rs index 535ea48..741783d 100644 --- a/mhm/src-tauri/src/runtime_config.rs +++ b/mhm/src-tauri/src/runtime_config.rs @@ -25,6 +25,7 @@ pub fn experimental_agent_runtime_enabled() -> bool { experimental_runtime_enabled() || env_flag("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME") } +#[allow(dead_code)] pub fn experimental_peripheral_runtime_enabled() -> bool { experimental_runtime_enabled() || env_flag("CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME") } From 5d00d3756f811a1362372dfb48060afa9608711f Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 10:20:10 +0700 Subject: [PATCH 29/45] chore: drop stale frontend wrapper spec --- ...26-05-14-frontend-invoke-wrapper-design.md | 238 ------------------ 1 file changed, 238 deletions(-) delete mode 100644 docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md diff --git a/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md b/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md deleted file mode 100644 index edb293f..0000000 --- a/docs/superpowers/specs/2026-05-14-frontend-invoke-wrapper-design.md +++ /dev/null @@ -1,238 +0,0 @@ -# Frontend Invoke Wrapper Design - -Date: 2026-05-14 - -Issue: #140 - -Planned PR title: `frontend: normalize PMS invoke wrapper usage` - -## Goal - -Normalize obvious frontend PMS write calls through the existing Tauri invocation wrapper without changing backend command names, business request fields, response shapes, or frontend store architecture. - -The work should make raw `invoke` usage easier to audit. PMS business writes should go through `invokeWriteCommand` when practical. Raw `invoke` may remain for system, runtime, export, diagnostics, gateway, bootstrap, and other non-PMS or low-risk calls when there is a clear reason. - -Issue #140 says not to change request/response payloads. This spec interprets that as: do not change business request fields, command names, response shapes, or UI behavior. Converted writes may add only the wrapper metadata that `invokeWriteCommand` already owns, currently `idempotencyKey` and optional `correlationId`, and only after the compatibility checks below show the command can tolerate that metadata. If a command cannot tolerate wrapper metadata, it must stay raw in Batch 1 and be recorded as a follow-up rather than forcing a backend change into this frontend batch. - -## Scope - -In scope: - -- Convert clear raw frontend PMS writes to `invokeWriteCommand`. -- Preserve business payload fields and command names at each converted call site. -- Keep `invokeWriteCommand` as the canonical place that adds `idempotencyKey`, optional `correlationId`, app-error normalization, and monitored command failure capture. -- Add a static frontend guardrail test so remaining raw `invoke` calls are intentional and explainable. -- Update focused frontend tests for converted call sites where the existing test surface can verify wrapper usage and payload shape. -- Validate with `npm test`, `npm run build`, and an `rg` scan for remaining raw invokes. - -Out of scope: - -- Backend command renames. -- Backend request or response schema changes. -- Zustand or page architecture rewrites. -- A full migration of every read command. -- A full migration of writes that already use `invokeCommand` but may not safely accept an added idempotency field yet. -- Runtime/system invoke cleanup for crash reporting, gateway, bootstrap, update, backup, export, or diagnostics flows. - -## Current State - -`mhm/src/lib/invokeCommand.ts` already provides the intended boundary: - -- `invokeCommand` wraps Tauri `invoke`, merges an optional `correlationId`, normalizes app errors, and sends command failure monitoring for monitored commands. -- `invokeWriteCommand` calls `invokeCommand` after adding a command-scoped `idempotencyKey`. -- `createIdempotencyKey` formats keys as `command:`. - -Several frontend PMS writes already use `invokeWriteCommand`, including reservation confirmation/cancel, reservation create/modify, check-in, check-out, group checkout, group services, invoice generation, and CEO agent settings. - -That list is descriptive, not scope expansion. Existing wrapper users such as CEO agent settings are not Batch 1 candidates unless they are already touched by the explicit tasks below. - -The remaining raw `invoke` usage contains a mix of reads, system/runtime calls, exports, diagnostics, gateway calls, and a small number of obvious PMS writes. - -## PMS Safety Boundary - -The frontend wrapper is not the full PMS command boundary described in `AGENTS.md`. It can supply or forward frontend metadata, but backend command handling is responsible for actor resolution, command name persistence, canonical payload hashing, timestamping, request context, authorization, locking, mutation, audit, outbox writes, and transactionality. - -For backend commands that already use `WriteCommandContext` or an equivalent backend command executor, `invokeWriteCommand` participates in that boundary by supplying an idempotency key and optional correlation id. For legacy backend commands that do not consume wrapper metadata, this batch may normalize the frontend call only if the command is invocation-compatible, but it must not claim the command is fully PMS-safety-compliant. Any missing backend command-boundary work is a follow-up outside #140 Batch 1. - -## Invocation Categories - -### PMS business writes - -PMS business writes are commands that mutate hotel operational state or persistent settings exposed as PMS configuration. These should use `invokeWriteCommand` when the backend command can safely accept the wrapper's added `idempotencyKey`. - -Batch 1 candidates: - -- `save_pricing_rule` in `mhm/src/pages/settings/PricingSection.tsx`. -- `save_settings` for `checkin_rules` in `mhm/src/pages/settings/CheckinRulesSection.tsx`. -- `save_settings` for `hotel_info` in `mhm/src/pages/settings/HotelInfoSection.tsx`. -- `update_housekeeping` in `mhm/src/stores/useHotelStore.ts`. - -These calls currently use raw `invoke` and are direct writes. The implementation must keep their existing business payload fields intact. - -Before converting any candidate, implementation must record compatibility evidence in the implementation notes or final summary: - -| Candidate | Business fields that must remain unchanged | Compatibility check | If incompatible | -| --- | --- | --- | --- | -| `save_pricing_rule` | `roomType`, `hourlyRate`, `overnightRate`, `dailyRate`, `earlyPct`, `latePct`, `weekendPct` | Inspect the Rust `#[tauri::command]` signature and run focused frontend tests proving the converted call routes those fields through `invokeWriteCommand`. The existing wrapper tests prove wrapper metadata is added before the low-level Tauri invoke. | Leave raw, keep the test/guard documenting why, and create a follow-up note for backend command-boundary support. | -| `save_settings` for `checkin_rules` | `key`, `value` | Inspect the Rust `save_settings` signature and run focused settings tests proving both fields route through `invokeWriteCommand`. The existing wrapper tests prove wrapper metadata is added before the low-level Tauri invoke. | Leave raw for this key and record why. | -| `save_settings` for `hotel_info` | `key`, `value` | Inspect the Rust `save_settings` signature and run focused settings tests proving both fields route through `invokeWriteCommand`. The existing wrapper tests prove wrapper metadata is added before the low-level Tauri invoke. | Leave raw for this key and record why. | -| `update_housekeeping` | `taskId`, `newStatus`, `note` | Inspect the Rust `update_housekeeping` signature and run focused store tests proving those fields route through `invokeWriteCommand`. The existing wrapper tests prove wrapper metadata is added before the low-level Tauri invoke. | Leave raw and record why. | - -The compatibility check has two levels: - -- Invocation compatibility: the call still succeeds with wrapper metadata in the command argument object. -- PMS safety completeness: the backend consumes and persists the metadata as part of an explicit command boundary. - -Batch 1 requires invocation compatibility for conversion. It does not require PMS safety completeness for legacy commands, but it must identify when that completeness is missing. - -### Read calls - -Read calls may remain raw `invoke` in this batch unless the file being changed benefits from using `invokeCommand` for local consistency. This avoids turning #140 Batch 1 into a broad frontend cleanup. - -Examples that can stay raw in this batch include dashboard reads, analytics reads, guest searches, availability checks, room detail reads, and other command calls that only retrieve data. - -### System and runtime calls - -System/runtime calls may remain raw `invoke` because they are not PMS business writes and often sit below or beside the business command boundary. - -Allowed examples include: - -- crash reporting lifecycle commands, -- JavaScript crash recording, -- pending crash report export and submission state, -- gateway status and key generation, -- bootstrap status, -- backup and CSV export commands, -- update/runtime support commands. - -This batch must add one required validation mechanism: a static guardrail test at `mhm/tests/frontend-invoke-wrapper-guardrails.test.ts`. - -The test should scan frontend source files for raw Tauri `invoke` calls and enforce two explicit lists: - -- `PMS_WRITE_COMMANDS_REQUIRING_WRAPPER`: Batch 1 commands that must not appear as raw `invoke`. -- `RAW_INVOKE_ALLOWED_COMMANDS`: read/system/runtime/export/diagnostics/gateway/bootstrap commands allowed to remain raw, each with an inline reason string in the test data. - -It should not force allowed calls through `invokeWriteCommand`. - -## Architecture - -The existing wrapper remains the only frontend abstraction: - -```ts -await invokeWriteCommand(commandName, businessArgs, options); -``` - -The implementation should not introduce a second command client, a command registry, or generated command API. The issue is a normalization pass, not a new architecture layer. - -Converted call sites should follow the existing local style: - -- import `invokeWriteCommand` from `@/lib/invokeCommand`; -- keep the command string unchanged; -- keep the business argument object unchanged except for the wrapper-added metadata; -- keep UI refresh and toast behavior in the same order; -- use `formatAppError(error)` when the edited file already uses app-error formatting or when the conversion makes normalized errors available without broad UI behavior changes. - -## Data Flow - -For converted writes, the data flow is: - -1. UI/store validates or prepares the same business fields it already sends today. -2. UI/store calls `invokeWriteCommand`. -3. `invokeWriteCommand` adds `idempotencyKey`. -4. `invokeCommand` optionally adds `correlationId`. -5. Tauri receives the same command name and the original business fields plus wrapper metadata. -6. Success handling, refreshes, and toasts continue as before. -7. Errors are normalized through `normalizeAppError` and thrown as `AppError` exceptions. - -No caller should manually create an idempotency key for the converted Batch 1 calls. Manual `createIdempotencyKey` usage should be left alone unless it is part of an explicitly converted call. - -For legacy backend commands, wrapper metadata may be accepted by the invocation layer without being consumed by backend safety tables. The implementation summary must distinguish those two cases. - -## Error Handling - -Converted writes should route backend and Tauri failures through `invokeCommand` error normalization. This gives callers a normalized exception with: - -- app error code, -- message, -- kind, -- optional support id, -- optional correlation id, -- original cause. - -UI behavior should stay close to today. If a component currently displays a simple generic error toast and the conversion enables `formatAppError`, the implementation may improve that one local toast without changing surrounding flows. - -Command failure monitoring remains limited to commands listed in `mhm/src/lib/crashReporting/commandFailure.ts`. Batch 1 does not expand the monitored command list unless a converted command already has monitoring requirements in existing code. - -## GitNexus Guardrails - -Before editing any function, class, or method, implementation must run GitNexus impact analysis for the target symbol and report: - -- direct callers, -- affected processes, -- risk level. - -If impact analysis reports HIGH or CRITICAL risk, implementation must pause and warn before editing. - -Before committing implementation changes, implementation must run `gitnexus_detect_changes()` to verify the affected symbols and flows match the planned scope. - -## Testing - -Focused test updates should verify the converted write paths at the wrapper boundary where practical: - -- converted calls still send the same business fields, -- converted calls include a command-scoped `idempotencyKey`, -- existing success refresh/toast behavior remains intact, -- normalized errors are displayed through existing UI error paths where tests cover them. - -Per-candidate evidence: - -- `save_pricing_rule`: add or update a `PricingSection` test that performs a successful save and expects `invokeWriteCommand("save_pricing_rule", { ...business fields... })`; also assert raw `invoke` is not called with `save_pricing_rule`. -- `save_settings` for `hotel_info`: add or update a settings component test that clicks the hotel-info save path and expects `invokeWriteCommand("save_settings", { key: "hotel_info", value: ... })`. -- `save_settings` for `checkin_rules`: add or update a settings component test that clicks the check-in-rules save path and expects `invokeWriteCommand("save_settings", { key: "checkin_rules", value: ... })`. -- `update_housekeeping`: add or update a store test that expects `invokeWriteCommand("update_housekeeping", { taskId, newStatus, note })` and confirms raw `invoke` is not used for that write. -- Raw invoke guardrail: add `mhm/tests/frontend-invoke-wrapper-guardrails.test.ts` with explicit allow/deny command lists and reasons. - -Wrapper metadata evidence remains in `mhm/src/lib/invokeCommand.test.ts`; call-site tests should not duplicate the wrapper unit test by asserting the generated random idempotency value. - -Validation commands: - -```bash -cd mhm && npm test -cd mhm && npm run build -rg -n "invoke<|invoke\\(" mhm/src -``` - -Expected raw invoke scan result: - -- no remaining raw `invoke` for the Batch 1 PMS write candidates, -- remaining raw invokes are reads or documented system/runtime/export/diagnostics/gateway/bootstrap calls, -- `mhm/src/lib/invokeCommand.ts` remains the low-level wrapper that directly calls Tauri `invoke`. - -## Acceptance Criteria - -- Each Batch 1 candidate has recorded compatibility evidence before conversion. -- If compatible, `save_pricing_rule` no longer uses raw frontend `invoke`, preserves `roomType`, `hourlyRate`, `overnightRate`, `dailyRate`, `earlyPct`, `latePct`, and `weekendPct`, and has focused test evidence for wrapper usage. -- If compatible, `save_settings` for `hotel_info` no longer uses raw frontend `invoke`, preserves `key` and `value`, and has focused test evidence for wrapper usage. -- If compatible, `save_settings` for `checkin_rules` no longer uses raw frontend `invoke`, preserves `key` and `value`, and has focused test evidence for wrapper usage. -- If compatible, `update_housekeeping` no longer uses raw frontend `invoke`, preserves `taskId`, `newStatus`, and `note`, and has focused test evidence for wrapper usage. -- Any incompatible candidate remains raw with a documented reason and follow-up; no backend command change is introduced to force compatibility. -- Converted write payloads preserve their existing business fields. -- `mhm/tests/frontend-invoke-wrapper-guardrails.test.ts` enforces forbidden raw PMS write commands and allowed raw read/system/runtime/export/diagnostics/gateway/bootstrap commands with reason strings. -- Raw `invoke` remains only where it is read-only or intentionally system/runtime/export/diagnostics/gateway/bootstrap oriented according to the guardrail test. -- No backend command names, backend payload semantics, response shapes, or Zustand architecture are changed. -- Tests and build pass. -- The final `rg` scan is reviewed and remaining raw invoke usage is explainable. - -## Risks And Mitigations - -Risk: adding `idempotencyKey` to a backend command that does not accept extra arguments could break the call. - -Mitigation: confirm compatibility before conversion. If a command cannot safely accept wrapper metadata without backend changes, leave it raw in Batch 1 and document it as a follow-up instead of expanding scope. - -Risk: converting too many call sites turns a narrow refactor into an architecture sweep. - -Mitigation: limit Batch 1 to obvious raw PMS writes and lightweight guard coverage. Defer writes currently using `invokeCommand` to a later batch. - -Risk: raw invoke scan still shows many matches. - -Mitigation: judge the scan by category, not by zero matches. Reads and system/runtime calls may remain raw under this design. From 9b97612fd246028aed39e797d24b52a676d3e22b Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 15:06:29 +0700 Subject: [PATCH 30/45] docs: design experimental runtime batch 2 gating --- ...erimental-runtime-batch-2-gating-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md diff --git a/docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md b/docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md new file mode 100644 index 0000000..e1e2247 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md @@ -0,0 +1,194 @@ +# Experimental Runtime Batch 2 Gating Design + +Date: 2026-05-15 + +Issue: #143 + +Planned PR title: `refactor: gate experimental agent and observer runtime surfaces` + +## Goal + +Finish the second runtime-gating slice for experimental surfaces without adding a confusing new universal flag. Normal PMS operation must run without digest, Telegram, CEO agent, OpenAI, gateway, MCP, observer, or outbox consumer runtime dependencies. + +Core command safety must remain intact. Business commands may still write transactional `outbox_events` inside the same atomic mutation, because those records are part of the PMS safety boundary. Runtime consumers of those records are experimental and must not run in the normal profile. + +## Approved Direction + +Use the existing clearly named runtime gates: + +- `CAPYINN_EXPERIMENTAL_RUNTIME=true` enables every experimental runtime surface. +- `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME=true` enables CEO agent, Telegram chat runtime, digest scheduler, and OpenAI-backed paths. +- `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true` enables MCP gateway and the observer stream route hosted by that gateway. + +Do not use `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` for this issue. The flag name is too broad for digest, Telegram, and CEO runtime, and using it as a universal plug would create naming debt. If the helper remains in `runtime_config.rs`, it should be treated as reserved and unused by #143. + +Existing disable overrides still win: + +- `CAPYINN_DISABLE_CEO_TELEGRAM=true` force-disables CEO Telegram and digest workflows even when agent experimental runtime is enabled. +- `CAPYINN_DISABLE_GATEWAY=true` force-disables gateway and observer exposure even when gateway experimental runtime is enabled. + +## Current State + +Batch 1 already gates gateway startup, agent supervisor reconciliation, gateway UI, CEO Agent UI, and MCP reservation frontend listeners by backend runtime status. The current branch already contains `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME`, `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME`, and a reserved `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` helper. + +The remaining issue #143 risk is not the core outbox write. The risk is accidental startup or exposure of the surrounding runtime paths: + +- CEO Telegram polling. +- CEO digest scheduler. +- OpenAI-backed agent runtime work. +- Observer stream exposure at `/observer/events`. +- Outbox dispatcher loops when real subscribers are registered. + +## Scope + +In scope: + +- Verify digest and Telegram startup remain controlled by `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME`. +- Verify observer exposure remains controlled by `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME` through gateway startup. +- Add guardrails so observer is not exposed in a future standalone route without an experimental gateway gate. +- Clarify outbox boundaries: transactional outbox writes are core; dispatcher subscribers and observer streaming are experimental consumers. +- Add focused tests for inactive outbox dispatcher behavior when no subscribers are registered. +- Document that `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` is not consumed by this issue. + +Out of scope: + +- Do not remove transactional outbox writes from business commands. +- Do not change command idempotency, locking, audit, or PMS state machine behavior. +- Do not add new agent, gateway, observer, Telegram, digest, or OpenAI capabilities. +- Do not rename `mhm/`. +- Do not turn `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` into a universal batch flag. + +## Runtime Design + +### Core PMS Flow + +Normal PMS writes keep their existing command boundary: + +```text +UI + -> Tauri command + -> validate, authorize, idempotency, lock + -> mutate PMS tables + -> audit + -> insert transactional outbox event when required + -> commit +``` + +This path must work with all experimental runtime flags absent. + +### Agent, Telegram, Digest, And OpenAI + +`reconcile_managed_supervisor` remains the central boundary for starting or stopping CEO chat and digest workflows. + +When `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME` and `CAPYINN_EXPERIMENTAL_RUNTIME` are both absent: + +- chat workflow shuts down; +- digest workflow shuts down; +- no Telegram polling starts; +- no digest scheduler starts; +- no Telegram or OpenAI secret is required for normal startup. + +When agent experimental runtime is enabled: + +- existing CEO cloud opt-in, Telegram config, digest config, token, model, and delivery-chat readiness gates continue to apply; +- missing readiness dependencies should behave exactly as they do today; +- `CAPYINN_DISABLE_CEO_TELEGRAM=true` still force-disables runtime workflows. + +CEO Agent config commands may still persist admin configuration while runtime is disabled, but they must not start workflows unless the effective agent runtime gate is enabled. + +### Gateway, MCP, And Observer + +Observer belongs to the gateway runtime for this slice because `/observer/events` is mounted inside the gateway server. If gateway startup is disabled, observer is not exposed. + +When `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME` and `CAPYINN_EXPERIMENTAL_RUNTIME` are both absent: + +- gateway server does not start; +- `/mcp` is unavailable; +- `/observer/events` is unavailable; +- gateway API key management stays disabled. + +When gateway experimental runtime is enabled: + +- existing gateway auth remains required; +- `/observer/events` remains behind the same protected gateway router; +- `CAPYINN_DISABLE_GATEWAY=true` force-disables both gateway and observer exposure. + +Add a lightweight guardrail test so future contributors do not mount `observe_events` outside the protected gateway router without adding an explicit experimental gate. + +### Outbox Dispatcher + +Transactional `outbox_events` writes are core PMS safety. The dispatcher is a runtime consumer. + +The current app starts `start_outbox_dispatcher(pool, Vec::new())`, which is inactive because there are no subscribers. Preserve this behavior and cover it with focused tests: + +- no subscribers means inactive handle; +- inactive handle has no running task and can be shut down safely; +- future subscribers must be started only from an explicit experimental runtime path. + +Do not gate or remove `insert_outbox_event_tx`. + +## Error Handling + +Experimental runtime disabled states are normal and quiet: + +- startup should log disabled runtime paths at info level, not error level; +- normal profile should not show gateway-off or CEO-agent UI warnings; +- missing Telegram/OpenAI/gateway configuration must not be required in the normal profile; +- observer being unavailable in the normal profile is expected because gateway is not started; +- outbox dispatcher with no subscribers should report inactive, not failed. + +If a runtime is explicitly enabled but readiness dependencies are missing, preserve the current readiness gate behavior and user-facing errors. + +## GitNexus Impact Notes + +Pre-design impact checks: + +- `start_outbox_dispatcher`: LOW risk. One direct caller, app startup. +- `observe_events`: LOW risk. No indexed upstream callers. +- `build_observer_sse_stream`: LOW risk. Direct caller is `observe_events`. +- `run_ceo_digest_scheduler`: LOW risk. Direct callers include supervisor digest task and a scheduler test. +- `experimental_peripheral_runtime_enabled`: LOW risk. No upstream callers. +- `run` in `lib.rs`: LOW risk. No upstream callers. +- `reconcile_managed_supervisor`: HIGH risk. Direct callers include app startup and multiple CEO agent settings commands. Affected flows include `run`, `set_ceo_cloud_data_opt_in`, and `set_ceo_telegram_config`. + +Because `reconcile_managed_supervisor` is HIGH risk, implementation should avoid changing its semantics unless a focused guard test proves a missing runtime boundary. Any edit there must stay narrow and preserve existing readiness behavior after the experimental gate passes. + +## Testing + +Rust tests: + +- `runtime_config` confirms `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` does not enable gateway or agent runtime. +- `agent::supervisor` confirms disabled agent runtime shuts down chat and digest workflows. +- `agent::supervisor` confirms `CAPYINN_DISABLE_CEO_TELEGRAM` force-disables workflows even when agent runtime is enabled. +- `gateway::server` confirms `/observer/events` stays inside the protected gateway router. +- `outbox` confirms `start_outbox_dispatcher(pool, Vec::new())` returns an inactive handle and does not spawn a loop. +- `lib.rs` confirms runtime status reports disabled agent and gateway gates by default. + +Frontend and guardrail tests: + +- normal profile does not render Gateway or CEO Agent settings. +- normal profile does not call gateway status or subscribe to MCP reservation events. +- agent guardrail tests continue to assert digest and chat runtimes do not mutate PMS business tables. +- a guardrail test states that transactional outbox writes are core while dispatcher and observer are experimental runtime consumers. + +Validation commands: + +```bash +cd mhm && npm test +cd mhm/src-tauri && cargo test +cd mhm/src-tauri && cargo clippy --all-targets -- -D warnings +rg -n "CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME|experimental_peripheral_runtime_enabled|start_outbox_dispatcher|observer/events|run_ceo_digest_scheduler|reconcile_managed_supervisor" mhm/src-tauri/src docs +``` + +Before committing implementation changes, run GitNexus change detection and confirm affected flows match this runtime-gating scope. + +## Acceptance Criteria + +- Normal PMS starts with no gateway, MCP, observer, Telegram, digest, CEO, OpenAI, or outbox subscriber runtime requirement. +- Business commands still write transactional outbox events where required. +- Digest and Telegram workflows require `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME=true` or `CAPYINN_EXPERIMENTAL_RUNTIME=true`. +- Gateway and observer exposure require `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true` or `CAPYINN_EXPERIMENTAL_RUNTIME=true`. +- `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` is not used as a universal plug for #143. +- `CAPYINN_DISABLE_CEO_TELEGRAM` and `CAPYINN_DISABLE_GATEWAY` remain force-disable overrides. +- Outbox dispatcher with no subscribers is explicitly inactive and safe in the normal profile. +- Core PMS tests pass with experimental runtime flags absent. From 59495ddb0d98430902077b5f10da7a6c81e529b7 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 15:14:36 +0700 Subject: [PATCH 31/45] docs: clarify experimental runtime batch 2 gates --- ...erimental-runtime-batch-2-gating-design.md | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md b/docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md index e1e2247..c799377 100644 --- a/docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md +++ b/docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md @@ -36,6 +36,7 @@ The remaining issue #143 risk is not the core outbox write. The risk is accident - CEO Telegram polling. - CEO digest scheduler. - OpenAI-backed agent runtime work. +- MCP stdio proxy behavior when the gateway is not running. - Observer stream exposure at `/observer/events`. - Outbox dispatcher loops when real subscribers are registered. @@ -45,9 +46,9 @@ In scope: - Verify digest and Telegram startup remain controlled by `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME`. - Verify observer exposure remains controlled by `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME` through gateway startup. -- Add guardrails so observer is not exposed in a future standalone route without an experimental gateway gate. +- Add guardrails so observer is not exposed through gateway internals or any future standalone route without an experimental gateway gate. - Clarify outbox boundaries: transactional outbox writes are core; dispatcher subscribers and observer streaming are experimental consumers. -- Add focused tests for inactive outbox dispatcher behavior when no subscribers are registered. +- Add focused tests for inactive outbox dispatcher behavior when no subscribers are registered and for profile-based subscriber selection. - Document that `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` is not consumed by this issue. Out of scope: @@ -100,12 +101,15 @@ CEO Agent config commands may still persist admin configuration while runtime is Observer belongs to the gateway runtime for this slice because `/observer/events` is mounted inside the gateway server. If gateway startup is disabled, observer is not exposed. +The production gateway start boundary must enforce the effective gateway runtime gate itself, not rely only on the caller in `lib.rs`. `gateway::start_gateway` should return `Err` with the existing gateway-management-disabled message when `effective_experimental_gateway_runtime_enabled()` is false. `lib.rs` may still check the gate first for logging and to avoid unnecessary work, but the gateway module should remain safe if another production caller is added in the future. + When `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME` and `CAPYINN_EXPERIMENTAL_RUNTIME` are both absent: - gateway server does not start; - `/mcp` is unavailable; - `/observer/events` is unavailable; - gateway API key management stays disabled. +- `gateway::start_gateway` itself refuses production startup. When gateway experimental runtime is enabled: @@ -113,17 +117,36 @@ When gateway experimental runtime is enabled: - `/observer/events` remains behind the same protected gateway router; - `CAPYINN_DISABLE_GATEWAY=true` force-disables both gateway and observer exposure. -Add a lightweight guardrail test so future contributors do not mount `observe_events` outside the protected gateway router without adding an explicit experimental gate. +Add a lightweight guardrail test so future contributors do not mount `observe_events` outside the protected gateway router without adding an explicit experimental gate. Also add a focused runtime test that absent gateway and master flags prevent production gateway startup, which also prevents production observer exposure. + +The `--mcp-stdio` proxy path does not start the gateway server. Document this in the implementation notes and add a guardrail assertion that `run_proxy` delegates only to the stdio proxy instead of starting `gateway::start_gateway`. ### Outbox Dispatcher Transactional `outbox_events` writes are core PMS safety. The dispatcher is a runtime consumer. -The current app starts `start_outbox_dispatcher(pool, Vec::new())`, which is inactive because there are no subscribers. Preserve this behavior and cover it with focused tests: +The current app starts `start_outbox_dispatcher(pool, Vec::new())`, which is inactive because there are no subscribers. Preserve this behavior and make subscriber selection explicit through a small private helper in `lib.rs` named `outbox_subscribers_for_runtime_profile()`. + +For #143, there are no production outbox subscribers. The helper should therefore return an empty subscriber list in every profile, including: + +- no experimental flags; +- `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME=true` by itself; +- gateway experimental runtime only; +- agent experimental runtime only; +- master experimental runtime. + +This sounds redundant, but it prevents the vague `PERIPHERAL_RUNTIME` flag from becoming an accidental subscriber plug. When a future issue adds a real production subscriber, that issue must map it to an explicit existing gate or introduce a new clearly named gate: + +- gateway or observer delivery subscribers must require effective gateway runtime; +- agent or CEO delivery subscribers must require effective agent runtime; +- unrelated external delivery workers must not use `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` unless that flag is renamed or redesigned in its own approved spec. + +Cover the dispatcher boundary with focused tests: - no subscribers means inactive handle; - inactive handle has no running task and can be shut down safely; -- future subscribers must be started only from an explicit experimental runtime path. +- all flags absent means app startup passes zero subscribers and receives an inactive handle; +- `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME=true` by itself still means zero subscribers and an inactive handle. Do not gate or remove `insert_outbox_event_tx`. @@ -136,6 +159,7 @@ Experimental runtime disabled states are normal and quiet: - missing Telegram/OpenAI/gateway configuration must not be required in the normal profile; - observer being unavailable in the normal profile is expected because gateway is not started; - outbox dispatcher with no subscribers should report inactive, not failed. +- `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME=true` by itself should not change gateway, agent, observer, or outbox subscriber runtime behavior. If a runtime is explicitly enabled but readiness dependencies are missing, preserve the current readiness gate behavior and user-facing errors. @@ -160,8 +184,11 @@ Rust tests: - `runtime_config` confirms `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` does not enable gateway or agent runtime. - `agent::supervisor` confirms disabled agent runtime shuts down chat and digest workflows. - `agent::supervisor` confirms `CAPYINN_DISABLE_CEO_TELEGRAM` force-disables workflows even when agent runtime is enabled. +- `gateway` confirms `gateway::start_gateway` refuses production startup when effective gateway runtime is disabled. - `gateway::server` confirms `/observer/events` stays inside the protected gateway router. - `outbox` confirms `start_outbox_dispatcher(pool, Vec::new())` returns an inactive handle and does not spawn a loop. +- `lib.rs` confirms the runtime-profile subscriber helper returns zero subscribers with all flags absent. +- `lib.rs` confirms `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME=true` by itself still returns zero subscribers. - `lib.rs` confirms runtime status reports disabled agent and gateway gates by default. Frontend and guardrail tests: @@ -189,6 +216,9 @@ Before committing implementation changes, run GitNexus change detection and conf - Digest and Telegram workflows require `CAPYINN_EXPERIMENTAL_AGENT_RUNTIME=true` or `CAPYINN_EXPERIMENTAL_RUNTIME=true`. - Gateway and observer exposure require `CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true` or `CAPYINN_EXPERIMENTAL_RUNTIME=true`. - `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME` is not used as a universal plug for #143. +- `CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME=true` by itself does not start gateway, observer, agent, digest, Telegram, or outbox subscribers. +- `gateway::start_gateway` refuses production startup when the effective gateway runtime gate is disabled. - `CAPYINN_DISABLE_CEO_TELEGRAM` and `CAPYINN_DISABLE_GATEWAY` remain force-disable overrides. - Outbox dispatcher with no subscribers is explicitly inactive and safe in the normal profile. +- Outbox production subscribers are selected only by explicit agent or gateway runtime gates; #143 registers none. - Core PMS tests pass with experimental runtime flags absent. From 2e41eb2f0e2eb90ce23a7c795a013a523a55a38e Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 15:35:57 +0700 Subject: [PATCH 32/45] test: lock peripheral runtime flag semantics --- mhm/src-tauri/src/runtime_config.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/mhm/src-tauri/src/runtime_config.rs b/mhm/src-tauri/src/runtime_config.rs index 741783d..f927047 100644 --- a/mhm/src-tauri/src/runtime_config.rs +++ b/mhm/src-tauri/src/runtime_config.rs @@ -167,6 +167,32 @@ mod tests { std::env::remove_var("CAPYINN_EXPERIMENTAL_AGENT_RUNTIME"); } + #[test] + fn peripheral_runtime_flag_does_not_enable_agent_or_gateway_runtime() { + let _guard = env_lock().lock().unwrap(); + + for name in [ + "CAPYINN_EXPERIMENTAL_RUNTIME", + "CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", + "CAPYINN_EXPERIMENTAL_AGENT_RUNTIME", + "CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME", + "CAPYINN_DISABLE_GATEWAY", + "CAPYINN_DISABLE_CEO_TELEGRAM", + ] { + std::env::remove_var(name); + } + + std::env::set_var("CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME", "true"); + + assert!(experimental_peripheral_runtime_enabled()); + assert!(!experimental_gateway_runtime_enabled()); + assert!(!experimental_agent_runtime_enabled()); + assert!(!effective_experimental_gateway_runtime_enabled()); + assert!(!effective_experimental_agent_runtime_enabled()); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME"); + } + #[test] fn disable_flags_override_effective_experimental_runtime_flags() { let _guard = env_lock().lock().unwrap(); From 57a053cbc15b94adaaf402395b37053747577c6a Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 15:44:28 +0700 Subject: [PATCH 33/45] refactor: fail closed inside gateway startup --- mhm/src-tauri/src/gateway/mod.rs | 83 +++++++++++++++++++++++++++++++- mhm/src-tauri/src/lib.rs | 2 +- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/mhm/src-tauri/src/gateway/mod.rs b/mhm/src-tauri/src/gateway/mod.rs index 0d623eb..edd86ce 100644 --- a/mhm/src-tauri/src/gateway/mod.rs +++ b/mhm/src-tauri/src/gateway/mod.rs @@ -17,12 +17,27 @@ use crate::app_identity; pub use server::RunningGatewayServer as RunningGateway; +const GATEWAY_RUNTIME_DISABLED_MESSAGE: &str = "MCP Gateway experimental runtime is disabled. Set CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true or CAPYINN_EXPERIMENTAL_RUNTIME=true to enable gateway management."; + +pub(crate) fn gateway_runtime_disabled_error() -> String { + GATEWAY_RUNTIME_DISABLED_MESSAGE.to_string() +} + +fn ensure_gateway_runtime_enabled() -> Result<(), String> { + if crate::runtime_config::effective_experimental_gateway_runtime_enabled() { + Ok(()) + } else { + Err(gateway_runtime_disabled_error()) + } +} + /// Start the MCP Gateway SSE server on a background Tokio task. /// Returns the port number the server is listening on. pub async fn start_gateway( pool: Pool, app_handle: AppHandle, ) -> Result { + ensure_gateway_runtime_enabled()?; cleanup_stale_lockfile(); let running_gateway = server::start_server(pool, app_handle).await?; @@ -86,7 +101,10 @@ fn is_port_live(port: u16) -> bool { #[cfg(test)] mod tests { - use super::{cleanup_lockfile_path, live_port_from_lockfile_path, write_lockfile}; + use super::{ + cleanup_lockfile_path, ensure_gateway_runtime_enabled, gateway_runtime_disabled_error, + live_port_from_lockfile_path, write_lockfile, + }; use std::path::PathBuf; fn temp_lockfile_path(label: &str) -> PathBuf { @@ -116,4 +134,67 @@ mod tests { assert_eq!(live_port_from_lockfile_path(&lockfile), None); assert!(!lockfile.exists()); } + + #[test] + fn gateway_runtime_gate_rejects_startup_when_experimental_gateway_is_disabled() { + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + + for name in [ + "CAPYINN_EXPERIMENTAL_RUNTIME", + "CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", + "CAPYINN_DISABLE_GATEWAY", + ] { + std::env::remove_var(name); + } + + let error = ensure_gateway_runtime_enabled() + .expect_err("gateway startup should fail closed without experimental gateway runtime"); + + assert_eq!(error, gateway_runtime_disabled_error()); + } + + #[test] + fn gateway_runtime_gate_allows_startup_when_effective_gateway_is_enabled() { + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_RUNTIME"); + std::env::set_var("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", "true"); + std::env::remove_var("CAPYINN_DISABLE_GATEWAY"); + + ensure_gateway_runtime_enabled().expect("gateway runtime is enabled"); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME"); + } + + #[test] + fn gateway_runtime_gate_respects_disable_override() { + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + + std::env::set_var("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", "true"); + std::env::set_var("CAPYINN_DISABLE_GATEWAY", "true"); + + let error = ensure_gateway_runtime_enabled() + .expect_err("disable override should force gateway startup closed"); + + assert_eq!(error, gateway_runtime_disabled_error()); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME"); + std::env::remove_var("CAPYINN_DISABLE_GATEWAY"); + } + + #[test] + fn start_gateway_checks_runtime_gate_before_starting_server() { + let source = include_str!("mod.rs"); + let gate_check = source + .find("ensure_gateway_runtime_enabled()?") + .expect("start_gateway calls the runtime gate"); + let server_start = source + .find("server::start_server") + .expect("start_gateway starts the server"); + + assert!( + gate_check < server_start, + "start_gateway must check runtime gate before server startup" + ); + } } diff --git a/mhm/src-tauri/src/lib.rs b/mhm/src-tauri/src/lib.rs index e38c2b5..0804ade 100644 --- a/mhm/src-tauri/src/lib.rs +++ b/mhm/src-tauri/src/lib.rs @@ -147,7 +147,7 @@ fn experimental_runtime_status_value() -> serde_json::Value { } fn gateway_management_disabled_error() -> String { - "MCP Gateway experimental runtime is disabled. Set CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true or CAPYINN_EXPERIMENTAL_RUNTIME=true to enable gateway management.".to_string() + gateway::gateway_runtime_disabled_error() } fn ensure_gateway_management_enabled() -> Result<(), String> { From b37cc3f26e01868f03a3ef5c36434ff17980708f Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 15:55:30 +0700 Subject: [PATCH 34/45] refactor: make outbox subscriber profile explicit --- mhm/src-tauri/src/lib.rs | 66 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/mhm/src-tauri/src/lib.rs b/mhm/src-tauri/src/lib.rs index 0804ade..1865cec 100644 --- a/mhm/src-tauri/src/lib.rs +++ b/mhm/src-tauri/src/lib.rs @@ -158,6 +158,10 @@ fn ensure_gateway_management_enabled() -> Result<(), String> { } } +fn outbox_subscribers_for_runtime_profile() -> Vec> { + Vec::new() +} + fn spawn_crash_index_rebuild() { std::thread::spawn(|| { if let Err(error) = crash_index::rebuild_current_runtime_root() { @@ -250,7 +254,10 @@ pub fn run() { }); app.manage(backup::BackupCoordinator::new()); app.manage(backup::start_backup_scheduler(app.handle().clone())); - app.manage(outbox::start_outbox_dispatcher(pool.clone(), Vec::new())); + app.manage(outbox::start_outbox_dispatcher( + pool.clone(), + outbox_subscribers_for_runtime_profile(), + )); app.manage(agent_supervisor); app.manage(GatewayRuntimeState::new(rt, gateway_runtime)); @@ -473,9 +480,23 @@ async fn gateway_get_status( mod tests { use super::{ experimental_runtime_status_value, gateway_management_disabled_error, - gateway_runtime_effective_enabled, updater_enabled_from_env, + gateway_runtime_effective_enabled, outbox_subscribers_for_runtime_profile, + updater_enabled_from_env, }; + fn clear_experimental_runtime_env() { + for name in [ + "CAPYINN_EXPERIMENTAL_RUNTIME", + "CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", + "CAPYINN_EXPERIMENTAL_AGENT_RUNTIME", + "CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME", + "CAPYINN_DISABLE_GATEWAY", + "CAPYINN_DISABLE_CEO_TELEGRAM", + ] { + std::env::remove_var(name); + } + } + #[test] fn updater_stays_disabled_for_plain_dev_runs() { assert!(!updater_enabled_from_env(true, false)); @@ -537,4 +558,45 @@ mod tests { "MCP Gateway experimental runtime is disabled. Set CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME=true or CAPYINN_EXPERIMENTAL_RUNTIME=true to enable gateway management." ); } + + #[test] + fn outbox_runtime_profile_has_no_subscribers_without_experimental_flags() { + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + clear_experimental_runtime_env(); + + assert_eq!(outbox_subscribers_for_runtime_profile().len(), 0); + } + + #[test] + fn peripheral_runtime_flag_does_not_register_outbox_subscribers() { + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + clear_experimental_runtime_env(); + std::env::set_var("CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME", "true"); + + assert_eq!(outbox_subscribers_for_runtime_profile().len(), 0); + + std::env::remove_var("CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME"); + } + + #[test] + fn batch_2_runtime_flags_do_not_register_outbox_subscribers() { + let _guard = crate::runtime_config::env_lock().lock().unwrap(); + + for flag in [ + "CAPYINN_EXPERIMENTAL_GATEWAY_RUNTIME", + "CAPYINN_EXPERIMENTAL_AGENT_RUNTIME", + "CAPYINN_EXPERIMENTAL_RUNTIME", + ] { + clear_experimental_runtime_env(); + std::env::set_var(flag, "true"); + + assert_eq!( + outbox_subscribers_for_runtime_profile().len(), + 0, + "{flag} should not register outbox subscribers in #143" + ); + } + + clear_experimental_runtime_env(); + } } From 6bc6bc6939186f25aa131aaf9716890614eef306 Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 16:01:00 +0700 Subject: [PATCH 35/45] test: cover inactive outbox dispatcher startup --- mhm/src-tauri/src/outbox.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mhm/src-tauri/src/outbox.rs b/mhm/src-tauri/src/outbox.rs index 7bbbec9..850fa59 100644 --- a/mhm/src-tauri/src/outbox.rs +++ b/mhm/src-tauri/src/outbox.rs @@ -967,6 +967,16 @@ mod tests { assert!(!handle.is_active()); } + #[tokio::test] + async fn start_dispatcher_with_empty_subscribers_is_inactive() { + let pool = test_pool().await; + + let handle = start_outbox_dispatcher(pool, Vec::new()); + + assert!(!handle.is_active()); + handle.shutdown(); + } + #[test] fn outbox_spec_builds_canonical_minimal_payload() { let spec = OutboxEventSpec::new( From fa341625f15c13dc06199f8fa7d33fac0660d0de Mon Sep 17 00:00:00 2001 From: binhan Date: Fri, 15 May 2026 16:05:10 +0700 Subject: [PATCH 36/45] test: guard experimental observer and outbox boundaries --- mhm/tests/agentic-guardrails.test.ts | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/mhm/tests/agentic-guardrails.test.ts b/mhm/tests/agentic-guardrails.test.ts index 7f29f82..b647aa5 100644 --- a/mhm/tests/agentic-guardrails.test.ts +++ b/mhm/tests/agentic-guardrails.test.ts @@ -71,6 +71,58 @@ describe("agentic integration guardrails", () => { ); }); + it("documents experimental observer and outbox runtime boundaries", () => { + const spec = readWorkspaceFile( + "../docs/superpowers/specs/2026-05-15-experimental-runtime-batch-2-gating-design.md", + ); + const lib = readWorkspaceFile("src-tauri/src/lib.rs"); + const gateway = readWorkspaceFile("src-tauri/src/gateway/mod.rs"); + + expect(spec).toContain( + "Transactional `outbox_events` writes are core PMS safety", + ); + expect(spec).toContain( + "`CAPYINN_EXPERIMENTAL_PERIPHERAL_RUNTIME=true` by itself does not start gateway, observer, agent, digest, Telegram, or outbox subscribers", + ); + expect(lib).toContain("outbox_subscribers_for_runtime_profile()"); + expect(lib).toMatch( + /outbox::start_outbox_dispatcher\(\s*pool\.clone\(\),\s*outbox_subscribers_for_runtime_profile\(\),\s*\)/, + ); + + const testModuleStart = gateway.indexOf("#[cfg(test)]"); + expect(testModuleStart).toBeGreaterThanOrEqual(0); + + const productionGateway = gateway.slice(0, testModuleStart); + const startGatewayMatch = productionGateway.match( + /pub async fn start_gateway\([\s\S]*?\n\}/, + ); + expect(startGatewayMatch).not.toBeNull(); + + const startGatewayBody = startGatewayMatch?.[0] ?? ""; + const gateCheck = startGatewayBody.indexOf( + "ensure_gateway_runtime_enabled()?", + ); + const serverStart = startGatewayBody.indexOf("server::start_server"); + expect(gateCheck).toBeGreaterThanOrEqual(0); + expect(serverStart).toBeGreaterThanOrEqual(0); + expect(gateCheck).toBeLessThan(serverStart); + }); + + it("keeps the stdio MCP proxy from starting gateway runtime", () => { + const lib = readWorkspaceFile("src-tauri/src/lib.rs"); + const proxy = readWorkspaceFile("src-tauri/src/gateway/proxy.rs"); + const runProxyMatch = lib.match(/pub fn run_proxy\(\) \{[\s\S]*?\n\}/); + expect(runProxyMatch).not.toBeNull(); + + const runProxyBody = runProxyMatch?.[0] ?? ""; + expect(runProxyBody).toContain("gateway::proxy::run_proxy()"); + expect(runProxyBody).not.toContain("start_gateway"); + expect(runProxyBody).not.toContain("start_server"); + expect(proxy).toContain("super::live_port_from_lockfile()"); + expect(proxy).not.toContain("start_gateway"); + expect(proxy).not.toContain("start_server"); + }); + it("documents CEO agent data sensitivity and cloud opt-in boundaries", () => { const skill = readWorkspaceFile("skills/hotel-manager.skill.md"); const openapi = readWorkspaceFile("skills/openapi.yaml"); From 72805eae86d9f41f3f264c6b3a61ae75f18a3cae Mon Sep 17 00:00:00 2001 From: binhan Date: Sat, 16 May 2026 09:08:23 +0700 Subject: [PATCH 37/45] docs: design migration extraction setup --- ...-16-migration-extraction-144-145-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-migration-extraction-144-145-design.md diff --git a/docs/superpowers/specs/2026-05-16-migration-extraction-144-145-design.md b/docs/superpowers/specs/2026-05-16-migration-extraction-144-145-design.md new file mode 100644 index 0000000..4f4a976 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-migration-extraction-144-145-design.md @@ -0,0 +1,166 @@ +# Migration Extraction Setup Design + +Issues: #144 ARCH-11 Batch 3: Introduce backend migration module structure; #145 ARCH-12 Batch 3: Move core PMS migrations in first small extraction batch + +Parent roadmap: #133 Core PMS Architecture Stabilization V2.1 + +Date: 2026-05-16 +Status: Approved for implementation planning + +## Purpose + +Introduce a backend migration module structure and use it to move the first early core PMS migration batch out of `mhm/src-tauri/src/db.rs`. + +This change is a no-behavior-change extraction. It must make `db.rs` smaller and easier to review without changing schema semantics, migration order, compatibility behavior, command idempotency behavior, outbox behavior, gateway runtime behavior, agent runtime behavior, or frontend behavior. + +## Scope + +- Keep database bootstrap and the main migration runner in `mhm/src-tauri/src/db.rs`. +- Add a migration module under `mhm/src-tauri/src/db/`. +- Move migration bodies `V1` through `V6` into the new module. +- Keep migrations `V7` through `V19` in `db.rs` for this batch. +- Keep the existing migration tests and fresh database guard intact. + +`V1` through `V6` cover the early PMS schema: + +- `V1`: base schema +- `V2`: foundation and RBAC +- `V3`: pricing engine +- `V4`: folio, billing, and night audit +- `V5`: dynamic room config +- `V6`: reservation calendar block system + +## Non-Goals + +- Do not change SQL semantics. +- Do not reorder migrations. +- Do not combine migrations. +- Do not change the final schema version. +- Do not move gateway, command safety, money, outbox, agent, or digest migrations in this batch. +- Do not touch command idempotency logic. +- Do not introduce a new migration framework. +- Do not change app startup behavior. + +## Context + +`run_migrations` currently lives in `mhm/src-tauri/src/db.rs` and contains inline migrations through schema version `19`. The file is over 2,400 lines, and inline migration SQL begins near the top of `run_migrations`. + +Issue #136 already added a fresh database migration guard. That test runs migrations, asserts schema version `19`, and checks required table groups for PMS core, command safety, experimental gateway, and experimental agent tables. This guard is the main protection before moving migration bodies. + +GitNexus impact analysis for `run_migrations` returned CRITICAL risk: 53 direct callers, 8 affected execution flows, and 19 affected modules. The affected surface includes app startup and many backend tests. Because of that blast radius, this extraction must be mechanical and small. + +## Chosen Approach + +Create a `db` submodule for migrations and move only `V1` through `V6` into early core PMS migration functions. + +The main runner should continue to read the current schema version once and evaluate version gates in the same order: + +```rust +if current < 1 { + migrations::core::migrate_v1_base_schema(pool).await?; +} + +if current < 2 { + migrations::core::migrate_v2_foundation_rbac(pool).await?; +} +``` + +The extracted functions should each own the same transaction boundary they own today inside `run_migrations`: begin transaction, run SQL, set schema version, commit. + +Rejected alternatives: + +- Move only `V1`. This is safest, but it gives too little value for issue #145. +- Move all migrations. This would make review too broad and would mix core PMS, gateway, command safety, money, outbox, and agent concerns. +- Introduce a generic migration registry. That adds abstraction before the project needs it and increases risk in a CRITICAL startup path. + +## Module Shape + +Use this shape: + +```text +mhm/src-tauri/src/db.rs +mhm/src-tauri/src/db/ + migrations.rs +``` + +`db.rs` remains the public database module from the crate perspective and declares the child module with `mod migrations;`. The nested `db/` directory is private implementation detail for migration extraction. + +`migrations.rs` should expose only parent-module-internal functions needed by `run_migrations`, such as: + +```rust +pub(super) async fn migrate_v1_base_schema(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v2_foundation_rbac(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v3_pricing_engine(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v4_folio_billing_night_audit(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v5_dynamic_room_config(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v6_reservation_calendar(pool: &Pool) -> Result<(), sqlx::Error>; +``` + +The extracted module should reuse `set_schema_version` and `execute_compat_alter` rather than duplicating them. + +## Data Flow + +The runtime flow remains: + +1. `init_db` creates the SQLite pool. +2. `init_db` calls `run_migrations`. +3. `run_migrations` reads the current schema version. +4. `run_migrations` checks each version gate in order. +5. For `V1` through `V6`, `run_migrations` delegates to the new migration module. +6. For `V7` through `V19`, `run_migrations` keeps the existing inline code. +7. `init_db` inserts default settings after migrations complete. + +The only intended code movement is the body of the `current < 1` through `current < 6` blocks. + +## Error Handling + +Error behavior must stay unchanged. + +Each extracted migration returns `Result<(), sqlx::Error>` and uses `?` exactly like the current inline code. If a query fails, the transaction is not committed and the error bubbles back to `run_migrations`. + +`execute_compat_alter` keeps its current behavior: duplicate column or already-exists errors are logged and ignored; other errors fail the migration. The new migration module must call the existing helper rather than reimplementing compatibility handling. + +No new recovery path, repair path, fallback, or migration skipping should be added. + +## Testing + +Validation commands: + +```bash +cd /Users/binhan/HotelManager/mhm/src-tauri && cargo test db::tests +cd /Users/binhan/HotelManager/mhm/src-tauri && cargo test +cd /Users/binhan/HotelManager/mhm/src-tauri && cargo clippy --all-targets -- -D warnings +``` + +Expected coverage: + +- fresh database migration still reaches schema version `19`; +- required PMS core tables still exist; +- compatibility alters still ignore duplicate columns; +- later migrations `V7` through `V19` still run after the extracted batch; +- V14 money migration tests still pass; +- V16 outbox migration tests still pass; +- V18 and V19 agent/digest migration tests still pass; +- clippy reports no visibility or module warnings. + +Before committing implementation changes, run GitNexus change detection: + +```bash +gitnexus_detect_changes +``` + +The expected affected scope should be limited to `db.rs`, the new migration module files, and migration execution flows. + +## Review Guardrails + +Implementation should use a mechanical move: + +- preserve SQL strings exactly; +- preserve comments where they help identify the migration version; +- preserve transaction boundaries; +- preserve version numbers; +- preserve call order; +- avoid unrelated formatting churn in untouched migrations; +- do not edit frontend or command safety files. + +The existing dirty file `mhm/src/stores/useHotelStore.test.ts` is unrelated to this work and must not be included in commits for this extraction. From 04596b4fcf1a64eec5014738d1ebe383254cdac3 Mon Sep 17 00:00:00 2001 From: binhan Date: Sat, 16 May 2026 09:11:46 +0700 Subject: [PATCH 38/45] docs: tighten migration extraction spec --- .../specs/2026-05-16-migration-extraction-144-145-design.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-16-migration-extraction-144-145-design.md b/docs/superpowers/specs/2026-05-16-migration-extraction-144-145-design.md index 4f4a976..d470127 100644 --- a/docs/superpowers/specs/2026-05-16-migration-extraction-144-145-design.md +++ b/docs/superpowers/specs/2026-05-16-migration-extraction-144-145-design.md @@ -57,11 +57,11 @@ The main runner should continue to read the current schema version once and eval ```rust if current < 1 { - migrations::core::migrate_v1_base_schema(pool).await?; + migrations::migrate_v1_base_schema(pool).await?; } if current < 2 { - migrations::core::migrate_v2_foundation_rbac(pool).await?; + migrations::migrate_v2_foundation_rbac(pool).await?; } ``` @@ -151,6 +151,8 @@ gitnexus_detect_changes The expected affected scope should be limited to `db.rs`, the new migration module files, and migration execution flows. +Before editing implementation symbols, run GitNexus impact analysis for each edited function or helper. At minimum, run impact analysis for `run_migrations`, `set_schema_version`, and `execute_compat_alter` if those symbols are modified or their visibility changes. `run_migrations` is already known to be CRITICAL risk; implementation should report that blast radius before editing and continue only with the approved mechanical extraction scope. + ## Review Guardrails Implementation should use a mechanical move: From 23899764155cff06fdf9fa3f621bed53f5106b16 Mon Sep 17 00:00:00 2001 From: binhan Date: Sat, 16 May 2026 09:44:34 +0700 Subject: [PATCH 39/45] refactor: extract core PMS migrations --- mhm/src-tauri/src/db.rs | 362 +-------------------------- mhm/src-tauri/src/db/migrations.rs | 383 +++++++++++++++++++++++++++++ 2 files changed, 391 insertions(+), 354 deletions(-) create mode 100644 mhm/src-tauri/src/db/migrations.rs diff --git a/mhm/src-tauri/src/db.rs b/mhm/src-tauri/src/db.rs index 97c7252..880f085 100644 --- a/mhm/src-tauri/src/db.rs +++ b/mhm/src-tauri/src/db.rs @@ -8,6 +8,8 @@ use sqlx::{ }; use std::{str::FromStr, time::Duration}; +mod migrations; + use crate::app_identity; const SQLITE_BUSY_TIMEOUT_MS: u64 = 5000; @@ -199,380 +201,32 @@ pub(crate) async fn run_migrations(pool: &Pool) -> Result<(), sqlx::Erro // ── V0: Base schema (original tables) ── if current < 1 { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS rooms ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL, - floor INTEGER NOT NULL, - has_balcony INTEGER NOT NULL, - base_price INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'vacant' - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS guests ( - id TEXT PRIMARY KEY, - guest_type TEXT NOT NULL DEFAULT 'domestic', - full_name TEXT NOT NULL, - doc_number TEXT NOT NULL, - dob TEXT, - gender TEXT, - nationality TEXT DEFAULT 'Việt Nam', - address TEXT, - visa_expiry TEXT, - scan_path TEXT, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS bookings ( - id TEXT PRIMARY KEY, - room_id TEXT NOT NULL REFERENCES rooms(id), - primary_guest_id TEXT NOT NULL REFERENCES guests(id), - check_in_at TEXT NOT NULL, - expected_checkout TEXT NOT NULL, - actual_checkout TEXT, - nights INTEGER NOT NULL, - total_price INTEGER NOT NULL, - paid_amount INTEGER DEFAULT 0, - status TEXT NOT NULL DEFAULT 'active', - source TEXT DEFAULT 'walk-in', - notes TEXT, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS booking_guests ( - booking_id TEXT NOT NULL REFERENCES bookings(id), - guest_id TEXT NOT NULL REFERENCES guests(id), - PRIMARY KEY (booking_id, guest_id) - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS transactions ( - id TEXT PRIMARY KEY, - booking_id TEXT NOT NULL REFERENCES bookings(id), - amount INTEGER NOT NULL, - type TEXT NOT NULL, - note TEXT, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS expenses ( - id TEXT PRIMARY KEY, - category TEXT NOT NULL, - amount INTEGER NOT NULL, - note TEXT, - expense_date TEXT NOT NULL, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS housekeeping ( - id TEXT PRIMARY KEY, - room_id TEXT NOT NULL REFERENCES rooms(id), - status TEXT NOT NULL DEFAULT 'needs_cleaning', - note TEXT, - triggered_at TEXT NOT NULL, - cleaned_at TEXT, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 1).await?; - tx.commit().await?; + migrations::migrate_v1_base_schema(pool).await?; } // ── V2: Phase 1 — Foundation + RBAC ── if current < 2 { - let mut tx = pool.begin().await?; - - // Users table - sqlx::query( - "CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - pin_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'receptionist', - active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - // Audit logs table - sqlx::query( - "CREATE TABLE IF NOT EXISTS audit_logs ( - id TEXT PRIMARY KEY, - user_id TEXT, - action TEXT NOT NULL, - entity_type TEXT NOT NULL, - entity_id TEXT, - details TEXT, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - // Add phone and notes to guests - // Using IF NOT EXISTS pattern: try ALTER, ignore if already exists - execute_compat_alter(&mut tx, "ALTER TABLE guests ADD COLUMN phone TEXT").await?; - execute_compat_alter(&mut tx, "ALTER TABLE guests ADD COLUMN notes TEXT").await?; - - // Add payment_method and created_by to transactions - execute_compat_alter( - &mut tx, - "ALTER TABLE transactions ADD COLUMN payment_method TEXT DEFAULT 'cash'", - ) - .await?; - execute_compat_alter( - &mut tx, - "ALTER TABLE transactions ADD COLUMN created_by TEXT", - ) - .await?; - - // Add created_by to bookings - execute_compat_alter(&mut tx, "ALTER TABLE bookings ADD COLUMN created_by TEXT").await?; - - set_schema_version(&mut tx, 2).await?; - tx.commit().await?; + migrations::migrate_v2_foundation_rbac(pool).await?; } // ── V3: Phase 2 — Pricing Engine ── if current < 3 { - let mut tx = pool.begin().await?; - - // pricing_rules: per room_type configuration - sqlx::query( - "CREATE TABLE IF NOT EXISTS pricing_rules ( - id TEXT PRIMARY KEY, - room_type TEXT NOT NULL, - hourly_rate INTEGER NOT NULL DEFAULT 0, - overnight_rate INTEGER NOT NULL DEFAULT 0, - daily_rate INTEGER NOT NULL DEFAULT 0, - overnight_start TEXT NOT NULL DEFAULT '22:00', - overnight_end TEXT NOT NULL DEFAULT '11:00', - daily_checkin TEXT NOT NULL DEFAULT '14:00', - daily_checkout TEXT NOT NULL DEFAULT '12:00', - early_checkin_surcharge_pct REAL NOT NULL DEFAULT 30, - late_checkout_surcharge_pct REAL NOT NULL DEFAULT 30, - weekend_uplift_pct REAL NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(room_type) - )", - ) - .execute(&mut *tx) - .await?; - - // special_dates: holiday/weekend overrides - sqlx::query( - "CREATE TABLE IF NOT EXISTS special_dates ( - id TEXT PRIMARY KEY, - date TEXT NOT NULL, - label TEXT NOT NULL DEFAULT '', - uplift_pct REAL NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - UNIQUE(date) - )", - ) - .execute(&mut *tx) - .await?; - - // Add pricing_snapshot to bookings (JSON) - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN pricing_snapshot TEXT", - ) - .await?; - - // Add pricing_type to bookings - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN pricing_type TEXT DEFAULT 'nightly'", - ) - .await?; - - set_schema_version(&mut tx, 3).await?; - tx.commit().await?; + migrations::migrate_v3_pricing_engine(pool).await?; } // ── V4: Phase 3+4 — Folio/Billing + Night Audit ── if current < 4 { - let mut tx = pool.begin().await?; - - // folio_lines: per-booking itemized charges - sqlx::query( - "CREATE TABLE IF NOT EXISTS folio_lines ( - id TEXT PRIMARY KEY, - booking_id TEXT NOT NULL REFERENCES bookings(id), - category TEXT NOT NULL, - description TEXT NOT NULL, - amount INTEGER NOT NULL, - created_by TEXT, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - // night_audit_logs: daily revenue snapshots - sqlx::query( - "CREATE TABLE IF NOT EXISTS night_audit_logs ( - id TEXT PRIMARY KEY, - audit_date TEXT NOT NULL, - total_revenue INTEGER NOT NULL DEFAULT 0, - room_revenue INTEGER NOT NULL DEFAULT 0, - folio_revenue INTEGER NOT NULL DEFAULT 0, - total_expenses INTEGER NOT NULL DEFAULT 0, - occupancy_pct REAL NOT NULL DEFAULT 0, - rooms_sold INTEGER NOT NULL DEFAULT 0, - total_rooms INTEGER NOT NULL DEFAULT 0, - notes TEXT, - created_by TEXT, - created_at TEXT NOT NULL, - UNIQUE(audit_date) - )", - ) - .execute(&mut *tx) - .await?; - - // Add is_audited flag to bookings - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN is_audited INTEGER DEFAULT 0", - ) - .await?; - - set_schema_version(&mut tx, 4).await?; - tx.commit().await?; + migrations::migrate_v4_folio_billing_night_audit(pool).await?; } // ── V5: Dynamic Room Config — room_types table + per-person pricing ── if current < 5 { - let mut tx = pool.begin().await?; - - // room_types: admin creates these first, rooms reference them - sqlx::query( - "CREATE TABLE IF NOT EXISTS room_types ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - // Seed default room types from existing rooms - sqlx::query( - "INSERT OR IGNORE INTO room_types (id, name, created_at) - SELECT DISTINCT lower(type), type, datetime('now') FROM rooms", - ) - .execute(&mut *tx) - .await?; - - // Add per-person pricing columns - execute_compat_alter( - &mut tx, - "ALTER TABLE rooms ADD COLUMN max_guests INTEGER NOT NULL DEFAULT 2", - ) - .await?; - execute_compat_alter( - &mut tx, - "ALTER TABLE rooms ADD COLUMN extra_person_fee INTEGER NOT NULL DEFAULT 0", - ) - .await?; - - set_schema_version(&mut tx, 5).await?; - tx.commit().await?; + migrations::migrate_v5_dynamic_room_config(pool).await?; } // ── V6: Reservation Calendar Block System ── if current < 6 { - let mut tx = pool.begin().await?; - - // room_calendar: each row = 1 day blocked for 1 room - sqlx::query( - "CREATE TABLE IF NOT EXISTS room_calendar ( - room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - date TEXT NOT NULL, - booking_id TEXT REFERENCES bookings(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'booked', - PRIMARY KEY (room_id, date) - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query("CREATE INDEX IF NOT EXISTS idx_calendar_booking ON room_calendar(booking_id)") - .execute(&mut *tx) - .await?; - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_calendar_status ON room_calendar(room_id, status)", - ) - .execute(&mut *tx) - .await?; - - // Add reservation fields to bookings - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN booking_type TEXT DEFAULT 'walk-in'", - ) - .await?; - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN deposit_amount INTEGER DEFAULT 0", - ) - .await?; - execute_compat_alter(&mut tx, "ALTER TABLE bookings ADD COLUMN guest_phone TEXT").await?; - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN scheduled_checkin TEXT", - ) - .await?; - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN scheduled_checkout TEXT", - ) - .await?; - - set_schema_version(&mut tx, 6).await?; - tx.commit().await?; + migrations::migrate_v6_reservation_calendar(pool).await?; } // ── V7: MCP Gateway — API Key Storage ── diff --git a/mhm/src-tauri/src/db/migrations.rs b/mhm/src-tauri/src/db/migrations.rs new file mode 100644 index 0000000..3ca4775 --- /dev/null +++ b/mhm/src-tauri/src/db/migrations.rs @@ -0,0 +1,383 @@ +use sqlx::{Pool, Sqlite}; + +use super::{execute_compat_alter, set_schema_version}; + +pub(super) async fn migrate_v1_base_schema(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + floor INTEGER NOT NULL, + has_balcony INTEGER NOT NULL, + base_price INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'vacant' + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS guests ( + id TEXT PRIMARY KEY, + guest_type TEXT NOT NULL DEFAULT 'domestic', + full_name TEXT NOT NULL, + doc_number TEXT NOT NULL, + dob TEXT, + gender TEXT, + nationality TEXT DEFAULT 'Việt Nam', + address TEXT, + visa_expiry TEXT, + scan_path TEXT, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS bookings ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL REFERENCES rooms(id), + primary_guest_id TEXT NOT NULL REFERENCES guests(id), + check_in_at TEXT NOT NULL, + expected_checkout TEXT NOT NULL, + actual_checkout TEXT, + nights INTEGER NOT NULL, + total_price INTEGER NOT NULL, + paid_amount INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + source TEXT DEFAULT 'walk-in', + notes TEXT, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS booking_guests ( + booking_id TEXT NOT NULL REFERENCES bookings(id), + guest_id TEXT NOT NULL REFERENCES guests(id), + PRIMARY KEY (booking_id, guest_id) + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id), + amount INTEGER NOT NULL, + type TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS expenses ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + amount INTEGER NOT NULL, + note TEXT, + expense_date TEXT NOT NULL, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS housekeeping ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL REFERENCES rooms(id), + status TEXT NOT NULL DEFAULT 'needs_cleaning', + note TEXT, + triggered_at TEXT NOT NULL, + cleaned_at TEXT, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 1).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v2_foundation_rbac(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + // Users table + sqlx::query( + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'receptionist', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + // Audit logs table + sqlx::query( + "CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + user_id TEXT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT, + details TEXT, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + // Add phone and notes to guests + // Using IF NOT EXISTS pattern: try ALTER, ignore if already exists + execute_compat_alter(&mut tx, "ALTER TABLE guests ADD COLUMN phone TEXT").await?; + execute_compat_alter(&mut tx, "ALTER TABLE guests ADD COLUMN notes TEXT").await?; + + // Add payment_method and created_by to transactions + execute_compat_alter( + &mut tx, + "ALTER TABLE transactions ADD COLUMN payment_method TEXT DEFAULT 'cash'", + ) + .await?; + execute_compat_alter( + &mut tx, + "ALTER TABLE transactions ADD COLUMN created_by TEXT", + ) + .await?; + + // Add created_by to bookings + execute_compat_alter(&mut tx, "ALTER TABLE bookings ADD COLUMN created_by TEXT").await?; + + set_schema_version(&mut tx, 2).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v3_pricing_engine(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + // pricing_rules: per room_type configuration + sqlx::query( + "CREATE TABLE IF NOT EXISTS pricing_rules ( + id TEXT PRIMARY KEY, + room_type TEXT NOT NULL, + hourly_rate INTEGER NOT NULL DEFAULT 0, + overnight_rate INTEGER NOT NULL DEFAULT 0, + daily_rate INTEGER NOT NULL DEFAULT 0, + overnight_start TEXT NOT NULL DEFAULT '22:00', + overnight_end TEXT NOT NULL DEFAULT '11:00', + daily_checkin TEXT NOT NULL DEFAULT '14:00', + daily_checkout TEXT NOT NULL DEFAULT '12:00', + early_checkin_surcharge_pct REAL NOT NULL DEFAULT 30, + late_checkout_surcharge_pct REAL NOT NULL DEFAULT 30, + weekend_uplift_pct REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(room_type) + )", + ) + .execute(&mut *tx) + .await?; + + // special_dates: holiday/weekend overrides + sqlx::query( + "CREATE TABLE IF NOT EXISTS special_dates ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + uplift_pct REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + UNIQUE(date) + )", + ) + .execute(&mut *tx) + .await?; + + // Add pricing_snapshot to bookings (JSON) + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN pricing_snapshot TEXT", + ) + .await?; + + // Add pricing_type to bookings + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN pricing_type TEXT DEFAULT 'nightly'", + ) + .await?; + + set_schema_version(&mut tx, 3).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v4_folio_billing_night_audit( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + // folio_lines: per-booking itemized charges + sqlx::query( + "CREATE TABLE IF NOT EXISTS folio_lines ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id), + category TEXT NOT NULL, + description TEXT NOT NULL, + amount INTEGER NOT NULL, + created_by TEXT, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + // night_audit_logs: daily revenue snapshots + sqlx::query( + "CREATE TABLE IF NOT EXISTS night_audit_logs ( + id TEXT PRIMARY KEY, + audit_date TEXT NOT NULL, + total_revenue INTEGER NOT NULL DEFAULT 0, + room_revenue INTEGER NOT NULL DEFAULT 0, + folio_revenue INTEGER NOT NULL DEFAULT 0, + total_expenses INTEGER NOT NULL DEFAULT 0, + occupancy_pct REAL NOT NULL DEFAULT 0, + rooms_sold INTEGER NOT NULL DEFAULT 0, + total_rooms INTEGER NOT NULL DEFAULT 0, + notes TEXT, + created_by TEXT, + created_at TEXT NOT NULL, + UNIQUE(audit_date) + )", + ) + .execute(&mut *tx) + .await?; + + // Add is_audited flag to bookings + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN is_audited INTEGER DEFAULT 0", + ) + .await?; + + set_schema_version(&mut tx, 4).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v5_dynamic_room_config(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + // room_types: admin creates these first, rooms reference them + sqlx::query( + "CREATE TABLE IF NOT EXISTS room_types ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + // Seed default room types from existing rooms + sqlx::query( + "INSERT OR IGNORE INTO room_types (id, name, created_at) + SELECT DISTINCT lower(type), type, datetime('now') FROM rooms", + ) + .execute(&mut *tx) + .await?; + + // Add per-person pricing columns + execute_compat_alter( + &mut tx, + "ALTER TABLE rooms ADD COLUMN max_guests INTEGER NOT NULL DEFAULT 2", + ) + .await?; + execute_compat_alter( + &mut tx, + "ALTER TABLE rooms ADD COLUMN extra_person_fee INTEGER NOT NULL DEFAULT 0", + ) + .await?; + + set_schema_version(&mut tx, 5).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v6_reservation_calendar( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + // room_calendar: each row = 1 day blocked for 1 room + sqlx::query( + "CREATE TABLE IF NOT EXISTS room_calendar ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + date TEXT NOT NULL, + booking_id TEXT REFERENCES bookings(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'booked', + PRIMARY KEY (room_id, date) + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_calendar_booking ON room_calendar(booking_id)") + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_calendar_status ON room_calendar(room_id, status)") + .execute(&mut *tx) + .await?; + + // Add reservation fields to bookings + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN booking_type TEXT DEFAULT 'walk-in'", + ) + .await?; + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN deposit_amount INTEGER DEFAULT 0", + ) + .await?; + execute_compat_alter(&mut tx, "ALTER TABLE bookings ADD COLUMN guest_phone TEXT").await?; + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN scheduled_checkin TEXT", + ) + .await?; + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN scheduled_checkout TEXT", + ) + .await?; + + set_schema_version(&mut tx, 6).await?; + tx.commit().await?; + Ok(()) +} From 7db391c0c76da32e1430edeec27590d879be6d64 Mon Sep 17 00:00:00 2001 From: binhan Date: Sun, 17 May 2026 09:36:44 +0700 Subject: [PATCH 40/45] docs: design migration extraction follow-up --- ...6-05-17-migration-extraction-146-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-migration-extraction-146-design.md diff --git a/docs/superpowers/specs/2026-05-17-migration-extraction-146-design.md b/docs/superpowers/specs/2026-05-17-migration-extraction-146-design.md new file mode 100644 index 0000000..c60d560 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-migration-extraction-146-design.md @@ -0,0 +1,204 @@ +# Migration Extraction Follow-Up Design + +Issue: #146 ARCH-13 Batch 3: Move command-safety and experimental migrations in follow-up batches + +Parent roadmap: #133 Core PMS Architecture Stabilization V2.1 + +Date: 2026-05-17 +Status: User-approved design; written spec pending review + +## Purpose + +Move the remaining inline migration bodies `V7` through `V19` out of `mhm/src-tauri/src/db.rs` without changing database behavior. + +This is a no-behavior-change extraction. It must make `db.rs` a migration runner and database bootstrap module instead of a migration monolith, while preserving schema semantics, migration order, compatibility behavior, command safety behavior, money migration behavior, outbox behavior, gateway runtime behavior, agent runtime behavior, and frontend behavior. + +## Scope + +- Keep database bootstrap and the main migration runner in `mhm/src-tauri/src/db.rs`. +- Keep existing helper functions in `db.rs`, including `set_schema_version`, `execute_compat_alter`, and `restore_foreign_keys_after_v14_migration`. +- Move remaining migration bodies `V7` through `V19` into private modules under `mhm/src-tauri/src/db/`. +- Keep the existing `V1` through `V6` extraction in `mhm/src-tauri/src/db/migrations.rs`. +- Keep the final schema version at `19`. +- Keep existing migration tests and fresh database guards intact. + +`V7` through `V19` cover: + +- `V7`: gateway API key storage +- `V8`: invoice PDF system +- `V9`: group booking system +- `V10`: command idempotency +- `V11`: command terminal error replay payload +- `V12`: operator-ready command ledger metadata +- `V13`: origin idempotency on ledger and folio rows +- `V14`: integer VND money foundation +- `V15`: command recovery queue and audit actions +- `V16`: durable outbox events +- `V17`: outbox per-aggregate open-row FIFO support +- `V18`: agent safety session, audit, and memory schema +- `V19`: CEO hourly digest run state + +## Non-Goals + +- Do not change SQL semantics. +- Do not reorder migrations. +- Do not combine migrations. +- Do not change the final schema version. +- Do not change command idempotency logic. +- Do not change outbox dispatcher behavior. +- Do not change gateway, agent, digest, Telegram, OpenAI, or frontend runtime behavior. +- Do not gate or delete experimental schemas. +- Do not introduce a new migration framework or registry abstraction. +- Do not move or refactor unrelated backend modules. + +## Context + +Issues #144 and #145 introduced the migration module structure and moved `V1` through `V6` into `mhm/src-tauri/src/db/migrations.rs`. The remaining migrations `V7` through `V19` still live inline in `run_migrations`. + +Issue #146 intentionally has higher risk than the first extraction because the remaining migrations include command-safety, money, outbox, gateway, agent, and digest schema. Those schemas are safety-sensitive even when the runtime surfaces are experimental. + +GitNexus impact analysis for `run_migrations` returned CRITICAL risk: 53 direct callers, 8 affected execution flows, and 20 affected modules. The affected surface includes app startup, database migration tests, command idempotency tests, outbox tests, gateway tests, agent tests, digest tests, setup tests, and booking tests. + +GitNexus impact analysis for `set_schema_version` and `execute_compat_alter` also returned CRITICAL risk. This extraction may widen visibility only as needed for private child migration modules, but it must not change helper behavior. + +## Chosen Approach + +Move all remaining inline migrations `V7` through `V19` in one issue, but split them into domain-focused private modules so the high-risk areas remain reviewable. + +Use this module shape: + +```text +mhm/src-tauri/src/db.rs +mhm/src-tauri/src/db/ + migrations.rs + core_extensions.rs + command_safety.rs + outbox.rs + agent.rs +``` + +Module ownership: + +- `migrations.rs` keeps the existing `V1` through `V6` early PMS migrations. +- `core_extensions.rs` owns `V7` through `V9`. +- `command_safety.rs` owns `V10` through `V15`. +- `outbox.rs` owns `V16` and `V17`. +- `agent.rs` owns `V18` and `V19`. + +Rejected alternatives: + +- Keep everything in a single growing `migrations.rs`. This reduces `db.rs` but recreates a migration monolith. +- Create one file per migration version. This gives maximum isolation, but creates more file churn than the current migration count needs. +- Split #146 into multiple issues. This is safest per PR, but the chosen scope is to move all remaining migrations while preserving review boundaries inside the PR. + +## Module Interfaces + +Each new module should expose only parent-module-internal functions needed by `run_migrations`. + +`core_extensions.rs`: + +```rust +pub(super) async fn migrate_v7_gateway_api_keys(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v8_invoice_pdf_system(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v9_group_booking_system(pool: &Pool) -> Result<(), sqlx::Error>; +``` + +`command_safety.rs`: + +```rust +pub(super) async fn migrate_v10_command_idempotency(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v11_command_terminal_error_replay(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v12_command_ledger_metadata(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v13_origin_idempotency(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v14_integer_vnd_money(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v15_command_recovery(pool: &Pool) -> Result<(), sqlx::Error>; +``` + +`outbox.rs`: + +```rust +pub(super) async fn migrate_v16_durable_outbox_events(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v17_outbox_fifo_support(pool: &Pool) -> Result<(), sqlx::Error>; +``` + +`agent.rs`: + +```rust +pub(super) async fn migrate_v18_agent_safety_tables(pool: &Pool) -> Result<(), sqlx::Error>; +pub(super) async fn migrate_v19_agent_digest_runs(pool: &Pool) -> Result<(), sqlx::Error>; +``` + +The modules should reuse shared helpers from `db.rs` instead of duplicating compatibility or schema-version logic. + +## Data Flow + +The runtime flow remains unchanged: + +1. `init_db` creates the SQLite pool. +2. `init_db` calls `run_migrations`. +3. `run_migrations` reads the current schema version. +4. `run_migrations` checks each version gate in order. +5. For `V1` through `V19`, `run_migrations` delegates to private migration module functions. +6. `init_db` inserts default settings after migrations complete. + +`run_migrations` remains the only place that decides migration order. The extracted modules own only migration bodies. + +The `V14` flow is the existing exception and must remain behaviorally identical: + +1. Acquire a connection. +2. Disable foreign keys. +3. Run the migration transaction callback. +4. Add `legacy_request_hash` through `execute_compat_alter`. +5. Call `crate::money_migration::migrate_integer_vnd_money`. +6. Set schema version `14`. +7. Restore foreign key behavior through `restore_foreign_keys_after_v14_migration`. + +## Error Handling + +Error behavior must stay unchanged. + +Each extracted migration returns `Result<(), sqlx::Error>` and uses `?` exactly like the current inline code. If a query fails, the transaction is not committed and the error bubbles back to `run_migrations`. + +`execute_compat_alter` keeps its current behavior: duplicate column and already-exists errors are logged and ignored; other errors fail the migration. + +`V14` keeps the existing foreign-key restore path. The extraction must not add a new recovery path, repair path, fallback path, migration skip path, or best-effort schema creation path. + +## Testing + +Validation commands: + +```bash +cd /Users/binhan/HotelManager/mhm/src-tauri && cargo test db::tests +cd /Users/binhan/HotelManager/mhm/src-tauri && cargo test migration +cd /Users/binhan/HotelManager/mhm/src-tauri && cargo test +cd /Users/binhan/HotelManager/mhm/src-tauri && cargo clippy --all-targets -- -D warnings +``` + +Expected coverage: + +- fresh database migration still reaches schema version `19`; +- required PMS, core extension, command safety, outbox, and agent tables still exist; +- existing database upgrade tests for `V10` through `V19` still pass; +- V14 money conversion and rollback tests still pass; +- V16 and V17 outbox schema, index, and insert contract tests still pass; +- V18 and V19 agent and digest schema tests still pass; +- compatibility alters still ignore duplicate columns where intended; +- later runtime tests that create migrated test pools still pass. + +Before committing implementation changes, run GitNexus change detection. The expected affected scope should be limited to `db.rs`, private `db/` migration module files, and migration execution flows. + +## Review Guardrails + +Implementation should be a mechanical move: + +- preserve SQL strings exactly; +- preserve comments where they identify migration versions or existing behavior; +- preserve transaction boundaries; +- preserve version numbers; +- preserve call order; +- preserve V14 foreign-key handling; +- avoid unrelated formatting churn in untouched code; +- do not edit command idempotency, outbox, gateway, agent, digest, frontend, or service behavior; +- do not edit the unrelated dirty file `mhm/src/stores/useHotelStore.test.ts`. + +Before editing implementation symbols, run GitNexus impact analysis for each edited function or helper. At minimum, run impact analysis for `run_migrations`, `set_schema_version`, `execute_compat_alter`, and `restore_foreign_keys_after_v14_migration` if their bodies or visibility are modified. `run_migrations`, `set_schema_version`, and `execute_compat_alter` are already known CRITICAL risk; implementation should report that blast radius before editing and continue only with the approved mechanical extraction scope. From dae4b6eb9ba72e1de9479d77754f14f8df9c8851 Mon Sep 17 00:00:00 2001 From: binhan Date: Sun, 17 May 2026 09:50:03 +0700 Subject: [PATCH 41/45] docs: address migration extraction spec review --- ...6-05-17-migration-extraction-146-design.md | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-05-17-migration-extraction-146-design.md b/docs/superpowers/specs/2026-05-17-migration-extraction-146-design.md index c60d560..a930c17 100644 --- a/docs/superpowers/specs/2026-05-17-migration-extraction-146-design.md +++ b/docs/superpowers/specs/2026-05-17-migration-extraction-146-design.md @@ -5,12 +5,14 @@ Issue: #146 ARCH-13 Batch 3: Move command-safety and experimental migrations in Parent roadmap: #133 Core PMS Architecture Stabilization V2.1 Date: 2026-05-17 -Status: User-approved design; written spec pending review +Status: User-approved design; subagent review findings addressed ## Purpose Move the remaining inline migration bodies `V7` through `V19` out of `mhm/src-tauri/src/db.rs` without changing database behavior. +Although issue #146 is framed around command-safety, outbox, gateway, and agent-related migrations, this approved follow-up intentionally includes `V8` invoice and `V9` group-booking migrations as well. Those versions are included so the follow-up completes the migration extraction after #144/#145 and leaves `db.rs` as a runner/bootstrap module rather than leaving isolated inline migration bodies behind. + This is a no-behavior-change extraction. It must make `db.rs` a migration runner and database bootstrap module instead of a migration monolith, while preserving schema semantics, migration order, compatibility behavior, command safety behavior, money migration behavior, outbox behavior, gateway runtime behavior, agent runtime behavior, and frontend behavior. ## Scope @@ -31,7 +33,7 @@ This is a no-behavior-change extraction. It must make `db.rs` a migration runner - `V11`: command terminal error replay payload - `V12`: operator-ready command ledger metadata - `V13`: origin idempotency on ledger and folio rows -- `V14`: integer VND money foundation +- `V14`: integer VND money foundation, including the existing command ledger legacy hash compatibility column - `V15`: command recovery queue and audit actions - `V16`: durable outbox events - `V17`: outbox per-aggregate open-row FIFO support @@ -73,6 +75,7 @@ mhm/src-tauri/src/db/ migrations.rs core_extensions.rs command_safety.rs + money.rs outbox.rs agent.rs ``` @@ -81,7 +84,8 @@ Module ownership: - `migrations.rs` keeps the existing `V1` through `V6` early PMS migrations. - `core_extensions.rs` owns `V7` through `V9`. -- `command_safety.rs` owns `V10` through `V15`. +- `command_safety.rs` owns `V10` through `V13` and `V15`. +- `money.rs` owns the `V14` integer VND money migration special case. - `outbox.rs` owns `V16` and `V17`. - `agent.rs` owns `V18` and `V19`. @@ -110,10 +114,15 @@ pub(super) async fn migrate_v10_command_idempotency(pool: &Pool) -> Resu pub(super) async fn migrate_v11_command_terminal_error_replay(pool: &Pool) -> Result<(), sqlx::Error>; pub(super) async fn migrate_v12_command_ledger_metadata(pool: &Pool) -> Result<(), sqlx::Error>; pub(super) async fn migrate_v13_origin_idempotency(pool: &Pool) -> Result<(), sqlx::Error>; -pub(super) async fn migrate_v14_integer_vnd_money(pool: &Pool) -> Result<(), sqlx::Error>; pub(super) async fn migrate_v15_command_recovery(pool: &Pool) -> Result<(), sqlx::Error>; ``` +`money.rs`: + +```rust +pub(super) async fn migrate_v14_integer_vnd_money(pool: &Pool) -> Result<(), sqlx::Error>; +``` + `outbox.rs`: ```rust @@ -178,7 +187,7 @@ Expected coverage: - fresh database migration still reaches schema version `19`; - required PMS, core extension, command safety, outbox, and agent tables still exist; -- existing database upgrade tests for `V10` through `V19` still pass; +- fresh migration tests and existing database upgrade tests for `V10` through `V19` still pass where those tests currently exist; - V14 money conversion and rollback tests still pass; - V16 and V17 outbox schema, index, and insert contract tests still pass; - V18 and V19 agent and digest schema tests still pass; @@ -201,4 +210,4 @@ Implementation should be a mechanical move: - do not edit command idempotency, outbox, gateway, agent, digest, frontend, or service behavior; - do not edit the unrelated dirty file `mhm/src/stores/useHotelStore.test.ts`. -Before editing implementation symbols, run GitNexus impact analysis for each edited function or helper. At minimum, run impact analysis for `run_migrations`, `set_schema_version`, `execute_compat_alter`, and `restore_foreign_keys_after_v14_migration` if their bodies or visibility are modified. `run_migrations`, `set_schema_version`, and `execute_compat_alter` are already known CRITICAL risk; implementation should report that blast radius before editing and continue only with the approved mechanical extraction scope. +Before editing implementation symbols, run GitNexus impact analysis for each edited function or helper. If GitNexus reports that the index is stale, run `npx gitnexus analyze` before relying on impact output. At minimum, run impact analysis for `run_migrations`, `set_schema_version`, `execute_compat_alter`, and `restore_foreign_keys_after_v14_migration` if their bodies or visibility are modified. `run_migrations`, `set_schema_version`, and `execute_compat_alter` are already known CRITICAL risk; implementation should report that blast radius before editing and continue only with the approved mechanical extraction scope. From bf21a09ecd53d28c96c7e5c574f0edd45487ae0c Mon Sep 17 00:00:00 2001 From: binhan Date: Sun, 17 May 2026 10:36:22 +0700 Subject: [PATCH 42/45] refactor: extract remaining migrations --- mhm/src-tauri/src/db.rs | 566 +----------------------- mhm/src-tauri/src/db/agent.rs | 163 +++++++ mhm/src-tauri/src/db/command_safety.rs | 207 +++++++++ mhm/src-tauri/src/db/core_extensions.rs | 130 ++++++ mhm/src-tauri/src/db/money.rs | 28 ++ mhm/src-tauri/src/db/outbox.rs | 78 ++++ 6 files changed, 630 insertions(+), 542 deletions(-) create mode 100644 mhm/src-tauri/src/db/agent.rs create mode 100644 mhm/src-tauri/src/db/command_safety.rs create mode 100644 mhm/src-tauri/src/db/core_extensions.rs create mode 100644 mhm/src-tauri/src/db/money.rs create mode 100644 mhm/src-tauri/src/db/outbox.rs diff --git a/mhm/src-tauri/src/db.rs b/mhm/src-tauri/src/db.rs index 880f085..7de54eb 100644 --- a/mhm/src-tauri/src/db.rs +++ b/mhm/src-tauri/src/db.rs @@ -4,11 +4,16 @@ use sqlx::{ SqliteConnectOptions, SqliteConnection, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous, }, - Connection, Pool, Row, Sqlite, Transaction, + Pool, Row, Sqlite, Transaction, }; use std::{str::FromStr, time::Duration}; +mod agent; +mod command_safety; +mod core_extensions; mod migrations; +mod money; +mod outbox; use crate::app_identity; @@ -229,583 +234,69 @@ pub(crate) async fn run_migrations(pool: &Pool) -> Result<(), sqlx::Erro migrations::migrate_v6_reservation_calendar(pool).await?; } - // ── V7: MCP Gateway — API Key Storage ── + // -- V7: MCP Gateway - API Key Storage -- if current < 7 { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS gateway_api_keys ( - id TEXT PRIMARY KEY, - key_hash TEXT NOT NULL, - label TEXT DEFAULT 'default', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_used_at TEXT - )", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 7).await?; - tx.commit().await?; + core_extensions::migrate_v7_gateway_api_keys(pool).await?; } - // ── V8: Invoice PDF System ── + // -- V8: Invoice PDF System -- if current < 8 { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS invoices ( - id TEXT PRIMARY KEY, - invoice_number TEXT NOT NULL UNIQUE, - booking_id TEXT NOT NULL REFERENCES bookings(id), - hotel_name TEXT NOT NULL, - hotel_address TEXT NOT NULL, - hotel_phone TEXT NOT NULL, - guest_name TEXT NOT NULL, - guest_phone TEXT, - room_name TEXT NOT NULL, - room_type TEXT NOT NULL, - check_in TEXT NOT NULL, - check_out TEXT NOT NULL, - nights INTEGER NOT NULL, - pricing_breakdown TEXT NOT NULL, - subtotal INTEGER NOT NULL, - deposit_amount INTEGER NOT NULL DEFAULT 0, - total INTEGER NOT NULL, - balance_due INTEGER NOT NULL, - policy_text TEXT, - notes TEXT, - status TEXT NOT NULL DEFAULT 'issued', - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_booking ON invoices(booking_id)") - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 8).await?; - tx.commit().await?; + core_extensions::migrate_v8_invoice_pdf_system(pool).await?; } - // ── V9: Group Booking System ── + // -- V9: Group Booking System -- if current < 9 { - let mut tx = pool.begin().await?; - - // booking_groups: group metadata - sqlx::query( - "CREATE TABLE IF NOT EXISTS booking_groups ( - id TEXT PRIMARY KEY, - group_name TEXT NOT NULL, - master_booking_id TEXT, - organizer_name TEXT NOT NULL, - organizer_phone TEXT, - total_rooms INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - notes TEXT, - created_by TEXT, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - // group_services: per-group add-on charges - sqlx::query( - "CREATE TABLE IF NOT EXISTS group_services ( - id TEXT PRIMARY KEY, - group_id TEXT NOT NULL REFERENCES booking_groups(id), - booking_id TEXT REFERENCES bookings(id), - name TEXT NOT NULL, - quantity INTEGER NOT NULL DEFAULT 1, - unit_price INTEGER NOT NULL, - total_price INTEGER NOT NULL, - note TEXT, - created_by TEXT, - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - // Add group columns to bookings - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN group_id TEXT REFERENCES booking_groups(id)", - ) - .await?; - execute_compat_alter( - &mut tx, - "ALTER TABLE bookings ADD COLUMN is_master_room INTEGER DEFAULT 0", - ) - .await?; - - // Indexes - sqlx::query("CREATE INDEX IF NOT EXISTS idx_bookings_group ON bookings(group_id)") - .execute(&mut *tx) - .await?; - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_group_services_group ON group_services(group_id)", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 9).await?; - tx.commit().await?; + core_extensions::migrate_v9_group_booking_system(pool).await?; } // ── V10: Command Idempotency ── if current < 10 { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS command_idempotency ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - idempotency_key TEXT NOT NULL, - command_name TEXT NOT NULL, - request_hash TEXT NOT NULL, - intent_json TEXT NOT NULL, - primary_aggregate_key TEXT, - lock_keys_json TEXT NOT NULL, - status TEXT NOT NULL, - claim_token TEXT NOT NULL, - response_json TEXT, - error_code TEXT, - retryable INTEGER NOT NULL DEFAULT 0, - lease_expires_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - last_attempt_at TEXT, - UNIQUE(command_name, idempotency_key) - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS command_idempotency_lease_idx - ON command_idempotency(lease_expires_at) - WHERE status = 'in_progress'", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS command_idempotency_completed_idx - ON command_idempotency(completed_at) - WHERE status IN ('completed', 'failed_terminal')", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 10).await?; - tx.commit().await?; + command_safety::migrate_v10_command_idempotency(pool).await?; } // ── V11: Command terminal error replay payload ── if current < 11 { - let mut tx = pool.begin().await?; - - execute_compat_alter( - &mut tx, - "ALTER TABLE command_idempotency ADD COLUMN error_json TEXT", - ) - .await?; - - set_schema_version(&mut tx, 11).await?; - tx.commit().await?; + command_safety::migrate_v11_command_terminal_error_replay(pool).await?; } // ── V12: Operator-ready command ledger metadata ── if current < 12 { - let mut tx = pool.begin().await?; - - for alter in [ - "ALTER TABLE command_idempotency ADD COLUMN request_id TEXT", - "ALTER TABLE command_idempotency ADD COLUMN actor_type TEXT NOT NULL DEFAULT 'system'", - "ALTER TABLE command_idempotency ADD COLUMN actor_id TEXT", - "ALTER TABLE command_idempotency ADD COLUMN client_id TEXT", - "ALTER TABLE command_idempotency ADD COLUMN session_id TEXT", - "ALTER TABLE command_idempotency ADD COLUMN channel_id TEXT", - "ALTER TABLE command_idempotency ADD COLUMN issued_at TEXT", - "ALTER TABLE command_idempotency ADD COLUMN summary_json TEXT NOT NULL DEFAULT '{}'", - "ALTER TABLE command_idempotency ADD COLUMN result_summary_json TEXT", - "ALTER TABLE command_idempotency ADD COLUMN error_summary_json TEXT", - ] { - execute_compat_alter(&mut tx, alter).await?; - } - - sqlx::query( - "CREATE INDEX IF NOT EXISTS command_idempotency_attention_status_idx - ON command_idempotency(status, updated_at) - WHERE status IN ('failed_retryable', 'failed_terminal')", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS command_idempotency_primary_aggregate_idx - ON command_idempotency(primary_aggregate_key, updated_at) - WHERE primary_aggregate_key IS NOT NULL", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 12).await?; - tx.commit().await?; + command_safety::migrate_v12_command_ledger_metadata(pool).await?; } // ── V13: Origin idempotency on ledger and folio rows ── if current < 13 { - let mut tx = pool.begin().await?; - - for alter in [ - "ALTER TABLE transactions ADD COLUMN origin_idempotency_key TEXT", - "ALTER TABLE transactions ADD COLUMN origin_transaction_ordinal INTEGER NOT NULL DEFAULT 0", - "ALTER TABLE folio_lines ADD COLUMN origin_idempotency_key TEXT", - "ALTER TABLE folio_lines ADD COLUMN origin_line_ordinal INTEGER NOT NULL DEFAULT 0", - ] { - execute_compat_alter(&mut tx, alter).await?; - } - - sqlx::query( - "CREATE UNIQUE INDEX IF NOT EXISTS transactions_origin_idem_uq - ON transactions (booking_id, origin_idempotency_key, origin_transaction_ordinal) - WHERE origin_idempotency_key IS NOT NULL AND origin_idempotency_key != ''", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE UNIQUE INDEX IF NOT EXISTS folio_lines_origin_idem_uq - ON folio_lines (booking_id, origin_idempotency_key, origin_line_ordinal) - WHERE origin_idempotency_key IS NOT NULL AND origin_idempotency_key != ''", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE UNIQUE INDEX IF NOT EXISTS transactions_origin_command_uq - ON transactions (origin_idempotency_key, origin_transaction_ordinal) - WHERE origin_idempotency_key IS NOT NULL AND origin_idempotency_key != ''", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE UNIQUE INDEX IF NOT EXISTS folio_lines_origin_command_uq - ON folio_lines (origin_idempotency_key, origin_line_ordinal) - WHERE origin_idempotency_key IS NOT NULL AND origin_idempotency_key != ''", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 13).await?; - tx.commit().await?; + command_safety::migrate_v13_origin_idempotency(pool).await?; } // ── V14: Integer VND money foundation ── if current < 14 { - let mut conn = pool.acquire().await?; - sqlx::query("PRAGMA foreign_keys=OFF") - .execute(&mut *conn) - .await?; - - let migration_result = (*conn) - .transaction(|tx| { - Box::pin(async move { - execute_compat_alter( - tx, - "ALTER TABLE command_idempotency ADD COLUMN legacy_request_hash TEXT", - ) - .await?; - crate::money_migration::migrate_integer_vnd_money(tx).await?; - set_schema_version(tx, 14).await?; - Ok::<(), sqlx::Error>(()) - }) - }) - .await; - - restore_foreign_keys_after_v14_migration(&mut conn, migration_result).await?; + money::migrate_v14_integer_vnd_money(pool).await?; } // ── V15: Command recovery queue and audit actions ── if current < 15 { - let mut tx = pool.begin().await?; - - for alter in [ - "ALTER TABLE command_idempotency ADD COLUMN recovery_dismissed_at TEXT", - "ALTER TABLE command_idempotency ADD COLUMN recovery_dismissed_by TEXT", - ] { - execute_compat_alter(&mut tx, alter).await?; - } - - sqlx::query( - "CREATE TABLE IF NOT EXISTS command_recovery_actions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - command_idempotency_id INTEGER NOT NULL, - action TEXT NOT NULL, - operator_id TEXT, - operator_role TEXT, - reason TEXT, - confirmed INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - FOREIGN KEY(command_idempotency_id) REFERENCES command_idempotency(id) - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS command_recovery_actions_command_idx - ON command_recovery_actions(command_idempotency_id, created_at)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS command_idempotency_recovery_queue_idx - ON command_idempotency(status, lease_expires_at, updated_at) - WHERE status IN ('in_progress', 'failed_retryable') - AND recovery_dismissed_at IS NULL", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 15).await?; - tx.commit().await?; + command_safety::migrate_v15_command_recovery(pool).await?; } // ── V16: Durable outbox events ── if current < 16 { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS outbox_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event_type TEXT NOT NULL, - aggregate_key TEXT NOT NULL, - payload_json TEXT NOT NULL, - origin_request_id TEXT NOT NULL, - origin_idempotency_key TEXT NOT NULL, - origin_command_name TEXT NOT NULL, - origin_request_hash TEXT NOT NULL, - status TEXT NOT NULL, - worker_token TEXT, - attempts INTEGER NOT NULL DEFAULT 0, - next_attempt_at TEXT, - processing_started_at TEXT, - processing_expires_at TEXT, - last_error TEXT, - created_at TEXT NOT NULL, - dispatched_at TEXT - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS outbox_events_pending_idx - ON outbox_events(next_attempt_at, aggregate_key, id) - WHERE status = 'pending'", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS outbox_events_processing_idx - ON outbox_events(processing_expires_at) - WHERE status = 'processing'", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE UNIQUE INDEX IF NOT EXISTS outbox_events_origin_command_uq - ON outbox_events(origin_command_name, origin_idempotency_key)", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 16).await?; - tx.commit().await?; + outbox::migrate_v16_durable_outbox_events(pool).await?; } // -- V17: Outbox per-aggregate open-row FIFO support -- if current < 17 { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS outbox_events_aggregate_open_idx - ON outbox_events(aggregate_key, id) - WHERE status IN ('pending', 'processing')", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 17).await?; - tx.commit().await?; + outbox::migrate_v17_outbox_fifo_support(pool).await?; } // -- V18: Agent safety session, audit, and memory schema -- if current < 18 { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS agent_sessions ( - id TEXT PRIMARY KEY, - role TEXT NOT NULL, - channel TEXT NOT NULL, - channel_actor_id TEXT, - status TEXT NOT NULL, - uses_memory INTEGER NOT NULL DEFAULT 0, - retention_policy TEXT NOT NULL, - metadata_json TEXT NOT NULL DEFAULT '{}', - started_at TEXT NOT NULL, - last_seen_at TEXT, - ended_at TEXT - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_sessions_actor_idx - ON agent_sessions(role, channel, channel_actor_id, last_seen_at)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_sessions_status_idx - ON agent_sessions(status, started_at)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS agent_audit_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT REFERENCES agent_sessions(id), - event_type TEXT NOT NULL, - actor_id TEXT, - role TEXT, - channel TEXT, - tool_name TEXT, - provider TEXT, - policy_outcome TEXT NOT NULL, - mutation_risk TEXT, - data_sensitivity TEXT, - summary_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_audit_events_type_idx - ON agent_audit_events(event_type, created_at)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_audit_events_session_idx - ON agent_audit_events(session_id, created_at)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_audit_events_role_channel_idx - ON agent_audit_events(role, channel, created_at)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS agent_memory_items ( - id TEXT PRIMARY KEY, - role TEXT NOT NULL, - scope TEXT NOT NULL, - key TEXT NOT NULL, - value_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(role, scope, key) - )", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 18).await?; - tx.commit().await?; + agent::migrate_v18_agent_safety_tables(pool).await?; } // -- V19: CEO hourly digest run state -- if current < 19 { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS agent_digest_runs ( - id TEXT PRIMARY KEY, - role TEXT NOT NULL, - channel TEXT NOT NULL, - channel_actor_id TEXT, - delivery_chat_id TEXT, - due_at TEXT NOT NULL, - status TEXT NOT NULL, - attempt_count INTEGER NOT NULL DEFAULT 0, - max_attempts INTEGER NOT NULL, - next_retry_at TEXT, - claimed_at TEXT, - claim_token TEXT, - delivered_at TEXT, - last_error_code TEXT, - last_error_summary_json TEXT NOT NULL DEFAULT '{}', - delivery_summary_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_digest_runs_status_due_idx - ON agent_digest_runs(status, due_at)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_digest_runs_retry_idx - ON agent_digest_runs(status, next_retry_at) - WHERE status = 'retry_waiting'", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_digest_runs_delivered_idx - ON agent_digest_runs(delivered_at)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS agent_digest_runs_actor_due_idx - ON agent_digest_runs(channel_actor_id, delivery_chat_id, due_at)", - ) - .execute(&mut *tx) - .await?; - - set_schema_version(&mut tx, 19).await?; - tx.commit().await?; + agent::migrate_v19_agent_digest_runs(pool).await?; } Ok(()) } @@ -1144,11 +635,7 @@ mod tests { "agent_digest_runs_delivered_idx", "agent_digest_runs_actor_due_idx", ] { - assert_eq!( - sqlite_index_count(pool, index).await, - 1, - "{index} exists" - ); + assert_eq!(sqlite_index_count(pool, index).await, 1, "{index} exists"); } } @@ -1356,12 +843,7 @@ mod tests { assert_table_group_exists(&pool, "PMS core", PMS_CORE_TABLES).await; assert_table_group_exists(&pool, "command safety", COMMAND_SAFETY_TABLES).await; - assert_table_group_exists( - &pool, - "experimental gateway", - EXPERIMENTAL_GATEWAY_TABLES, - ) - .await; + assert_table_group_exists(&pool, "experimental gateway", EXPERIMENTAL_GATEWAY_TABLES).await; assert_table_group_exists(&pool, "experimental agent", EXPERIMENTAL_AGENT_TABLES).await; } diff --git a/mhm/src-tauri/src/db/agent.rs b/mhm/src-tauri/src/db/agent.rs new file mode 100644 index 0000000..5101010 --- /dev/null +++ b/mhm/src-tauri/src/db/agent.rs @@ -0,0 +1,163 @@ +use sqlx::{Pool, Sqlite}; + +use super::set_schema_version; + +pub(super) async fn migrate_v18_agent_safety_tables( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY, + role TEXT NOT NULL, + channel TEXT NOT NULL, + channel_actor_id TEXT, + status TEXT NOT NULL, + uses_memory INTEGER NOT NULL DEFAULT 0, + retention_policy TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + started_at TEXT NOT NULL, + last_seen_at TEXT, + ended_at TEXT + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_sessions_actor_idx + ON agent_sessions(role, channel, channel_actor_id, last_seen_at)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_sessions_status_idx + ON agent_sessions(status, started_at)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS agent_audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES agent_sessions(id), + event_type TEXT NOT NULL, + actor_id TEXT, + role TEXT, + channel TEXT, + tool_name TEXT, + provider TEXT, + policy_outcome TEXT NOT NULL, + mutation_risk TEXT, + data_sensitivity TEXT, + summary_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_audit_events_type_idx + ON agent_audit_events(event_type, created_at)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_audit_events_session_idx + ON agent_audit_events(session_id, created_at)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_audit_events_role_channel_idx + ON agent_audit_events(role, channel, created_at)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS agent_memory_items ( + id TEXT PRIMARY KEY, + role TEXT NOT NULL, + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(role, scope, key) + )", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 18).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v19_agent_digest_runs(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS agent_digest_runs ( + id TEXT PRIMARY KEY, + role TEXT NOT NULL, + channel TEXT NOT NULL, + channel_actor_id TEXT, + delivery_chat_id TEXT, + due_at TEXT NOT NULL, + status TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL, + next_retry_at TEXT, + claimed_at TEXT, + claim_token TEXT, + delivered_at TEXT, + last_error_code TEXT, + last_error_summary_json TEXT NOT NULL DEFAULT '{}', + delivery_summary_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_digest_runs_status_due_idx + ON agent_digest_runs(status, due_at)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_digest_runs_retry_idx + ON agent_digest_runs(status, next_retry_at) + WHERE status = 'retry_waiting'", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_digest_runs_delivered_idx + ON agent_digest_runs(delivered_at)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS agent_digest_runs_actor_due_idx + ON agent_digest_runs(channel_actor_id, delivery_chat_id, due_at)", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 19).await?; + tx.commit().await?; + Ok(()) +} diff --git a/mhm/src-tauri/src/db/command_safety.rs b/mhm/src-tauri/src/db/command_safety.rs new file mode 100644 index 0000000..ba1c996 --- /dev/null +++ b/mhm/src-tauri/src/db/command_safety.rs @@ -0,0 +1,207 @@ +use sqlx::{Pool, Sqlite}; + +use super::{execute_compat_alter, set_schema_version}; + +pub(super) async fn migrate_v10_command_idempotency( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS command_idempotency ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + idempotency_key TEXT NOT NULL, + command_name TEXT NOT NULL, + request_hash TEXT NOT NULL, + intent_json TEXT NOT NULL, + primary_aggregate_key TEXT, + lock_keys_json TEXT NOT NULL, + status TEXT NOT NULL, + claim_token TEXT NOT NULL, + response_json TEXT, + error_code TEXT, + retryable INTEGER NOT NULL DEFAULT 0, + lease_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + last_attempt_at TEXT, + UNIQUE(command_name, idempotency_key) + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS command_idempotency_lease_idx + ON command_idempotency(lease_expires_at) + WHERE status = 'in_progress'", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS command_idempotency_completed_idx + ON command_idempotency(completed_at) + WHERE status IN ('completed', 'failed_terminal')", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 10).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v11_command_terminal_error_replay( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + execute_compat_alter( + &mut tx, + "ALTER TABLE command_idempotency ADD COLUMN error_json TEXT", + ) + .await?; + + set_schema_version(&mut tx, 11).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v12_command_ledger_metadata( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + for alter in [ + "ALTER TABLE command_idempotency ADD COLUMN request_id TEXT", + "ALTER TABLE command_idempotency ADD COLUMN actor_type TEXT NOT NULL DEFAULT 'system'", + "ALTER TABLE command_idempotency ADD COLUMN actor_id TEXT", + "ALTER TABLE command_idempotency ADD COLUMN client_id TEXT", + "ALTER TABLE command_idempotency ADD COLUMN session_id TEXT", + "ALTER TABLE command_idempotency ADD COLUMN channel_id TEXT", + "ALTER TABLE command_idempotency ADD COLUMN issued_at TEXT", + "ALTER TABLE command_idempotency ADD COLUMN summary_json TEXT NOT NULL DEFAULT '{}'", + "ALTER TABLE command_idempotency ADD COLUMN result_summary_json TEXT", + "ALTER TABLE command_idempotency ADD COLUMN error_summary_json TEXT", + ] { + execute_compat_alter(&mut tx, alter).await?; + } + + sqlx::query( + "CREATE INDEX IF NOT EXISTS command_idempotency_attention_status_idx + ON command_idempotency(status, updated_at) + WHERE status IN ('failed_retryable', 'failed_terminal')", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS command_idempotency_primary_aggregate_idx + ON command_idempotency(primary_aggregate_key, updated_at) + WHERE primary_aggregate_key IS NOT NULL", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 12).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v13_origin_idempotency(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + for alter in [ + "ALTER TABLE transactions ADD COLUMN origin_idempotency_key TEXT", + "ALTER TABLE transactions ADD COLUMN origin_transaction_ordinal INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE folio_lines ADD COLUMN origin_idempotency_key TEXT", + "ALTER TABLE folio_lines ADD COLUMN origin_line_ordinal INTEGER NOT NULL DEFAULT 0", + ] { + execute_compat_alter(&mut tx, alter).await?; + } + + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS transactions_origin_idem_uq + ON transactions (booking_id, origin_idempotency_key, origin_transaction_ordinal) + WHERE origin_idempotency_key IS NOT NULL AND origin_idempotency_key != ''", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS folio_lines_origin_idem_uq + ON folio_lines (booking_id, origin_idempotency_key, origin_line_ordinal) + WHERE origin_idempotency_key IS NOT NULL AND origin_idempotency_key != ''", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS transactions_origin_command_uq + ON transactions (origin_idempotency_key, origin_transaction_ordinal) + WHERE origin_idempotency_key IS NOT NULL AND origin_idempotency_key != ''", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS folio_lines_origin_command_uq + ON folio_lines (origin_idempotency_key, origin_line_ordinal) + WHERE origin_idempotency_key IS NOT NULL AND origin_idempotency_key != ''", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 13).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v15_command_recovery(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + for alter in [ + "ALTER TABLE command_idempotency ADD COLUMN recovery_dismissed_at TEXT", + "ALTER TABLE command_idempotency ADD COLUMN recovery_dismissed_by TEXT", + ] { + execute_compat_alter(&mut tx, alter).await?; + } + + sqlx::query( + "CREATE TABLE IF NOT EXISTS command_recovery_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + command_idempotency_id INTEGER NOT NULL, + action TEXT NOT NULL, + operator_id TEXT, + operator_role TEXT, + reason TEXT, + confirmed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY(command_idempotency_id) REFERENCES command_idempotency(id) + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS command_recovery_actions_command_idx + ON command_recovery_actions(command_idempotency_id, created_at)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS command_idempotency_recovery_queue_idx + ON command_idempotency(status, lease_expires_at, updated_at) + WHERE status IN ('in_progress', 'failed_retryable') + AND recovery_dismissed_at IS NULL", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 15).await?; + tx.commit().await?; + Ok(()) +} diff --git a/mhm/src-tauri/src/db/core_extensions.rs b/mhm/src-tauri/src/db/core_extensions.rs new file mode 100644 index 0000000..06d6127 --- /dev/null +++ b/mhm/src-tauri/src/db/core_extensions.rs @@ -0,0 +1,130 @@ +use sqlx::{Pool, Sqlite}; + +use super::{execute_compat_alter, set_schema_version}; + +pub(super) async fn migrate_v7_gateway_api_keys(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS gateway_api_keys ( + id TEXT PRIMARY KEY, + key_hash TEXT NOT NULL, + label TEXT DEFAULT 'default', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT + )", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 7).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v8_invoice_pdf_system(pool: &Pool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS invoices ( + id TEXT PRIMARY KEY, + invoice_number TEXT NOT NULL UNIQUE, + booking_id TEXT NOT NULL REFERENCES bookings(id), + hotel_name TEXT NOT NULL, + hotel_address TEXT NOT NULL, + hotel_phone TEXT NOT NULL, + guest_name TEXT NOT NULL, + guest_phone TEXT, + room_name TEXT NOT NULL, + room_type TEXT NOT NULL, + check_in TEXT NOT NULL, + check_out TEXT NOT NULL, + nights INTEGER NOT NULL, + pricing_breakdown TEXT NOT NULL, + subtotal INTEGER NOT NULL, + deposit_amount INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL, + balance_due INTEGER NOT NULL, + policy_text TEXT, + notes TEXT, + status TEXT NOT NULL DEFAULT 'issued', + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_booking ON invoices(booking_id)") + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 8).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v9_group_booking_system( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + // booking_groups: group metadata + sqlx::query( + "CREATE TABLE IF NOT EXISTS booking_groups ( + id TEXT PRIMARY KEY, + group_name TEXT NOT NULL, + master_booking_id TEXT, + organizer_name TEXT NOT NULL, + organizer_phone TEXT, + total_rooms INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + notes TEXT, + created_by TEXT, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + // group_services: per-group add-on charges + sqlx::query( + "CREATE TABLE IF NOT EXISTS group_services ( + id TEXT PRIMARY KEY, + group_id TEXT NOT NULL REFERENCES booking_groups(id), + booking_id TEXT REFERENCES bookings(id), + name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price INTEGER NOT NULL, + total_price INTEGER NOT NULL, + note TEXT, + created_by TEXT, + created_at TEXT NOT NULL + )", + ) + .execute(&mut *tx) + .await?; + + // Add group columns to bookings + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN group_id TEXT REFERENCES booking_groups(id)", + ) + .await?; + execute_compat_alter( + &mut tx, + "ALTER TABLE bookings ADD COLUMN is_master_room INTEGER DEFAULT 0", + ) + .await?; + + // Indexes + sqlx::query("CREATE INDEX IF NOT EXISTS idx_bookings_group ON bookings(group_id)") + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_group_services_group ON group_services(group_id)") + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 9).await?; + tx.commit().await?; + Ok(()) +} diff --git a/mhm/src-tauri/src/db/money.rs b/mhm/src-tauri/src/db/money.rs new file mode 100644 index 0000000..876d5a1 --- /dev/null +++ b/mhm/src-tauri/src/db/money.rs @@ -0,0 +1,28 @@ +use sqlx::{Connection, Pool, Sqlite}; + +use super::{execute_compat_alter, restore_foreign_keys_after_v14_migration, set_schema_version}; + +pub(super) async fn migrate_v14_integer_vnd_money(pool: &Pool) -> Result<(), sqlx::Error> { + let mut conn = pool.acquire().await?; + sqlx::query("PRAGMA foreign_keys=OFF") + .execute(&mut *conn) + .await?; + + let migration_result = (*conn) + .transaction(|tx| { + Box::pin(async move { + execute_compat_alter( + tx, + "ALTER TABLE command_idempotency ADD COLUMN legacy_request_hash TEXT", + ) + .await?; + crate::money_migration::migrate_integer_vnd_money(tx).await?; + set_schema_version(tx, 14).await?; + Ok::<(), sqlx::Error>(()) + }) + }) + .await; + + restore_foreign_keys_after_v14_migration(&mut conn, migration_result).await?; + Ok(()) +} diff --git a/mhm/src-tauri/src/db/outbox.rs b/mhm/src-tauri/src/db/outbox.rs new file mode 100644 index 0000000..11b4872 --- /dev/null +++ b/mhm/src-tauri/src/db/outbox.rs @@ -0,0 +1,78 @@ +use sqlx::{Pool, Sqlite}; + +use super::set_schema_version; + +pub(super) async fn migrate_v16_durable_outbox_events( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS outbox_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + aggregate_key TEXT NOT NULL, + payload_json TEXT NOT NULL, + origin_request_id TEXT NOT NULL, + origin_idempotency_key TEXT NOT NULL, + origin_command_name TEXT NOT NULL, + origin_request_hash TEXT NOT NULL, + status TEXT NOT NULL, + worker_token TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + processing_started_at TEXT, + processing_expires_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + dispatched_at TEXT + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS outbox_events_pending_idx + ON outbox_events(next_attempt_at, aggregate_key, id) + WHERE status = 'pending'", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS outbox_events_processing_idx + ON outbox_events(processing_expires_at) + WHERE status = 'processing'", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS outbox_events_origin_command_uq + ON outbox_events(origin_command_name, origin_idempotency_key)", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 16).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn migrate_v17_outbox_fifo_support( + pool: &Pool, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS outbox_events_aggregate_open_idx + ON outbox_events(aggregate_key, id) + WHERE status IN ('pending', 'processing')", + ) + .execute(&mut *tx) + .await?; + + set_schema_version(&mut tx, 17).await?; + tx.commit().await?; + Ok(()) +} From 7a58a73474173610688d5bbb3178880c1d05c9f3 Mon Sep 17 00:00:00 2001 From: binhan Date: Sun, 17 May 2026 16:29:05 +0700 Subject: [PATCH 43/45] refactor: extract command idempotency types --- mhm/src-tauri/src/command_idempotency.rs | 524 +----------------- .../src/command_idempotency/types.rs | 517 +++++++++++++++++ 2 files changed, 529 insertions(+), 512 deletions(-) create mode 100644 mhm/src-tauri/src/command_idempotency/types.rs diff --git a/mhm/src-tauri/src/command_idempotency.rs b/mhm/src-tauri/src/command_idempotency.rs index da90b58..b3c6ae9 100644 --- a/mhm/src-tauri/src/command_idempotency.rs +++ b/mhm/src-tauri/src/command_idempotency.rs @@ -1,13 +1,21 @@ +mod types; + +pub use types::{ + ActorType, CommandLedgerErrorSummary, CommandLedgerResultSummary, CommandLedgerSummary, + CommandStatus, IdempotentCommandResult, LedgerAggregateRef, LockKeyDeriver, + ResolvedWriteCommandGuard, SanitizedLedgerIntent, WriteCommandContext, WriteCommandRequest, + WriteCommandServiceFuture, +}; + use crate::{ app_error::{codes, CommandError, CommandResult}, outbox::{insert_outbox_event_tx, OutboxEventSpec}, services::settings_store, }; -use chrono::{DateTime, FixedOffset, Utc}; -use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; use sqlx::{sqlite::SqliteRow, Pool, Row, Sqlite, Transaction}; -use std::{collections::BTreeMap, future::Future, pin::Pin}; +use std::future::Future; pub const SET_CRASH_REPORTING_PREFERENCE_COMMAND: &str = "settings.set_crash_reporting_preference"; const CLAIM_LEASE_SECONDS: i64 = 30; @@ -20,446 +28,6 @@ const CLAIM_LEASE_REFRESH_TIMEOUT_SECONDS: u64 = 1; #[cfg(not(test))] const CLAIM_LEASE_REFRESH_TIMEOUT_SECONDS: u64 = 2; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ActorType { - Human, - AiAgent, - System, - Integration, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WriteCommandContext { - pub request_id: String, - pub idempotency_key: String, - pub command_name: String, - pub actor_id: Option, - pub actor_type: ActorType, - pub client_id: Option, - pub session_id: Option, - pub channel_id: Option, - pub issued_at: DateTime, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct IdempotentCommandResult { - pub response: T, - pub replayed: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct LedgerAggregateRef { - #[serde(rename = "type")] - ref_type: String, - id: String, - label: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct CommandLedgerSummary { - label: String, - aggregate_refs: Vec, - business_dates: Vec, - safe_fields: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct SanitizedLedgerIntent { - fields: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct CommandLedgerResultSummary { - label: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct CommandLedgerErrorSummary { - code: String, - kind: String, - retryable: bool, - message: String, - support_id: Option, -} - -const SAFE_TEXT_MAX_CHARS: usize = 160; -const SENSITIVE_NUMERIC_MIN_DIGITS: usize = 10; -const FORBIDDEN_SAFE_FIELD_PARTS: &[&str] = &[ - "phone", - "email", - "payment", - "card", - "token", - "secret", - "password", - "guest_note", - "prompt", - "raw", - "payload", -]; - -impl CommandLedgerSummary { - pub fn new(label: impl Into) -> CommandResult { - Ok(Self { - label: validate_safe_text(label.into())?, - aggregate_refs: Vec::new(), - business_dates: Vec::new(), - safe_fields: BTreeMap::new(), - }) - } - - pub fn with_aggregate_ref( - mut self, - ref_type: impl Into, - id: impl Into, - label: Option>, - ) -> CommandResult { - self.aggregate_refs.push(LedgerAggregateRef { - ref_type: validate_safe_key(ref_type.into())?, - id: validate_safe_text(id.into())?, - label: label.map(Into::into).map(validate_safe_text).transpose()?, - }); - Ok(self) - } - - pub fn with_business_date(mut self, business_date: impl Into) -> CommandResult { - self.business_dates - .push(validate_safe_text(business_date.into())?); - Ok(self) - } - - pub fn with_safe_field( - mut self, - key: impl Into, - value: impl Into, - ) -> CommandResult { - self.safe_fields.insert( - validate_safe_key(key.into())?, - validate_safe_text(value.into())?, - ); - Ok(self) - } - - pub fn to_value(&self) -> CommandResult { - serde_json::to_value(self.validated()?).map_err(system_error) - } - - fn validated(&self) -> CommandResult { - let mut summary = Self::new(self.label.clone())?; - - for aggregate_ref in &self.aggregate_refs { - summary = summary.with_aggregate_ref( - aggregate_ref.ref_type.clone(), - aggregate_ref.id.clone(), - aggregate_ref.label.clone(), - )?; - } - - for business_date in &self.business_dates { - summary = summary.with_business_date(business_date.clone())?; - } - - for (key, value) in &self.safe_fields { - summary = summary.with_safe_field(key.clone(), value.clone())?; - } - - Ok(summary) - } -} - -impl SanitizedLedgerIntent { - pub fn from_pairs(pairs: I) -> CommandResult - where - K: Into, - V: Into, - I: IntoIterator, - { - let mut fields = BTreeMap::new(); - for (key, value) in pairs { - fields.insert( - validate_safe_key(key.into())?, - validate_safe_value(value.into())?, - ); - } - Ok(Self { fields }) - } - - pub fn to_value(&self) -> CommandResult { - serde_json::to_value(self.validated()?).map_err(system_error) - } - - fn validated(&self) -> CommandResult { - Self::from_pairs( - self.fields - .iter() - .map(|(key, value)| (key.clone(), value.clone())), - ) - } -} - -impl CommandLedgerResultSummary { - pub fn success(label: impl Into) -> CommandResult { - Ok(Self { - label: validate_safe_text(label.into())?, - }) - } - - #[allow(dead_code)] - fn to_json_string(&self) -> CommandResult { - let summary = Self::success(self.label.clone())?; - stable_json_string(&serde_json::to_value(summary).map_err(system_error)?) - } -} - -impl CommandLedgerErrorSummary { - #[allow(dead_code)] - fn from_error(error: &CommandError) -> Self { - let message = validate_safe_text(error.message.clone()) - .unwrap_or_else(|_| "Command failed".to_string()); - let support_id = error - .support_id - .clone() - .and_then(|support_id| validate_safe_text(support_id).ok()); - let code = validate_safe_key(error.code.clone()) - .unwrap_or_else(|_| codes::SYSTEM_INTERNAL_ERROR.to_string()); - let kind = serde_json::to_value(error.kind) - .ok() - .and_then(|value| value.as_str().map(ToString::to_string)) - .and_then(|kind| validate_safe_key(kind).ok()) - .unwrap_or_else(|| "system".to_string()); - - Self { - code, - kind, - retryable: error.retryable, - message, - support_id, - } - } - - #[allow(dead_code)] - fn to_json_string(&self) -> CommandResult { - let summary = Self { - code: validate_safe_key(self.code.clone())?, - kind: validate_safe_key(self.kind.clone())?, - retryable: self.retryable, - message: validate_safe_text(self.message.clone())?, - support_id: self - .support_id - .clone() - .map(validate_safe_text) - .transpose()?, - }; - stable_json_string(&serde_json::to_value(summary).map_err(system_error)?) - } -} - -fn validate_safe_key(value: String) -> CommandResult { - if value.is_empty() || contains_forbidden_safe_term(&value) { - return Err(system_error(format!("unsafe ledger key: {value}"))); - } - Ok(value) -} - -fn validate_safe_text(value: String) -> CommandResult { - let trimmed = value.trim().to_string(); - if trimmed.len() > SAFE_TEXT_MAX_CHARS - || trimmed.contains('@') - || contains_sensitive_numeric_sequence(&trimmed) - || contains_forbidden_safe_term(&trimmed) - { - return Err(system_error("unsafe ledger text")); - } - Ok(trimmed) -} - -fn contains_forbidden_safe_term(value: &str) -> bool { - let with_case_boundaries = split_case_boundaries(value); - let lower = with_case_boundaries.to_ascii_lowercase(); - let parts = lower - .split(|ch: char| !ch.is_ascii_alphanumeric()) - .filter(|part| !part.is_empty()) - .collect::>(); - let normalized = parts.join("_"); - let compact = parts.join(""); - - parts - .iter() - .any(|part| FORBIDDEN_SAFE_FIELD_PARTS.contains(part)) - || FORBIDDEN_SAFE_FIELD_PARTS - .iter() - .filter(|part| part.contains('_')) - .any(|part| normalized.contains(part)) - || FORBIDDEN_SAFE_FIELD_PARTS - .iter() - .filter(|part| !part.contains('_')) - .any(|part| compact.contains(part)) -} - -fn split_case_boundaries(value: &str) -> String { - let mut normalized = String::with_capacity(value.len()); - let mut previous_was_lower_or_digit = false; - - for ch in value.chars() { - if ch.is_ascii_uppercase() && previous_was_lower_or_digit { - normalized.push('_'); - } - normalized.push(ch); - previous_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit(); - } - - normalized -} - -fn contains_sensitive_numeric_sequence(value: &str) -> bool { - value.chars().filter(|ch| ch.is_ascii_digit()).count() >= SENSITIVE_NUMERIC_MIN_DIGITS -} - -fn validate_safe_value(value: serde_json::Value) -> CommandResult { - match value { - serde_json::Value::String(text) => Ok(serde_json::Value::String(validate_safe_text(text)?)), - serde_json::Value::Number(number) => { - if contains_sensitive_numeric_sequence(&number.to_string()) { - return Err(system_error("unsafe ledger value")); - } - Ok(serde_json::Value::Number(number)) - } - serde_json::Value::Bool(_) | serde_json::Value::Null => Ok(value), - serde_json::Value::Array(values) => values - .into_iter() - .map(validate_safe_value) - .collect::>>() - .map(serde_json::Value::Array), - serde_json::Value::Object(object) => { - let mut safe = serde_json::Map::new(); - for (key, value) in object { - safe.insert(validate_safe_key(key)?, validate_safe_value(value)?); - } - Ok(serde_json::Value::Object(safe)) - } - } -} - -pub type LockKeyDeriver = fn(&serde_json::Value) -> CommandResult>; - -pub type WriteCommandServiceFuture<'tx> = - Pin> + Send + 'tx>>; - -#[derive(Debug)] -pub struct ResolvedWriteCommandGuard { - pub guard: T, - pub lock_keys: Vec, -} - -impl ResolvedWriteCommandGuard { - pub fn new(guard: T, lock_keys: I) -> Self - where - I: IntoIterator, - S: Into, - { - Self { - guard, - lock_keys: lock_keys.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Debug, Clone)] -pub struct WriteCommandRequest { - hash_payload: serde_json::Value, - ledger_intent: SanitizedLedgerIntent, - summary: CommandLedgerSummary, - success_summary: CommandLedgerResultSummary, - primary_aggregate_key: Option, - lock_key_deriver: LockKeyDeriver, - outbox_event: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CommandStatus { - InProgress, - Completed, - FailedRetryable, - FailedTerminal, -} - -impl CommandStatus { - fn as_str(self) -> &'static str { - match self { - Self::InProgress => "in_progress", - Self::Completed => "completed", - Self::FailedRetryable => "failed_retryable", - Self::FailedTerminal => "failed_terminal", - } - } - - fn from_str(value: &str) -> CommandResult { - match value { - "in_progress" => Ok(Self::InProgress), - "completed" => Ok(Self::Completed), - "failed_retryable" => Ok(Self::FailedRetryable), - "failed_terminal" => Ok(Self::FailedTerminal), - _ => Err(system_error(format!( - "unknown idempotency command status: {value}" - ))), - } - } -} - -impl WriteCommandRequest { - pub fn new_sanitized( - hash_payload: serde_json::Value, - ledger_intent: SanitizedLedgerIntent, - summary: CommandLedgerSummary, - ) -> CommandResult { - Ok(Self { - hash_payload, - ledger_intent: SanitizedLedgerIntent::from_pairs( - ledger_intent - .fields - .iter() - .map(|(key, value)| (key.clone(), value.clone())), - )?, - summary: summary.validated()?, - success_summary: CommandLedgerResultSummary::success("Command completed")?, - primary_aggregate_key: None, - lock_key_deriver: default_lock_key_deriver, - outbox_event: None, - }) - } - - pub fn new_low_risk( - intent: serde_json::Value, - label: impl Into, - ) -> CommandResult { - let ledger_intent = sanitized_intent_from_value(intent.clone())?; - let summary = CommandLedgerSummary::new(label)?; - Self::new_sanitized(intent, ledger_intent, summary) - } - - pub fn with_primary_aggregate_key(mut self, primary_aggregate_key: impl Into) -> Self { - self.primary_aggregate_key = Some(primary_aggregate_key.into()); - self - } - - pub fn with_lock_key_deriver(mut self, lock_key_deriver: LockKeyDeriver) -> Self { - self.lock_key_deriver = lock_key_deriver; - self - } - - pub fn with_success_summary(mut self, success_summary: CommandLedgerResultSummary) -> Self { - self.success_summary = success_summary; - self - } - - pub fn with_outbox_event(mut self, outbox_event: OutboxEventSpec) -> Self { - self.outbox_event = Some(outbox_event); - self - } -} - pub fn default_lock_key_deriver(_intent: &serde_json::Value) -> CommandResult> { Ok(Vec::new()) } @@ -473,13 +41,6 @@ fn actor_type_as_str(actor_type: ActorType) -> &'static str { } } -fn sanitized_intent_from_value(value: serde_json::Value) -> CommandResult { - match value { - serde_json::Value::Object(object) => SanitizedLedgerIntent::from_pairs(object), - value => SanitizedLedgerIntent::from_pairs([("value", value)]), - } -} - pub struct WriteCommandExecutor { pool: Pool, } @@ -1214,68 +775,6 @@ fn lease_refresh_error_is_transient(error: &sqlx::Error) -> bool { == Some(codes::DB_LOCKED_RETRYABLE) } -impl WriteCommandContext { - pub fn for_scoped_command( - request_id: impl Into, - idempotency_key: impl Into, - command_name: impl Into, - ) -> CommandResult { - let request_id = request_id.into(); - let idempotency_key = idempotency_key.into().trim().to_string(); - if idempotency_key.is_empty() { - return Err(CommandError::user( - codes::IDEMPOTENCY_KEY_REQUIRED, - "Idempotency key is required", - ) - .with_request_id(request_id.clone())); - } - - Ok(Self { - request_id, - idempotency_key, - command_name: command_name.into(), - actor_id: None, - actor_type: ActorType::Human, - client_id: None, - session_id: None, - channel_id: None, - issued_at: chrono::Local::now().fixed_offset(), - }) - } - - pub fn new_internal(command_name: &str) -> Self { - Self { - request_id: uuid::Uuid::new_v4().to_string(), - idempotency_key: uuid::Uuid::new_v4().to_string(), - command_name: command_name.to_string(), - actor_id: None, - actor_type: ActorType::System, - client_id: None, - session_id: None, - channel_id: None, - issued_at: chrono::Local::now().fixed_offset(), - } - } - - #[cfg(test)] - pub fn for_internal_test(request_id: &str, idempotency_key: &str, command_name: &str) -> Self { - let issued_at = DateTime::parse_from_rfc3339("2026-04-24T10:00:00+07:00") - .expect("fixed test timestamp parses"); - - Self { - request_id: request_id.to_string(), - idempotency_key: idempotency_key.to_string(), - command_name: command_name.to_string(), - actor_id: Some("test".to_string()), - actor_type: ActorType::System, - client_id: None, - session_id: None, - channel_id: None, - issued_at, - } - } -} - pub async fn set_crash_reporting_preference_idempotent( pool: &Pool, ctx: &WriteCommandContext, @@ -1547,6 +1046,7 @@ mod tests { use super::*; use crate::app_error::codes; use sqlx::sqlite::SqlitePoolOptions; + use std::collections::BTreeMap; async fn test_pool_with_max_connections(max_connections: u32) -> Pool { let database_url = format!( diff --git a/mhm/src-tauri/src/command_idempotency/types.rs b/mhm/src-tauri/src/command_idempotency/types.rs new file mode 100644 index 0000000..731c97f --- /dev/null +++ b/mhm/src-tauri/src/command_idempotency/types.rs @@ -0,0 +1,517 @@ +use super::{default_lock_key_deriver, stable_json_string, system_error}; +use crate::{ + app_error::{codes, CommandError, CommandResult}, + outbox::OutboxEventSpec, +}; +use chrono::{DateTime, FixedOffset}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, future::Future, pin::Pin}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActorType { + Human, + AiAgent, + System, + Integration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WriteCommandContext { + pub request_id: String, + pub idempotency_key: String, + pub command_name: String, + pub actor_id: Option, + pub actor_type: ActorType, + pub client_id: Option, + pub session_id: Option, + pub channel_id: Option, + pub issued_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct IdempotentCommandResult { + pub response: T, + pub replayed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LedgerAggregateRef { + #[serde(rename = "type")] + ref_type: String, + id: String, + label: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CommandLedgerSummary { + pub(super) label: String, + pub(super) aggregate_refs: Vec, + pub(super) business_dates: Vec, + pub(super) safe_fields: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SanitizedLedgerIntent { + fields: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CommandLedgerResultSummary { + label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CommandLedgerErrorSummary { + code: String, + kind: String, + retryable: bool, + message: String, + support_id: Option, +} + +const SAFE_TEXT_MAX_CHARS: usize = 160; +const SENSITIVE_NUMERIC_MIN_DIGITS: usize = 10; +const FORBIDDEN_SAFE_FIELD_PARTS: &[&str] = &[ + "phone", + "email", + "payment", + "card", + "token", + "secret", + "password", + "guest_note", + "prompt", + "raw", + "payload", +]; + +impl CommandLedgerSummary { + pub fn new(label: impl Into) -> CommandResult { + Ok(Self { + label: validate_safe_text(label.into())?, + aggregate_refs: Vec::new(), + business_dates: Vec::new(), + safe_fields: BTreeMap::new(), + }) + } + + pub fn with_aggregate_ref( + mut self, + ref_type: impl Into, + id: impl Into, + label: Option>, + ) -> CommandResult { + self.aggregate_refs.push(LedgerAggregateRef { + ref_type: validate_safe_key(ref_type.into())?, + id: validate_safe_text(id.into())?, + label: label.map(Into::into).map(validate_safe_text).transpose()?, + }); + Ok(self) + } + + pub fn with_business_date(mut self, business_date: impl Into) -> CommandResult { + self.business_dates + .push(validate_safe_text(business_date.into())?); + Ok(self) + } + + pub fn with_safe_field( + mut self, + key: impl Into, + value: impl Into, + ) -> CommandResult { + self.safe_fields.insert( + validate_safe_key(key.into())?, + validate_safe_text(value.into())?, + ); + Ok(self) + } + + pub fn to_value(&self) -> CommandResult { + serde_json::to_value(self.validated()?).map_err(system_error) + } + + fn validated(&self) -> CommandResult { + let mut summary = Self::new(self.label.clone())?; + + for aggregate_ref in &self.aggregate_refs { + summary = summary.with_aggregate_ref( + aggregate_ref.ref_type.clone(), + aggregate_ref.id.clone(), + aggregate_ref.label.clone(), + )?; + } + + for business_date in &self.business_dates { + summary = summary.with_business_date(business_date.clone())?; + } + + for (key, value) in &self.safe_fields { + summary = summary.with_safe_field(key.clone(), value.clone())?; + } + + Ok(summary) + } +} + +impl SanitizedLedgerIntent { + pub fn from_pairs(pairs: I) -> CommandResult + where + K: Into, + V: Into, + I: IntoIterator, + { + let mut fields = BTreeMap::new(); + for (key, value) in pairs { + fields.insert( + validate_safe_key(key.into())?, + validate_safe_value(value.into())?, + ); + } + Ok(Self { fields }) + } + + pub fn to_value(&self) -> CommandResult { + serde_json::to_value(self.validated()?).map_err(system_error) + } + + fn validated(&self) -> CommandResult { + Self::from_pairs( + self.fields + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ) + } +} + +impl CommandLedgerResultSummary { + pub fn success(label: impl Into) -> CommandResult { + Ok(Self { + label: validate_safe_text(label.into())?, + }) + } + + #[allow(dead_code)] + pub(super) fn to_json_string(&self) -> CommandResult { + let summary = Self::success(self.label.clone())?; + stable_json_string(&serde_json::to_value(summary).map_err(system_error)?) + } +} + +impl CommandLedgerErrorSummary { + #[allow(dead_code)] + pub(super) fn from_error(error: &CommandError) -> Self { + let message = validate_safe_text(error.message.clone()) + .unwrap_or_else(|_| "Command failed".to_string()); + let support_id = error + .support_id + .clone() + .and_then(|support_id| validate_safe_text(support_id).ok()); + let code = validate_safe_key(error.code.clone()) + .unwrap_or_else(|_| codes::SYSTEM_INTERNAL_ERROR.to_string()); + let kind = serde_json::to_value(error.kind) + .ok() + .and_then(|value| value.as_str().map(ToString::to_string)) + .and_then(|kind| validate_safe_key(kind).ok()) + .unwrap_or_else(|| "system".to_string()); + + Self { + code, + kind, + retryable: error.retryable, + message, + support_id, + } + } + + #[allow(dead_code)] + pub(super) fn to_json_string(&self) -> CommandResult { + let summary = Self { + code: validate_safe_key(self.code.clone())?, + kind: validate_safe_key(self.kind.clone())?, + retryable: self.retryable, + message: validate_safe_text(self.message.clone())?, + support_id: self + .support_id + .clone() + .map(validate_safe_text) + .transpose()?, + }; + stable_json_string(&serde_json::to_value(summary).map_err(system_error)?) + } +} + +fn validate_safe_key(value: String) -> CommandResult { + if value.is_empty() || contains_forbidden_safe_term(&value) { + return Err(system_error(format!("unsafe ledger key: {value}"))); + } + Ok(value) +} + +fn validate_safe_text(value: String) -> CommandResult { + let trimmed = value.trim().to_string(); + if trimmed.len() > SAFE_TEXT_MAX_CHARS + || trimmed.contains('@') + || contains_sensitive_numeric_sequence(&trimmed) + || contains_forbidden_safe_term(&trimmed) + { + return Err(system_error("unsafe ledger text")); + } + Ok(trimmed) +} + +fn contains_forbidden_safe_term(value: &str) -> bool { + let with_case_boundaries = split_case_boundaries(value); + let lower = with_case_boundaries.to_ascii_lowercase(); + let parts = lower + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|part| !part.is_empty()) + .collect::>(); + let normalized = parts.join("_"); + let compact = parts.join(""); + + parts + .iter() + .any(|part| FORBIDDEN_SAFE_FIELD_PARTS.contains(part)) + || FORBIDDEN_SAFE_FIELD_PARTS + .iter() + .filter(|part| part.contains('_')) + .any(|part| normalized.contains(part)) + || FORBIDDEN_SAFE_FIELD_PARTS + .iter() + .filter(|part| !part.contains('_')) + .any(|part| compact.contains(part)) +} + +fn split_case_boundaries(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut previous_was_lower_or_digit = false; + + for ch in value.chars() { + if ch.is_ascii_uppercase() && previous_was_lower_or_digit { + normalized.push('_'); + } + normalized.push(ch); + previous_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit(); + } + + normalized +} + +fn contains_sensitive_numeric_sequence(value: &str) -> bool { + value.chars().filter(|ch| ch.is_ascii_digit()).count() >= SENSITIVE_NUMERIC_MIN_DIGITS +} + +fn validate_safe_value(value: serde_json::Value) -> CommandResult { + match value { + serde_json::Value::String(text) => Ok(serde_json::Value::String(validate_safe_text(text)?)), + serde_json::Value::Number(number) => { + if contains_sensitive_numeric_sequence(&number.to_string()) { + return Err(system_error("unsafe ledger value")); + } + Ok(serde_json::Value::Number(number)) + } + serde_json::Value::Bool(_) | serde_json::Value::Null => Ok(value), + serde_json::Value::Array(values) => values + .into_iter() + .map(validate_safe_value) + .collect::>>() + .map(serde_json::Value::Array), + serde_json::Value::Object(object) => { + let mut safe = serde_json::Map::new(); + for (key, value) in object { + safe.insert(validate_safe_key(key)?, validate_safe_value(value)?); + } + Ok(serde_json::Value::Object(safe)) + } + } +} + +pub type LockKeyDeriver = fn(&serde_json::Value) -> CommandResult>; + +pub type WriteCommandServiceFuture<'tx> = + Pin> + Send + 'tx>>; + +#[derive(Debug)] +pub struct ResolvedWriteCommandGuard { + pub guard: T, + pub lock_keys: Vec, +} + +impl ResolvedWriteCommandGuard { + pub fn new(guard: T, lock_keys: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + guard, + lock_keys: lock_keys.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Debug, Clone)] +pub struct WriteCommandRequest { + pub(super) hash_payload: serde_json::Value, + pub(super) ledger_intent: SanitizedLedgerIntent, + pub(super) summary: CommandLedgerSummary, + pub(super) success_summary: CommandLedgerResultSummary, + pub(super) primary_aggregate_key: Option, + pub(super) lock_key_deriver: LockKeyDeriver, + pub(super) outbox_event: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommandStatus { + InProgress, + Completed, + FailedRetryable, + FailedTerminal, +} + +impl CommandStatus { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::InProgress => "in_progress", + Self::Completed => "completed", + Self::FailedRetryable => "failed_retryable", + Self::FailedTerminal => "failed_terminal", + } + } + + pub(super) fn from_str(value: &str) -> CommandResult { + match value { + "in_progress" => Ok(Self::InProgress), + "completed" => Ok(Self::Completed), + "failed_retryable" => Ok(Self::FailedRetryable), + "failed_terminal" => Ok(Self::FailedTerminal), + _ => Err(system_error(format!( + "unknown idempotency command status: {value}" + ))), + } + } +} + +impl WriteCommandRequest { + pub fn new_sanitized( + hash_payload: serde_json::Value, + ledger_intent: SanitizedLedgerIntent, + summary: CommandLedgerSummary, + ) -> CommandResult { + Ok(Self { + hash_payload, + ledger_intent: SanitizedLedgerIntent::from_pairs( + ledger_intent + .fields + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + )?, + summary: summary.validated()?, + success_summary: CommandLedgerResultSummary::success("Command completed")?, + primary_aggregate_key: None, + lock_key_deriver: default_lock_key_deriver, + outbox_event: None, + }) + } + + pub fn new_low_risk( + intent: serde_json::Value, + label: impl Into, + ) -> CommandResult { + let ledger_intent = sanitized_intent_from_value(intent.clone())?; + let summary = CommandLedgerSummary::new(label)?; + Self::new_sanitized(intent, ledger_intent, summary) + } + + pub fn with_primary_aggregate_key(mut self, primary_aggregate_key: impl Into) -> Self { + self.primary_aggregate_key = Some(primary_aggregate_key.into()); + self + } + + pub fn with_lock_key_deriver(mut self, lock_key_deriver: LockKeyDeriver) -> Self { + self.lock_key_deriver = lock_key_deriver; + self + } + + pub fn with_success_summary(mut self, success_summary: CommandLedgerResultSummary) -> Self { + self.success_summary = success_summary; + self + } + + pub fn with_outbox_event(mut self, outbox_event: OutboxEventSpec) -> Self { + self.outbox_event = Some(outbox_event); + self + } +} + +fn sanitized_intent_from_value(value: serde_json::Value) -> CommandResult { + match value { + serde_json::Value::Object(object) => SanitizedLedgerIntent::from_pairs(object), + value => SanitizedLedgerIntent::from_pairs([("value", value)]), + } +} + +impl WriteCommandContext { + pub fn for_scoped_command( + request_id: impl Into, + idempotency_key: impl Into, + command_name: impl Into, + ) -> CommandResult { + let request_id = request_id.into(); + let idempotency_key = idempotency_key.into().trim().to_string(); + if idempotency_key.is_empty() { + return Err(CommandError::user( + codes::IDEMPOTENCY_KEY_REQUIRED, + "Idempotency key is required", + ) + .with_request_id(request_id.clone())); + } + + Ok(Self { + request_id, + idempotency_key, + command_name: command_name.into(), + actor_id: None, + actor_type: ActorType::Human, + client_id: None, + session_id: None, + channel_id: None, + issued_at: chrono::Local::now().fixed_offset(), + }) + } + + pub fn new_internal(command_name: &str) -> Self { + Self { + request_id: uuid::Uuid::new_v4().to_string(), + idempotency_key: uuid::Uuid::new_v4().to_string(), + command_name: command_name.to_string(), + actor_id: None, + actor_type: ActorType::System, + client_id: None, + session_id: None, + channel_id: None, + issued_at: chrono::Local::now().fixed_offset(), + } + } + + #[cfg(test)] + pub fn for_internal_test(request_id: &str, idempotency_key: &str, command_name: &str) -> Self { + let issued_at = DateTime::parse_from_rfc3339("2026-04-24T10:00:00+07:00") + .expect("fixed test timestamp parses"); + + Self { + request_id: request_id.to_string(), + idempotency_key: idempotency_key.to_string(), + command_name: command_name.to_string(), + actor_id: Some("test".to_string()), + actor_type: ActorType::System, + client_id: None, + session_id: None, + channel_id: None, + issued_at, + } + } +} From 9a00c0faa72d48d4ef91209d8c5461571f964b82 Mon Sep 17 00:00:00 2001 From: binhan Date: Mon, 18 May 2026 14:52:41 +0700 Subject: [PATCH 44/45] docs: design command lock-key extraction --- ...6-05-18-command-lock-key-builder-design.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-18-command-lock-key-builder-design.md diff --git a/docs/superpowers/specs/2026-05-18-command-lock-key-builder-design.md b/docs/superpowers/specs/2026-05-18-command-lock-key-builder-design.md new file mode 100644 index 0000000..0262fe2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-command-lock-key-builder-design.md @@ -0,0 +1,205 @@ +# Issue 149: Command Lock-Key Builder Extraction Design + +Date: 2026-05-18 +Status: Approved for implementation planning + +GitHub issue: + +## Purpose + +Extract the command idempotency lock-key preparation internals into a focused module without changing the stable lock-key format, command serialization behavior, or high-risk write lock coverage. + +This issue exists because lock keys are part of the PMS safety contract. A command that mutates currently covered resources such as bookings, rooms, folios, groups, and settings must keep using the same stable lock key strings so retries, duplicate detection, command recovery metadata, and aggregate locking continue to agree about the same resource. + +## Problem + +`mhm/src-tauri/src/command_idempotency.rs` currently prepares lock-key JSON in more than one place: + +- initial command preparation runs the request lock-key deriver, sorts, deduplicates, and serializes `lock_keys_json`; +- resolved-guard refresh sorts, deduplicates, requires non-empty lock keys, and updates `command_idempotency.lock_keys_json`. + +That behavior is correct but too easy to drift because it is embedded in the large executor file. The issue is not to invent a new lock format. The issue is to isolate the current behavior behind one internal command-idempotency boundary and add format tripwire tests. + +## Goals + +- Add a focused command idempotency lock-key helper module. +- Preserve exact lock-key strings and JSON output. +- Preserve the distinction between optional initial lock keys and required resolved-guard lock keys. +- Preserve existing command payload hashing, ledger intent serialization, summary serialization, replay behavior, and outbox behavior. +- Document the lock-key format in source comments or docs close to the helper module. +- Add or keep tests that assert exact persisted `lock_keys_json` output. + +## Non-Goals + +- Do not change stable lock-key strings. +- Do not change command request hashing or canonical payload semantics. +- Do not change `intent_json`, `summary_json`, `result_summary_json`, or response serialization. +- Do not change which aggregates each command locks. +- Do not migrate all booking, billing, group, invoice, or agent setting lock-key derivers into one large global API. +- Do not refactor `aggregate_locks.rs` unless compilation or tests prove a tiny compatibility change is required. + +## Selected Approach + +Use a command-specific helper module: + +```text +mhm/src-tauri/src/command_idempotency/lock_keys.rs +``` + +The module will be a thin boundary for command idempotency persistence. It will not own the aggregate lock format itself. Existing constructors in `aggregate_locks.rs` remain the source for aggregate strings such as `room:{id}`, `booking:{id}`, `folio:{id}`, and `group:{id}`. Existing ad hoc command formats such as `settings:{setting_key}` must be preserved as existing command lock keys, not moved into a new global constructor as part of this issue. + +This gives command idempotency one place to prepare lock-key JSON while keeping the implementation narrow enough for a safety-sensitive refactor. + +## Stable Lock-Key Format + +The implementation must preserve the currently persisted strings: + +| Aggregate | Format | +| --- | --- | +| Room | `room:{room_id}` | +| Booking | `booking:{booking_id}` | +| Folio | `folio:{booking_id}` | +| Group | `group:{group_id}` | +| Setting | `settings:{setting_key}` | + +The canonical persisted representation remains a stable JSON array of strings after sorting and deduplication: + +```json +["booking:B1","room:R1"] +``` + +Low-risk commands may still persist an empty array: + +```json +[] +``` + +## Architecture + +`command_idempotency.rs` remains the public module root and executor home. `types.rs` continues to own command contract types. The new `lock_keys.rs` module owns only lock-key preparation helpers used by the executor. + +Expected module shape: + +```rust +mod lock_keys; +mod types; +``` + +The helper module should expose narrow parent-only functions, for example: + +```rust +pub(super) fn optional_lock_keys_json(keys: I) -> CommandResult +where + I: IntoIterator, + S: Into; + +pub(super) fn required_lock_keys_json(keys: I) -> CommandResult +where + I: IntoIterator, + S: Into; +``` + +Naming can change during implementation if the final names are clearer, but the boundary should stay narrow: + +- optional helper allows empty input for low-risk/default lock derivation; +- required helper rejects empty input for resolved guard refresh with the current system error classification/code and exact message `Resolved idempotency lock keys are required`; +- both helpers produce stable JSON through the same serialization path. + +## Data Flow + +Initial command preparation: + +```text +WriteCommandRequest + -> request.lock_key_deriver(hash_payload) + -> command_idempotency::lock_keys optional helper + -> stable lock_keys_json + -> command_idempotency row claim +``` + +Resolved guard refresh: + +```text +ResolvedWriteCommandGuard.lock_keys + -> command_idempotency::lock_keys required helper + -> stable lock_keys_json + -> UPDATE command_idempotency.lock_keys_json before business transaction +``` + +The lock-key deriver must continue to run against `hash_payload`, not sanitized ledger intent. This preserves the existing safety rule that operator-safe metadata cleanup cannot accidentally remove conflict keys needed for command locking. + +## Error Handling + +Initial lock keys may be empty. This preserves existing low-risk command behavior where `default_lock_key_deriver` returns no locks. + +Resolved guard lock keys must be non-empty. If the guard resolves to an empty key set, the command must fail before running the guarded mutation and finalize the claimed idempotency row as it does today. + +Deriver-specific errors must pass through unchanged. For example, if a command-specific deriver cannot find `booking_id` or `room_id` in its hash payload, the helper module should not mask that error. + +Duplicate and ordering behavior must match current command idempotency persistence exactly: + +- do not trim or otherwise normalize deriver-returned lock key strings inside the command idempotency helper; +- do not add new blank-key rejection for non-empty key lists in this issue; +- sort lexicographically; +- deduplicate; +- serialize with stable JSON. + +The aggregate lock constructors may trim aggregate IDs before producing strings such as `room:{room_id}`, but #149 must not add a second normalization layer inside command idempotency. The command idempotency helper must not call `aggregate_locks::canonicalize_lock_keys`; that function is for aggregate lock acquisition semantics, not persisted command idempotency metadata. If future work wants to harden blank or whitespace-padded lock keys, that should be a separate behavior-change issue with explicit test updates. + +## Testing + +Tests should act as format tripwires. At minimum, implementation should keep or add coverage for: + +- optional initial lock keys serialize sorted and deduplicated, for example `["booking:B1","room:R1"]`; +- optional initial lock keys allow empty output as `[]`; +- command idempotency helper behavior does not trim deriver-returned strings if a focused helper test covers malformed input; +- existing `settings:{setting_key}` lock output remains documented or covered by a focused tripwire test if the implementation touches that path; +- resolved guard lock keys serialize sorted and deduplicated before the service transaction runs; +- resolved guard empty lock keys still fail with the current system error classification/code and exact message `Resolved idempotency lock keys are required`; +- replay and hash-mismatch behavior remain unchanged. + +Existing command idempotency tests already cover several of these behaviors. The implementation should add only focused tests where exact persisted output is missing. + +Primary validation: + +```bash +cd mhm/src-tauri && cargo test command_idempotency +rg -n "lock_key|lock keys|lock-key" docs mhm/src-tauri/src +``` + +If implementation touches `aggregate_locks.rs`, also run: + +```bash +cd mhm/src-tauri && cargo test aggregate_locks +``` + +## Impact And Risk + +Fresh GitNexus analysis before this design found high blast radius around the safety-sensitive symbols: + +- `prepare_write_command_request`: CRITICAL risk, 7 direct callers, 9 affected execution flows, 3 affected modules. +- `refresh_claim_lock_keys`: LOW risk, 1 direct caller, 0 indexed execution flows, 2 affected modules. +- `canonicalize_lock_keys`: CRITICAL risk, 6 direct callers, 7 affected execution flows, 2 affected modules. +- `aggregate_key`: HIGH risk, 4 direct callers, 3 affected execution flows, 1 affected module. + +Implementation must rerun GitNexus impact analysis before editing any symbol and must stop for user confirmation if fresh analysis reports HIGH or CRITICAL risk for an intended edit. The preferred implementation path avoids editing `aggregate_locks.rs` and keeps changes limited to command idempotency internals plus focused tests. + +## Implementation Boundaries + +Expected files: + +- create `mhm/src-tauri/src/command_idempotency/lock_keys.rs`; +- modify `mhm/src-tauri/src/command_idempotency.rs` to declare the module and use its helpers; +- optionally modify tests in `mhm/src-tauri/src/command_idempotency.rs`; +- do not modify unrelated frontend files. + +The existing dirty file `mhm/src/stores/useHotelStore.test.ts` is unrelated and must not be staged or changed for this issue. + +## Success Criteria + +- Lock-key preparation is isolated in a focused command idempotency module. +- Persisted lock-key JSON is byte-for-byte equivalent for covered cases. +- Low-risk empty lock keys still persist as `[]`. +- Resolved guard empty lock keys still fail before mutation. +- Existing command idempotency tests pass. +- Validation search shows the lock-key format and safety boundary are documented. From a14a5a5a677122a107b68d9fda4bd443d1c53be2 Mon Sep 17 00:00:00 2001 From: binhan Date: Mon, 18 May 2026 15:33:47 +0700 Subject: [PATCH 45/45] refactor: extract command lock-key preparation --- mhm/src-tauri/src/command_idempotency.rs | 57 +++++++++---- .../src/command_idempotency/lock_keys.rs | 83 +++++++++++++++++++ 2 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 mhm/src-tauri/src/command_idempotency/lock_keys.rs diff --git a/mhm/src-tauri/src/command_idempotency.rs b/mhm/src-tauri/src/command_idempotency.rs index b3c6ae9..13e665f 100644 --- a/mhm/src-tauri/src/command_idempotency.rs +++ b/mhm/src-tauri/src/command_idempotency.rs @@ -1,3 +1,4 @@ +mod lock_keys; mod types; pub use types::{ @@ -209,16 +210,7 @@ impl WriteCommandExecutor { I: IntoIterator, S: Into, { - let mut lock_keys = lock_keys - .into_iter() - .map(Into::into) - .collect::>(); - lock_keys.sort(); - lock_keys.dedup(); - if lock_keys.is_empty() { - return Err(system_error("Resolved idempotency lock keys are required")); - } - let lock_keys_json = stable_json_string(&serde_json::json!(lock_keys))?; + let lock_keys_json = lock_keys::required_lock_keys_json(lock_keys)?; let now = Utc::now().to_rfc3339(); let result = sqlx::query( "UPDATE command_idempotency @@ -843,11 +835,8 @@ fn stable_request_hash_from_json(intent_json: &str) -> CommandResult { fn prepare_write_command_request( request: WriteCommandRequest, ) -> CommandResult { - let mut lock_keys = (request.lock_key_deriver)(&request.hash_payload)?; - lock_keys.sort(); - lock_keys.dedup(); - - let lock_keys_json = stable_json_string(&serde_json::json!(lock_keys))?; + let lock_keys_json = + lock_keys::optional_lock_keys_json((request.lock_key_deriver)(&request.hash_payload)?)?; let hash_payload_json = stable_json_string(&request.hash_payload)?; let request_hash = stable_request_hash_from_json(&hash_payload_json)?; let intent_json = stable_json_string(&request.ledger_intent.to_value()?)?; @@ -1556,6 +1545,16 @@ mod tests { Ok(vec!["booking:999".to_string()]) } + fn unsorted_duplicate_test_lock_keys( + _intent: &serde_json::Value, + ) -> CommandResult> { + Ok(vec![ + "room:R1".to_string(), + "booking:B1".to_string(), + "room:R1".to_string(), + ]) + } + #[tokio::test] async fn write_command_executor_persists_operator_safe_metadata() { let pool = test_pool().await; @@ -2082,6 +2081,7 @@ mod tests { .expect_err("empty resolved lock keys should fail after claim"); assert_eq!(error.code, codes::SYSTEM_INTERNAL_ERROR); + assert_eq!(error.message, "Resolved idempotency lock keys are required"); let status: String = sqlx::query_scalar( "SELECT status FROM command_idempotency @@ -2171,6 +2171,33 @@ mod tests { ); } + #[test] + fn prepare_write_command_request_serializes_initial_lock_keys_sorted_and_deduplicated() { + let request = WriteCommandRequest::new_low_risk( + serde_json::json!({ "schema": "test.lock_keys.v1" }), + "Initial lock keys", + ) + .expect("request builds") + .with_lock_key_deriver(unsorted_duplicate_test_lock_keys); + + let prepared = prepare_write_command_request(request).expect("request prepares"); + + assert_eq!(prepared.lock_keys_json, "[\"booking:B1\",\"room:R1\"]"); + } + + #[test] + fn prepare_write_command_request_serializes_empty_initial_lock_keys_as_empty_array() { + let request = WriteCommandRequest::new_low_risk( + serde_json::json!({ "schema": "test.empty_lock_keys.v1" }), + "Empty initial lock keys", + ) + .expect("request builds"); + + let prepared = prepare_write_command_request(request).expect("request prepares"); + + assert_eq!(prepared.lock_keys_json, "[]"); + } + #[tokio::test] async fn refresh_claim_lease_write_lock_timeout_fails_claim() { let pool = test_pool().await; diff --git a/mhm/src-tauri/src/command_idempotency/lock_keys.rs b/mhm/src-tauri/src/command_idempotency/lock_keys.rs new file mode 100644 index 0000000..5db039f --- /dev/null +++ b/mhm/src-tauri/src/command_idempotency/lock_keys.rs @@ -0,0 +1,83 @@ +use super::{stable_json_string, system_error}; +use crate::app_error::CommandResult; + +pub(super) fn optional_lock_keys_json(lock_keys: I) -> CommandResult +where + I: IntoIterator, + S: Into, +{ + let lock_keys = canonical_lock_keys(lock_keys); + stable_json_string(&serde_json::json!(lock_keys)) +} + +pub(super) fn required_lock_keys_json(lock_keys: I) -> CommandResult +where + I: IntoIterator, + S: Into, +{ + let lock_keys = canonical_lock_keys(lock_keys); + if lock_keys.is_empty() { + return Err(system_error("Resolved idempotency lock keys are required")); + } + + stable_json_string(&serde_json::json!(lock_keys)) +} + +fn canonical_lock_keys(lock_keys: I) -> Vec +where + I: IntoIterator, + S: Into, +{ + let mut lock_keys = lock_keys.into_iter().map(Into::into).collect::>(); + lock_keys.sort(); + lock_keys.dedup(); + lock_keys +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_error::codes; + + #[test] + fn optional_lock_keys_json_sorts_and_deduplicates_without_trimming() { + let lock_keys_json = optional_lock_keys_json([ + "room:R1".to_string(), + " booking:B1".to_string(), + "booking:B1".to_string(), + "room:R1".to_string(), + ]) + .expect("lock keys serialize"); + + assert_eq!( + lock_keys_json, + "[\" booking:B1\",\"booking:B1\",\"room:R1\"]" + ); + } + + #[test] + fn optional_lock_keys_json_allows_empty_initial_keys() { + let lock_keys_json = + optional_lock_keys_json(Vec::::new()).expect("empty keys serialize"); + + assert_eq!(lock_keys_json, "[]"); + } + + #[test] + fn optional_lock_keys_json_preserves_settings_format() { + let lock_keys_json = + optional_lock_keys_json(["settings:ceo_cloud_data_opt_in".to_string()]) + .expect("settings lock key serializes"); + + assert_eq!(lock_keys_json, "[\"settings:ceo_cloud_data_opt_in\"]"); + } + + #[test] + fn required_lock_keys_json_rejects_empty_with_current_system_error() { + let error = required_lock_keys_json(Vec::::new()) + .expect_err("empty resolved keys should fail"); + + assert_eq!(error.code, codes::SYSTEM_INTERNAL_ERROR); + assert_eq!(error.message, "Resolved idempotency lock keys are required"); + } +}