From 25398690d154132cbbde001338cc03b878de34ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Fri, 5 Jun 2026 14:52:06 +0200 Subject: [PATCH 01/13] state: introduce Observable alongside Slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/observable.rs introduces a typed state holder that fans out changes to a list of subscriber closures. Subscribers run synchronously from the write guard's Drop, the same point where Slice emits and flushes today. Two recurring patterns get convenience builders: * .emit_to(&app, "event_name") — Tauri event emission * .persist_to(backend) — JSON-serialize and flush via the existing Persistence trait Anything more exotic (debouncing, breadcrumbs, custom backends) is just .subscribe(move |value| ...). Persistence stays as a trait so future backends can layer debouncing/batching without touching the core type. No callers in this commit; existing Slice users keep working. The new module carries #![allow(dead_code)] until callers migrate in C2-C5. --- apps/native/src-tauri/src/main.rs | 1 + apps/native/src-tauri/src/observable.rs | 256 ++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 apps/native/src-tauri/src/observable.rs diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 119483f60..68315ff8c 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -21,6 +21,7 @@ mod feedback; mod git; mod history; mod managed_edits; +mod observable; mod panic_handler; mod peek; mod rebuild; diff --git a/apps/native/src-tauri/src/observable.rs b/apps/native/src-tauri/src/observable.rs new file mode 100644 index 000000000..fd8af5dc2 --- /dev/null +++ b/apps/native/src-tauri/src/observable.rs @@ -0,0 +1,256 @@ +//! Typed state holder that fans out changes to subscribers. +//! +//! `Observable` owns a value and broadcasts every mutation to a list of +//! subscribers. Subscribers are closures and run synchronously from the write +//! guard's `Drop`; they can serialize to disk, emit Tauri events, push audit +//! logs, or do anything else that reacts to "the value changed." +//! +//! Two recurring patterns get convenience builders: +//! +//! - `.emit_to(&app, "some_event")` — Tauri event emission with the current value +//! - `.persist_to(backend)` — JSON-serialize the value and flush through a +//! [`Persistence`] backend +//! +//! Both are sugar over `.subscribe(move |value| ...)`. Anything more exotic +//! (debouncing, breadcrumbs, custom transports) just calls `subscribe` with +//! its own closure. +//! +//! ```ignore +//! use crate::observable::Observable; +//! +//! let state = Observable::new(MyState::default()) +//! .emit_to(&app, "my_state_changed") +//! .persist_to(persistence); +//! +//! { +//! let mut guard = state.write_sync(); +//! guard.count = 7; +//! } +//! // subscribers fire here as the guard drops. +//! ``` +//! +//! `Observable` is meant to live in `tauri::State`. Readers get a synchronous +//! `RwLockReadGuard`; writers get an `ObservableWriteGuard` that is the only +//! mutation path — and therefore the only place subscribers are invoked. + +// The first call sites migrate in the next commit; suppress dead-code on the +// public surface only until then. +#![allow(dead_code)] + +use serde::Serialize; +use std::ops::{Deref, DerefMut}; +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use tauri::{AppHandle, Emitter, Runtime}; + +use crate::state::slice::Persistence; + +type Subscriber = Arc; + +/// Typed state owner with a fan-out list of change subscribers. +pub struct Observable { + inner: RwLock, + subscribers: Vec>, +} + +impl Observable { + /// Construct an observable from an already materialized initial value. + pub fn new(initial: T) -> Self { + Self { + inner: RwLock::new(initial), + subscribers: Vec::new(), + } + } + + /// Register a closure that runs on every write, with the new value. + /// + /// Subscribers fire from the write guard's `Drop` in registration order. + /// They should not panic; errors are the subscriber's responsibility to + /// log because `Drop` cannot return them. + pub fn subscribe(mut self, f: impl Fn(&T) + Send + Sync + 'static) -> Self { + self.subscribers.push(Arc::new(f)); + self + } + + /// Borrow the current value synchronously. + pub fn read_sync(&self) -> RwLockReadGuard<'_, T> { + self.inner.read().expect("observable lock poisoned") + } + + /// Borrow the current value mutably; subscribers fire when the guard drops. + pub fn write_sync(&self) -> ObservableWriteGuard<'_, T> { + ObservableWriteGuard { + guard: self.inner.write().expect("observable lock poisoned"), + subscribers: &self.subscribers, + } + } +} + +impl Observable +where + T: Serialize + Send + Sync + 'static, +{ + /// Subscribe Tauri event emission for this observable. + /// + /// On every write, emits `event` to the frontend carrying the new value as + /// its payload. Emission failures are logged. + pub fn emit_to(self, app: &AppHandle, event: &'static str) -> Self { + let app = app.clone(); + self.subscribe(move |value| { + if let Err(error) = app.emit(event, value) { + log::error!("observable: failed to emit {event}: {error:#}"); + } + }) + } + + /// Subscribe a [`Persistence`] backend to flush the value on every write. + /// + /// The value is JSON-serialized and handed to `backend.flush`. Serialize + /// or flush errors are logged. + pub fn persist_to(self, backend: Arc) -> Self { + self.subscribe(move |value| { + let json = match serde_json::to_value(value) { + Ok(json) => json, + Err(error) => { + log::error!("observable: failed to serialize for persistence: {error:#}"); + return; + } + }; + if let Err(error) = backend.flush(&json) { + log::error!("observable: failed to flush persistence: {error:#}"); + } + }) + } +} + +/// Mutable guard returned by [`Observable::write_sync`]. +/// +/// Deref's to `T` so callers can mutate fields naturally. On drop it invokes +/// every registered subscriber with the final value. +pub struct ObservableWriteGuard<'a, T> { + guard: RwLockWriteGuard<'a, T>, + subscribers: &'a [Subscriber], +} + +impl Deref for ObservableWriteGuard<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.guard + } +} + +impl DerefMut for ObservableWriteGuard<'_, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.guard + } +} + +impl Drop for ObservableWriteGuard<'_, T> { + fn drop(&mut self) { + for subscriber in self.subscribers { + subscriber(&self.guard); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + struct DemoState { + count: u32, + label: String, + } + + #[test] + fn write_guard_fires_subscribers_on_drop_with_final_value() { + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let captured_for_closure = captured.clone(); + let observable = Observable::new(DemoState::default()).subscribe(move |value| { + captured_for_closure.lock().unwrap().push(value.clone()); + }); + + { + let mut guard = observable.write_sync(); + guard.count = 2; + guard.label = "updated".to_string(); + } + + let events = captured.lock().unwrap(); + assert_eq!(events.len(), 1, "subscriber fires once per write guard drop"); + assert_eq!( + events[0], + DemoState { + count: 2, + label: "updated".to_string() + } + ); + } + + #[test] + fn multiple_subscribers_fire_in_registration_order() { + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let first = log.clone(); + let second = log.clone(); + let observable = Observable::new(0_u32) + .subscribe(move |_| first.lock().unwrap().push("first")) + .subscribe(move |_| second.lock().unwrap().push("second")); + + { + let mut guard = observable.write_sync(); + *guard = 5; + } + + assert_eq!(*log.lock().unwrap(), vec!["first", "second"]); + } + + #[test] + fn observable_with_no_subscribers_acts_as_in_memory_cell() { + let observable = Observable::new(0_u32); + + { + let mut guard = observable.write_sync(); + *guard = 42; + } + + assert_eq!(*observable.read_sync(), 42); + } + + #[test] + fn persist_to_routes_serialized_value_through_backend() { + use crate::state::slice::Persistence; + use anyhow::Result; + use serde_json::Value; + + #[derive(Default)] + struct CapturingBackend { + captured: Mutex>, + } + + impl Persistence for CapturingBackend { + fn load(&self) -> Result> { + Ok(self.captured.lock().unwrap().clone()) + } + + fn flush(&self, value: &Value) -> Result<()> { + *self.captured.lock().unwrap() = Some(value.clone()); + Ok(()) + } + } + + let backend = Arc::new(CapturingBackend::default()); + let observable = Observable::new(DemoState::default()).persist_to(backend.clone()); + + { + let mut guard = observable.write_sync(); + guard.count = 7; + guard.label = "persisted".into(); + } + + let stored = backend.captured.lock().unwrap().clone().unwrap(); + assert_eq!(stored["count"], 7); + assert_eq!(stored["label"], "persisted"); + } +} From f6b32ee615327d035aba8a7a0fdfe79066558edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Fri, 5 Jun 2026 14:58:58 +0200 Subject: [PATCH 02/13] state: migrate EvolutionLimits from Slice to Observable evolve::config::load_slice becomes load_observable; persistence is now attached via the .persist_to() subscriber, and the change-event name flows through .emit_to() rather than being a struct field of the slice. The onboarding fallback that used VolatileJson is now simply: if no storage path is available, build the observable without a persistence subscriber. That deletes VolatileJson (it had no other users) and removes one layer of indirection. Configurable derive updated to look for `Observable<#name>` in tauri::State and call `write_sync()` without an emitter argument. EvolutionLimits is currently the only Configurable in the codebase, so this change is isolated. Other callers updated to use the new type: - storage/store.rs (4 get/set helpers) - commands/settings_io.rs (export + import) - main.rs (two manage() call sites) --- .../configurable-derive/src/codegen.rs | 29 ++++++++++--------- .../src-tauri/src/commands/settings_io.rs | 7 +++-- apps/native/src-tauri/src/evolve/config.rs | 14 ++++----- apps/native/src-tauri/src/main.rs | 4 +-- apps/native/src-tauri/src/storage/store.rs | 6 ++-- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/apps/native/src-tauri/configurable-derive/src/codegen.rs b/apps/native/src-tauri/configurable-derive/src/codegen.rs index 4b5359d4c..84b5e7124 100644 --- a/apps/native/src-tauri/configurable-derive/src/codegen.rs +++ b/apps/native/src-tauri/configurable-derive/src/codegen.rs @@ -99,19 +99,21 @@ fn build_scope_methods( let schema_fields = fields.schema_fields; let set_field_arms = fields.set_field_arms; - // The derive is slice-only: reads mirror the managed slice, and writes go - // through the slice guard so persistence and change events stay centralized. + // The derive is observable-only: reads mirror the managed observable, and + // writes go through the observable guard so persistence and change events + // stay centralized. // - // `load` can be called before startup finishes managing all slices, such as - // in tests or early schema rendering. Defaults keep that path deterministic. + // `load` can be called before startup finishes managing all observables, + // such as in tests or early schema rendering. Defaults keep that path + // deterministic. quote! { pub fn load( app: &::tauri::AppHandle, ) -> ::std::result::Result { - if let ::std::option::Option::Some(__slice) = - ::tauri::Manager::try_state::>(app) + if let ::std::option::Option::Some(__observable) = + ::tauri::Manager::try_state::>(app) { - return ::std::result::Result::Ok(__slice.read_sync().clone()); + return ::std::result::Result::Ok(__observable.read_sync().clone()); } ::std::result::Result::Ok(Self { #(#default_inits)* @@ -137,12 +139,13 @@ fn build_scope_methods( key: &str, value: ::serde_json::Value, ) -> ::std::result::Result<(), ::anyhow::Error> { - let __slice = ::tauri::Manager::try_state::>(app) - .ok_or_else(|| ::anyhow::anyhow!( - "Configurable {}: {} slice is not managed", - #name_str, #scope_name, - ))?; - let mut __state = __slice.write_sync(app); + let __observable = + ::tauri::Manager::try_state::>(app) + .ok_or_else(|| ::anyhow::anyhow!( + "Configurable {}: {} observable is not managed", + #name_str, #scope_name, + ))?; + let mut __state = __observable.write_sync(); match key { #(#set_field_arms)* other => ::std::result::Result::Err(::anyhow::anyhow!( diff --git a/apps/native/src-tauri/src/commands/settings_io.rs b/apps/native/src-tauri/src/commands/settings_io.rs index b1eaccaaf..566aca479 100644 --- a/apps/native/src-tauri/src/commands/settings_io.rs +++ b/apps/native/src-tauri/src/commands/settings_io.rs @@ -14,6 +14,7 @@ use super::helpers::capture_err; use crate::evolve::config::EvolutionLimits; use crate::shared_types::{ExportResult, ImportResult}; use crate::state::preferences::GlobalPreferences; +use crate::observable::Observable; use crate::state::slice::Slice; use serde_json::{Map, Value}; use std::borrow::Borrow; @@ -79,7 +80,7 @@ fn collect_slice_export_entries( ); } - if let Some(limits) = app.try_state::>() { + if let Some(limits) = app.try_state::>() { merge_export_object( &mut output, &mut skipped, @@ -166,10 +167,10 @@ pub async fn settings_import(app: AppHandle) -> Result, Str *global = prefs; } - if let Some(limits) = app.try_state::>() { + if let Some(limits) = app.try_state::>() { let prefs = serde_json::from_value::(imported_value) .map_err(|e| capture_err("settings_import", e))?; - let mut limits = limits.write_sync(&app); + let mut limits = limits.write_sync(); *limits = prefs; } diff --git a/apps/native/src-tauri/src/evolve/config.rs b/apps/native/src-tauri/src/evolve/config.rs index 83e986ca2..61592b24e 100644 --- a/apps/native/src-tauri/src/evolve/config.rs +++ b/apps/native/src-tauri/src/evolve/config.rs @@ -14,9 +14,10 @@ use specta::Type; use std::sync::Arc; use tauri::{AppHandle, Runtime}; +use crate::observable::Observable; use crate::state::preferences; use crate::state::slice::{ - ConfiguredRepoScopedJson, Persistence, RegisteredSliceConfig, Slice, SliceRegistry, + ConfiguredRepoScopedJson, Persistence, RegisteredSliceConfig, SliceRegistry, }; pub const EVOLUTION_LIMITS_CHANGED_EVENT: &str = "evolution_limits_changed"; @@ -71,15 +72,12 @@ impl Default for EvolutionLimits { } } -pub fn load_slice(app: &AppHandle) -> Result> { +pub fn load_observable(app: &AppHandle) -> Result> { let persistence: Arc = Arc::new(ConfiguredRepoScopedJson::new(app.clone())); let initial = preferences::load_or_default::(persistence.as_ref())?; - - Ok(Slice::new( - EVOLUTION_LIMITS_CHANGED_EVENT, - initial, - persistence, - )) + Ok(Observable::new(initial) + .emit_to(app, EVOLUTION_LIMITS_CHANGED_EVENT) + .persist_to(persistence)) } pub fn register_slice_config(registry: &SliceRegistry) -> Result<()> { diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 68315ff8c..92118384e 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -339,7 +339,7 @@ fn run_cli_mode(context: tauri::Context) -> i32 { .invoke_handler(tauri::generate_handler![]) .setup(|app| { app.manage(state::preferences::load_global_slice(app.handle())?); - app.manage(evolve::config::load_slice(app.handle())?); + app.manage(evolve::config::load_observable(app.handle())?); evolve::config::register_slice_config( &app.state::(), )?; @@ -592,7 +592,7 @@ fn run_gui_mode( panic_handler::setup_panic_hook(handle.clone()); app.manage(state::preferences::load_global_slice(handle)?); - app.manage(evolve::config::load_slice(handle)?); + app.manage(evolve::config::load_observable(handle)?); evolve::config::register_slice_config(&app.state::())?; app.manage(state::evolve_state::load_slice(handle)?); diff --git a/apps/native/src-tauri/src/storage/store.rs b/apps/native/src-tauri/src/storage/store.rs index 690ebcdd4..edb6200dc 100644 --- a/apps/native/src-tauri/src/storage/store.rs +++ b/apps/native/src-tauri/src/storage/store.rs @@ -639,7 +639,7 @@ pub fn set_max_token_budget(app: &AppHandle, max: u32) -> Result< /// Gets the maximum build attempts for evolution (default: 5). Repo-scoped. pub fn get_max_build_attempts(app: &AppHandle) -> Result { if let Some(limits) = - app.try_state::>() + app.try_state::>() { return Ok(limits.read_sync().max_build_attempts); } @@ -654,9 +654,9 @@ pub fn get_max_build_attempts(app: &AppHandle) -> Result { pub fn set_max_build_attempts(app: &AppHandle, max: usize) -> Result<()> { if let Some(limits) = - app.try_state::>() + app.try_state::>() { - let mut limits = limits.write_sync(app); + let mut limits = limits.write_sync(); limits.max_build_attempts = max; return Ok(()); } From 61456f27a77178237e2bb2dfc63434d3d54d585e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Fri, 5 Jun 2026 15:05:56 +0200 Subject: [PATCH 03/13] state: migrate EvolveState from Slice to Observable evolve_state::load_slice becomes load_observable. The change-event name flows through .emit_to() and persistence is attached via .persist_to(). get() and set() switch from try_state::> to try_state::>; write_sync() drops its emitter argument. The "no managed observable" fallback path (used during early startup before app.manage) keeps its bespoke emit + flush sequence. main.rs updated for both manage() call sites. --- apps/native/src-tauri/src/main.rs | 4 ++-- .../src-tauri/src/state/evolve_state.rs | 23 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 92118384e..b93ba5a6d 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -343,7 +343,7 @@ fn run_cli_mode(context: tauri::Context) -> i32 { evolve::config::register_slice_config( &app.state::(), )?; - app.manage(state::evolve_state::load_slice(app.handle())?); + app.manage(state::evolve_state::load_observable(app.handle())?); Ok(()) }) .build(context) @@ -594,7 +594,7 @@ fn run_gui_mode( app.manage(state::preferences::load_global_slice(handle)?); app.manage(evolve::config::load_observable(handle)?); evolve::config::register_slice_config(&app.state::())?; - app.manage(state::evolve_state::load_slice(handle)?); + app.manage(state::evolve_state::load_observable(handle)?); // Initialize SQLite database before any consumer that reads the // managed DbPool from app state. diff --git a/apps/native/src-tauri/src/state/evolve_state.rs b/apps/native/src-tauri/src/state/evolve_state.rs index fd1382c61..ec2377efa 100644 --- a/apps/native/src-tauri/src/state/evolve_state.rs +++ b/apps/native/src-tauri/src/state/evolve_state.rs @@ -6,9 +6,10 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, Runtime}; use crate::evolve::session_chat_memory_store; +use crate::observable::Observable; use crate::shared_types::{EvolutionState, EvolveState, EvolveStep}; use crate::sqlite_types::Change; -use crate::state::slice::{AppDataJson, Persistence, Slice}; +use crate::state::slice::{AppDataJson, Persistence}; impl EvolveState { pub fn recompute_step(&mut self, is_built: bool, has_changes: bool) { @@ -52,7 +53,7 @@ fn load_from_persistence(persistence: &dyn Persistence) -> Result { .unwrap_or_default()) } -fn persist_without_managed_slice( +fn persist_without_managed_observable( app: &AppHandle, state: &EvolveState, ) -> Result<()> { @@ -62,16 +63,18 @@ fn persist_without_managed_slice( Ok(()) } -pub fn load_slice(app: &AppHandle) -> Result> { - let persistence = Arc::new(AppDataJson::for_app(app, EVOLVE_STATE_PATH)?); +pub fn load_observable(app: &AppHandle) -> Result> { + let persistence: Arc = Arc::new(AppDataJson::for_app(app, EVOLVE_STATE_PATH)?); let initial = load_from_persistence(persistence.as_ref())?; - Ok(Slice::new(EVOLVE_STATE_CHANGED_EVENT, initial, persistence)) + Ok(Observable::new(initial) + .emit_to(app, EVOLVE_STATE_CHANGED_EVENT) + .persist_to(persistence)) } /// Load the persisted evolve state, returning `EvolveState::default()` if absent or corrupt. pub fn get(app: &AppHandle) -> Result { - if let Some(slice) = app.try_state::>() { - return Ok(slice.read_sync().clone()); + if let Some(observable) = app.try_state::>() { + return Ok(observable.read_sync().clone()); } let persistence = AppDataJson::for_app(app, EVOLVE_STATE_PATH)?; @@ -99,12 +102,12 @@ pub fn set( Some(EvolutionState::Conversational) ); - if let Some(slice) = app.try_state::>() { - let mut guard = slice.write_sync(app); + if let Some(observable) = app.try_state::>() { + let mut guard = observable.write_sync(); *guard = state.clone(); drop(guard); } else { - persist_without_managed_slice(app, &state)?; + persist_without_managed_observable(app, &state)?; } // Clear conversational thread memory whenever routing returns to Begin. From 055b4326ce5d2c9e967fffbdcdb424ad884bdbdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Fri, 5 Jun 2026 17:21:41 +0200 Subject: [PATCH 04/13] state: migrate GlobalPreferences from Slice to Observable state::preferences::load_global_slice becomes load_global_observable. Event name flows through .emit_to(); persistence is attached via .persist_to(). main.rs updated for both manage() call sites; commands/settings_io.rs switches the export and import paths to try_state::>(), and the write_sync call drops its emitter argument. This is the last Slice caller. The type itself, SliceWriteGuard, and the SliceEventEmitter trait now have no users; state/slice/mod.rs gets #![allow(dead_code)] as a one-commit shim. The next commit (C6) deletes the dead code and moves persistence + json_io under src/observable/. --- .../src-tauri/src/commands/settings_io.rs | 7 +++---- apps/native/src-tauri/src/main.rs | 4 ++-- apps/native/src-tauri/src/state/preferences.rs | 18 ++++++++++-------- apps/native/src-tauri/src/state/slice/mod.rs | 4 ++++ 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/native/src-tauri/src/commands/settings_io.rs b/apps/native/src-tauri/src/commands/settings_io.rs index 566aca479..4d653e739 100644 --- a/apps/native/src-tauri/src/commands/settings_io.rs +++ b/apps/native/src-tauri/src/commands/settings_io.rs @@ -15,7 +15,6 @@ use crate::evolve::config::EvolutionLimits; use crate::shared_types::{ExportResult, ImportResult}; use crate::state::preferences::GlobalPreferences; use crate::observable::Observable; -use crate::state::slice::Slice; use serde_json::{Map, Value}; use std::borrow::Borrow; use tauri::{AppHandle, Manager}; @@ -70,7 +69,7 @@ fn collect_slice_export_entries( let mut output = Map::new(); let mut skipped = Vec::new(); - if let Some(global) = app.try_state::>() { + if let Some(global) = app.try_state::>() { merge_export_object( &mut output, &mut skipped, @@ -160,10 +159,10 @@ pub async fn settings_import(app: AppHandle) -> Result, Str let keys_imported = entries.len(); - if let Some(global) = app.try_state::>() { + if let Some(global) = app.try_state::>() { let prefs = serde_json::from_value::(imported_value.clone()) .map_err(|e| capture_err("settings_import", e))?; - let mut global = global.write_sync(&app); + let mut global = global.write_sync(); *global = prefs; } diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index b93ba5a6d..97d72e59b 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -338,7 +338,7 @@ fn run_cli_mode(context: tauri::Context) -> i32 { .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![]) .setup(|app| { - app.manage(state::preferences::load_global_slice(app.handle())?); + app.manage(state::preferences::load_global_observable(app.handle())?); app.manage(evolve::config::load_observable(app.handle())?); evolve::config::register_slice_config( &app.state::(), @@ -591,7 +591,7 @@ fn run_gui_mode( // Set up panic handler to catch crashes and show feedback dialog panic_handler::setup_panic_hook(handle.clone()); - app.manage(state::preferences::load_global_slice(handle)?); + app.manage(state::preferences::load_global_observable(handle)?); app.manage(evolve::config::load_observable(handle)?); evolve::config::register_slice_config(&app.state::())?; app.manage(state::evolve_state::load_observable(handle)?); diff --git a/apps/native/src-tauri/src/state/preferences.rs b/apps/native/src-tauri/src/state/preferences.rs index 8406e5602..ae81c0cf5 100644 --- a/apps/native/src-tauri/src/state/preferences.rs +++ b/apps/native/src-tauri/src/state/preferences.rs @@ -13,8 +13,9 @@ use specta::Type; use std::sync::Arc; use tauri::{AppHandle, Runtime}; +use crate::observable::Observable; use crate::shared_types::UpdateChannel; -use crate::state::slice::{AppDataJson, Persistence, Slice}; +use crate::state::slice::{AppDataJson, Persistence}; const GLOBAL_PREFERENCES_PATH: &str = "global-preferences.json"; @@ -73,15 +74,16 @@ impl Default for GlobalPreferences { } } -pub fn load_global_slice(app: &AppHandle) -> Result> { - let persistence = Arc::new(AppDataJson::for_app(app, GLOBAL_PREFERENCES_PATH)?); +pub fn load_global_observable( + app: &AppHandle, +) -> Result> { + let persistence: Arc = + Arc::new(AppDataJson::for_app(app, GLOBAL_PREFERENCES_PATH)?); let initial = load_or_default::(persistence.as_ref())?; - Ok(Slice::new( - GLOBAL_PREFERENCES_CHANGED_EVENT, - initial, - persistence, - )) + Ok(Observable::new(initial) + .emit_to(app, GLOBAL_PREFERENCES_CHANGED_EVENT) + .persist_to(persistence)) } pub(crate) fn load_or_default(persistence: &dyn Persistence) -> Result diff --git a/apps/native/src-tauri/src/state/slice/mod.rs b/apps/native/src-tauri/src/state/slice/mod.rs index df4e4de82..8ea985044 100644 --- a/apps/native/src-tauri/src/state/slice/mod.rs +++ b/apps/native/src-tauri/src/state/slice/mod.rs @@ -57,6 +57,10 @@ //! } //! ``` +// Slice itself has no callers after the Observable migration; the next +// commit deletes it. Persistence + registry stay alive for now. +#![allow(dead_code)] + pub mod json_io; pub mod persistence; pub mod registry; From 0d5c84265b3460efb0e6bb870ba5f5ff5b0774c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Fri, 5 Jun 2026 20:29:56 +0200 Subject: [PATCH 05/13] state: delete Slice, move persistence under observable After the three caller migrations, Slice has no users left. This commit: - Deletes Slice, SliceWriteGuard, and the SliceEventEmitter trait from state/slice/mod.rs. The three slice-level tests are absorbed by observable's own tests; persistence + registry tests move with their respective code. - Moves apps/native/src-tauri/src/observable.rs to src/observable/mod.rs and pulls persistence.rs + json_io.rs under it. The Persistence trait + AppDataJson + RepoScopedJson are re-exported at crate::observable::*, so callers say `use crate::observable::{AppDataJson, Persistence, Observable}`. - Shrinks state/slice/mod.rs to a thin re-export of registry.rs. The module stays as the home for SliceRegistry until B1 retires it. - Updates state/mod.rs prose to describe the new shape (observables, not slices). --- apps/native/src-tauri/src/evolve/config.rs | 12 +- .../{state/slice => observable}/json_io.rs | 0 .../src/{observable.rs => observable/mod.rs} | 11 +- .../slice => observable}/persistence.rs | 51 ++- .../src-tauri/src/state/evolve_state.rs | 3 +- apps/native/src-tauri/src/state/mod.rs | 25 +- .../native/src-tauri/src/state/preferences.rs | 3 +- apps/native/src-tauri/src/state/slice/mod.rs | 349 +----------------- .../src-tauri/src/state/slice/registry.rs | 37 ++ 9 files changed, 100 insertions(+), 391 deletions(-) rename apps/native/src-tauri/src/{state/slice => observable}/json_io.rs (100%) rename apps/native/src-tauri/src/{observable.rs => observable/mod.rs} (97%) rename apps/native/src-tauri/src/{state/slice => observable}/persistence.rs (75%) diff --git a/apps/native/src-tauri/src/evolve/config.rs b/apps/native/src-tauri/src/evolve/config.rs index 61592b24e..1ff8c9b40 100644 --- a/apps/native/src-tauri/src/evolve/config.rs +++ b/apps/native/src-tauri/src/evolve/config.rs @@ -3,9 +3,9 @@ //! Storage is repo-scoped — values live under `/.nixmac/settings.json` //! so they ride along with the user's nix config repo across machines. //! -//! The struct is managed as a `Slice` at startup and registered -//! with the slice registry so Developer settings can render and update it -//! without opening store files directly. +//! The struct is managed as an `Observable` at startup and +//! registered with the slice registry so Developer settings can render and +//! update it without opening store files directly. use anyhow::Result; use configurable::Configurable; @@ -14,11 +14,9 @@ use specta::Type; use std::sync::Arc; use tauri::{AppHandle, Runtime}; -use crate::observable::Observable; +use crate::observable::{ConfiguredRepoScopedJson, Observable, Persistence}; use crate::state::preferences; -use crate::state::slice::{ - ConfiguredRepoScopedJson, Persistence, RegisteredSliceConfig, SliceRegistry, -}; +use crate::state::slice::{RegisteredSliceConfig, SliceRegistry}; pub const EVOLUTION_LIMITS_CHANGED_EVENT: &str = "evolution_limits_changed"; diff --git a/apps/native/src-tauri/src/state/slice/json_io.rs b/apps/native/src-tauri/src/observable/json_io.rs similarity index 100% rename from apps/native/src-tauri/src/state/slice/json_io.rs rename to apps/native/src-tauri/src/observable/json_io.rs diff --git a/apps/native/src-tauri/src/observable.rs b/apps/native/src-tauri/src/observable/mod.rs similarity index 97% rename from apps/native/src-tauri/src/observable.rs rename to apps/native/src-tauri/src/observable/mod.rs index fd8af5dc2..bd94458f8 100644 --- a/apps/native/src-tauri/src/observable.rs +++ b/apps/native/src-tauri/src/observable/mod.rs @@ -33,17 +33,16 @@ //! `RwLockReadGuard`; writers get an `ObservableWriteGuard` that is the only //! mutation path — and therefore the only place subscribers are invoked. -// The first call sites migrate in the next commit; suppress dead-code on the -// public surface only until then. -#![allow(dead_code)] +pub mod json_io; +pub mod persistence; + +pub use persistence::{AppDataJson, ConfiguredRepoScopedJson, Persistence}; use serde::Serialize; use std::ops::{Deref, DerefMut}; use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; use tauri::{AppHandle, Emitter, Runtime}; -use crate::state::slice::Persistence; - type Subscriber = Arc; /// Typed state owner with a fan-out list of change subscribers. @@ -220,7 +219,7 @@ mod tests { #[test] fn persist_to_routes_serialized_value_through_backend() { - use crate::state::slice::Persistence; + use super::Persistence; use anyhow::Result; use serde_json::Value; diff --git a/apps/native/src-tauri/src/state/slice/persistence.rs b/apps/native/src-tauri/src/observable/persistence.rs similarity index 75% rename from apps/native/src-tauri/src/state/slice/persistence.rs rename to apps/native/src-tauri/src/observable/persistence.rs index 7c8722821..a1c65232c 100644 --- a/apps/native/src-tauri/src/state/slice/persistence.rs +++ b/apps/native/src-tauri/src/observable/persistence.rs @@ -1,7 +1,9 @@ -//! Persistence backends for state slices. +//! Persistence backends for observable state. //! //! `AppDataJson` stores per-device state in the OS app-data directory. //! `RepoScopedJson` stores repo-scoped state under the user's config repo. +//! Subscribers attach a backend to an [`Observable`] via +//! [`Observable::persist_to`](super::Observable::persist_to). use anyhow::{Context, Result}; use serde_json::Value; @@ -10,9 +12,9 @@ use tauri::{Manager, Runtime}; use super::json_io::{read_json_file, write_json_file}; -/// Storage boundary for a slice. +/// Storage boundary for an observable. /// -/// Implementations work with JSON values so `Slice` can keep the typed +/// Implementations work with JSON values so `Observable` can keep the typed /// serialization/deserialization logic central while backends only decide /// where bytes live. pub trait Persistence: Send + Sync { @@ -52,11 +54,6 @@ impl AppDataJson { Ok(Self::new(app_data.join(file_name))) } - /// Return the backing JSON path. - #[cfg(test)] - pub fn path(&self) -> &Path { - &self.path - } } impl Persistence for AppDataJson { @@ -96,11 +93,6 @@ impl RepoScopedJson { )) } - /// Return the backing JSON path. - #[cfg(test)] - pub fn path(&self) -> &Path { - &self.path - } } impl Persistence for RepoScopedJson { @@ -157,3 +149,36 @@ impl Persistence for ConfiguredRepoScopedJson { write_json_file(&path, value) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn json_persistence_round_trips_by_scope_path() { + let temp = tempfile::tempdir().expect("temp dir"); + let app_data_path = temp.path().join("app-data").join("settings.json"); + let repo_path = temp.path().join("repo").join(".nixmac").join("settings.json"); + let app_data = AppDataJson::new(&app_data_path); + let repo_scoped = RepoScopedJson::new(&repo_path); + + app_data + .flush(&json!({ "count": 3, "label": "global" })) + .expect("app data flushes"); + repo_scoped + .flush(&json!({ "count": 4, "label": "repo" })) + .expect("repo scoped flushes"); + + assert!(app_data_path.ends_with("app-data/settings.json")); + assert!(repo_path.ends_with("repo/.nixmac/settings.json")); + assert_eq!( + app_data.load().expect("app data loads"), + Some(json!({ "count": 3, "label": "global" })) + ); + assert_eq!( + repo_scoped.load().expect("repo scoped loads"), + Some(json!({ "count": 4, "label": "repo" })) + ); + } +} diff --git a/apps/native/src-tauri/src/state/evolve_state.rs b/apps/native/src-tauri/src/state/evolve_state.rs index ec2377efa..b70fcd1a5 100644 --- a/apps/native/src-tauri/src/state/evolve_state.rs +++ b/apps/native/src-tauri/src/state/evolve_state.rs @@ -6,10 +6,9 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, Runtime}; use crate::evolve::session_chat_memory_store; -use crate::observable::Observable; +use crate::observable::{AppDataJson, Observable, Persistence}; use crate::shared_types::{EvolutionState, EvolveState, EvolveStep}; use crate::sqlite_types::Change; -use crate::state::slice::{AppDataJson, Persistence}; impl EvolveState { pub fn recompute_step(&mut self, is_built: bool, has_changes: bool) { diff --git a/apps/native/src-tauri/src/state/mod.rs b/apps/native/src-tauri/src/state/mod.rs index 3fa7856a8..a4429db34 100644 --- a/apps/native/src-tauri/src/state/mod.rs +++ b/apps/native/src-tauri/src/state/mod.rs @@ -1,25 +1,21 @@ //! Persisted application state, split by scope and lifecycle. //! -//! This module owns two categories of state: -//! -//! - **Slices** (`slice`): generic typed containers with pluggable persistence. -//! Each `Slice` holds in-memory state, flushes to a JSON file on write, -//! and emits a Tauri event so the frontend stays in sync. See the slice -//! module docs for the full contract. -//! -//! - **Domain modules**: thin wrappers that define a concrete state type, -//! choose a persistence backend (app-data vs repo-scoped), and expose -//! load/get/set functions. These are the call sites that commands and -//! lifecycle code actually use. +//! Each domain module defines a concrete state type, picks a persistence +//! backend (app-data vs repo-scoped), and exposes load/get/set functions +//! that wrap an [`Observable`](crate::observable::Observable). Commands +//! and lifecycle code call into those wrappers. //! //! The key distinction is persistence scope: //! - `GlobalPreferences` → per-device, stored in the OS app-data directory //! - `EvolutionLimits` → repo-scoped, stored under `/.nixmac/` //! - `EvolveState` → per-device (step routing is machine-local) //! -//! Adding a new state type: define the struct, pick a persistence backend, -//! load a `Slice` during Tauri setup, and optionally register it with -//! the slice registry for developer-settings UI. +//! Adding a new state type: define the struct, pick a persistence backend +//! from `crate::observable`, and construct an `Observable` during Tauri +//! setup with `.emit_to()` and `.persist_to()` attached as needed. +//! +//! The `slice` submodule survives only for the runtime `SliceRegistry` used +//! by the developer-settings UI; B1 in the followup plan retires it. pub mod build_state; pub mod completion_log; @@ -27,6 +23,5 @@ pub mod drift_notifications; pub mod evolve_state; pub mod preferences; pub mod session_log; -/// Generic state slices used by runtime state and scoped preferences. pub mod slice; pub mod watcher; diff --git a/apps/native/src-tauri/src/state/preferences.rs b/apps/native/src-tauri/src/state/preferences.rs index ae81c0cf5..f26cd412e 100644 --- a/apps/native/src-tauri/src/state/preferences.rs +++ b/apps/native/src-tauri/src/state/preferences.rs @@ -13,9 +13,8 @@ use specta::Type; use std::sync::Arc; use tauri::{AppHandle, Runtime}; -use crate::observable::Observable; +use crate::observable::{AppDataJson, Observable, Persistence}; use crate::shared_types::UpdateChannel; -use crate::state::slice::{AppDataJson, Persistence}; const GLOBAL_PREFERENCES_PATH: &str = "global-preferences.json"; diff --git a/apps/native/src-tauri/src/state/slice/mod.rs b/apps/native/src-tauri/src/state/slice/mod.rs index 8ea985044..471ebd2ff 100644 --- a/apps/native/src-tauri/src/state/slice/mod.rs +++ b/apps/native/src-tauri/src/state/slice/mod.rs @@ -1,350 +1,7 @@ -//! Typed state slices for Rust-owned application state. -//! -//! A `Slice` gives a state owner three things in one place: -//! typed in-memory access, a persistence backend, and automatic UI -//! notification. Callers mutate state through `SliceWriteGuard`; when the -//! guard is dropped it emits the configured change event and then flushes the -//! serialized state through the configured `Persistence` implementation. -//! -//! This module is intentionally generic. Later migrations can put runtime -//! state, global preferences, and repo-scoped preferences on the same primitive -//! without leaking the chosen storage location into command call sites. -//! -//! To use this module: -//! -//! 1. Define a state type that implements the required interfaces: -//! `serde::Serialize`, `serde::Deserialize`, `Send`, `Sync`, and `'static`. -//! 2. Choose a persistence backend, usually `AppDataJson` for per-device state -//! or `RepoScopedJson` for config-repo state. -//! 3. Manage the slice in Tauri startup with a stable change-event name. -//! 4. Read through `State<'_, Slice>`. -//! 5. Write through `slice.write_sync(&app)`; dropping the guard emits the -//! change event and flushes JSON. -//! -//! Important storage contract: the current persistence implementations are -//! whole-file backends. A slice serializes its entire `T` and overwrites the -//! backend path on flush, so two slices must not share the same JSON file unless -//! the persistence layer is changed to be key-aware. -//! -//! ```ignore -//! #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -//! struct MyState { -//! enabled: bool, -//! } -//! -//! // Startup: -//! let persistence = std::sync::Arc::new(AppDataJson::for_app(app.handle(), "my-state.json")?); -//! let initial = persistence -//! .load()? -//! .map(serde_json::from_value) -//! .transpose()? -//! .unwrap_or_default(); -//! app.manage(Slice::new( -//! "my_state_changed", -//! initial, -//! persistence, -//! )); -//! -//! // Command: -//! #[tauri::command] -//! async fn set_enabled( -//! app: tauri::AppHandle, -//! state: tauri::State<'_, Slice>, -//! enabled: bool, -//! ) { -//! let mut current = state.write_sync(&app); -//! current.enabled = enabled; -//! } -//! ``` +//! Runtime registry for `Configurable` state. The registry is scheduled for +//! deletion in the `inventory`-based redesign tracked as B1; for now it lives +//! here as the last vestige of the old `state/slice/` namespace. -// Slice itself has no callers after the Observable migration; the next -// commit deletes it. Persistence + registry stay alive for now. -#![allow(dead_code)] - -pub mod json_io; -pub mod persistence; pub mod registry; -pub use persistence::{AppDataJson, ConfiguredRepoScopedJson, Persistence}; pub use registry::{RegisteredSliceConfig, SliceRegistry}; - -use anyhow::{Context, Result}; -use serde::Serialize; -use std::{ - marker::PhantomData, - ops::{Deref, DerefMut}, - sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, -}; -use tauri::{Emitter, Runtime}; - -/// Minimal event boundary used by `SliceWriteGuard`. -/// -/// The production implementation is `tauri::AppHandle`; tests can provide a -/// recorder without constructing a full Tauri app. -pub trait SliceEventEmitter: Clone + Send + Sync + 'static { - /// Emit the slice change event with the current state payload. - fn emit_slice(&self, event: &str, payload: &T) -> Result<()>; -} - -impl SliceEventEmitter for tauri::AppHandle { - fn emit_slice(&self, event: &str, payload: &T) -> Result<()> { - self.emit(event, payload) - .with_context(|| format!("failed to emit slice event {event}")) - } -} - -/// Typed state owner with pluggable persistence and a configured change event. -/// -/// `Slice` is meant to be stored in `tauri::State`. Readers get an async -/// read guard. Writers get a `SliceWriteGuard`, which is the only mutation path -/// and therefore the single place that emits and persists changes. -pub struct Slice { - inner: RwLock, - event: &'static str, - persistence: Arc, -} - -impl Slice -where - T: Serialize + Send + Sync + 'static, -{ - /// Create a slice from an already materialized initial state. - pub fn new(event: &'static str, initial: T, persistence: Arc) -> Self { - Self { - inner: RwLock::new(initial), - event, - persistence, - } - } - - /// Synchronously borrow the current in-memory state. - pub fn read_sync(&self) -> RwLockReadGuard<'_, T> { - self.inner.read().expect("slice lock poisoned") - } - - /// Synchronously borrow state and return a guard that emits and flushes on drop. - pub fn write_sync(&self, emitter: &E) -> SliceWriteGuard<'_, T, E> - where - E: SliceEventEmitter, - { - SliceWriteGuard { - guard: self.inner.write().expect("slice lock poisoned"), - event: self.event, - persistence: self.persistence.clone(), - emitter: emitter.clone(), - _state: PhantomData, - } - } -} - -/// Mutable slice guard that owns notification and persistence. -/// -/// The guard dereferences to `T`, so callers can update fields naturally. On -/// drop it emits the configured event first, then serializes and flushes the -/// final value. Errors are logged because `Drop` cannot return them. -pub struct SliceWriteGuard<'a, T, E> -where - T: Serialize, - E: SliceEventEmitter, -{ - guard: RwLockWriteGuard<'a, T>, - event: &'static str, - persistence: Arc, - emitter: E, - _state: PhantomData, -} - -impl SliceWriteGuard<'_, T, E> -where - T: Serialize, - E: SliceEventEmitter, -{ - /// Flush the current guard value through the configured persistence backend. - pub fn flush(&self) -> Result<()> { - let value = serde_json::to_value(&*self.guard) - .with_context(|| format!("failed to serialize slice state for {}", self.event))?; - self.persistence.flush(&value) - } -} - -impl Deref for SliceWriteGuard<'_, T, E> -where - T: Serialize, - E: SliceEventEmitter, -{ - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.guard - } -} - -impl DerefMut for SliceWriteGuard<'_, T, E> -where - T: Serialize, - E: SliceEventEmitter, -{ - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.guard - } -} - -impl Drop for SliceWriteGuard<'_, T, E> -where - T: Serialize, - E: SliceEventEmitter, -{ - fn drop(&mut self) { - if let Err(error) = self.emitter.emit_slice(self.event, &*self.guard) { - log::error!("failed to emit slice change for {}: {error:#}", self.event); - } - if let Err(error) = self.flush() { - log::error!("failed to flush slice state for {}: {error:#}", self.event); - } - } -} - -#[cfg(test)] -mod tests { - use super::persistence::RepoScopedJson; - use super::{ - AppDataJson, Persistence, RegisteredSliceConfig, Slice, SliceEventEmitter, SliceRegistry, - }; - use anyhow::Result; - use serde::{Deserialize, Serialize}; - use serde_json::{Value, json}; - use std::sync::{Arc, Mutex}; - - #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] - struct DemoState { - count: u32, - label: String, - } - - #[derive(Default)] - struct MemoryPersistence { - value: Mutex>, - } - - impl MemoryPersistence { - fn value(&self) -> Option { - self.value.lock().unwrap().clone() - } - } - - impl Persistence for MemoryPersistence { - fn load(&self) -> Result> { - Ok(self.value()) - } - - fn flush(&self, value: &Value) -> Result<()> { - *self.value.lock().unwrap() = Some(value.clone()); - Ok(()) - } - } - - #[derive(Clone, Default)] - struct RecordingEmitter { - events: Arc>>, - } - - impl RecordingEmitter { - fn events(&self) -> Vec<(String, Value)> { - self.events.lock().unwrap().clone() - } - } - - impl SliceEventEmitter for RecordingEmitter { - fn emit_slice(&self, event: &str, payload: &T) -> Result<()> { - self.events - .lock() - .unwrap() - .push((event.to_string(), serde_json::to_value(payload)?)); - Ok(()) - } - } - - #[test] - fn write_guard_emits_and_flushes_on_drop() { - let persistence = Arc::new(MemoryPersistence::default()); - let emitter = RecordingEmitter::default(); - let slice = Slice::new("demo_changed", DemoState::default(), persistence.clone()); - - { - let mut state = slice.write_sync(&emitter); - state.count = 2; - state.label = "updated".to_string(); - } - - let expected = json!({ "count": 2, "label": "updated" }); - assert_eq!( - emitter.events(), - vec![("demo_changed".to_string(), expected.clone())] - ); - assert_eq!(persistence.value(), Some(expected)); - } - - #[test] - fn json_persistence_round_trips_by_scope_path() { - let temp = tempfile::tempdir().expect("temp dir"); - let app_data = AppDataJson::new(temp.path().join("app-data").join("settings.json")); - let repo_scoped = RepoScopedJson::new( - temp.path() - .join("repo") - .join(".nixmac") - .join("settings.json"), - ); - - app_data - .flush(&json!({ "count": 3, "label": "global" })) - .expect("app data flushes"); - repo_scoped - .flush(&json!({ "count": 4, "label": "repo" })) - .expect("repo scoped flushes"); - - assert!(app_data.path().ends_with("app-data/settings.json")); - assert!(repo_scoped.path().ends_with("repo/.nixmac/settings.json")); - assert_eq!( - app_data.load().expect("app data loads"), - Some(json!({ "count": 3, "label": "global" })) - ); - assert_eq!( - repo_scoped.load().expect("repo scoped loads"), - Some(json!({ "count": 4, "label": "repo" })) - ); - } - - #[test] - fn registry_exposes_registered_slice_configs() { - fn schema_stub( - _: &tauri::AppHandle, - ) -> Result { - unreachable!("schema is not invoked by this registry test") - } - - fn set_stub(_: &tauri::AppHandle, _: &str, _: serde_json::Value) -> Result<()> { - unreachable!("set is not invoked by this registry test") - } - - let registry = SliceRegistry::default(); - registry - .register(RegisteredSliceConfig { - name: "DemoState", - schema_fn: schema_stub, - set_field_fn: set_stub, - }) - .expect("slice config registers"); - - let entries = registry.entries().expect("registry entries load"); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].name, "DemoState"); - let _schema_fn = entries[0].schema_fn; - let _set_field_fn = entries[0].set_field_fn; - assert!( - registry - .get("DemoState") - .expect("registry lookup") - .is_some() - ); - } -} diff --git a/apps/native/src-tauri/src/state/slice/registry.rs b/apps/native/src-tauri/src/state/slice/registry.rs index 46a0faa3c..50338fa5c 100644 --- a/apps/native/src-tauri/src/state/slice/registry.rs +++ b/apps/native/src-tauri/src/state/slice/registry.rs @@ -88,3 +88,40 @@ impl SliceRegistry { (entry.set_field_fn)(app, field_key, value) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_exposes_registered_slice_configs() { + fn schema_stub( + _: &tauri::AppHandle, + ) -> Result { + unreachable!("schema is not invoked by this registry test") + } + + fn set_stub(_: &tauri::AppHandle, _: &str, _: serde_json::Value) -> Result<()> { + unreachable!("set is not invoked by this registry test") + } + + let registry = SliceRegistry::default(); + registry + .register(RegisteredSliceConfig { + name: "DemoState", + schema_fn: schema_stub, + set_field_fn: set_stub, + }) + .expect("slice config registers"); + + let entries = registry.entries().expect("registry entries load"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "DemoState"); + let _schema_fn = entries[0].schema_fn; + let _set_field_fn = entries[0].set_field_fn; + assert!(registry + .get("DemoState") + .expect("registry lookup") + .is_some()); + } +} From 460ea6409b97904dc83c6ccb179319c3bff8f255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Wed, 10 Jun 2026 22:32:52 +0200 Subject: [PATCH 06/13] configurable: replace SliceRegistry with compile-time inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the runtime SliceRegistry / RegisteredSliceConfig pair in favor of compile-time registration via the `inventory` crate, addressing B1 from docs/2026-06-03-pr-review-followups.md. The derive now emits one `inventory::submit!{ ConfigurableMeta {...} }` per #[derive(Configurable)] struct, alongside the existing Wry-specialized shim functions. `ConfigurableMeta` lives in the `configurable` crate and carries the same triple as the old RegisteredSliceConfig (name + schema_fn + set_field_fn). The `inventory` crate is re-exported from `configurable::inventory` so the derive output never needs the consuming crate to add it as a direct dep. `commands/dev_configs.rs` walks `inventory::iter::()` instead of looking up `tauri::State`; a new test confirms the link-time submit actually lands ("EvolutionLimits is registered via inventory") so a future toolchain regression on linker sections is caught before dev settings silently goes empty. Deleted: - apps/native/src-tauri/src/state/slice/ (whole directory) - evolve::config::register_slice_config - the app.manage(SliceRegistry::default()) line in main.rs - the register_slice_config(...) call in the Tauri setup hook - state/mod.rs stops re-exporting the `slice` submodule main.rs no longer references EvolutionLimits or any other Configurable struct directly — registration happens entirely through the derive. --- Cargo.lock | 10 ++ .../configurable-derive/src/codegen.rs | 14 +- apps/native/src-tauri/configurable/Cargo.toml | 1 + apps/native/src-tauri/configurable/src/lib.rs | 26 ++++ .../src-tauri/src/commands/dev_configs.rs | 42 ++++-- apps/native/src-tauri/src/evolve/config.rs | 15 +-- apps/native/src-tauri/src/main.rs | 7 - apps/native/src-tauri/src/state/mod.rs | 4 - apps/native/src-tauri/src/state/slice/mod.rs | 7 - .../src-tauri/src/state/slice/registry.rs | 127 ------------------ 10 files changed, 84 insertions(+), 169 deletions(-) delete mode 100644 apps/native/src-tauri/src/state/slice/mod.rs delete mode 100644 apps/native/src-tauri/src/state/slice/registry.rs diff --git a/Cargo.lock b/Cargo.lock index d26398ca1..35418d0ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -851,6 +851,7 @@ version = "0.1.0" dependencies = [ "anyhow", "configurable-derive", + "inventory", "serde", "serde_json", "specta", @@ -2791,6 +2792,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.12.0" diff --git a/apps/native/src-tauri/configurable-derive/src/codegen.rs b/apps/native/src-tauri/configurable-derive/src/codegen.rs index 84b5e7124..9ccff4001 100644 --- a/apps/native/src-tauri/configurable-derive/src/codegen.rs +++ b/apps/native/src-tauri/configurable-derive/src/codegen.rs @@ -45,8 +45,8 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { impl #name { #methods - // Wry-specialized shims for the type-erased registry. Generic - // functions can't be cast to fn pointers; these monomorphic + // Wry-specialized shims for the type-erased compile-time registry. + // Generic functions can't be cast to fn pointers; these monomorphic // wrappers can. #[doc(hidden)] pub fn __configurable_schema_wry( @@ -64,6 +64,16 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { Self::set_field(app, key, value) } } + + // Push this configurable into the inventory collection so + // `inventory::iter::()` enumerates it at runtime. + ::configurable::inventory::submit! { + ::configurable::ConfigurableMeta { + name: #name_str, + schema_fn: #name::__configurable_schema_wry, + set_field_fn: #name::__configurable_set_field_wry, + } + } }) } diff --git a/apps/native/src-tauri/configurable/Cargo.toml b/apps/native/src-tauri/configurable/Cargo.toml index c3de21e2f..621a6e357 100644 --- a/apps/native/src-tauri/configurable/Cargo.toml +++ b/apps/native/src-tauri/configurable/Cargo.toml @@ -7,6 +7,7 @@ license = "MIT" [dependencies] anyhow = "1" +inventory = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" specta = { version = "=2.0.0-rc.22", features = ["derive", "serde_json"] } diff --git a/apps/native/src-tauri/configurable/src/lib.rs b/apps/native/src-tauri/configurable/src/lib.rs index 70d539c48..f5ade31ee 100644 --- a/apps/native/src-tauri/configurable/src/lib.rs +++ b/apps/native/src-tauri/configurable/src/lib.rs @@ -40,6 +40,10 @@ use specta::Type; pub use configurable_derive::Configurable; +// Re-exported so derive output can write `::configurable::inventory::submit!` +// without consumers needing to add the crate themselves. +pub use inventory; + // ============================================================================= // Schema types — flow to TS via specta // ============================================================================= @@ -106,3 +110,25 @@ pub struct ConfigurableSchema { pub description: Option, pub fields: Vec, } + +// ============================================================================= +// Compile-time registry — populated by the derive via `inventory::submit!` +// ============================================================================= + +/// Static metadata for one `#[derive(Configurable)]` struct. +/// +/// The derive macro pushes one of these per struct via `inventory::submit!` +/// at link time, so iterating every registered configurable is just a walk +/// over `inventory::iter::()` — no runtime registry, no +/// app-startup registration step. +pub struct ConfigurableMeta { + /// Stable Rust-side name of the configurable state type. + pub name: &'static str, + /// Returns the UI schema with current values populated. + pub schema_fn: fn(&tauri::AppHandle) -> anyhow::Result, + /// Writes one validated field value into the managed observable. + pub set_field_fn: + fn(&tauri::AppHandle, &str, serde_json::Value) -> anyhow::Result<()>, +} + +inventory::collect!(ConfigurableMeta); diff --git a/apps/native/src-tauri/src/commands/dev_configs.rs b/apps/native/src-tauri/src/commands/dev_configs.rs index 26c9fc673..dd8b2789a 100644 --- a/apps/native/src-tauri/src/commands/dev_configs.rs +++ b/apps/native/src-tauri/src/commands/dev_configs.rs @@ -1,21 +1,32 @@ -//! Tauri commands that walk the slice-backed configurable registry. +//! Tauri commands that walk the compile-time configurable registry. //! //! Frontend calls `dev_configs_list` to enumerate every `#[derive(Configurable)]` //! struct in the codebase, get its schema (labels, types, ranges, current //! values), and render a section per struct. Edits go back through -//! `dev_config_set`, which dispatches by struct name to the registered slice -//! shim generated by the derive. +//! `dev_config_set`, which dispatches by struct name to the registered shim +//! emitted by the derive. +//! +//! The registry itself lives in `inventory`: the derive macro pushes one +//! `ConfigurableMeta` per struct at compile time, so these commands never +//! see a runtime registry handle. use super::helpers::capture_err; -use crate::state::slice::SliceRegistry; -use configurable::ConfigurableSchema; -use tauri::{AppHandle, Manager}; +use configurable::{inventory, ConfigurableMeta, ConfigurableSchema}; +use tauri::AppHandle; + +fn find_meta(struct_name: &str) -> Option<&'static ConfigurableMeta> { + inventory::iter::() + .into_iter() + .find(|meta| meta.name == struct_name) +} /// Enumerate every registered Configurable struct with its current values. #[tauri::command] pub async fn dev_configs_list(app: AppHandle) -> Result, String> { - app.state::() - .schemas(&app) + inventory::iter::() + .into_iter() + .map(|meta| (meta.schema_fn)(&app)) + .collect::>>() .map_err(|e| capture_err("dev_configs_list", e)) } @@ -27,9 +38,9 @@ pub async fn dev_config_set( key: String, value: serde_json::Value, ) -> Result<(), String> { - app.state::() - .set_field_by_name(&app, &struct_name, &key, value) - .map_err(|e| capture_err("dev_config_set", e)) + let meta = find_meta(&struct_name) + .ok_or_else(|| format!("dev_config_set: unknown configurable: {struct_name}"))?; + (meta.set_field_fn)(&app, &key, value).map_err(|e| capture_err("dev_config_set", e)) } #[cfg(test)] @@ -56,4 +67,13 @@ mod tests { assert_list_command(dev_configs_list); assert_set_command(dev_config_set); } + + #[test] + fn evolution_limits_is_registered_via_inventory() { + // Verifies the link-time submit! actually wires EvolutionLimits into + // the static collection. If inventory's linker tricks regress on a + // future toolchain, this test catches it before the dev settings UI + // silently goes empty. + assert!(find_meta("EvolutionLimits").is_some()); + } } diff --git a/apps/native/src-tauri/src/evolve/config.rs b/apps/native/src-tauri/src/evolve/config.rs index 1ff8c9b40..e4618683a 100644 --- a/apps/native/src-tauri/src/evolve/config.rs +++ b/apps/native/src-tauri/src/evolve/config.rs @@ -3,9 +3,10 @@ //! Storage is repo-scoped — values live under `/.nixmac/settings.json` //! so they ride along with the user's nix config repo across machines. //! -//! The struct is managed as an `Observable` at startup and -//! registered with the slice registry so Developer settings can render and -//! update it without opening store files directly. +//! The struct is managed as an `Observable` at startup. The +//! derive macro pushes its metadata into the compile-time `inventory` +//! collection so Developer settings can render and update it without opening +//! store files directly. use anyhow::Result; use configurable::Configurable; @@ -16,7 +17,6 @@ use tauri::{AppHandle, Runtime}; use crate::observable::{ConfiguredRepoScopedJson, Observable, Persistence}; use crate::state::preferences; -use crate::state::slice::{RegisteredSliceConfig, SliceRegistry}; pub const EVOLUTION_LIMITS_CHANGED_EVENT: &str = "evolution_limits_changed"; @@ -78,13 +78,6 @@ pub fn load_observable(app: &AppHandle) -> Result Result<()> { - registry.register(RegisteredSliceConfig { - name: "EvolutionLimits", - schema_fn: EvolutionLimits::__configurable_schema_wry, - set_field_fn: EvolutionLimits::__configurable_set_field_wry, - }) -} #[cfg(test)] mod tests { diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 97d72e59b..20a0b05f9 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -340,9 +340,6 @@ fn run_cli_mode(context: tauri::Context) -> i32 { .setup(|app| { app.manage(state::preferences::load_global_observable(app.handle())?); app.manage(evolve::config::load_observable(app.handle())?); - evolve::config::register_slice_config( - &app.state::(), - )?; app.manage(state::evolve_state::load_observable(app.handle())?); Ok(()) }) @@ -457,9 +454,6 @@ fn run_gui_mode( .plugin(tauri_plugin_upload::init()) .plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_notification::init()) - // Configurable slices register here during setup so dev settings - // commands can update typed state without opening store files directly. - .manage(state::slice::SliceRegistry::default()) .invoke_handler(tauri::generate_handler![ // Configuration commands::config::config_get, @@ -593,7 +587,6 @@ fn run_gui_mode( app.manage(state::preferences::load_global_observable(handle)?); app.manage(evolve::config::load_observable(handle)?); - evolve::config::register_slice_config(&app.state::())?; app.manage(state::evolve_state::load_observable(handle)?); // Initialize SQLite database before any consumer that reads the diff --git a/apps/native/src-tauri/src/state/mod.rs b/apps/native/src-tauri/src/state/mod.rs index a4429db34..9462ca6c2 100644 --- a/apps/native/src-tauri/src/state/mod.rs +++ b/apps/native/src-tauri/src/state/mod.rs @@ -13,9 +13,6 @@ //! Adding a new state type: define the struct, pick a persistence backend //! from `crate::observable`, and construct an `Observable` during Tauri //! setup with `.emit_to()` and `.persist_to()` attached as needed. -//! -//! The `slice` submodule survives only for the runtime `SliceRegistry` used -//! by the developer-settings UI; B1 in the followup plan retires it. pub mod build_state; pub mod completion_log; @@ -23,5 +20,4 @@ pub mod drift_notifications; pub mod evolve_state; pub mod preferences; pub mod session_log; -pub mod slice; pub mod watcher; diff --git a/apps/native/src-tauri/src/state/slice/mod.rs b/apps/native/src-tauri/src/state/slice/mod.rs deleted file mode 100644 index 471ebd2ff..000000000 --- a/apps/native/src-tauri/src/state/slice/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Runtime registry for `Configurable` state. The registry is scheduled for -//! deletion in the `inventory`-based redesign tracked as B1; for now it lives -//! here as the last vestige of the old `state/slice/` namespace. - -pub mod registry; - -pub use registry::{RegisteredSliceConfig, SliceRegistry}; diff --git a/apps/native/src-tauri/src/state/slice/registry.rs b/apps/native/src-tauri/src/state/slice/registry.rs deleted file mode 100644 index 50338fa5c..000000000 --- a/apps/native/src-tauri/src/state/slice/registry.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Runtime registry for slice-backed configurable state. -//! -//! Startup registers generated `Configurable` entries here so commands can -//! iterate slices instead of opening store files directly. - -use anyhow::Result; -use serde_json::Value; -use std::sync::RwLock as StdRwLock; - -/// Type-erased configurable metadata for a registered slice. -/// -/// Generated `Configurable` implementations expose these shims so developer -/// settings commands can enumerate schemas and write fields without knowing -/// which persistence backend a slice uses. -#[derive(Clone, Copy)] -pub struct RegisteredSliceConfig { - /// Stable Rust-side name of the configurable state type. - pub name: &'static str, - /// Returns the UI schema with current values populated. - pub schema_fn: fn(&tauri::AppHandle) -> Result, - /// Writes one validated field value into the slice. - pub set_field_fn: fn(&tauri::AppHandle, &str, Value) -> Result<()>, -} - -/// Runtime registry for slice-backed configurable state. -/// -/// Startup registers generated `Configurable` entries here so commands can -/// iterate slices instead of opening store files directly. -#[derive(Default)] -pub struct SliceRegistry { - entries: StdRwLock>, -} - -impl SliceRegistry { - /// Add one configurable slice entry. - pub fn register(&self, entry: RegisteredSliceConfig) -> Result<()> { - self.entries - .write() - .map_err(|_| anyhow::anyhow!("slice registry lock poisoned"))? - .push(entry); - Ok(()) - } - - /// Return a stable snapshot of the registered entries. - pub fn entries(&self) -> Result> { - Ok(self - .entries - .read() - .map_err(|_| anyhow::anyhow!("slice registry lock poisoned"))? - .clone()) - } - - /// Find one registered entry by name. - pub fn get(&self, name: &str) -> Result> { - Ok(self - .entries - .read() - .map_err(|_| anyhow::anyhow!("slice registry lock poisoned"))? - .iter() - .copied() - .find(|entry| entry.name == name)) - } - - /// Build schemas for every registered configurable slice. - #[allow(dead_code)] - pub fn schemas( - &self, - app: &tauri::AppHandle, - ) -> Result> { - self.entries()? - .into_iter() - .map(|entry| (entry.schema_fn)(app)) - .collect() - } - - /// Dispatch a validated field write by registered slice name. - #[allow(dead_code)] - pub fn set_field_by_name( - &self, - app: &tauri::AppHandle, - slice_name: &str, - field_key: &str, - value: Value, - ) -> Result<()> { - let entry = self - .get(slice_name)? - .ok_or_else(|| anyhow::anyhow!("unknown slice config: {slice_name}"))?; - (entry.set_field_fn)(app, field_key, value) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registry_exposes_registered_slice_configs() { - fn schema_stub( - _: &tauri::AppHandle, - ) -> Result { - unreachable!("schema is not invoked by this registry test") - } - - fn set_stub(_: &tauri::AppHandle, _: &str, _: serde_json::Value) -> Result<()> { - unreachable!("set is not invoked by this registry test") - } - - let registry = SliceRegistry::default(); - registry - .register(RegisteredSliceConfig { - name: "DemoState", - schema_fn: schema_stub, - set_field_fn: set_stub, - }) - .expect("slice config registers"); - - let entries = registry.entries().expect("registry entries load"); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].name, "DemoState"); - let _schema_fn = entries[0].schema_fn; - let _set_field_fn = entries[0].set_field_fn; - assert!(registry - .get("DemoState") - .expect("registry lookup") - .is_some()); - } -} From 097daa38884220838c9519f2ef0aa0e48c6ba160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Wed, 10 Jun 2026 23:10:05 +0200 Subject: [PATCH 07/13] configurable: split schema from current value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses B3 from docs/2026-06-03-pr-review-followups.md. The schema half is now pure static metadata and no longer requires an AppHandle; the dynamic half (current store-backed values) is fetched separately and the two are joined at the IPC boundary. Type changes in the configurable crate: - ConfigField is gone. Split into: * ConfigFieldSchema { key, label, help, ty, default } — static * ConfigFieldValue { key, current } — dynamic - ConfigurableSchema.fields: Vec (no `current` field) - New ConfigurableSnapshot { schema, values } is what dev_configs_list returns to the frontend. - ConfigurableMeta gains a `load_value_fn` pointer; `schema_fn` drops its AppHandle argument and now returns ConfigurableSchema directly (no Result, no app). Derive macro: - schema() generated without an `app` parameter; same value every call. - Adds __configurable_load_value_wry that calls Self::load(app) and serializes to serde_json::Value for the IPC join. commands/dev_configs.rs: - dev_configs_list returns Vec. - snapshot_for() joins the static schema with current values by key. specta_gen_ts.rs registers the new types; ipc/types.ts regenerated. Frontend: - tauriAPI.devConfigs.list() returns ConfigurableSnapshot[]. - AutoConfigField now takes `field: ConfigFieldSchema` and a separate `current: JsonValue` prop, instead of pulling `current` off the field. - Both auto-tuning-section.tsx and tuning-tab.tsx build a per-snapshot valuesByKey map and pass each field's current value in. - Storybook stories updated to the new snapshot shape. Acceptance per the followup doc: - `EvolutionLimits::schema()` is callable without an AppHandle. - Same value every call. - IPC payload joins schema and values at the command boundary. --- .../configurable-derive/src/codegen.rs | 29 +++++--- .../configurable-derive/src/fields.rs | 4 +- apps/native/src-tauri/configurable/src/lib.rs | 54 ++++++++++---- .../src-tauri/examples/specta_gen_ts.rs | 4 +- .../src-tauri/src/commands/dev_configs.rs | 37 ++++++++-- .../settings/auto-config-field.stories.tsx | 67 +++++++++-------- .../widget/settings/auto-config-field.tsx | 21 +++--- .../settings/auto-tuning-section.stories.tsx | 52 +++++++------ .../widget/settings/auto-tuning-section.tsx | 73 +++++++++++++------ .../components/widget/settings/tuning-tab.tsx | 73 +++++++++++++------ apps/native/src/ipc/api.ts | 4 +- apps/native/src/ipc/types.ts | 27 +++++-- 12 files changed, 288 insertions(+), 157 deletions(-) diff --git a/apps/native/src-tauri/configurable-derive/src/codegen.rs b/apps/native/src-tauri/configurable-derive/src/codegen.rs index 9ccff4001..feb2fd340 100644 --- a/apps/native/src-tauri/configurable-derive/src/codegen.rs +++ b/apps/native/src-tauri/configurable-derive/src/codegen.rs @@ -49,10 +49,16 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { // Generic functions can't be cast to fn pointers; these monomorphic // wrappers can. #[doc(hidden)] - pub fn __configurable_schema_wry( + pub fn __configurable_schema_wry() -> ::configurable::ConfigurableSchema { + Self::schema() + } + + #[doc(hidden)] + pub fn __configurable_load_value_wry( app: &::tauri::AppHandle<::tauri::Wry>, - ) -> ::std::result::Result<::configurable::ConfigurableSchema, ::anyhow::Error> { - Self::schema(app) + ) -> ::std::result::Result<::serde_json::Value, ::anyhow::Error> { + let __current = Self::load(app)?; + ::std::result::Result::Ok(::serde_json::to_value(&__current)?) } #[doc(hidden)] @@ -71,6 +77,7 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { ::configurable::ConfigurableMeta { name: #name_str, schema_fn: #name::__configurable_schema_wry, + load_value_fn: #name::__configurable_load_value_wry, set_field_fn: #name::__configurable_set_field_wry, } } @@ -90,9 +97,10 @@ fn description_expr(description: Option<&String>) -> TokenStream2 { /// Emits the generated `load`, `schema`, and `set_field` methods. /// -/// All generated methods target the managed `Slice` for the derived type. -/// That keeps persistence and UI change events behind the slice guard instead -/// of letting each configurable type choose its own storage path. +/// `schema` is intentionally context-free: it returns a `ConfigurableSchema` +/// built from the derive attributes alone, with no `AppHandle` involved. The +/// dev-settings command joins it with current values at the IPC boundary via +/// `load_value` so the static metadata stays cacheable. fn build_scope_methods( name: &syn::Ident, name_str: &str, @@ -130,18 +138,15 @@ fn build_scope_methods( }) } - pub fn schema( - app: &::tauri::AppHandle, - ) -> ::std::result::Result<::configurable::ConfigurableSchema, ::anyhow::Error> { - let __current = Self::load(app)?; - ::std::result::Result::Ok(::configurable::ConfigurableSchema { + pub fn schema() -> ::configurable::ConfigurableSchema { + ::configurable::ConfigurableSchema { name: #name_str.to_string(), display_name: #display_name.to_string(), description: #description_expr, fields: ::std::vec![ #(#schema_fields)* ], - }) + } } pub fn set_field( diff --git a/apps/native/src-tauri/configurable-derive/src/fields.rs b/apps/native/src-tauri/configurable-derive/src/fields.rs index 4ab629d74..a46c0d120 100644 --- a/apps/native/src-tauri/configurable-derive/src/fields.rs +++ b/apps/native/src-tauri/configurable-derive/src/fields.rs @@ -117,14 +117,12 @@ fn generate_field(field: &syn::Field, name_str: &str) -> syn::Result #ident: #default, }, schema_field: quote! { - ::configurable::ConfigField { + ::configurable::ConfigFieldSchema { key: #key.to_string(), label: #label.to_string(), help: #help_expr, ty: #ty_expr, default: ::serde_json::json!(#default), - current: ::serde_json::to_value(&__current.#ident) - .unwrap_or_else(|_| ::serde_json::json!(#default)), }, }, set_field_arm: quote! { diff --git a/apps/native/src-tauri/configurable/src/lib.rs b/apps/native/src-tauri/configurable/src/lib.rs index f5ade31ee..5c69a59eb 100644 --- a/apps/native/src-tauri/configurable/src/lib.rs +++ b/apps/native/src-tauri/configurable/src/lib.rs @@ -5,12 +5,14 @@ //! returns to settings/dev tooling. //! //! Derive `Configurable` on a struct to: -//! 1. Generate a `load(app)` method that reads the managed slice, falling -//! back to per-field defaults when the slice is not yet registered. -//! 2. Expose a rich schema (`schema()`) describing every field's -//! type, label, help text, range, and default value. -//! 3. Generate Wry-specialized shim methods that callers can register with -//! the slice registry. +//! 1. Generate a `load(app)` method that reads the managed observable, +//! falling back to per-field defaults when it isn't yet managed. +//! 2. Expose a static schema (`schema()`) describing every field's type, +//! label, help text, range, and default value. No `AppHandle` needed — +//! the schema is the same value every call and trivially cacheable. +//! 3. Push the type into a compile-time `inventory` collection so the dev +//! settings UI can enumerate every configurable without explicit +//! registration. //! //! ```ignore //! use configurable::Configurable; @@ -32,7 +34,7 @@ //! } //! //! let limits = EvolutionLimits::load(&app)?; -//! let schema = EvolutionLimits::schema(&app)?; +//! let schema = EvolutionLimits::schema(); //! ``` use serde::{Deserialize, Serialize}; @@ -76,10 +78,14 @@ pub struct EnumVariant { pub label: String, } -/// Per-field description rendered into a UI control. +/// Static description of one Configurable field. +/// +/// Produced by the derive macro with no runtime context; the same value every +/// call. Pair with a [`ConfigFieldValue`] (matched by `key`) to get the +/// current store-backed value for rendering. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] -pub struct ConfigField { +pub struct ConfigFieldSchema { /// Key as written to the underlying store (typically camelCase). pub key: String, /// Human-readable label rendered above the input. @@ -91,12 +97,20 @@ pub struct ConfigField { pub ty: FieldType, /// Default if the store has no value yet. pub default: serde_json::Value, - /// Current value loaded from the store. +} + +/// Current value for one Configurable field, keyed identically to its +/// [`ConfigFieldSchema`]. Sent alongside the schema in the dev-settings IPC +/// response so the frontend can render initial input state. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ConfigFieldValue { + pub key: String, pub current: serde_json::Value, } /// One section in the auto-rendered settings panel — corresponds to one -/// `#[derive(Configurable)]` struct. +/// `#[derive(Configurable)]` struct. Static metadata only; no current values. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct ConfigurableSchema { @@ -108,7 +122,16 @@ pub struct ConfigurableSchema { /// Optional one-line description shown under the title. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - pub fields: Vec, + pub fields: Vec, +} + +/// Joined-at-the-boundary response for `dev_configs_list`: the static schema +/// plus the current values loaded from the managed observable. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ConfigurableSnapshot { + pub schema: ConfigurableSchema, + pub values: Vec, } // ============================================================================= @@ -124,8 +147,11 @@ pub struct ConfigurableSchema { pub struct ConfigurableMeta { /// Stable Rust-side name of the configurable state type. pub name: &'static str, - /// Returns the UI schema with current values populated. - pub schema_fn: fn(&tauri::AppHandle) -> anyhow::Result, + /// Returns the static UI schema. Same value every call; no app needed. + pub schema_fn: fn() -> ConfigurableSchema, + /// Loads the current state as a JSON object so the dev-settings command + /// can join it with the static schema by field key. + pub load_value_fn: fn(&tauri::AppHandle) -> anyhow::Result, /// Writes one validated field value into the managed observable. pub set_field_fn: fn(&tauri::AppHandle, &str, serde_json::Value) -> anyhow::Result<()>, diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index 65d0bcfd6..8502d1671 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -86,7 +86,9 @@ fn main() { .register::() .register::() .register::() - .register::() + .register::() + .register::() + .register::() .register::() .register::() .register::() diff --git a/apps/native/src-tauri/src/commands/dev_configs.rs b/apps/native/src-tauri/src/commands/dev_configs.rs index dd8b2789a..46777af96 100644 --- a/apps/native/src-tauri/src/commands/dev_configs.rs +++ b/apps/native/src-tauri/src/commands/dev_configs.rs @@ -1,17 +1,18 @@ //! Tauri commands that walk the compile-time configurable registry. //! //! Frontend calls `dev_configs_list` to enumerate every `#[derive(Configurable)]` -//! struct in the codebase, get its schema (labels, types, ranges, current -//! values), and render a section per struct. Edits go back through -//! `dev_config_set`, which dispatches by struct name to the registered shim -//! emitted by the derive. +//! struct in the codebase. Each entry returns as a [`ConfigurableSnapshot`]: +//! the static schema (labels, types, ranges, defaults — same value every call) +//! paired with the current values pulled from the managed observable. Edits go +//! back through `dev_config_set`, which dispatches by struct name to the +//! registered shim emitted by the derive. //! //! The registry itself lives in `inventory`: the derive macro pushes one //! `ConfigurableMeta` per struct at compile time, so these commands never //! see a runtime registry handle. use super::helpers::capture_err; -use configurable::{inventory, ConfigurableMeta, ConfigurableSchema}; +use configurable::{inventory, ConfigFieldValue, ConfigurableMeta, ConfigurableSnapshot}; use tauri::AppHandle; fn find_meta(struct_name: &str) -> Option<&'static ConfigurableMeta> { @@ -20,12 +21,32 @@ fn find_meta(struct_name: &str) -> Option<&'static ConfigurableMeta> { .find(|meta| meta.name == struct_name) } +fn snapshot_for( + meta: &ConfigurableMeta, + app: &AppHandle, +) -> anyhow::Result { + let schema = (meta.schema_fn)(); + let current = (meta.load_value_fn)(app)?; + let values = schema + .fields + .iter() + .map(|field| ConfigFieldValue { + key: field.key.clone(), + current: current + .get(&field.key) + .cloned() + .unwrap_or(serde_json::Value::Null), + }) + .collect(); + Ok(ConfigurableSnapshot { schema, values }) +} + /// Enumerate every registered Configurable struct with its current values. #[tauri::command] -pub async fn dev_configs_list(app: AppHandle) -> Result, String> { +pub async fn dev_configs_list(app: AppHandle) -> Result, String> { inventory::iter::() .into_iter() - .map(|meta| (meta.schema_fn)(&app)) + .map(|meta| snapshot_for(meta, &app)) .collect::>>() .map_err(|e| capture_err("dev_configs_list", e)) } @@ -53,7 +74,7 @@ mod tests { fn assert_list_command(_f: F) where F: Fn(AppHandle) -> Fut, - Fut: Future, String>>, + Fut: Future, String>>, { } diff --git a/apps/native/src/components/widget/settings/auto-config-field.stories.tsx b/apps/native/src/components/widget/settings/auto-config-field.stories.tsx index a2f4153a5..48e6b4573 100644 --- a/apps/native/src/components/widget/settings/auto-config-field.stories.tsx +++ b/apps/native/src/components/widget/settings/auto-config-field.stories.tsx @@ -3,44 +3,52 @@ import preview from "#storybook/preview"; import { AutoConfigField } from "@/components/widget/settings/auto-config-field"; import { tauriAPI } from "@/ipc/api"; -import type { ConfigField } from "@/ipc/types"; +import type { ConfigFieldSchema, JsonValue } from "@/ipc/types"; -const fields: ConfigField[] = [ +const fields: Array<{ schema: ConfigFieldSchema; current: JsonValue }> = [ { - key: "maxTokenBudget", - label: "Max token budget", - help: "Provider-reported tokens before the agent stops.", - ty: { kind: "number", min: 1000, max: 1000000, step: 1000 }, - default: 50000, + schema: { + key: "maxTokenBudget", + label: "Max token budget", + help: "Provider-reported tokens before the agent stops.", + ty: { kind: "number", min: 1000, max: 1000000, step: 1000 }, + default: 50000, + }, current: 50000, }, { - key: "autoSummarize", - label: "Auto summarize", - help: "Create a summary when focus returns to the widget.", - ty: { kind: "boolean" }, - default: true, + schema: { + key: "autoSummarize", + label: "Auto summarize", + help: "Create a summary when focus returns to the widget.", + ty: { kind: "boolean" }, + default: true, + }, current: true, }, { - key: "defaultPrompt", - label: "Default prompt", - ty: { kind: "string", multiline: true }, - default: "", + schema: { + key: "defaultPrompt", + label: "Default prompt", + ty: { kind: "string", multiline: true }, + default: "", + }, current: "Install ripgrep and keep the existing module layout.", }, { - key: "provider", - label: "Provider", - ty: { - kind: "enum", - variants: [ - { value: "openrouter", label: "OpenRouter" }, - { value: "openai", label: "OpenAI" }, - { value: "ollama", label: "Ollama" }, - ], + schema: { + key: "provider", + label: "Provider", + ty: { + kind: "enum", + variants: [ + { value: "openrouter", label: "OpenRouter" }, + { value: "openai", label: "OpenAI" }, + { value: "ollama", label: "Ollama" }, + ], + }, + default: "openrouter", }, - default: "openrouter", current: "openrouter", }, ]; @@ -74,11 +82,12 @@ export default meta; export const Controls = meta.story({ render: () => (
- {fields.map((field) => ( + {fields.map(({ schema, current }) => ( ))}
diff --git a/apps/native/src/components/widget/settings/auto-config-field.tsx b/apps/native/src/components/widget/settings/auto-config-field.tsx index a62b9c566..9ab314af6 100644 --- a/apps/native/src/components/widget/settings/auto-config-field.tsx +++ b/apps/native/src/components/widget/settings/auto-config-field.tsx @@ -10,28 +10,31 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { tauriAPI } from "@/ipc/api"; -import type { ConfigField } from "@/ipc/types"; +import type { ConfigFieldSchema, JsonValue } from "@/ipc/types"; import { Info } from "lucide-react"; import { useState } from "react"; interface Props { /** Stable identifier of the Configurable struct this field belongs to. */ structName: string; - /** Field metadata + current value, sourced from the backend registry. */ - field: ConfigField; + /** Static field metadata sourced from the backend schema. */ + field: ConfigFieldSchema; + /** Current value loaded from the managed observable, looked up by key + * from the snapshot's `values` array. */ + current: JsonValue; /** Called after a successful save with the new value so the parent can * refresh the schema or surface a status message. Optional. */ onSaved?: (key: string, value: unknown) => void; } /** - * Renders the appropriate control for a `ConfigField` based on `field.ty.kind` - * and writes changes back through `tauriAPI.devConfigs.set`. Local optimistic - * state keeps the input snappy while the backend persists. On error, reverts - * and surfaces the message inline. + * Renders the appropriate control for a `ConfigFieldSchema` based on + * `field.ty.kind` and writes changes back through `tauriAPI.devConfigs.set`. + * Local optimistic state keeps the input snappy while the backend persists. + * On error, reverts and surfaces the message inline. */ -export function AutoConfigField({ structName, field, onSaved }: Props) { - const [value, setValue] = useState(field.current); +export function AutoConfigField({ structName, field, current, onSaved }: Props) { + const [value, setValue] = useState(current); const [error, setError] = useState(null); const commit = async (next: unknown) => { diff --git a/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx b/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx index f1f4c04f3..0d688f541 100644 --- a/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx +++ b/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx @@ -3,36 +3,40 @@ import preview from "#storybook/preview"; import { AutoTuningSection } from "@/components/widget/settings/auto-tuning-section"; import { tauriAPI } from "@/ipc/api"; -import type { ConfigurableSchema } from "@/ipc/types"; +import type { ConfigurableSnapshot } from "@/ipc/types"; import { waitFor, within } from "storybook/test"; -const schemas: ConfigurableSchema[] = [ +const snapshots: ConfigurableSnapshot[] = [ { - name: "EvolutionLimits", - displayName: "Evolution", - description: "How long the agent will try before giving up.", - fields: [ - { - key: "maxTokenBudget", - label: "Max token budget", - help: "Provider-reported tokens before the agent stops. Lower is faster but may not finish complex changes.", - ty: { kind: "number", min: 1000, max: 1000000, step: 1000 }, - default: 50000, - current: 50000, - }, - { - key: "maxBuildAttempts", - label: "Max build attempts", - help: "Failed builds before giving up on a run.", - ty: { kind: "number", min: 1, max: 20, step: 1 }, - default: 5, - current: 5, - }, + schema: { + name: "EvolutionLimits", + displayName: "Evolution", + description: "How long the agent will try before giving up.", + fields: [ + { + key: "maxTokenBudget", + label: "Max token budget", + help: "Provider-reported tokens before the agent stops. Lower is faster but may not finish complex changes.", + ty: { kind: "number", min: 1000, max: 1000000, step: 1000 }, + default: 50000, + }, + { + key: "maxBuildAttempts", + label: "Max build attempts", + help: "Failed builds before giving up on a run.", + ty: { kind: "number", min: 1, max: 20, step: 1 }, + default: 5, + }, + ], + }, + values: [ + { key: "maxTokenBudget", current: 50000 }, + { key: "maxBuildAttempts", current: 5 }, ], }, ]; -function installDevConfigMock(next: ConfigurableSchema[] | Error) { +function installDevConfigMock(next: ConfigurableSnapshot[] | Error) { tauriAPI.devConfigs = { list: async () => { if (next instanceof Error) { @@ -63,7 +67,7 @@ export default meta; export const EvolutionSettings = meta.story({ decorators: [ (Story) => { - installDevConfigMock(schemas); + installDevConfigMock(snapshots); return ; }, ], diff --git a/apps/native/src/components/widget/settings/auto-tuning-section.tsx b/apps/native/src/components/widget/settings/auto-tuning-section.tsx index 0748a9a09..383eb5407 100644 --- a/apps/native/src/components/widget/settings/auto-tuning-section.tsx +++ b/apps/native/src/components/widget/settings/auto-tuning-section.tsx @@ -1,7 +1,7 @@ import { tauriAPI } from "@/ipc/api"; -import type { ConfigurableSchema } from "@/ipc/types"; +import type { ConfigurableSnapshot, JsonValue } from "@/ipc/types"; import { SlidersHorizontal } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AutoConfigField } from "@/components/widget/settings/auto-config-field"; /** @@ -11,7 +11,7 @@ import { AutoConfigField } from "@/components/widget/settings/auto-config-field" * sub-section here automatically, no frontend changes needed. */ export function AutoTuningSection() { - const [schemas, setSchemas] = useState([]); + const [snapshots, setSnapshots] = useState([]); const [loadError, setLoadError] = useState(null); const refresh = useCallback(async () => { @@ -19,7 +19,7 @@ export function AutoTuningSection() { const next = await tauriAPI.devConfigs.list(); // In environments where the Tauri command isn't registered (Storybook, // tests), invoke can resolve with null instead of an array. - setSchemas(Array.isArray(next) ? next : []); + setSnapshots(Array.isArray(next) ? next : []); setLoadError(null); } catch (err) { setLoadError(err instanceof Error ? err.message : String(err)); @@ -47,28 +47,53 @@ export function AutoTuningSection() { )}
- {schemas.map((schema) => ( -
- {schemas.length > 1 && ( -
-

{schema.displayName}

- {schema.description && ( -

{schema.description}

- )} -
- )} -
- {schema.fields.map((field) => ( - - ))} -
-
+ {snapshots.map((snapshot) => ( + 1} + /> ))}
); } + +function SnapshotSection({ + snapshot, + showHeader, +}: { + snapshot: ConfigurableSnapshot; + showHeader: boolean; +}) { + const valuesByKey = useMemo(() => { + const map = new Map(); + for (const value of snapshot.values) { + map.set(value.key, value.current); + } + return map; + }, [snapshot.values]); + + return ( +
+ {showHeader && ( +
+

{snapshot.schema.displayName}

+ {snapshot.schema.description && ( +

{snapshot.schema.description}

+ )} +
+ )} +
+ {snapshot.schema.fields.map((field) => ( + + ))} +
+
+ ); +} diff --git a/apps/native/src/components/widget/settings/tuning-tab.tsx b/apps/native/src/components/widget/settings/tuning-tab.tsx index c7f6d0b7c..ba29d9974 100644 --- a/apps/native/src/components/widget/settings/tuning-tab.tsx +++ b/apps/native/src/components/widget/settings/tuning-tab.tsx @@ -1,7 +1,7 @@ import { tauriAPI } from "@/ipc/api"; -import type { ConfigurableSchema } from "@/ipc/types"; +import type { ConfigurableSnapshot, JsonValue } from "@/ipc/types"; import { SlidersHorizontal } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AutoConfigField } from "@/components/widget/settings/auto-config-field"; import { BackupRestoreSection } from "@/components/widget/settings/backup-restore-section"; @@ -15,7 +15,7 @@ import { BackupRestoreSection } from "@/components/widget/settings/backup-restor * Also includes Backup & Restore for settings export/import. */ export function TuningTab() { - const [schemas, setSchemas] = useState([]); + const [snapshots, setSnapshots] = useState([]); const [loadError, setLoadError] = useState(null); const refresh = useCallback(async () => { @@ -23,7 +23,7 @@ export function TuningTab() { const next = await tauriAPI.devConfigs.list(); // In environments where the Tauri command isn't registered (Storybook, // tests), invoke can resolve with null instead of an array. - setSchemas(Array.isArray(next) ? next : []); + setSnapshots(Array.isArray(next) ? next : []); setLoadError(null); } catch (err) { setLoadError(err instanceof Error ? err.message : String(err)); @@ -59,26 +59,12 @@ export function TuningTab() { )}
- {schemas.map((schema) => ( -
- {schemas.length > 1 && ( -
-

{schema.displayName}

- {schema.description && ( -

{schema.description}

- )} -
- )} -
- {schema.fields.map((field) => ( - - ))} -
-
+ {snapshots.map((snapshot) => ( + 1} + /> ))}
@@ -88,3 +74,42 @@ export function TuningTab() { ); } + +function SnapshotSection({ + snapshot, + showHeader, +}: { + snapshot: ConfigurableSnapshot; + showHeader: boolean; +}) { + const valuesByKey = useMemo(() => { + const map = new Map(); + for (const value of snapshot.values) { + map.set(value.key, value.current); + } + return map; + }, [snapshot.values]); + + return ( +
+ {showHeader && ( +
+

{snapshot.schema.displayName}

+ {snapshot.schema.description && ( +

{snapshot.schema.description}

+ )} +
+ )} +
+ {snapshot.schema.fields.map((field) => ( + + ))} +
+
+ ); +} diff --git a/apps/native/src/ipc/api.ts b/apps/native/src/ipc/api.ts index 793e4b64c..308259d1c 100644 --- a/apps/native/src/ipc/api.ts +++ b/apps/native/src/ipc/api.ts @@ -11,7 +11,7 @@ import type { CommitResult, Config as DarwinConfig, ConfigEditApplyResult, - ConfigurableSchema, + ConfigurableSnapshot, EvolveCancelResult, EvolutionResult, EvolveState, @@ -142,7 +142,7 @@ export const tauriAPI = { import: () => invoke("settings_import"), }, devConfigs: { - list: () => invoke("dev_configs_list"), + list: () => invoke("dev_configs_list"), set: (structName: string, key: string, value: unknown) => invoke("dev_config_set", { structName, key, value }), }, diff --git a/apps/native/src/ipc/types.ts b/apps/native/src/ipc/types.ts index e81d0ebbb..daa521ac2 100644 --- a/apps/native/src/ipc/types.ts +++ b/apps/native/src/ipc/types.ts @@ -203,9 +203,13 @@ gitStatus: GitStatus; evolveState: EvolveState } /** - * Per-field description rendered into a UI control. + * Static description of one Configurable field. + * + * Produced by the derive macro with no runtime context; the same value every + * call. Pair with a [`ConfigFieldValue`] (matched by `key`) to get the + * current store-backed value for rendering. */ -export type ConfigField = { +export type ConfigFieldSchema = { /** * Key as written to the underlying store (typically camelCase). */ @@ -225,15 +229,18 @@ ty: FieldType; /** * Default if the store has no value yet. */ -default: JsonValue; +default: JsonValue } + /** - * Current value loaded from the store. + * Current value for one Configurable field, keyed identically to its + * [`ConfigFieldSchema`]. Sent alongside the schema in the dev-settings IPC + * response so the frontend can render initial input state. */ -current: JsonValue } +export type ConfigFieldValue = { key: string; current: JsonValue } /** * One section in the auto-rendered settings panel — corresponds to one - * `#[derive(Configurable)]` struct. + * `#[derive(Configurable)]` struct. Static metadata only; no current values. */ export type ConfigurableSchema = { /** @@ -248,7 +255,13 @@ displayName: string; /** * Optional one-line description shown under the title. */ -description?: string | null; fields: ConfigField[] } +description?: string | null; fields: ConfigFieldSchema[] } + +/** + * Joined-at-the-boundary response for `dev_configs_list`: the static schema + * plus the current values loaded from the managed observable. + */ +export type ConfigurableSnapshot = { schema: ConfigurableSchema; values: ConfigFieldValue[] } /** * Payload for `darwin:apply:data`. From 84e6a2f7f7469e0b37954e2c63d1e45f4f5ed81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Thu, 11 Jun 2026 00:56:22 +0200 Subject: [PATCH 08/13] db: drop the unused queued_summaries table PR #330 removed the queue-summarizer pipeline (per-hunk grouping replaced by a whole-diff pass), but left the queued_summaries table in 01-initial/up.sql and its Diesel declaration in tables.rs. Every fresh nixmac database has been initializing a table that nothing ever touches. This commit: - Adds 03-drop-queued-summaries migration that drops the table and the idx_queued_summaries_status index. The original CREATE in 01-initial stays untouched so existing user_version=1 databases still apply the full migration history when they upgrade. - Removes the diesel::table! declaration and the allow_tables_to_appear_in_same_query! entry from db/tables.rs. - Deletes the QueuedSummary struct from sqlite_types.rs (it was already marked #[allow(dead_code)] and had no remaining importers). - Adds a migration_03_drops_queued_summaries_table test in db/mod.rs that asserts a freshly initialized database does not contain the table. - Drops the stale queue_summarizer.rs line from src/README.md. --- .../03-drop-queued-summaries/up.sql | 5 ++++ apps/native/src-tauri/src/README.md | 1 - apps/native/src-tauri/src/db/mod.rs | 24 ++++++++++++++++++- apps/native/src-tauri/src/db/tables.rs | 15 ------------ apps/native/src-tauri/src/sqlite_types.rs | 17 ------------- 5 files changed, 28 insertions(+), 34 deletions(-) create mode 100644 apps/native/src-tauri/migrations/03-drop-queued-summaries/up.sql diff --git a/apps/native/src-tauri/migrations/03-drop-queued-summaries/up.sql b/apps/native/src-tauri/migrations/03-drop-queued-summaries/up.sql new file mode 100644 index 000000000..d8feef490 --- /dev/null +++ b/apps/native/src-tauri/migrations/03-drop-queued-summaries/up.sql @@ -0,0 +1,5 @@ +-- The queued_summaries table backed the per-hunk summarization queue worker +-- that PR #330 deleted. Nothing reads or writes the table anymore, so drop it +-- and its status index to keep fresh databases lean. +DROP INDEX IF EXISTS idx_queued_summaries_status; +DROP TABLE IF EXISTS queued_summaries; diff --git a/apps/native/src-tauri/src/README.md b/apps/native/src-tauri/src/README.md index e6b37a27e..5d7ef5769 100644 --- a/apps/native/src-tauri/src/README.md +++ b/apps/native/src-tauri/src/README.md @@ -148,7 +148,6 @@ Orchestrates the summarization pipeline from raw git diffs to DB-persisted chang - `model_calls.rs` — AI API calls for hunk/group/commit-message summarization - `build_prompt.rs` — Constructs prompts - `simplify_grouped.rs` — Converts semantic maps into simplified forms for AI input -- `queue_summarizer.rs` — Background service draining the queued_summaries table - `token_budgets.rs` — Computes input/output token allocations - `model_output_types.rs` — Structured AI response types - `sumlog.rs` — Toggleable debug logging diff --git a/apps/native/src-tauri/src/db/mod.rs b/apps/native/src-tauri/src/db/mod.rs index 6ba8d4d40..dda115471 100644 --- a/apps/native/src-tauri/src/db/mod.rs +++ b/apps/native/src-tauri/src/db/mod.rs @@ -58,7 +58,7 @@ pub async fn init_pool_at_path(db_path: &Path) -> Result { #[cfg(test)] mod tests { use super::*; - use diesel::{dsl::count_star, prelude::*}; + use diesel::{dsl::count_star, prelude::*, sql_types::BigInt}; #[tokio::test] async fn init_pool_at_path_runs_migrations_and_returns_working_diesel_connection() { @@ -74,4 +74,26 @@ mod tests { .unwrap(); assert_eq!(count, 0); } + + #[tokio::test] + async fn migration_03_drops_queued_summaries_table() { + // PR #330 removed the queued summary pipeline; migration 03 then drops + // the table the worker used to drain. New databases shouldn't contain + // it after init. + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("nixmac.db"); + + let pool = init_pool_at_path(&db_path).await.unwrap(); + let mut conn = pool.get().unwrap(); + + let surviving = diesel::select(diesel::dsl::sql::( + "COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'queued_summaries'", + )) + .get_result::(&mut conn) + .unwrap(); + assert_eq!( + surviving, 0, + "queued_summaries should be dropped by 03-drop-queued-summaries" + ); + } } diff --git a/apps/native/src-tauri/src/db/tables.rs b/apps/native/src-tauri/src/db/tables.rs index 8f67d24ca..5ad90dddf 100644 --- a/apps/native/src-tauri/src/db/tables.rs +++ b/apps/native/src-tauri/src/db/tables.rs @@ -67,20 +67,6 @@ diesel::table! { } } -diesel::table! { - queued_summaries (id) { - id -> BigInt, - status -> Text, - attempted_count -> BigInt, - prompt -> Text, - model_response -> Nullable, - group_summary_id -> Nullable, - hash_own_summary_id_pairs -> Nullable, - #[sql_name = "type"] - type_ -> Text, - } -} - diesel::table! { prompts (id) { id -> BigInt, @@ -113,7 +99,6 @@ diesel::allow_tables_to_appear_in_same_query!( evolutions, group_summaries, prompts, - queued_summaries, restore_commits, set_changes, ); diff --git a/apps/native/src-tauri/src/sqlite_types.rs b/apps/native/src-tauri/src/sqlite_types.rs index cf74c2eac..5933624c2 100644 --- a/apps/native/src-tauri/src/sqlite_types.rs +++ b/apps/native/src-tauri/src/sqlite_types.rs @@ -61,23 +61,6 @@ pub struct ChangeSummary { pub created_at: i64, } -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct QueuedSummary { - pub id: i64, - pub status: String, - pub attempted_count: i64, - pub prompt: String, - pub model_response: Option, - pub group_summary_id: Option, - /// JSON-encoded `[{"hash": "...", "summary_id": N}]` pairs used by the - /// queue processor to link model output back to the right summary rows. - pub hash_own_summary_id_pairs: Option, - /// One of `"NEW_SINGLE"`, `"NEW_GROUP"`, or `"EVOLVED_GROUP"`. - pub summary_type: String, -} - /// Groups Changes for a commit→base_commit pair. `commit_id` is NULL for speculative /// (uncommitted) changesets. Membership is stored in the `set_changes` join table. #[allow(dead_code)] From c91d754d49a6656ccb3b0dbc017468b592d6770d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Thu, 11 Jun 2026 01:07:51 +0200 Subject: [PATCH 09/13] docs(readme): refresh summarize and db sections for the whole-diff pipeline The src-tauri/README.md still listed the per-hunk grouping module set that PR #330 deleted (assignments.rs, simplify_grouped.rs, model_output_types.rs, fresh_changeset/evolved_changeset pipelines). The db/ section likewise listed two deleted store_*_changeset.rs files. Rewrites the summarize/ bullet list to match the current tree (mod, find_existing, group_existing, model_calls, build_prompt, token_budgets, sumlog) and the pipelines/ entries (whole_diff, history, commit_message). Adds a one-line note explaining the whole-diff direction so a future reader doesn't have to dig through git log. In the db/ section, replaces store_new_changeset / store_evolved_changeset with the surviving store_whole_diff_changeset, and adds pool.rs + tables.rs which already existed but were missing from the listing. --- apps/native/src-tauri/src/README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/native/src-tauri/src/README.md b/apps/native/src-tauri/src/README.md index 5d7ef5769..4838c5de0 100644 --- a/apps/native/src-tauri/src/README.md +++ b/apps/native/src-tauri/src/README.md @@ -50,9 +50,10 @@ Each file (apply, cli_tool, config, debug, editor, evolve, evolve_state, feedbac ### `db/` — SQLite persistence - `schema.rs` — Runs migrations +- `pool.rs`, `tables.rs` — Diesel r2d2 connection pool and `table!` declarations - `commits.rs`, `evolutions.rs` — CRUD for their respective tables - `changesets.rs` — Shared insert helpers for changeset tables -- `store_new_changeset.rs`, `store_evolved_changeset.rs`, `store_bare_changeset.rs` — Persist summarization pipeline results +- `store_whole_diff_changeset.rs`, `store_bare_changeset.rs` — Persist summarization pipeline results - `restore_commits.rs` — Tracks restore-origin provenance **Called by:** history, summarize, evolve/lifecycle, managed_edits, state/watcher @@ -139,19 +140,19 @@ Shared "managed edit" pattern (prepare, apply, finalize into review flow). ### `summarize/` — AI-powered change summarization -Orchestrates the summarization pipeline from raw git diffs to DB-persisted changesets. +Orchestrates summarization of a git diff into a DB-persisted changeset. +The current pipeline issues one model call over the full diff and stores +the resulting commit message (PR #330 replaced an earlier per-hunk +grouping scheme). -- `mod.rs` — Top-level `new_changeset` flow -- `find_existing.rs` — Queries DB for existing summarized changes +- `mod.rs` — Top-level `new_changeset` / `summarize_since` flow +- `find_existing.rs` — Queries DB for existing summarized changesets - `group_existing.rs` — Builds SemanticChangeMap from found changesets -- `assignments.rs` — Reconciles model output into DB-writable assignments -- `model_calls.rs` — AI API calls for hunk/group/commit-message summarization -- `build_prompt.rs` — Constructs prompts -- `simplify_grouped.rs` — Converts semantic maps into simplified forms for AI input +- `model_calls.rs` — AI API call for the whole-diff summary +- `build_prompt.rs` — Constructs the whole-diff prompt - `token_budgets.rs` — Computes input/output token allocations -- `model_output_types.rs` — Structured AI response types - `sumlog.rs` — Toggleable debug logging -- `pipelines/` — fresh_changeset, evolved_changeset, history, commit_message implementations +- `pipelines/` — `whole_diff`, `history`, and `commit_message` entry points **Called by:** commands::summarize, evolve/lifecycle, state/watcher, history From 5efed932aec974b514b947322807591075b9579b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Thu, 11 Jun 2026 01:47:20 +0200 Subject: [PATCH 10/13] configurable: take a whole-struct payload on set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses B4 from docs/2026-06-03-pr-review-followups.md. Replaces the per-field set_field(struct_name, key, value) dispatch with a whole-struct set(struct_name, value) that lets Serde validate every field in one pass on the way into a typed Self. Backend: - ConfigurableMeta.set_field_fn -> set_fn (now takes one Value, no key). - Derive emits `set(app, value)` instead of `set_field(app, key, value)`. Body: deserialize the JSON payload into Self via serde_json::from_value, then replace *observable.write_sync() with the new value. One Serde error per failure, not a per-field match arm. - Derive drops the `__configurable_set_field_wry` shim in favor of `__configurable_set_wry`, with the matching inventory::submit! update. - fields.rs::FieldCode drops the set_field_arm fragment, and GeneratedFields drops the set_field_arms vec; generate_fields no longer needs the struct name string. - dev_config_set IPC command drops its `key` parameter; signature is now (app, struct_name, value). Frontend: - tauriAPI.devConfigs.set(structName, value) — payload is the whole struct (every field), not a partial update. - AutoConfigField swaps the direct tauriAPI call for an `onCommit(key, value)` callback the parent supplies. Parent owns the snapshot; on each field commit it overlays the new value onto the snapshot's existing values, POSTs the full struct, and updates its local state so the next commit reads back the freshly persisted value. - auto-tuning-section.tsx and tuning-tab.tsx both grow a small commitField helper that does the overlay + POST + return-next-snapshot. - Storybook stories pass `onCommit={async () => undefined}` for static rendering. Tradeoff: whole-struct writes clobber any concurrent backend-side edits to other fields. Acceptable for the single-user dev settings panel (documented in §2.4 of the followup doc); if a multi-actor config surface ever shows up, this assumption would need revisiting. --- .../configurable-derive/src/codegen.rs | 31 ++++---- .../configurable-derive/src/fields.rs | 35 +++------ apps/native/src-tauri/configurable/src/lib.rs | 6 +- .../src-tauri/src/commands/dev_configs.rs | 15 ++-- .../settings/auto-config-field.stories.tsx | 1 + .../widget/settings/auto-config-field.tsx | 20 +++--- .../widget/settings/auto-tuning-section.tsx | 71 ++++++++++++++----- .../components/widget/settings/tuning-tab.tsx | 63 +++++++++++----- apps/native/src/ipc/api.ts | 9 ++- 9 files changed, 154 insertions(+), 97 deletions(-) diff --git a/apps/native/src-tauri/configurable-derive/src/codegen.rs b/apps/native/src-tauri/configurable-derive/src/codegen.rs index feb2fd340..a9769a66f 100644 --- a/apps/native/src-tauri/configurable-derive/src/codegen.rs +++ b/apps/native/src-tauri/configurable-derive/src/codegen.rs @@ -31,7 +31,7 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { .display_name .clone() .unwrap_or_else(|| name_str.clone()); - let fields = generate_fields(named_fields(&input)?, &name_str)?; + let fields = generate_fields(named_fields(&input)?)?; let methods = build_scope_methods( name, &name_str, @@ -62,12 +62,11 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { } #[doc(hidden)] - pub fn __configurable_set_field_wry( + pub fn __configurable_set_wry( app: &::tauri::AppHandle<::tauri::Wry>, - key: &str, value: ::serde_json::Value, ) -> ::std::result::Result<(), ::anyhow::Error> { - Self::set_field(app, key, value) + Self::set(app, value) } } @@ -78,7 +77,7 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { name: #name_str, schema_fn: #name::__configurable_schema_wry, load_value_fn: #name::__configurable_load_value_wry, - set_field_fn: #name::__configurable_set_field_wry, + set_fn: #name::__configurable_set_wry, } } }) @@ -95,12 +94,16 @@ fn description_expr(description: Option<&String>) -> TokenStream2 { } } -/// Emits the generated `load`, `schema`, and `set_field` methods. +/// Emits the generated `load`, `schema`, and `set` methods. /// /// `schema` is intentionally context-free: it returns a `ConfigurableSchema` /// built from the derive attributes alone, with no `AppHandle` involved. The /// dev-settings command joins it with current values at the IPC boundary via /// `load_value` so the static metadata stays cacheable. +/// +/// `set` takes the whole struct as a single JSON payload. One Serde +/// deserialize validates every field at once; there is no per-field dispatch +/// table because the type system already knows the shape of `Self`. fn build_scope_methods( name: &syn::Ident, name_str: &str, @@ -115,7 +118,6 @@ fn build_scope_methods( }; let default_inits = fields.default_inits; let schema_fields = fields.schema_fields; - let set_field_arms = fields.set_field_arms; // The derive is observable-only: reads mirror the managed observable, and // writes go through the observable guard so persistence and change events @@ -149,9 +151,8 @@ fn build_scope_methods( } } - pub fn set_field( + pub fn set( app: &::tauri::AppHandle, - key: &str, value: ::serde_json::Value, ) -> ::std::result::Result<(), ::anyhow::Error> { let __observable = @@ -160,14 +161,12 @@ fn build_scope_methods( "Configurable {}: {} observable is not managed", #name_str, #scope_name, ))?; + let __next: Self = ::serde_json::from_value(value).map_err(|e| { + ::anyhow::anyhow!("Configurable {}: invalid payload: {}", #name_str, e) + })?; let mut __state = __observable.write_sync(); - match key { - #(#set_field_arms)* - other => ::std::result::Result::Err(::anyhow::anyhow!( - "Configurable {}: unknown field `{}`", - #name_str, other, - )), - } + *__state = __next; + ::std::result::Result::Ok(()) } } } diff --git a/apps/native/src-tauri/configurable-derive/src/fields.rs b/apps/native/src-tauri/configurable-derive/src/fields.rs index a46c0d120..c0124cc20 100644 --- a/apps/native/src-tauri/configurable-derive/src/fields.rs +++ b/apps/native/src-tauri/configurable-derive/src/fields.rs @@ -18,16 +18,15 @@ use syn::{Data, DeriveInput, Fields}; pub(crate) struct FieldCode { default_init: TokenStream2, schema_field: TokenStream2, - set_field_arm: TokenStream2, } /// The field-level fragments are consumed by different generated methods: -/// defaults feed `load`, schema entries feed `schema`, and setter arms feed -/// `set_field`. +/// defaults feed `load`, schema entries feed `schema`. The whole-struct +/// `set` path doesn't need per-field fragments — Serde validates every +/// field at once. pub(crate) struct GeneratedFields { pub(crate) default_inits: Vec, pub(crate) schema_fields: Vec, - pub(crate) set_field_arms: Vec, } /// Returns the named fields the derive knows how to expose as settings. @@ -56,37 +55,34 @@ pub(crate) fn named_fields( /// Generates all field-level fragments in one pass. /// -/// Each configurable field contributes to three generated methods. Grouping +/// Defaults feed `load`'s fallback; schema entries feed `schema`. Grouping /// fragments by method keeps `build_scope_methods` simple without reparsing /// field attributes later. pub(crate) fn generate_fields( fields: &syn::punctuated::Punctuated, - name_str: &str, ) -> syn::Result { let mut default_inits = Vec::new(); let mut schema_fields = Vec::new(); - let mut set_field_arms = Vec::new(); for field in fields { - let generated = generate_field(field, name_str)?; + let generated = generate_field(field)?; default_inits.push(generated.default_init); schema_fields.push(generated.schema_field); - set_field_arms.push(generated.set_field_arm); } Ok(GeneratedFields { default_inits, schema_fields, - set_field_arms, }) } /// Validates one field and emits the snippets needed by the generated methods. /// -/// This is where field attributes become concrete behavior: default values feed -/// fallback loading, type metadata feeds the UI schema, and the setter arm -/// round-trips through the declared Rust type before mutating the slice. -fn generate_field(field: &syn::Field, name_str: &str) -> syn::Result { +/// This is where field attributes become concrete behavior: default values +/// feed fallback loading and type metadata feeds the UI schema. Setting is +/// handled at the struct level by Serde, so no per-field setter code is +/// emitted here. +fn generate_field(field: &syn::Field) -> syn::Result { let ident = field .ident .as_ref() @@ -125,16 +121,5 @@ fn generate_field(field: &syn::Field, name_str: &str) -> syn::Result default: ::serde_json::json!(#default), }, }, - set_field_arm: quote! { - #key => { - let __typed: #ty = ::serde_json::from_value(value.clone()) - .map_err(|e| ::anyhow::anyhow!( - "Configurable {}: invalid value for `{}`: {}", - #name_str, #key, e, - ))?; - __state.#ident = __typed; - ::std::result::Result::Ok(()) - } - }, }) } diff --git a/apps/native/src-tauri/configurable/src/lib.rs b/apps/native/src-tauri/configurable/src/lib.rs index 5c69a59eb..47282d7b0 100644 --- a/apps/native/src-tauri/configurable/src/lib.rs +++ b/apps/native/src-tauri/configurable/src/lib.rs @@ -152,9 +152,9 @@ pub struct ConfigurableMeta { /// Loads the current state as a JSON object so the dev-settings command /// can join it with the static schema by field key. pub load_value_fn: fn(&tauri::AppHandle) -> anyhow::Result, - /// Writes one validated field value into the managed observable. - pub set_field_fn: - fn(&tauri::AppHandle, &str, serde_json::Value) -> anyhow::Result<()>, + /// Replaces the managed observable's value with a deserialized + /// whole-struct payload. Serde validates every field in one pass. + pub set_fn: fn(&tauri::AppHandle, serde_json::Value) -> anyhow::Result<()>, } inventory::collect!(ConfigurableMeta); diff --git a/apps/native/src-tauri/src/commands/dev_configs.rs b/apps/native/src-tauri/src/commands/dev_configs.rs index 46777af96..f2e7d5b87 100644 --- a/apps/native/src-tauri/src/commands/dev_configs.rs +++ b/apps/native/src-tauri/src/commands/dev_configs.rs @@ -5,7 +5,8 @@ //! the static schema (labels, types, ranges, defaults — same value every call) //! paired with the current values pulled from the managed observable. Edits go //! back through `dev_config_set`, which dispatches by struct name to the -//! registered shim emitted by the derive. +//! registered shim emitted by the derive and replaces the whole struct in one +//! Serde-validated write. //! //! The registry itself lives in `inventory`: the derive macro pushes one //! `ConfigurableMeta` per struct at compile time, so these commands never @@ -51,17 +52,21 @@ pub async fn dev_configs_list(app: AppHandle) -> Result Result<(), String> { let meta = find_meta(&struct_name) .ok_or_else(|| format!("dev_config_set: unknown configurable: {struct_name}"))?; - (meta.set_field_fn)(&app, &key, value).map_err(|e| capture_err("dev_config_set", e)) + (meta.set_fn)(&app, value).map_err(|e| capture_err("dev_config_set", e)) } #[cfg(test)] @@ -80,7 +85,7 @@ mod tests { fn assert_set_command(_f: F) where - F: Fn(AppHandle, String, String, serde_json::Value) -> Fut, + F: Fn(AppHandle, String, serde_json::Value) -> Fut, Fut: Future>, { } diff --git a/apps/native/src/components/widget/settings/auto-config-field.stories.tsx b/apps/native/src/components/widget/settings/auto-config-field.stories.tsx index 48e6b4573..0bdecba92 100644 --- a/apps/native/src/components/widget/settings/auto-config-field.stories.tsx +++ b/apps/native/src/components/widget/settings/auto-config-field.stories.tsx @@ -88,6 +88,7 @@ export const Controls = meta.story({ structName="EvolutionLimits" field={schema} current={current} + onCommit={async () => undefined} /> ))} diff --git a/apps/native/src/components/widget/settings/auto-config-field.tsx b/apps/native/src/components/widget/settings/auto-config-field.tsx index 9ab314af6..df923fe8d 100644 --- a/apps/native/src/components/widget/settings/auto-config-field.tsx +++ b/apps/native/src/components/widget/settings/auto-config-field.tsx @@ -9,7 +9,6 @@ import { import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { tauriAPI } from "@/ipc/api"; import type { ConfigFieldSchema, JsonValue } from "@/ipc/types"; import { Info } from "lucide-react"; import { useState } from "react"; @@ -22,18 +21,20 @@ interface Props { /** Current value loaded from the managed observable, looked up by key * from the snapshot's `values` array. */ current: JsonValue; - /** Called after a successful save with the new value so the parent can - * refresh the schema or surface a status message. Optional. */ - onSaved?: (key: string, value: unknown) => void; + /** Called when the user commits a new value. The parent owns the whole + * snapshot, so it builds the full struct payload and POSTs it via the + * whole-struct `devConfigs.set`. Must reject on backend failure so this + * component can revert its optimistic UI state. */ + onCommit: (key: string, value: unknown) => Promise; } /** * Renders the appropriate control for a `ConfigFieldSchema` based on - * `field.ty.kind` and writes changes back through `tauriAPI.devConfigs.set`. - * Local optimistic state keeps the input snappy while the backend persists. - * On error, reverts and surfaces the message inline. + * `field.ty.kind`. Local optimistic state keeps the input snappy while the + * parent persists the whole struct; on backend failure, `onCommit` rejects + * and this component reverts and surfaces the message inline. */ -export function AutoConfigField({ structName, field, current, onSaved }: Props) { +export function AutoConfigField({ structName, field, current, onCommit }: Props) { const [value, setValue] = useState(current); const [error, setError] = useState(null); @@ -42,8 +43,7 @@ export function AutoConfigField({ structName, field, current, onSaved }: Props) setValue(next); setError(null); try { - await tauriAPI.devConfigs.set(structName, field.key, next); - onSaved?.(field.key, next); + await onCommit(field.key, next); } catch (e) { setValue(previous); setError(e instanceof Error ? e.message : String(e)); diff --git a/apps/native/src/components/widget/settings/auto-tuning-section.tsx b/apps/native/src/components/widget/settings/auto-tuning-section.tsx index 383eb5407..55ece084d 100644 --- a/apps/native/src/components/widget/settings/auto-tuning-section.tsx +++ b/apps/native/src/components/widget/settings/auto-tuning-section.tsx @@ -1,7 +1,7 @@ import { tauriAPI } from "@/ipc/api"; import type { ConfigurableSnapshot, JsonValue } from "@/ipc/types"; import { SlidersHorizontal } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { AutoConfigField } from "@/components/widget/settings/auto-config-field"; /** @@ -47,11 +47,24 @@ export function AutoTuningSection() { )}
- {snapshots.map((snapshot) => ( + {snapshots.map((snapshot, index) => ( 1} + onCommit={async (key, value) => { + const next = await commitField(snapshot, key, value); + // Mutate the local snapshot in place so the next field commit + // reads back the freshly persisted value. React doesn't re-render + // anything because AutoConfigField owns its own field-level + // optimistic state; the snapshot is only consulted on the next + // commit's payload construction. + setSnapshots((prev) => { + const copy = prev.slice(); + copy[index] = next; + return copy; + }); + }} /> ))}
@@ -62,18 +75,12 @@ export function AutoTuningSection() { function SnapshotSection({ snapshot, showHeader, + onCommit, }: { snapshot: ConfigurableSnapshot; showHeader: boolean; + onCommit: (key: string, value: unknown) => Promise; }) { - const valuesByKey = useMemo(() => { - const map = new Map(); - for (const value of snapshot.values) { - map.set(value.key, value.current); - } - return map; - }, [snapshot.values]); - return (
{showHeader && ( @@ -85,15 +92,43 @@ function SnapshotSection({ )}
- {snapshot.schema.fields.map((field) => ( - - ))} + {snapshot.schema.fields.map((field) => { + const current = snapshot.values.find((v) => v.key === field.key)?.current ?? null; + return ( + + ); + })}
); } + +/** + * Builds the whole-struct payload by overlaying the new value on the snapshot's + * existing values, POSTs it via `devConfigs.set`, and returns the next snapshot + * so the parent can keep its state in sync. + * + * The backend (Serde) clobbers any concurrent backend-side edits to other + * fields — that's fine for a single-user dev settings panel. + */ +async function commitField( + snapshot: ConfigurableSnapshot, + key: string, + value: unknown, +): Promise { + const nextValues = snapshot.values.map((v) => + v.key === key ? { ...v, current: value as JsonValue } : v, + ); + const payload: Record = {}; + for (const v of nextValues) { + payload[v.key] = v.current; + } + await tauriAPI.devConfigs.set(snapshot.schema.name, payload); + return { ...snapshot, values: nextValues }; +} diff --git a/apps/native/src/components/widget/settings/tuning-tab.tsx b/apps/native/src/components/widget/settings/tuning-tab.tsx index ba29d9974..c3661b8d0 100644 --- a/apps/native/src/components/widget/settings/tuning-tab.tsx +++ b/apps/native/src/components/widget/settings/tuning-tab.tsx @@ -1,7 +1,7 @@ import { tauriAPI } from "@/ipc/api"; import type { ConfigurableSnapshot, JsonValue } from "@/ipc/types"; import { SlidersHorizontal } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { AutoConfigField } from "@/components/widget/settings/auto-config-field"; import { BackupRestoreSection } from "@/components/widget/settings/backup-restore-section"; @@ -59,11 +59,19 @@ export function TuningTab() { )}
- {snapshots.map((snapshot) => ( + {snapshots.map((snapshot, index) => ( 1} + onCommit={async (key, value) => { + const next = await commitField(snapshot, key, value); + setSnapshots((prev) => { + const copy = prev.slice(); + copy[index] = next; + return copy; + }); + }} /> ))}
@@ -78,18 +86,12 @@ export function TuningTab() { function SnapshotSection({ snapshot, showHeader, + onCommit, }: { snapshot: ConfigurableSnapshot; showHeader: boolean; + onCommit: (key: string, value: unknown) => Promise; }) { - const valuesByKey = useMemo(() => { - const map = new Map(); - for (const value of snapshot.values) { - map.set(value.key, value.current); - } - return map; - }, [snapshot.values]); - return (
{showHeader && ( @@ -101,15 +103,40 @@ function SnapshotSection({ )}
- {snapshot.schema.fields.map((field) => ( - - ))} + {snapshot.schema.fields.map((field) => { + const current = snapshot.values.find((v) => v.key === field.key)?.current ?? null; + return ( + + ); + })}
); } + +/** + * Builds the whole-struct payload by overlaying the new value on the snapshot's + * existing values, POSTs it via `devConfigs.set`, and returns the next snapshot + * so the parent can keep its state in sync. + */ +async function commitField( + snapshot: ConfigurableSnapshot, + key: string, + value: unknown, +): Promise { + const nextValues = snapshot.values.map((v) => + v.key === key ? { ...v, current: value as JsonValue } : v, + ); + const payload: Record = {}; + for (const v of nextValues) { + payload[v.key] = v.current; + } + await tauriAPI.devConfigs.set(snapshot.schema.name, payload); + return { ...snapshot, values: nextValues }; +} diff --git a/apps/native/src/ipc/api.ts b/apps/native/src/ipc/api.ts index 308259d1c..77ac8baca 100644 --- a/apps/native/src/ipc/api.ts +++ b/apps/native/src/ipc/api.ts @@ -143,8 +143,13 @@ export const tauriAPI = { }, devConfigs: { list: () => invoke("dev_configs_list"), - set: (structName: string, key: string, value: unknown) => - invoke("dev_config_set", { structName, key, value }), + /** + * Replace a Configurable struct with a whole-struct payload. `value` must + * be the full struct (every field), not a partial update — Serde validates + * the whole thing in one pass on the backend. + */ + set: (structName: string, value: Record) => + invoke("dev_config_set", { structName, value }), }, models: { getCached: (provider: string) => invoke("get_cached_models", { provider }), From a7b1d97105f636bd69d60aa5f6f2859e12cad310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Thu, 11 Jun 2026 01:50:31 +0200 Subject: [PATCH 11/13] configurable: rename load_value_fn -> load_fn for symmetry with set_fn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigurableMeta had `load_value_fn` next to `set_fn` and `schema_fn` — the `_value` suffix was meant to flag "returns JSON, not Self", but every field on ConfigurableMeta is a fn pointer that operates on serde_json::Value at the boundary, so the suffix wasn't carrying information. Renames `load_value_fn` to `load_fn` in the ConfigurableMeta struct, the derive's `__configurable_load_value_wry` shim to `__configurable_load_wry`, and the one read site in dev_configs.rs. The typed concrete method `EvolutionLimits::load(app) -> Self` was already named symmetrically with `set(app, Value)`; this aligns the type-erased registry shim with the same convention. --- apps/native/src-tauri/configurable-derive/src/codegen.rs | 4 ++-- apps/native/src-tauri/configurable/src/lib.rs | 2 +- apps/native/src-tauri/src/commands/dev_configs.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/native/src-tauri/configurable-derive/src/codegen.rs b/apps/native/src-tauri/configurable-derive/src/codegen.rs index a9769a66f..06c001e6d 100644 --- a/apps/native/src-tauri/configurable-derive/src/codegen.rs +++ b/apps/native/src-tauri/configurable-derive/src/codegen.rs @@ -54,7 +54,7 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { } #[doc(hidden)] - pub fn __configurable_load_value_wry( + pub fn __configurable_load_wry( app: &::tauri::AppHandle<::tauri::Wry>, ) -> ::std::result::Result<::serde_json::Value, ::anyhow::Error> { let __current = Self::load(app)?; @@ -76,7 +76,7 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result { ::configurable::ConfigurableMeta { name: #name_str, schema_fn: #name::__configurable_schema_wry, - load_value_fn: #name::__configurable_load_value_wry, + load_fn: #name::__configurable_load_wry, set_fn: #name::__configurable_set_wry, } } diff --git a/apps/native/src-tauri/configurable/src/lib.rs b/apps/native/src-tauri/configurable/src/lib.rs index 47282d7b0..ba4984019 100644 --- a/apps/native/src-tauri/configurable/src/lib.rs +++ b/apps/native/src-tauri/configurable/src/lib.rs @@ -151,7 +151,7 @@ pub struct ConfigurableMeta { pub schema_fn: fn() -> ConfigurableSchema, /// Loads the current state as a JSON object so the dev-settings command /// can join it with the static schema by field key. - pub load_value_fn: fn(&tauri::AppHandle) -> anyhow::Result, + pub load_fn: fn(&tauri::AppHandle) -> anyhow::Result, /// Replaces the managed observable's value with a deserialized /// whole-struct payload. Serde validates every field in one pass. pub set_fn: fn(&tauri::AppHandle, serde_json::Value) -> anyhow::Result<()>, diff --git a/apps/native/src-tauri/src/commands/dev_configs.rs b/apps/native/src-tauri/src/commands/dev_configs.rs index f2e7d5b87..2224315d1 100644 --- a/apps/native/src-tauri/src/commands/dev_configs.rs +++ b/apps/native/src-tauri/src/commands/dev_configs.rs @@ -27,7 +27,7 @@ fn snapshot_for( app: &AppHandle, ) -> anyhow::Result { let schema = (meta.schema_fn)(); - let current = (meta.load_value_fn)(app)?; + let current = (meta.load_fn)(app)?; let values = schema .fields .iter() From 0917453880351d4f0b6888c0759313a038a2cd76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Thu, 11 Jun 2026 01:58:07 +0200 Subject: [PATCH 12/13] configurable: split dev_configs IPC into schemas and values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev_configs_list (returning the joined ConfigurableSnapshot) bundled two things that have different cacheability: the static schema (same value every call) and the dynamic current values (change on every set). Splitting the IPC into two commands mirrors the type-level split B3 already established and lets the frontend cache schemas independently of value refreshes. Backend: - dev_configs_schemas -> Vec: static metadata only. - dev_configs_values -> HashMap: current state of every Configurable, keyed by struct name. Each value is the full struct as a JSON object — same shape load_fn already returns. - Deleted dev_configs_list and the snapshot-construction helper. - main.rs registers the two new commands in place of the old one. Type cleanup in the configurable crate: - Deleted ConfigurableSnapshot (frontend joins schemas + values itself). - Deleted ConfigFieldValue (values come back as struct-shaped JSON objects, so a separate {key, current} wrapper carries no information). - ConfigFieldSchema, ConfigurableSchema, FieldType, EnumVariant survive and are the only configurable-domain types on the IPC boundary. - specta_gen_ts.rs updated; ipc/types.ts regenerated. Frontend: - tauriAPI.devConfigs gains schemas() and values(); list() removed. - AutoTuningSection and TuningTab keep two pieces of state: schemas (ConfigurableSchema[]) and values (Record). They Promise.all both on mount; on commit they overlay the new field on the struct's existing values, POST the whole struct, and update only the values map. Schemas don't need refreshing. - readStructValues helper extracts the struct's value map (handling the "not yet loaded" case as an empty object). - Storybook mocks updated to expose schemas() + values() + set() instead of list() + set(). Net concept count in the configurable crate: down from 4 wrapper types (ConfigFieldSchema, ConfigFieldValue, ConfigurableSchema, ConfigurableSnapshot) to 2 (ConfigFieldSchema, ConfigurableSchema). --- apps/native/src-tauri/configurable/src/lib.rs | 27 +---- .../src-tauri/examples/specta_gen_ts.rs | 2 - .../src-tauri/src/commands/dev_configs.rs | 73 ++++++------ apps/native/src-tauri/src/main.rs | 3 +- .../settings/auto-config-field.stories.tsx | 3 +- .../settings/auto-tuning-section.stories.tsx | 68 +++++------ .../widget/settings/auto-tuning-section.tsx | 106 +++++++++--------- .../components/widget/settings/tuning-tab.tsx | 101 +++++++++-------- apps/native/src/ipc/api.ts | 16 ++- apps/native/src/ipc/types.ts | 23 +--- 10 files changed, 208 insertions(+), 214 deletions(-) diff --git a/apps/native/src-tauri/configurable/src/lib.rs b/apps/native/src-tauri/configurable/src/lib.rs index ba4984019..0e95182ee 100644 --- a/apps/native/src-tauri/configurable/src/lib.rs +++ b/apps/native/src-tauri/configurable/src/lib.rs @@ -81,8 +81,7 @@ pub struct EnumVariant { /// Static description of one Configurable field. /// /// Produced by the derive macro with no runtime context; the same value every -/// call. Pair with a [`ConfigFieldValue`] (matched by `key`) to get the -/// current store-backed value for rendering. +/// call. Joined with the current store-backed value by `key` at render time. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct ConfigFieldSchema { @@ -99,22 +98,13 @@ pub struct ConfigFieldSchema { pub default: serde_json::Value, } -/// Current value for one Configurable field, keyed identically to its -/// [`ConfigFieldSchema`]. Sent alongside the schema in the dev-settings IPC -/// response so the frontend can render initial input state. -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct ConfigFieldValue { - pub key: String, - pub current: serde_json::Value, -} - /// One section in the auto-rendered settings panel — corresponds to one -/// `#[derive(Configurable)]` struct. Static metadata only; no current values. +/// `#[derive(Configurable)]` struct. Static metadata only; current values are +/// fetched separately and joined by struct name + field key on the frontend. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct ConfigurableSchema { - /// Unique stable identifier (struct's Rust name). Used by `set_field` to + /// Unique stable identifier (struct's Rust name). Used by the setter to /// dispatch to the right registered configurable. pub name: String, /// Title shown above the section in the UI. @@ -125,15 +115,6 @@ pub struct ConfigurableSchema { pub fields: Vec, } -/// Joined-at-the-boundary response for `dev_configs_list`: the static schema -/// plus the current values loaded from the managed observable. -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct ConfigurableSnapshot { - pub schema: ConfigurableSchema, - pub values: Vec, -} - // ============================================================================= // Compile-time registry — populated by the derive via `inventory::submit!` // ============================================================================= diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index 8502d1671..b03ce286d 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -86,9 +86,7 @@ fn main() { .register::() .register::() .register::() - .register::() .register::() - .register::() .register::() .register::() .register::() diff --git a/apps/native/src-tauri/src/commands/dev_configs.rs b/apps/native/src-tauri/src/commands/dev_configs.rs index 2224315d1..5bf54b74f 100644 --- a/apps/native/src-tauri/src/commands/dev_configs.rs +++ b/apps/native/src-tauri/src/commands/dev_configs.rs @@ -1,19 +1,24 @@ //! Tauri commands that walk the compile-time configurable registry. //! -//! Frontend calls `dev_configs_list` to enumerate every `#[derive(Configurable)]` -//! struct in the codebase. Each entry returns as a [`ConfigurableSnapshot`]: -//! the static schema (labels, types, ranges, defaults — same value every call) -//! paired with the current values pulled from the managed observable. Edits go -//! back through `dev_config_set`, which dispatches by struct name to the -//! registered shim emitted by the derive and replaces the whole struct in one -//! Serde-validated write. +//! The dev-settings UI fetches metadata in two halves: +//! +//! - [`dev_configs_schemas`] returns the static `ConfigurableSchema` for every +//! `#[derive(Configurable)]` struct. Same value every call — cacheable. +//! - [`dev_configs_values`] returns the current store-backed value of each +//! configurable as a JSON object, keyed by struct name. Refresh this after +//! `dev_config_set` instead of re-fetching the schemas. +//! +//! Edits go back through `dev_config_set`, which dispatches by struct name to +//! the registered shim emitted by the derive and replaces the whole struct in +//! one Serde-validated write. //! //! The registry itself lives in `inventory`: the derive macro pushes one //! `ConfigurableMeta` per struct at compile time, so these commands never //! see a runtime registry handle. use super::helpers::capture_err; -use configurable::{inventory, ConfigFieldValue, ConfigurableMeta, ConfigurableSnapshot}; +use configurable::{inventory, ConfigurableMeta, ConfigurableSchema}; +use std::collections::HashMap; use tauri::AppHandle; fn find_meta(struct_name: &str) -> Option<&'static ConfigurableMeta> { @@ -22,34 +27,26 @@ fn find_meta(struct_name: &str) -> Option<&'static ConfigurableMeta> { .find(|meta| meta.name == struct_name) } -fn snapshot_for( - meta: &ConfigurableMeta, - app: &AppHandle, -) -> anyhow::Result { - let schema = (meta.schema_fn)(); - let current = (meta.load_fn)(app)?; - let values = schema - .fields - .iter() - .map(|field| ConfigFieldValue { - key: field.key.clone(), - current: current - .get(&field.key) - .cloned() - .unwrap_or(serde_json::Value::Null), - }) - .collect(); - Ok(ConfigurableSnapshot { schema, values }) +/// Enumerate the static schema for every registered Configurable struct. +#[tauri::command] +pub async fn dev_configs_schemas(_app: AppHandle) -> Result, String> { + Ok(inventory::iter::() + .into_iter() + .map(|meta| (meta.schema_fn)()) + .collect()) } -/// Enumerate every registered Configurable struct with its current values. +/// Fetch the current store-backed value of every registered Configurable +/// struct. Keyed by struct name (matches `ConfigurableSchema::name`). #[tauri::command] -pub async fn dev_configs_list(app: AppHandle) -> Result, String> { +pub async fn dev_configs_values( + app: AppHandle, +) -> Result, String> { inventory::iter::() .into_iter() - .map(|meta| snapshot_for(meta, &app)) - .collect::>>() - .map_err(|e| capture_err("dev_configs_list", e)) + .map(|meta| (meta.load_fn)(&app).map(|v| (meta.name.to_string(), v))) + .collect::>>() + .map_err(|e| capture_err("dev_configs_values", e)) } /// Replace one Configurable struct with a new whole-struct payload. @@ -76,10 +73,17 @@ mod tests { #[test] fn command_signatures_match_frontend_contract() { - fn assert_list_command(_f: F) + fn assert_schemas_command(_f: F) + where + F: Fn(AppHandle) -> Fut, + Fut: Future, String>>, + { + } + + fn assert_values_command(_f: F) where F: Fn(AppHandle) -> Fut, - Fut: Future, String>>, + Fut: Future, String>>, { } @@ -90,7 +94,8 @@ mod tests { { } - assert_list_command(dev_configs_list); + assert_schemas_command(dev_configs_schemas); + assert_values_command(dev_configs_values); assert_set_command(dev_config_set); } diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 20a0b05f9..595b73273 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -531,7 +531,8 @@ fn run_gui_mode( commands::settings_io::settings_export, commands::settings_io::settings_import, // Configurable registry (auto-UI for dev settings) - commands::dev_configs::dev_configs_list, + commands::dev_configs::dev_configs_schemas, + commands::dev_configs::dev_configs_values, commands::dev_configs::dev_config_set, // Model cache commands::ui_prefs::get_cached_models, diff --git a/apps/native/src/components/widget/settings/auto-config-field.stories.tsx b/apps/native/src/components/widget/settings/auto-config-field.stories.tsx index 0bdecba92..deb49444f 100644 --- a/apps/native/src/components/widget/settings/auto-config-field.stories.tsx +++ b/apps/native/src/components/widget/settings/auto-config-field.stories.tsx @@ -55,7 +55,8 @@ const fields: Array<{ schema: ConfigFieldSchema; current: JsonValue }> = [ function installDevConfigMock() { tauriAPI.devConfigs = { - list: async () => [], + schemas: async () => [], + values: async () => ({}), set: async () => undefined, }; } diff --git a/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx b/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx index 0d688f541..d94b34f5b 100644 --- a/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx +++ b/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx @@ -3,47 +3,51 @@ import preview from "#storybook/preview"; import { AutoTuningSection } from "@/components/widget/settings/auto-tuning-section"; import { tauriAPI } from "@/ipc/api"; -import type { ConfigurableSnapshot } from "@/ipc/types"; +import type { ConfigurableSchema, JsonValue } from "@/ipc/types"; import { waitFor, within } from "storybook/test"; -const snapshots: ConfigurableSnapshot[] = [ +const schemas: ConfigurableSchema[] = [ { - schema: { - name: "EvolutionLimits", - displayName: "Evolution", - description: "How long the agent will try before giving up.", - fields: [ - { - key: "maxTokenBudget", - label: "Max token budget", - help: "Provider-reported tokens before the agent stops. Lower is faster but may not finish complex changes.", - ty: { kind: "number", min: 1000, max: 1000000, step: 1000 }, - default: 50000, - }, - { - key: "maxBuildAttempts", - label: "Max build attempts", - help: "Failed builds before giving up on a run.", - ty: { kind: "number", min: 1, max: 20, step: 1 }, - default: 5, - }, - ], - }, - values: [ - { key: "maxTokenBudget", current: 50000 }, - { key: "maxBuildAttempts", current: 5 }, + name: "EvolutionLimits", + displayName: "Evolution", + description: "How long the agent will try before giving up.", + fields: [ + { + key: "maxTokenBudget", + label: "Max token budget", + help: "Provider-reported tokens before the agent stops. Lower is faster but may not finish complex changes.", + ty: { kind: "number", min: 1000, max: 1000000, step: 1000 }, + default: 50000, + }, + { + key: "maxBuildAttempts", + label: "Max build attempts", + help: "Failed builds before giving up on a run.", + ty: { kind: "number", min: 1, max: 20, step: 1 }, + default: 5, + }, ], }, ]; -function installDevConfigMock(next: ConfigurableSnapshot[] | Error) { +const values: Record = { + EvolutionLimits: { + maxTokenBudget: 50000, + maxBuildAttempts: 5, + }, +}; + +function installDevConfigMock( + schemasOrError: ConfigurableSchema[] | Error, +) { tauriAPI.devConfigs = { - list: async () => { - if (next instanceof Error) { - throw next; + schemas: async () => { + if (schemasOrError instanceof Error) { + throw schemasOrError; } - return next; + return schemasOrError; }, + values: async () => values, set: async () => undefined, }; } @@ -67,7 +71,7 @@ export default meta; export const EvolutionSettings = meta.story({ decorators: [ (Story) => { - installDevConfigMock(snapshots); + installDevConfigMock(schemas); return ; }, ], diff --git a/apps/native/src/components/widget/settings/auto-tuning-section.tsx b/apps/native/src/components/widget/settings/auto-tuning-section.tsx index 55ece084d..9b71db142 100644 --- a/apps/native/src/components/widget/settings/auto-tuning-section.tsx +++ b/apps/native/src/components/widget/settings/auto-tuning-section.tsx @@ -1,5 +1,5 @@ import { tauriAPI } from "@/ipc/api"; -import type { ConfigurableSnapshot, JsonValue } from "@/ipc/types"; +import type { ConfigurableSchema, JsonValue } from "@/ipc/types"; import { SlidersHorizontal } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { AutoConfigField } from "@/components/widget/settings/auto-config-field"; @@ -11,15 +11,20 @@ import { AutoConfigField } from "@/components/widget/settings/auto-config-field" * sub-section here automatically, no frontend changes needed. */ export function AutoTuningSection() { - const [snapshots, setSnapshots] = useState([]); + const [schemas, setSchemas] = useState([]); + const [values, setValues] = useState>({}); const [loadError, setLoadError] = useState(null); const refresh = useCallback(async () => { try { - const next = await tauriAPI.devConfigs.list(); + const [nextSchemas, nextValues] = await Promise.all([ + tauriAPI.devConfigs.schemas(), + tauriAPI.devConfigs.values(), + ]); // In environments where the Tauri command isn't registered (Storybook, - // tests), invoke can resolve with null instead of an array. - setSnapshots(Array.isArray(next) ? next : []); + // tests), invoke can resolve with null. + setSchemas(Array.isArray(nextSchemas) ? nextSchemas : []); + setValues(nextValues ?? {}); setLoadError(null); } catch (err) { setLoadError(err instanceof Error ? err.message : String(err)); @@ -47,23 +52,15 @@ export function AutoTuningSection() { )}
- {snapshots.map((snapshot, index) => ( - 1} + {schemas.map((schema) => ( + 1} onCommit={async (key, value) => { - const next = await commitField(snapshot, key, value); - // Mutate the local snapshot in place so the next field commit - // reads back the freshly persisted value. React doesn't re-render - // anything because AutoConfigField owns its own field-level - // optimistic state; the snapshot is only consulted on the next - // commit's payload construction. - setSnapshots((prev) => { - const copy = prev.slice(); - copy[index] = next; - return copy; - }); + const next = await commitField(values, schema.name, key, value); + setValues(next); }} /> ))} @@ -72,12 +69,14 @@ export function AutoTuningSection() { ); } -function SnapshotSection({ - snapshot, +function SchemaSection({ + schema, + structValues, showHeader, onCommit, }: { - snapshot: ConfigurableSnapshot; + schema: ConfigurableSchema; + structValues: Record; showHeader: boolean; onCommit: (key: string, value: unknown) => Promise; }) { @@ -85,50 +84,51 @@ function SnapshotSection({
{showHeader && (
-

{snapshot.schema.displayName}

- {snapshot.schema.description && ( -

{snapshot.schema.description}

+

{schema.displayName}

+ {schema.description && ( +

{schema.description}

)}
)}
- {snapshot.schema.fields.map((field) => { - const current = snapshot.values.find((v) => v.key === field.key)?.current ?? null; - return ( - - ); - })} + {schema.fields.map((field) => ( + + ))}
); } +function readStructValues( + values: Record, + structName: string, +): Record { + const v = values[structName]; + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; +} + /** - * Builds the whole-struct payload by overlaying the new value on the snapshot's - * existing values, POSTs it via `devConfigs.set`, and returns the next snapshot - * so the parent can keep its state in sync. + * Builds the whole-struct payload by overlaying the new value on the struct's + * existing values, POSTs it via `devConfigs.set`, and returns the next values + * map so the parent can keep its state in sync. * * The backend (Serde) clobbers any concurrent backend-side edits to other * fields — that's fine for a single-user dev settings panel. */ async function commitField( - snapshot: ConfigurableSnapshot, + values: Record, + structName: string, key: string, value: unknown, -): Promise { - const nextValues = snapshot.values.map((v) => - v.key === key ? { ...v, current: value as JsonValue } : v, - ); - const payload: Record = {}; - for (const v of nextValues) { - payload[v.key] = v.current; - } - await tauriAPI.devConfigs.set(snapshot.schema.name, payload); - return { ...snapshot, values: nextValues }; +): Promise> { + const currentStruct = readStructValues(values, structName); + const nextStruct = { ...currentStruct, [key]: value as JsonValue }; + await tauriAPI.devConfigs.set(structName, nextStruct); + return { ...values, [structName]: nextStruct }; } diff --git a/apps/native/src/components/widget/settings/tuning-tab.tsx b/apps/native/src/components/widget/settings/tuning-tab.tsx index c3661b8d0..06be6c0be 100644 --- a/apps/native/src/components/widget/settings/tuning-tab.tsx +++ b/apps/native/src/components/widget/settings/tuning-tab.tsx @@ -1,5 +1,5 @@ import { tauriAPI } from "@/ipc/api"; -import type { ConfigurableSnapshot, JsonValue } from "@/ipc/types"; +import type { ConfigurableSchema, JsonValue } from "@/ipc/types"; import { SlidersHorizontal } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { AutoConfigField } from "@/components/widget/settings/auto-config-field"; @@ -15,15 +15,20 @@ import { BackupRestoreSection } from "@/components/widget/settings/backup-restor * Also includes Backup & Restore for settings export/import. */ export function TuningTab() { - const [snapshots, setSnapshots] = useState([]); + const [schemas, setSchemas] = useState([]); + const [values, setValues] = useState>({}); const [loadError, setLoadError] = useState(null); const refresh = useCallback(async () => { try { - const next = await tauriAPI.devConfigs.list(); + const [nextSchemas, nextValues] = await Promise.all([ + tauriAPI.devConfigs.schemas(), + tauriAPI.devConfigs.values(), + ]); // In environments where the Tauri command isn't registered (Storybook, - // tests), invoke can resolve with null instead of an array. - setSnapshots(Array.isArray(next) ? next : []); + // tests), invoke can resolve with null. + setSchemas(Array.isArray(nextSchemas) ? nextSchemas : []); + setValues(nextValues ?? {}); setLoadError(null); } catch (err) { setLoadError(err instanceof Error ? err.message : String(err)); @@ -59,18 +64,15 @@ export function TuningTab() { )}
- {snapshots.map((snapshot, index) => ( - 1} + {schemas.map((schema) => ( + 1} onCommit={async (key, value) => { - const next = await commitField(snapshot, key, value); - setSnapshots((prev) => { - const copy = prev.slice(); - copy[index] = next; - return copy; - }); + const next = await commitField(values, schema.name, key, value); + setValues(next); }} /> ))} @@ -83,12 +85,14 @@ export function TuningTab() { ); } -function SnapshotSection({ - snapshot, +function SchemaSection({ + schema, + structValues, showHeader, onCommit, }: { - snapshot: ConfigurableSnapshot; + schema: ConfigurableSchema; + structValues: Record; showHeader: boolean; onCommit: (key: string, value: unknown) => Promise; }) { @@ -96,47 +100,48 @@ function SnapshotSection({
{showHeader && (
-

{snapshot.schema.displayName}

- {snapshot.schema.description && ( -

{snapshot.schema.description}

+

{schema.displayName}

+ {schema.description && ( +

{schema.description}

)}
)}
- {snapshot.schema.fields.map((field) => { - const current = snapshot.values.find((v) => v.key === field.key)?.current ?? null; - return ( - - ); - })} + {schema.fields.map((field) => ( + + ))}
); } +function readStructValues( + values: Record, + structName: string, +): Record { + const v = values[structName]; + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; +} + /** - * Builds the whole-struct payload by overlaying the new value on the snapshot's - * existing values, POSTs it via `devConfigs.set`, and returns the next snapshot - * so the parent can keep its state in sync. + * Builds the whole-struct payload by overlaying the new value on the struct's + * existing values, POSTs it via `devConfigs.set`, and returns the next values + * map so the parent can keep its state in sync. */ async function commitField( - snapshot: ConfigurableSnapshot, + values: Record, + structName: string, key: string, value: unknown, -): Promise { - const nextValues = snapshot.values.map((v) => - v.key === key ? { ...v, current: value as JsonValue } : v, - ); - const payload: Record = {}; - for (const v of nextValues) { - payload[v.key] = v.current; - } - await tauriAPI.devConfigs.set(snapshot.schema.name, payload); - return { ...snapshot, values: nextValues }; +): Promise> { + const currentStruct = readStructValues(values, structName); + const nextStruct = { ...currentStruct, [key]: value as JsonValue }; + await tauriAPI.devConfigs.set(structName, nextStruct); + return { ...values, [structName]: nextStruct }; } diff --git a/apps/native/src/ipc/api.ts b/apps/native/src/ipc/api.ts index 77ac8baca..0430c2a8e 100644 --- a/apps/native/src/ipc/api.ts +++ b/apps/native/src/ipc/api.ts @@ -11,7 +11,8 @@ import type { CommitResult, Config as DarwinConfig, ConfigEditApplyResult, - ConfigurableSnapshot, + ConfigurableSchema, + JsonValue, EvolveCancelResult, EvolutionResult, EvolveState, @@ -142,7 +143,18 @@ export const tauriAPI = { import: () => invoke("settings_import"), }, devConfigs: { - list: () => invoke("dev_configs_list"), + /** + * Returns the static schema for every registered Configurable struct. + * Same value every call — safe to cache. + */ + schemas: () => invoke("dev_configs_schemas"), + /** + * Returns the current store-backed value of every registered Configurable, + * keyed by struct name (matching `ConfigurableSchema.name`). Each value + * is the full struct as a JSON object. Refresh this after `set` instead + * of re-fetching schemas. + */ + values: () => invoke>("dev_configs_values"), /** * Replace a Configurable struct with a whole-struct payload. `value` must * be the full struct (every field), not a partial update — Serde validates diff --git a/apps/native/src/ipc/types.ts b/apps/native/src/ipc/types.ts index daa521ac2..5adbfe198 100644 --- a/apps/native/src/ipc/types.ts +++ b/apps/native/src/ipc/types.ts @@ -206,8 +206,7 @@ evolveState: EvolveState } * Static description of one Configurable field. * * Produced by the derive macro with no runtime context; the same value every - * call. Pair with a [`ConfigFieldValue`] (matched by `key`) to get the - * current store-backed value for rendering. + * call. Joined with the current store-backed value by `key` at render time. */ export type ConfigFieldSchema = { /** @@ -231,20 +230,14 @@ ty: FieldType; */ default: JsonValue } -/** - * Current value for one Configurable field, keyed identically to its - * [`ConfigFieldSchema`]. Sent alongside the schema in the dev-settings IPC - * response so the frontend can render initial input state. - */ -export type ConfigFieldValue = { key: string; current: JsonValue } - /** * One section in the auto-rendered settings panel — corresponds to one - * `#[derive(Configurable)]` struct. Static metadata only; no current values. + * `#[derive(Configurable)]` struct. Static metadata only; current values are + * fetched separately and joined by struct name + field key on the frontend. */ export type ConfigurableSchema = { /** - * Unique stable identifier (struct's Rust name). Used by `set_field` to + * Unique stable identifier (struct's Rust name). Used by the setter to * dispatch to the right registered configurable. */ name: string; @@ -257,12 +250,6 @@ displayName: string; */ description?: string | null; fields: ConfigFieldSchema[] } -/** - * Joined-at-the-boundary response for `dev_configs_list`: the static schema - * plus the current values loaded from the managed observable. - */ -export type ConfigurableSnapshot = { schema: ConfigurableSchema; values: ConfigFieldValue[] } - /** * Payload for `darwin:apply:data`. */ @@ -295,7 +282,7 @@ error: string | null; /** * Whether the failed operation completed before changing system state. */ -system_untouched: boolean | null; +system_untouched: boolean | null; /** * Path to the captured rebuild log, when available. */ From 7de416962258d38063b025ada99c271cb5fe285f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Thu, 11 Jun 2026 02:11:10 +0200 Subject: [PATCH 13/13] evolve: drop silent EvolutionLimits default fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses B6 from docs/2026-06-03-pr-review-followups.md. The generate_evolution loop read configurable limits with: EvolutionLimits::load(app) .inspect_err(|e| warn!("EvolutionLimits::load failed ({e}); using defaults")) .unwrap_or_default() This was dead-error-handling AND a silent fallback. The derive-generated `load` never returns Err today (it either reads the managed observable or synthesizes field defaults), but the `.unwrap_or_default()` would mask a real misconfiguration if a future refactor missed `app.manage(load_observable(handle)?)` in main.rs's setup hook. The agent would then run with EvolutionLimits::default() instead of panicking visibly. Replaces with a direct `app.state::>() .read_sync().clone()`. `app.state` panics naturally on missing managed state, which is the correct behavior for a startup-time misconfig. Investigation notes: - generate_evolution has exactly one caller (evolve::lifecycle), which is reached only after Tauri setup completes. The observable is always managed by that point in production. - No tests reach generate_evolution; no Tauri mock infrastructure anywhere in the codebase. The fallback was purely defensive against a hypothetical setup bug. - The derive's `load` keeps its existing contract (Result with the silent-fallback path) — not changing the derive surface as part of B6 since the call-site fix is sufficient. Future Configurables that want loud-on-missing should call `app.state` directly the same way. Also refreshes the stale comment on `impl Default for EvolutionLimits`: it was claiming Default catches "load failures during onboarding," but ConfiguredRepoScopedJson handles the "no config_dir yet" case at the persistence layer (returns None, never errors). Default is actually load-bearing for two reasons: `preferences::load_or_default` falls back to it when the JSON file is absent, and `#[serde(default)]` uses it when a whole-struct payload arrives with missing fields. Includes minor treefmt fixups in observable/{mod,persistence}.rs and commands/{dev_configs,settings_io}.rs that the linter applied during this commit's pre-commit pass — import sort, trailing-blank-line trim, and an assert_eq! line break. No behavior change. --- apps/native/src-tauri/src/commands/dev_configs.rs | 2 +- apps/native/src-tauri/src/commands/settings_io.rs | 2 +- apps/native/src-tauri/src/evolve/config.rs | 10 +++++----- apps/native/src-tauri/src/evolve/mod.rs | 14 +++++++++----- apps/native/src-tauri/src/observable/mod.rs | 6 +++++- .../native/src-tauri/src/observable/persistence.rs | 8 +++++--- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/native/src-tauri/src/commands/dev_configs.rs b/apps/native/src-tauri/src/commands/dev_configs.rs index 5bf54b74f..ec79d2d62 100644 --- a/apps/native/src-tauri/src/commands/dev_configs.rs +++ b/apps/native/src-tauri/src/commands/dev_configs.rs @@ -17,7 +17,7 @@ //! see a runtime registry handle. use super::helpers::capture_err; -use configurable::{inventory, ConfigurableMeta, ConfigurableSchema}; +use configurable::{ConfigurableMeta, ConfigurableSchema, inventory}; use std::collections::HashMap; use tauri::AppHandle; diff --git a/apps/native/src-tauri/src/commands/settings_io.rs b/apps/native/src-tauri/src/commands/settings_io.rs index 4d653e739..e59ca271e 100644 --- a/apps/native/src-tauri/src/commands/settings_io.rs +++ b/apps/native/src-tauri/src/commands/settings_io.rs @@ -12,9 +12,9 @@ use super::helpers::capture_err; use crate::evolve::config::EvolutionLimits; +use crate::observable::Observable; use crate::shared_types::{ExportResult, ImportResult}; use crate::state::preferences::GlobalPreferences; -use crate::observable::Observable; use serde_json::{Map, Value}; use std::borrow::Borrow; use tauri::{AppHandle, Manager}; diff --git a/apps/native/src-tauri/src/evolve/config.rs b/apps/native/src-tauri/src/evolve/config.rs index e4618683a..9ae463ca0 100644 --- a/apps/native/src-tauri/src/evolve/config.rs +++ b/apps/native/src-tauri/src/evolve/config.rs @@ -56,10 +56,11 @@ pub struct EvolutionLimits { pub max_output_tokens: usize, } -// Matches the `#[config(default = ...)]` values above. Used as the fallback -// in evolve::mod when EvolutionLimits::load fails (e.g. config_dir not yet -// set during onboarding); deriving `Default` would produce zeros, which -// would be wrong here. +// Matches the `#[config(default = ...)]` values above. Reached during +// startup when the repo's settings.json is absent or unreadable (see +// `preferences::load_or_default`), and on the dev-settings whole-struct +// `set` path when an incoming JSON payload is missing fields. Deriving +// `Default` would produce zeros, which would be wrong here. impl Default for EvolutionLimits { fn default() -> Self { Self { @@ -78,7 +79,6 @@ pub fn load_observable(app: &AppHandle) -> Result( EvolveEvent::info(start_time, None, &format!("Target host: {}", host_attr)), ); - // Read configurable limits from store (hot-reloaded on every run). + // Read configurable limits from the managed observable (hot-reloaded on + // every run). `app.state` panics if the observable isn't managed; that is + // intentional — it surfaces a startup misconfiguration immediately + // instead of silently swapping in field defaults. let config::EvolutionLimits { mut max_build_attempts, .. - } = config::EvolutionLimits::load(app) - .inspect_err(|e| warn!("EvolutionLimits::load failed ({e}); using defaults")) - .unwrap_or_default(); + } = app + .state::>() + .read_sync() + .clone(); let max_token_budget = store::get_max_token_budget(app).unwrap_or(store::DEFAULT_MAX_TOKEN_BUDGET); let max_build_attempts_increment = max_build_attempts.max(1); diff --git a/apps/native/src-tauri/src/observable/mod.rs b/apps/native/src-tauri/src/observable/mod.rs index bd94458f8..939408ad3 100644 --- a/apps/native/src-tauri/src/observable/mod.rs +++ b/apps/native/src-tauri/src/observable/mod.rs @@ -178,7 +178,11 @@ mod tests { } let events = captured.lock().unwrap(); - assert_eq!(events.len(), 1, "subscriber fires once per write guard drop"); + assert_eq!( + events.len(), + 1, + "subscriber fires once per write guard drop" + ); assert_eq!( events[0], DemoState { diff --git a/apps/native/src-tauri/src/observable/persistence.rs b/apps/native/src-tauri/src/observable/persistence.rs index a1c65232c..af61baff5 100644 --- a/apps/native/src-tauri/src/observable/persistence.rs +++ b/apps/native/src-tauri/src/observable/persistence.rs @@ -53,7 +53,6 @@ impl AppDataJson { .context("failed to resolve app data directory")?; Ok(Self::new(app_data.join(file_name))) } - } impl Persistence for AppDataJson { @@ -92,7 +91,6 @@ impl RepoScopedJson { crate::storage::configurable_scope::repo_store_path(app)?, )) } - } impl Persistence for RepoScopedJson { @@ -159,7 +157,11 @@ mod tests { fn json_persistence_round_trips_by_scope_path() { let temp = tempfile::tempdir().expect("temp dir"); let app_data_path = temp.path().join("app-data").join("settings.json"); - let repo_path = temp.path().join("repo").join(".nixmac").join("settings.json"); + let repo_path = temp + .path() + .join("repo") + .join(".nixmac") + .join("settings.json"); let app_data = AppDataJson::new(&app_data_path); let repo_scoped = RepoScopedJson::new(&repo_path);