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 4b5359d4c..06c001e6d 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, @@ -45,23 +45,39 @@ 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( + pub fn __configurable_schema_wry() -> ::configurable::ConfigurableSchema { + Self::schema() + } + + #[doc(hidden)] + pub fn __configurable_load_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)] - 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) + } + } + + // 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, + load_fn: #name::__configurable_load_wry, + set_fn: #name::__configurable_set_wry, } } }) @@ -78,11 +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. /// -/// 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. +/// +/// `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, @@ -97,59 +118,55 @@ 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 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)* }) } - 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( + pub fn set( app: &::tauri::AppHandle, - 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); - match key { - #(#set_field_arms)* - other => ::std::result::Result::Err(::anyhow::anyhow!( - "Configurable {}: unknown field `{}`", - #name_str, other, - )), - } + let __observable = + ::tauri::Manager::try_state::>(app) + .ok_or_else(|| ::anyhow::anyhow!( + "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(); + *__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 4ab629d74..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() @@ -117,26 +113,13 @@ 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! { - #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/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..0e95182ee 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}; @@ -40,6 +42,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 // ============================================================================= @@ -72,10 +78,13 @@ 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. Joined with the current store-backed value by `key` at render time. #[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. @@ -87,16 +96,15 @@ 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. - 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; 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. @@ -104,5 +112,30 @@ 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, } + +// ============================================================================= +// 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 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_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<()>, +} + +inventory::collect!(ConfigurableMeta); diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index 65d0bcfd6..b03ce286d 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -86,7 +86,7 @@ fn main() { .register::() .register::() .register::() - .register::() + .register::() .register::() .register::() .register::() 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..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,20 +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 -- `queue_summarizer.rs` — Background service draining the queued_summaries table +- `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 diff --git a/apps/native/src-tauri/src/commands/dev_configs.rs b/apps/native/src-tauri/src/commands/dev_configs.rs index 26c9fc673..ec79d2d62 100644 --- a/apps/native/src-tauri/src/commands/dev_configs.rs +++ b/apps/native/src-tauri/src/commands/dev_configs.rs @@ -1,35 +1,69 @@ -//! 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. +//! 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 crate::state::slice::SliceRegistry; -use configurable::ConfigurableSchema; -use tauri::{AppHandle, Manager}; +use configurable::{ConfigurableMeta, ConfigurableSchema, inventory}; +use std::collections::HashMap; +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. +/// Enumerate the static schema for every registered Configurable struct. #[tauri::command] -pub async fn dev_configs_list(app: AppHandle) -> Result, String> { - app.state::() - .schemas(&app) - .map_err(|e| capture_err("dev_configs_list", e)) +pub async fn dev_configs_schemas(_app: AppHandle) -> Result, String> { + Ok(inventory::iter::() + .into_iter() + .map(|meta| (meta.schema_fn)()) + .collect()) +} + +/// 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_values( + app: AppHandle, +) -> Result, String> { + inventory::iter::() + .into_iter() + .map(|meta| (meta.load_fn)(&app).map(|v| (meta.name.to_string(), v))) + .collect::>>() + .map_err(|e| capture_err("dev_configs_values", e)) } -/// Set one field on one Configurable struct, dispatched by struct name. +/// Replace one Configurable struct with a new whole-struct payload. +/// +/// `value` must deserialize into the target Configurable type as a whole; +/// Serde validates every field in one pass. Frontends that update a single +/// field should send the full struct with the other fields' current values +/// preserved. #[tauri::command] pub async fn dev_config_set( app: AppHandle, struct_name: String, - 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_fn)(&app, value).map_err(|e| capture_err("dev_config_set", e)) } #[cfg(test)] @@ -39,21 +73,38 @@ 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>>, + { + } + 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>, { } - assert_list_command(dev_configs_list); + assert_schemas_command(dev_configs_schemas); + assert_values_command(dev_configs_values); 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/commands/settings_io.rs b/apps/native/src-tauri/src/commands/settings_io.rs index b1eaccaaf..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::state::slice::Slice; use serde_json::{Map, Value}; use std::borrow::Borrow; use tauri::{AppHandle, Manager}; @@ -69,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, @@ -79,7 +79,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, @@ -159,17 +159,17 @@ 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; } - 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/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/evolve/config.rs b/apps/native/src-tauri/src/evolve/config.rs index 83e986ca2..9ae463ca0 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 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. 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; @@ -14,10 +15,8 @@ use specta::Type; use std::sync::Arc; use tauri::{AppHandle, Runtime}; +use crate::observable::{ConfiguredRepoScopedJson, Observable, Persistence}; use crate::state::preferences; -use crate::state::slice::{ - ConfiguredRepoScopedJson, Persistence, RegisteredSliceConfig, Slice, SliceRegistry, -}; pub const EVOLUTION_LIMITS_CHANGED_EVENT: &str = "evolution_limits_changed"; @@ -57,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 { @@ -71,23 +71,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, - )) -} - -pub fn register_slice_config(registry: &SliceRegistry) -> Result<()> { - registry.register(RegisteredSliceConfig { - name: "EvolutionLimits", - schema_fn: EvolutionLimits::__configurable_schema_wry, - set_field_fn: EvolutionLimits::__configurable_set_field_wry, - }) + Ok(Observable::new(initial) + .emit_to(app, EVOLUTION_LIMITS_CHANGED_EVENT) + .persist_to(persistence)) } #[cfg(test)] diff --git a/apps/native/src-tauri/src/evolve/mod.rs b/apps/native/src-tauri/src/evolve/mod.rs index 6c81336dc..24a22c641 100644 --- a/apps/native/src-tauri/src/evolve/mod.rs +++ b/apps/native/src-tauri/src/evolve/mod.rs @@ -39,7 +39,7 @@ use std::fs::OpenOptions; use std::io::Write; use std::sync::Arc; use std::time::Duration; -use tauri::{AppHandle, Runtime}; +use tauri::{AppHandle, Manager, Runtime}; use tokio::time::sleep; use tools::{ToolResult, create_tools, execute_tool}; pub use types::{EvolutionProgress, EvolutionRunError}; @@ -825,13 +825,17 @@ pub async fn generate_evolution( 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/main.rs b/apps/native/src-tauri/src/main.rs index 119483f60..595b73273 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; @@ -337,12 +338,9 @@ 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(evolve::config::load_slice(app.handle())?); - evolve::config::register_slice_config( - &app.state::(), - )?; - app.manage(state::evolve_state::load_slice(app.handle())?); + app.manage(state::preferences::load_global_observable(app.handle())?); + app.manage(evolve::config::load_observable(app.handle())?); + app.manage(state::evolve_state::load_observable(app.handle())?); Ok(()) }) .build(context) @@ -456,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, @@ -536,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, @@ -590,10 +586,9 @@ 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(evolve::config::load_slice(handle)?); - evolve::config::register_slice_config(&app.state::())?; - app.manage(state::evolve_state::load_slice(handle)?); + app.manage(state::preferences::load_global_observable(handle)?); + app.manage(evolve::config::load_observable(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/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/mod.rs b/apps/native/src-tauri/src/observable/mod.rs new file mode 100644 index 000000000..939408ad3 --- /dev/null +++ b/apps/native/src-tauri/src/observable/mod.rs @@ -0,0 +1,259 @@ +//! 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. + +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}; + +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 super::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"); + } +} 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..af61baff5 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 { @@ -51,12 +53,6 @@ impl AppDataJson { .context("failed to resolve app data directory")?; 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 { @@ -95,12 +91,6 @@ impl RepoScopedJson { crate::storage::configurable_scope::repo_store_path(app)?, )) } - - /// Return the backing JSON path. - #[cfg(test)] - pub fn path(&self) -> &Path { - &self.path - } } impl Persistence for RepoScopedJson { @@ -157,3 +147,40 @@ 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/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)] diff --git a/apps/native/src-tauri/src/state/evolve_state.rs b/apps/native/src-tauri/src/state/evolve_state.rs index fd1382c61..b70fcd1a5 100644 --- a/apps/native/src-tauri/src/state/evolve_state.rs +++ b/apps/native/src-tauri/src/state/evolve_state.rs @@ -6,9 +6,9 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, Runtime}; use crate::evolve::session_chat_memory_store; +use crate::observable::{AppDataJson, Observable, Persistence}; use crate::shared_types::{EvolutionState, EvolveState, EvolveStep}; use crate::sqlite_types::Change; -use crate::state::slice::{AppDataJson, Persistence, Slice}; impl EvolveState { pub fn recompute_step(&mut self, is_built: bool, has_changes: bool) { @@ -52,7 +52,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 +62,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 +101,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. diff --git a/apps/native/src-tauri/src/state/mod.rs b/apps/native/src-tauri/src/state/mod.rs index 3fa7856a8..9462ca6c2 100644 --- a/apps/native/src-tauri/src/state/mod.rs +++ b/apps/native/src-tauri/src/state/mod.rs @@ -1,25 +1,18 @@ //! 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. pub mod build_state; pub mod completion_log; @@ -27,6 +20,4 @@ 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 8406e5602..f26cd412e 100644 --- a/apps/native/src-tauri/src/state/preferences.rs +++ b/apps/native/src-tauri/src/state/preferences.rs @@ -13,8 +13,8 @@ use specta::Type; use std::sync::Arc; use tauri::{AppHandle, Runtime}; +use crate::observable::{AppDataJson, Observable, Persistence}; use crate::shared_types::UpdateChannel; -use crate::state::slice::{AppDataJson, Persistence, Slice}; const GLOBAL_PREFERENCES_PATH: &str = "global-preferences.json"; @@ -73,15 +73,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 deleted file mode 100644 index df4e4de82..000000000 --- a/apps/native/src-tauri/src/state/slice/mod.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! 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; -//! } -//! ``` - -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 deleted file mode 100644 index 46a0faa3c..000000000 --- a/apps/native/src-tauri/src/state/slice/registry.rs +++ /dev/null @@ -1,90 +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) - } -} 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(()); } 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..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 @@ -3,51 +3,60 @@ 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", }, ]; function installDevConfigMock() { tauriAPI.devConfigs = { - list: async () => [], + schemas: async () => [], + values: async () => ({}), set: async () => undefined, }; } @@ -74,11 +83,13 @@ export default meta; export const Controls = meta.story({ render: () => (
- {fields.map((field) => ( + {fields.map(({ schema, current }) => ( 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 a62b9c566..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,29 +9,33 @@ 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 { 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; - /** 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; + /** 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 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 `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`. 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, onSaved }: Props) { - const [value, setValue] = useState(field.current); +export function AutoConfigField({ structName, field, current, onCommit }: Props) { + const [value, setValue] = useState(current); const [error, setError] = useState(null); const commit = async (next: unknown) => { @@ -39,8 +43,7 @@ export function AutoConfigField({ structName, field, 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.stories.tsx b/apps/native/src/components/widget/settings/auto-tuning-section.stories.tsx index f1f4c04f3..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,7 +3,7 @@ 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 { ConfigurableSchema, JsonValue } from "@/ipc/types"; import { waitFor, within } from "storybook/test"; const schemas: ConfigurableSchema[] = [ @@ -18,7 +18,6 @@ const schemas: ConfigurableSchema[] = [ 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", @@ -26,20 +25,29 @@ const schemas: ConfigurableSchema[] = [ help: "Failed builds before giving up on a run.", ty: { kind: "number", min: 1, max: 20, step: 1 }, default: 5, - current: 5, }, ], }, ]; -function installDevConfigMock(next: ConfigurableSchema[] | 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, }; } 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..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 { ConfigurableSchema } 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"; @@ -12,14 +12,19 @@ import { AutoConfigField } from "@/components/widget/settings/auto-config-field" */ export function AutoTuningSection() { 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. - setSchemas(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)); @@ -48,27 +53,82 @@ export function AutoTuningSection() {
{schemas.map((schema) => ( -
- {schemas.length > 1 && ( -
-

{schema.displayName}

- {schema.description && ( -

{schema.description}

- )} -
- )} -
- {schema.fields.map((field) => ( - - ))} -
-
+ 1} + onCommit={async (key, value) => { + const next = await commitField(values, schema.name, key, value); + setValues(next); + }} + /> ))}
); } + +function SchemaSection({ + schema, + structValues, + showHeader, + onCommit, +}: { + schema: ConfigurableSchema; + structValues: Record; + showHeader: boolean; + onCommit: (key: string, value: unknown) => Promise; +}) { + return ( +
+ {showHeader && ( +
+

{schema.displayName}

+ {schema.description && ( +

{schema.description}

+ )} +
+ )} +
+ {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 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( + values: Record, + structName: string, + key: string, + value: unknown, +): 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 c7f6d0b7c..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 { ConfigurableSchema } 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"; @@ -16,14 +16,19 @@ import { BackupRestoreSection } from "@/components/widget/settings/backup-restor */ export function TuningTab() { 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. - setSchemas(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)); @@ -60,25 +65,16 @@ export function TuningTab() {
{schemas.map((schema) => ( -
- {schemas.length > 1 && ( -
-

{schema.displayName}

- {schema.description && ( -

{schema.description}

- )} -
- )} -
- {schema.fields.map((field) => ( - - ))} -
-
+ 1} + onCommit={async (key, value) => { + const next = await commitField(values, schema.name, key, value); + setValues(next); + }} + /> ))}
@@ -88,3 +84,64 @@ export function TuningTab() { ); } + +function SchemaSection({ + schema, + structValues, + showHeader, + onCommit, +}: { + schema: ConfigurableSchema; + structValues: Record; + showHeader: boolean; + onCommit: (key: string, value: unknown) => Promise; +}) { + return ( +
+ {showHeader && ( +
+

{schema.displayName}

+ {schema.description && ( +

{schema.description}

+ )} +
+ )} +
+ {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 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( + values: Record, + structName: string, + key: string, + value: unknown, +): 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 793e4b64c..0430c2a8e 100644 --- a/apps/native/src/ipc/api.ts +++ b/apps/native/src/ipc/api.ts @@ -12,6 +12,7 @@ import type { Config as DarwinConfig, ConfigEditApplyResult, ConfigurableSchema, + JsonValue, EvolveCancelResult, EvolutionResult, EvolveState, @@ -142,9 +143,25 @@ export const tauriAPI = { import: () => invoke("settings_import"), }, devConfigs: { - list: () => invoke("dev_configs_list"), - set: (structName: string, key: string, value: unknown) => - invoke("dev_config_set", { structName, key, value }), + /** + * 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 + * 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 }), diff --git a/apps/native/src/ipc/types.ts b/apps/native/src/ipc/types.ts index e81d0ebbb..5adbfe198 100644 --- a/apps/native/src/ipc/types.ts +++ b/apps/native/src/ipc/types.ts @@ -203,9 +203,12 @@ 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. Joined with the current store-backed value by `key` at render time. */ -export type ConfigField = { +export type ConfigFieldSchema = { /** * Key as written to the underlying store (typically camelCase). */ @@ -225,19 +228,16 @@ ty: FieldType; /** * Default if the store has no value yet. */ -default: JsonValue; -/** - * Current value loaded from the store. - */ -current: JsonValue } +default: JsonValue } /** * One section in the auto-rendered settings panel — corresponds to one - * `#[derive(Configurable)]` struct. + * `#[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; @@ -248,7 +248,7 @@ displayName: string; /** * Optional one-line description shown under the title. */ -description?: string | null; fields: ConfigField[] } +description?: string | null; fields: ConfigFieldSchema[] } /** * Payload for `darwin:apply:data`. @@ -282,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. */