Skip to content

Replace Slice by Observable, simplify and clean up the Configurable mechanism#391

Merged
cooper (czxtm) merged 13 commits into
developfrom
jp/refactor-the-refactor
Jun 12, 2026
Merged

Replace Slice by Observable, simplify and clean up the Configurable mechanism#391
cooper (czxtm) merged 13 commits into
developfrom
jp/refactor-the-refactor

Conversation

@arximboldi

Copy link
Copy Markdown
Contributor

Summary

Follows up on #317, taking care of some of my comments in PR's #228, #244#255.

The main things happening here are:

  • Slice becomes Observable with a more "single responsibility" principle API (call callbackas on change) and things like auto-persistence and auto-emit-to-JS added on top.

  • SliceRegistry goes away, the Configurable builds a static metadata registry (using inventory!) under the hood; less code and less error prone.

  • Configurable API various cleanups: separating schema from values (static from dynamic parts), symmetric setter/getter, etc.

  • Some other minor changes or fixes (see commit by commit)

Test Plan

  • No test plan needed

Docs

  • Docs updated (companion PR in darkmatter/nixmac-web: #___)
  • No docs update needed

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<T> 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<T> users keep working. The new
module carries #![allow(dead_code)] until callers migrate in C2-C5.
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)
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::<Slice<EvolveState>> to
try_state::<Observable<EvolveState>>; 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.
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::<Observable<GlobalPreferences>>(), and the write_sync call
drops its emitter argument.

This is the last Slice<T> 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/.
After the three caller migrations, Slice<T> 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).
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::<ConfigurableMeta>()`
instead of looking up `tauri::State<SliceRegistry>`; 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.
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<ConfigFieldSchema>  (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<ConfigurableSnapshot>.
  - 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.
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.
…peline

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.
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<R: Runtime>(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.
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.
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<ConfigurableSchema>: static metadata only.
  - dev_configs_values  -> HashMap<String, serde_json::Value>: 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<string, JsonValue>). 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).
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::<Observable<EvolutionLimits>>()
.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<Self> 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.
@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🎨 Storybook preview

Open Storybook preview

Updated for 7de4169


❌ Failed snapshots (1)

These stories' HTML snapshots changed. Current renderings (run bun run test:update-snapshots and commit if intended):

Widget/Steps/SetupStep › Default Config Required

Widget/Steps/SetupStep › Default Config Required

@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor
Warnings
⚠️ ❗ Big PR (1684 lines changed). Consider splitting it into smaller, focused changes.
Messages
📖 No docs update needed — acknowledged.

📋 PR Overview

Lines changed 1684 (+881 / -803)
Files 2 added, 28 modified, 2 deleted
Draft / WIP no
Has Test Plan no
No Test Plan Needed yes
New UI components no
New Storybook stories no
New Rust modules yes (1)
New TS source files no
New tests no
package.json touched no
Cargo.toml touched yes
Infra / CI touched no

🔬 Coverage

Report Lines Statements Functions Branches
apps/native/coverage/coverage-summary.json 25.5% 25.6% 24.3% 18.1%

Generated by 🚫 dangerJS against 7de4169

@czxtm cooper (czxtm) added this pull request to the merge queue Jun 12, 2026
Merged via the queue into develop with commit 9e07bb7 Jun 12, 2026
21 of 24 checks passed
@czxtm cooper (czxtm) deleted the jp/refactor-the-refactor branch June 12, 2026 22:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants