From 9cb7b7dd6ea750b9f148e7b0c17edb8040fde176 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 30 Jul 2026 20:24:03 +1000 Subject: [PATCH 01/16] fix(ledger-core): repair committed conflict fragments --- crates/ledger-core/src/crypto.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ledger-core/src/crypto.rs b/crates/ledger-core/src/crypto.rs index 1b8abd1..7929b10 100644 --- a/crates/ledger-core/src/crypto.rs +++ b/crates/ledger-core/src/crypto.rs @@ -33,7 +33,7 @@ impl Chain { "arbitrum" | "arb" => Some(Self::Arbitrum), "optimism" | "op" => Some(Self::Optimism), "bsc" | "bnb" => Some(Self::Bsc), - _ => None, + other => Some(Self::Other(other.to_string())), } } From 2f817190f682f8e9576c34ae2c2ade8311b2102a Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 19:16:36 +1000 Subject: [PATCH 02/16] docs: add implementation plan for gh#118 Phase A (settings unification) Co-Authored-By: Claude Sonnet 5 --- ...6-08-06-unify-desktop-background-server.md | 1080 +++++++++++++++++ 1 file changed, 1080 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-unify-desktop-background-server.md diff --git a/docs/superpowers/plans/2026-08-06-unify-desktop-background-server.md b/docs/superpowers/plans/2026-08-06-unify-desktop-background-server.md new file mode 100644 index 0000000..d222e89 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-unify-desktop-background-server.md @@ -0,0 +1,1080 @@ +# Unify Desktop Background Server (gh#118) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `ledgrrr-service` (crate `ledgerr-desktop-agent`, binary `ledgrrr-service`) the one long-lived local background process that owns settings/chat/review-log/evidence state, and make `host-tauri` and `host-tray` (crate `ledgerr-host`) HTTP clients of it instead of each owning independent local state. + +**Architecture:** `ledgrrr-service` grows a hand-rolled HTTP server (same pattern as the existing `internal_openai.rs` endpoint — nonblocking `TcpListener`, no async runtime) bound to `127.0.0.1:15116` by default. State types currently defined in `ledgerr-host` (settings, notification sub-types, model-provider label) move into a new dependency-light crate, `ledgrrr-settings`, so `ledgerr-desktop-agent` can construct/serve them without pulling in `ledgerr-host`'s heavy Windows/Slint/LLM dependency graph. `ledgerr-host`'s `settings`/`notify`/`internal_openai` modules become thin re-export shims over the new crate so every existing call site keeps compiling unchanged. + +**Tech Stack:** Rust, `std::net::TcpListener` (no async runtime added), `reqwest::blocking` (already a workspace dependency with `blocking`+`json` features enabled) for the client side, `serde_json` for the wire format. + +## Global Constraints + +- No `unsafe_code` (workspace lint: `deny`). +- `ledgrrr-settings` must not depend on `windows`, `slint`, `tauri`, `mistralrs`, `candle-*`, `tokio`, or any other heavy/platform-specific crate — `serde`, `serde_json`, `thiserror`, `chrono`, and (Windows-only, already lightweight) `windows-registry` are the ceiling. +- Every existing `ledgerr_host::settings::*`, `ledgerr_host::notify::*`, and root-level `ledgerr_host::ModelProviderLabel` re-export must keep resolving after this plan — this is a refactor, not a breaking API change, and `crates/ledgerr-host/src/bin/tauri/commands.rs` / `main.rs` / `crates/ledgerr-host/src/settings/schema.rs` (`internal_openai::ModelProviderLabel` import) must not need edits beyond what Task 8/9 explicitly call out. +- New HTTP surface binds to `127.0.0.1` only (never `0.0.0.0`) — this is a local-only control surface, matching the existing `internal_openai.rs` endpoint's own binding discipline. +- `cargo test --workspace` and `cargo clippy --workspace --all-targets --all-features` must stay green after every task's commit. + +--- + +## Roadmap (this plan covers Phase A only) + +| Phase | Scope | Status | +|---|---|---| +| **A** | IPC scaffold on `ledgrrr-service` + settings migration (this plan) | Detailed below | +| B | Migrate chat history + review log (`ledgerr_host::chat` — `send_chat_message`, `ChatTurn`, `ReviewLog`) to be served by `ledgrrr-service` | Not yet planned | +| C | Migrate evidence state (`ledgerr_host::evidence::EvidenceState`) to be served by `ledgrrr-service` | Not yet planned | +| D | Retire `host-tauri`'s local `AppState` entirely; tray icon (both the Windows-native path in `ledgerr-host/src/tray/native.rs` and the non-Windows Tauri `TrayIconBuilder` path) displays live `ledgrrr_status`-derived state instead of static menu items | Not yet planned | + +`ledgerr-mcp-server` (the domain MCP server) is explicitly **out of scope** for all phases — it stays stdio-only. `host-window` (the legacy Slint binary) is not touched by Phase A; its own migration is deferred to a later phase, noted but not scheduled above. + +--- + +## File Structure (Phase A) + +New: +- `crates/ledgrrr-settings/Cargo.toml`, `crates/ledgrrr-settings/src/lib.rs` — new crate. +- `crates/ledgrrr-settings/src/backend/{mod.rs, json_file.rs, windows_registry.rs}` — moved from `crates/ledgerr-host/src/settings_backend.rs` + its `json_file`/`windows_registry` submodules. +- `crates/ledgrrr-settings/src/schema.rs` — moved from `crates/ledgerr-host/src/settings/schema.rs`, minus `AppSettings::resolve_chat`. +- `crates/ledgrrr-settings/src/store.rs` — moved from `crates/ledgerr-host/src/settings/store.rs`. +- `crates/ledgrrr-settings/src/path.rs` — moved from `crates/ledgerr-host/src/settings/path.rs`. +- `crates/ledgrrr-settings/src/model_provider.rs` — `ModelProviderLabel` moved from `crates/ledgerr-host/src/internal_openai.rs`. +- `crates/ledgrrr-settings/src/notification.rs` — `NotificationBackend`, `NotificationStatus`, `NotificationTestResult` moved from `crates/ledgerr-host/src/notify/types.rs`. +- `crates/ledgerr-desktop-agent/src/settings_server.rs` — the new HTTP server module. + +Modified: +- `Cargo.toml` (workspace root) — add `crates/ledgrrr-settings` to `members`. +- `crates/ledgerr-desktop-agent/Cargo.toml` — add `ledgrrr-settings` and `reqwest` is NOT needed here (server side only writes responses, doesn't make outbound calls). +- `crates/ledgerr-host/Cargo.toml` — add `ledgrrr-settings` path dependency. +- `crates/ledgerr-host/src/settings/mod.rs`, `crates/ledgerr-host/src/settings_backend.rs`, `crates/ledgerr-host/src/notify/types.rs`, `crates/ledgerr-host/src/internal_openai.rs` — become re-export shims (see Task 4). +- `crates/ledgerr-desktop-agent/src/status.rs` — fix `TRAY_CANDIDATES` bug. +- `crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs` — wire in the settings server alongside the heartbeat loop. +- `crates/ledgerr-host/src/bin/tauri/state.rs`, `main.rs`, `commands.rs` — `AppState.store` becomes an HTTP client wrapper instead of a local `SettingsStore`. +- `crates/ledgerr-host/src/bin/host-tray.rs` — same client swap. + +--- + +### Task 1: Create `ledgrrr-settings` crate + move the storage backend + +**Files:** +- Create: `crates/ledgrrr-settings/Cargo.toml` +- Create: `crates/ledgrrr-settings/src/lib.rs` +- Create: `crates/ledgrrr-settings/src/backend/mod.rs` (from `crates/ledgerr-host/src/settings_backend.rs`) +- Create: `crates/ledgrrr-settings/src/backend/json_file.rs` (from `crates/ledgerr-host/src/settings_backend/json_file.rs`) +- Create: `crates/ledgrrr-settings/src/backend/windows_registry.rs` (from `crates/ledgerr-host/src/settings_backend/windows_registry.rs`, Windows-only) +- Modify: `Cargo.toml` (workspace root, `members` array) +- Delete: `crates/ledgerr-host/src/settings_backend.rs` and its submodule files (replaced by Task 4's shim) + +**Interfaces:** +- Produces: `ledgrrr_settings::backend::{SettingsBackend, SettingsBackendError, create_backend, JsonFileBackend}` (and `WindowsRegistryBackend` on Windows) — same names/signatures as today's `ledgerr_host::settings_backend`, just under the new crate path. + +- [ ] **Step 1: Read the exact current file contents to copy verbatim** + +Run: `cat crates/ledgerr-host/src/settings_backend.rs crates/ledgerr-host/src/settings_backend/json_file.rs` +(and `crates/ledgerr-host/src/settings_backend/windows_registry.rs` if it exists as a separate file — confirm with `ls crates/ledgerr-host/src/settings_backend/`) + +Do not paraphrase — copy the file bodies exactly into the new locations in Step 2. This plan does not reproduce their full text here because they must be copied byte-for-byte, not retyped from a description. + +- [ ] **Step 2: Create the new crate** + +`crates/ledgrrr-settings/Cargo.toml`: +```toml +[package] +name = "ledgrrr-settings" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows-registry = "0.6" + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true +``` + +`crates/ledgrrr-settings/src/lib.rs`: +```rust +pub mod backend; +pub mod model_provider; +pub mod notification; +pub mod path; +pub mod schema; +pub mod store; + +pub use model_provider::ModelProviderLabel; +pub use notification::{NotificationBackend, NotificationStatus, NotificationTestResult}; +pub use path::default_settings_path; +pub use schema::{AppSettings, ChatSettings, SettingsSchemaVersion, ShowNotificationsFor}; +pub use store::{SettingsError, SettingsStore}; +``` + +Copy `crates/ledgerr-host/src/settings_backend.rs` verbatim to `crates/ledgrrr-settings/src/backend/mod.rs`, and its `json_file`/`windows_registry` submodule files verbatim to `crates/ledgrrr-settings/src/backend/json_file.rs` / `windows_registry.rs`. No content changes in this step — this is a pure file move. + +Register in workspace root `Cargo.toml`, in the `members` array, alongside the other `crates/*` entries (exact insertion point: after the `"crates/ledgerr-focus",` line, matching the existing alphabetical-ish grouping): +```toml + "crates/ledgerr-focus", + "crates/ledgrrr-settings", + "crates/ledgerr-host", +``` + +- [ ] **Step 3: Build to confirm the new crate compiles standalone** + +Run: `cargo check -p ledgrrr-settings` +Expected: compiles clean (the crate has no consumers yet, so no downstream breakage possible at this step). + +- [ ] **Step 4: Commit** + +```bash +git add Cargo.toml crates/ledgrrr-settings +git commit -m "feat(ledgrrr-settings): scaffold new crate, move settings_backend" +``` + +(`crates/ledgerr-host/src/settings_backend.rs` deletion happens in Task 4, once the shim is in place — do not delete it yet, or `ledgerr-host` stops compiling.) + +--- + +### Task 2: Move notification sub-types + +**Files:** +- Modify: `crates/ledgrrr-settings/src/notification.rs` (create, content below) +- Modify: `crates/ledgerr-host/src/notify/types.rs` (remove the three moved items, keep the rest, import the new crate for them) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `ledgrrr_settings::notification::{NotificationBackend, NotificationStatus, NotificationTestResult}`. + +- [ ] **Step 1: Write the moved file** + +`crates/ledgrrr-settings/src/notification.rs`: +```rust +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationBackend { + Auto, + PowerShell, + Noop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationStatus { + Disabled, + Unknown, + Ready, + Degraded, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotificationTestResult { + pub status: NotificationStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} +``` + +- [ ] **Step 2: Update `crates/ledgerr-host/src/notify/types.rs`** + +Remove the `NotificationBackend`, `NotificationStatus`, `NotificationTestResult` definitions (lines 5-21 and 34-41 in the current file). Add at the top of the file, alongside the existing `use` block: +```rust +pub use ledgrrr_settings::{NotificationBackend, NotificationStatus, NotificationTestResult}; +``` +Leave `NotificationEvent`, `NotificationSettings`, `NotifyError`, and the `Notifier` trait exactly as they are — they reference the now-imported types by name, which still resolves. + +`ledgerr-host`'s `Cargo.toml` needs the new dependency for this to compile — add now (this crate will need it for every remaining task in this plan, so add it once here): +```toml +ledgrrr-settings = { path = "../ledgrrr-settings" } +``` + +- [ ] **Step 3: Build** + +Run: `cargo check -p ledgerr-host --all-features` +Expected: compiles clean. If it doesn't, the error will name every call site that referenced `crate::notify::types::NotificationBackend` (etc.) directly instead of via `crate::notify::NotificationBackend` — fix by adding a matching `pub use` at `crates/ledgerr-host/src/notify/mod.rs` if one doesn't already re-export `types::*`. + +- [ ] **Step 4: Run existing tests** + +Run: `cargo test -p ledgerr-host --all-features` +Expected: same pass/fail set as before this task (no new failures introduced by a pure type-relocation). + +- [ ] **Step 5: Commit** + +```bash +git add crates/ledgrrr-settings/src/notification.rs crates/ledgrrr-settings/src/lib.rs \ + crates/ledgerr-host/src/notify/types.rs crates/ledgerr-host/Cargo.toml +git commit -m "refactor(ledgerr-host): move NotificationBackend/Status/TestResult to ledgrrr-settings" +``` + +--- + +### Task 3: Move `ModelProviderLabel` + +**Files:** +- Create: `crates/ledgrrr-settings/src/model_provider.rs` +- Modify: `crates/ledgerr-host/src/internal_openai.rs` (remove the definition, re-export instead) + +**Interfaces:** +- Produces: `ledgrrr_settings::model_provider::ModelProviderLabel` (with its `display_name()` inherent method). + +- [ ] **Step 1: Write the moved file** + +`crates/ledgrrr-settings/src/model_provider.rs`: +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModelProviderLabel { + /// Private local inference. Works immediately. May use a deterministic stub if no GGUF is configured. + LocalDemo, + /// Private local inference via Windows AI / Foundry Local. Requires setup first. + WindowsAi, + /// Explicit external API call. Requires operator-supplied endpoint and key. + Cloud, +} + +impl ModelProviderLabel { + pub fn display_name(&self) -> &'static str { + match self { + Self::LocalDemo => "Local Demo", + Self::WindowsAi => "Windows AI", + Self::Cloud => "Cloud", + } + } +} +``` + +(Read `crates/ledgerr-host/src/internal_openai.rs` lines 46-62 first to confirm the derive list and match arms above are byte-identical to the source before deleting the original — copy exactly, don't retype from memory if the real file differs from what's shown here.) + +- [ ] **Step 2: Update `crates/ledgerr-host/src/internal_openai.rs`** + +Delete the `ModelProviderLabel` enum + impl block. Add near the top of the file: +```rust +pub use ledgrrr_settings::ModelProviderLabel; +``` + +- [ ] **Step 3: Build** + +Run: `cargo check -p ledgerr-host --all-features` +Expected: clean. `crates/ledgerr-host/src/lib.rs` already does `pub use internal_openai::{..., ModelProviderLabel, ...};` at crate root — this keeps working unchanged since the name still resolves from `internal_openai`, just via re-export now instead of definition. + +- [ ] **Step 4: Run tests, commit** + +```bash +cargo test -p ledgerr-host --all-features +git add crates/ledgrrr-settings/src/model_provider.rs crates/ledgrrr-settings/src/lib.rs \ + crates/ledgerr-host/src/internal_openai.rs +git commit -m "refactor(ledgerr-host): move ModelProviderLabel to ledgrrr-settings" +``` + +--- + +### Task 4: Move `AppSettings`/`SettingsStore`/`default_settings_path`, delete old `settings_backend.rs` + +**Files:** +- Create: `crates/ledgrrr-settings/src/schema.rs` (from `crates/ledgerr-host/src/settings/schema.rs`, minus `resolve_chat`) +- Create: `crates/ledgrrr-settings/src/store.rs` (from `crates/ledgerr-host/src/settings/store.rs`) +- Create: `crates/ledgrrr-settings/src/path.rs` (from `crates/ledgerr-host/src/settings/path.rs`) +- Modify: `crates/ledgerr-host/src/settings/mod.rs` → becomes a pure re-export shim +- Modify: `crates/ledgerr-host/src/internal_openai.rs` → add `resolve_chat` as a free function +- Delete: `crates/ledgerr-host/src/settings/schema.rs`, `store.rs`, `path.rs` +- Delete: `crates/ledgerr-host/src/settings_backend.rs` and its submodule files (superseded by Task 1's copy — this is the point where the original is finally removed) + +**Interfaces:** +- Consumes: `ledgrrr_settings::backend::{SettingsBackend, SettingsBackendError, create_backend}` (Task 1), `ledgrrr_settings::{ModelProviderLabel, NotificationBackend, NotificationTestResult}` (Tasks 2-3). +- Produces: `ledgrrr_settings::{AppSettings, ChatSettings, SettingsSchemaVersion, ShowNotificationsFor, SettingsStore, SettingsError, default_settings_path}`. + +- [ ] **Step 1: Write `crates/ledgrrr-settings/src/schema.rs`** + +Copy `crates/ledgerr-host/src/settings/schema.rs` verbatim, with two changes: +1. Replace `use crate::internal_openai::ModelProviderLabel;` with `use crate::model_provider::ModelProviderLabel;` +2. Replace `use crate::notify::{NotificationBackend, NotificationTestResult};` with `use crate::notification::{NotificationBackend, NotificationTestResult};` +3. Delete the `impl AppSettings { pub fn resolve_chat(&self) -> ... }` block entirely (it moves to `ledgerr-host` in Step 4 below, since it needs `internal_openai::resolve_chat_settings` which must not become a dependency of this crate). + +- [ ] **Step 2: Write `crates/ledgrrr-settings/src/store.rs` and `src/path.rs`** + +Copy `crates/ledgerr-host/src/settings/store.rs` verbatim to `crates/ledgrrr-settings/src/store.rs`, with one change: `use super::schema::{AppSettings, SettingsSchemaVersion};` becomes `use crate::schema::{AppSettings, SettingsSchemaVersion};`, and `use crate::settings_backend::{create_backend, SettingsBackend, SettingsBackendError};` becomes `use crate::backend::{create_backend, SettingsBackend, SettingsBackendError};`. Its existing `#[cfg(test)] mod tests` block (the `load_returns_defaults_when_backend_is_empty` / `save_and_load_roundtrip` tests) copies unchanged — these become the new crate's regression tests. + +Copy `crates/ledgerr-host/src/settings/path.rs` verbatim to `crates/ledgrrr-settings/src/path.rs` — no changes needed, it has no internal crate references. + +- [ ] **Step 3: Turn `crates/ledgerr-host/src/settings/mod.rs` into a shim** + +Replace its entire content with: +```rust +pub use ledgrrr_settings::{ + default_settings_path, AppSettings, ChatSettings, SettingsError, SettingsSchemaVersion, + SettingsStore, ShowNotificationsFor, +}; +``` +Delete `crates/ledgerr-host/src/settings/schema.rs`, `store.rs`, `path.rs` (now dead — content lives in `ledgrrr-settings`). + +- [ ] **Step 4: Add `resolve_chat` as a free function in `ledgerr-host`** + +Confirmed via `grep -rn "\.resolve_chat()" crates/ledgerr-host/` that this method has zero current call sites — it's dead code today, but keep its behavior available under a new name so nothing is silently dropped. In `crates/ledgerr-host/src/internal_openai.rs`, near `resolve_chat_settings`, add: +```rust +/// Resolve ChatSettings from the operator's model_provider choice. +/// +/// Returns (resolved_settings, Option) where the second +/// element is Some when a fallback occurred (e.g., WindowsAi selected but +/// Foundry not installed). The caller decides whether to surface the warning. +pub fn resolve_chat(settings: &ledgrrr_settings::AppSettings) -> (ChatSettings, Option) { + resolve_chat_settings(settings) +} +``` +(`ChatSettings` here is `crate::settings::ChatSettings`, which after Step 3 resolves via the shim to `ledgrrr_settings::ChatSettings` — no import change needed if `internal_openai.rs` already imports `ChatSettings` from `crate::settings`; if it doesn't, add `use crate::settings::ChatSettings;`.) + +- [ ] **Step 5: Delete the old backend files, update `ledgerr-host/src/lib.rs`** + +Delete `crates/ledgerr-host/src/settings_backend.rs` and its submodule directory. Remove `pub mod settings_backend;` from `crates/ledgerr-host/src/lib.rs`. If anything outside `settings/` referenced `crate::settings_backend::*` directly, change it to `ledgrrr_settings::backend::*` (search with `grep -rn "settings_backend" crates/ledgerr-host/src/` after the deletion — the build error will also catch any miss). + +- [ ] **Step 6: Build** + +Run: `cargo check -p ledgerr-host --all-features` +Expected: clean. This is the step most likely to surface a missed call site — fix forward from whatever the compiler names. + +- [ ] **Step 7: Run tests** + +Run: `cargo test -p ledgrrr-settings && cargo test -p ledgerr-host --all-features` +Expected: `ledgrrr-settings`'s two moved tests pass; `ledgerr-host`'s suite has the same pass/fail set as before Task 1. + +- [ ] **Step 8: Commit** + +```bash +git add crates/ledgrrr-settings/src crates/ledgerr-host/src/settings crates/ledgerr-host/src/internal_openai.rs \ + crates/ledgerr-host/src/lib.rs +git rm -r crates/ledgerr-host/src/settings_backend.rs crates/ledgerr-host/src/settings_backend/ 2>/dev/null || true +git commit -m "refactor(ledgerr-host): move AppSettings/SettingsStore/default_settings_path to ledgrrr-settings" +``` + +--- + +### Task 5: Fix the `TRAY_CANDIDATES` bug + +**Files:** +- Modify: `crates/ledgerr-desktop-agent/src/status.rs:99` + +**Interfaces:** none (self-contained bugfix, no new interface). + +- [ ] **Step 1: Write the failing test** + +Add to `crates/ledgerr-desktop-agent/src/status.rs`, inside a `#[cfg(test)] mod tests` block (create one if none exists yet in this file): +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tray_candidates_matches_the_real_host_tauri_binary_name() { + assert!( + TRAY_CANDIDATES.contains(&"host-tauri"), + "TRAY_CANDIDATES must list the real host-tauri bin target, not a nonexistent ledgerr-tauri binary: {TRAY_CANDIDATES:?}" + ); + } +} +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `cargo test -p ledgerr-desktop-agent tray_candidates_matches_the_real_host_tauri_binary_name` +Expected: FAIL — `TRAY_CANDIDATES` is currently `["host-tray", "ledgerr-tauri"]`. + +- [ ] **Step 3: Fix it** + +```rust +const TRAY_CANDIDATES: &[&str] = &["host-tray", "host-tauri"]; +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `cargo test -p ledgerr-desktop-agent tray_candidates_matches_the_real_host_tauri_binary_name` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ledgerr-desktop-agent/src/status.rs +git commit -m "fix(ledgerr-desktop-agent): TRAY_CANDIDATES named a binary (ledgerr-tauri) that doesn't exist" +``` + +--- + +### Task 6: Add the settings HTTP server to `ledgerr-desktop-agent` + +**Files:** +- Create: `crates/ledgerr-desktop-agent/src/settings_server.rs` +- Modify: `crates/ledgerr-desktop-agent/src/lib.rs` (add `pub mod settings_server;`) +- Modify: `crates/ledgerr-desktop-agent/Cargo.toml` (add `ledgrrr-settings` dependency) + +**Interfaces:** +- Consumes: `ledgrrr_settings::{SettingsStore, AppSettings, default_settings_path}` (Task 4). +- Produces: `settings_server::{SETTINGS_SERVER_ADDR, route_request, spawn}` — `route_request` is the pure, directly-testable request handler; `spawn` wraps it in the actual `TcpListener` accept loop for `ledgrrr-service.rs` to call in Task 7. + +- [ ] **Step 1: Add the dependency** + +`crates/ledgerr-desktop-agent/Cargo.toml`, in `[dependencies]`: +```toml +ledgrrr-settings = { path = "../ledgrrr-settings" } +``` + +- [ ] **Step 2: Write the failing tests for `route_request`** + +`crates/ledgerr-desktop-agent/src/settings_server.rs` (test module first): +```rust +//! HTTP settings server for `ledgrrr-service` — same hand-rolled, +//! nonblocking-`TcpListener` style as `ledgerr-host`'s `internal_openai.rs` +//! endpoint. GET /settings returns the current `AppSettings` as JSON; +//! POST /settings replaces them. No async runtime. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use ledgrrr_settings::{AppSettings, SettingsStore}; + +pub const SETTINGS_SERVER_ADDR: &str = "127.0.0.1:15116"; + +#[cfg(test)] +mod tests { + use super::*; + + fn store_with_defaults() -> SettingsStore { + let dir = tempfile::tempdir().unwrap(); + SettingsStore::new(dir.path().join("settings.json")) + } + + #[test] + fn get_settings_returns_defaults_as_json() { + let store = store_with_defaults(); + let response = route_request(b"GET /settings HTTP/1.1\r\n\r\n", &store); + assert!(response.starts_with("HTTP/1.1 200 OK")); + let body_start = response.find("\r\n\r\n").unwrap() + 4; + let parsed: AppSettings = serde_json::from_str(&response[body_start..]).unwrap(); + assert!(parsed.toast_enabled); + } + + #[test] + fn post_settings_persists_and_get_reflects_it() { + let store = store_with_defaults(); + let mut updated = store.load().unwrap(); + updated.toast_enabled = false; + let body = serde_json::to_string(&updated).unwrap(); + let request = format!( + "POST /settings HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + + let post_response = route_request(request.as_bytes(), &store); + assert!(post_response.starts_with("HTTP/1.1 200 OK")); + + let get_response = route_request(b"GET /settings HTTP/1.1\r\n\r\n", &store); + let body_start = get_response.find("\r\n\r\n").unwrap() + 4; + let parsed: AppSettings = serde_json::from_str(&get_response[body_start..]).unwrap(); + assert!(!parsed.toast_enabled); + } + + #[test] + fn post_settings_rejects_malformed_json() { + let store = store_with_defaults(); + let body = "{not json"; + let request = format!( + "POST /settings HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let response = route_request(request.as_bytes(), &store); + assert!(response.starts_with("HTTP/1.1 400 Bad Request")); + } + + #[test] + fn unknown_route_returns_404() { + let store = store_with_defaults(); + let response = route_request(b"GET /nope HTTP/1.1\r\n\r\n", &store); + assert!(response.starts_with("HTTP/1.1 404 Not Found")); + } +} +``` + +Add `tempfile = { workspace = true }` to `crates/ledgerr-desktop-agent/Cargo.toml`'s `[dev-dependencies]` (create that section if it doesn't exist) — needed for the tests above. + +- [ ] **Step 3: Run tests to confirm they fail** + +Run: `cargo test -p ledgerr-desktop-agent settings_server` +Expected: FAIL to compile — `route_request` doesn't exist yet. + +- [ ] **Step 4: Implement `route_request` and the accept-loop `spawn`** + +Append to `crates/ledgerr-desktop-agent/src/settings_server.rs` (above the `#[cfg(test)]` module): +```rust +fn json_response(status: u16, payload: &impl serde::Serialize) -> String { + let body = serde_json::to_string(payload) + .unwrap_or_else(|_| "{\"error\":\"serialization failure\"}".to_string()); + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "OK", + }; + format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn parse_content_length(headers: &str) -> Option { + headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().ok()) + .flatten() + }) +} + +fn route_request(raw: &[u8], store: &SettingsStore) -> String { + let Some(header_end) = find_header_end(raw) else { + return json_response(400, &serde_json::json!({ "error": "invalid request" })); + }; + let headers = String::from_utf8_lossy(&raw[..header_end]); + let request_line = headers.lines().next().unwrap_or_default(); + let body = &raw[header_end + 4..]; + + if request_line.starts_with("GET /settings ") || request_line.starts_with("GET /settings HTTP") { + return match store.load() { + Ok(settings) => json_response(200, &settings), + Err(error) => json_response(500, &serde_json::json!({ "error": error.to_string() })), + }; + } + + if request_line.starts_with("POST /settings ") || request_line.starts_with("POST /settings HTTP") { + let settings: AppSettings = match serde_json::from_slice(body) { + Ok(settings) => settings, + Err(error) => { + return json_response( + 400, + &serde_json::json!({ "error": format!("invalid settings body: {error}") }), + ); + } + }; + return match store.save(&settings) { + Ok(()) => json_response(200, &settings), + Err(error) => json_response(500, &serde_json::json!({ "error": error.to_string() })), + }; + } + + json_response(404, &serde_json::json!({ "error": "not found" })) +} + +fn request_complete(buffer: &[u8]) -> bool { + let Some(header_end) = find_header_end(buffer) else { + return false; + }; + let headers = String::from_utf8_lossy(&buffer[..header_end]); + let content_length = parse_content_length(&headers).unwrap_or_default(); + buffer.len() >= header_end + 4 + content_length +} + +fn handle_stream(mut stream: TcpStream, store: &SettingsStore) { + let mut buffer = Vec::with_capacity(4096); + let mut chunk = [0_u8; 2048]; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + buffer.extend_from_slice(&chunk[..n]); + if request_complete(&buffer) { + break; + } + } + Err(_) => break, + } + } + let response = route_request(&buffer, store); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +/// Bind the settings server and return the live listener, already set to +/// nonblocking so the caller can interleave `accept()` polling with other +/// periodic work (the heartbeat write in `ledgrrr-service`'s main loop). +pub fn bind() -> std::io::Result { + let listener = TcpListener::bind(SETTINGS_SERVER_ADDR)?; + listener.set_nonblocking(true)?; + Ok(listener) +} + +/// Poll the listener once. Call this in a loop; returns immediately if no +/// connection is pending (`WouldBlock`) rather than blocking, so the caller +/// stays free to also run heartbeat writes on the same thread. +pub fn accept_once(listener: &TcpListener, store: &SettingsStore) { + match listener.accept() { + Ok((stream, _)) => handle_stream(stream, store), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => {} + } +} +``` + +Add `pub mod settings_server;` to `crates/ledgerr-desktop-agent/src/lib.rs`. + +- [ ] **Step 5: Run tests to confirm they pass** + +Run: `cargo test -p ledgerr-desktop-agent settings_server` +Expected: PASS — all 4 tests (`get_settings_returns_defaults_as_json`, `post_settings_persists_and_get_reflects_it`, `post_settings_rejects_malformed_json`, `unknown_route_returns_404`). + +- [ ] **Step 6: Commit** + +```bash +git add crates/ledgerr-desktop-agent/src/settings_server.rs crates/ledgerr-desktop-agent/src/lib.rs \ + crates/ledgerr-desktop-agent/Cargo.toml +git commit -m "feat(ledgerr-desktop-agent): add settings HTTP server (GET/POST /settings)" +``` + +--- + +### Task 7: Wire the settings server into `ledgrrr-service`'s main loop + +**Files:** +- Modify: `crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs` + +**Interfaces:** +- Consumes: `settings_server::{bind, accept_once}` (Task 6), `ledgrrr_settings::{SettingsStore, default_settings_path}` (Task 4). + +- [ ] **Step 1: Read the current file to confirm nothing has drifted since this plan's research** + +Run: `cat crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs` +Expected content (as of this plan's research — confirm it still matches before editing): +```rust +use ledgerr_desktop_agent::state; +use std::time::Duration; + +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); + +fn main() { + let pid = std::process::id(); + let started_at = state::now(); + loop { + let _ = state::write_heartbeat(pid, started_at); + std::thread::sleep(HEARTBEAT_INTERVAL); + } +} +``` +(No signal handler by design, per the file's own header comment — Phase A does not change that.) + +- [ ] **Step 2: Replace the loop body** + +```rust +use ledgerr_desktop_agent::{settings_server, state}; +use ledgrrr_settings::{default_settings_path, SettingsStore}; +use std::time::{Duration, Instant}; + +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); +const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(25); + +fn main() { + let pid = std::process::id(); + let started_at = state::now(); + let store = SettingsStore::new(default_settings_path()); + + let listener = match settings_server::bind() { + Ok(listener) => Some(listener), + Err(error) => { + eprintln!( + "ledgrrr-service: failed to bind settings server on {}: {error} — heartbeat only, no settings HTTP surface this run", + settings_server::SETTINGS_SERVER_ADDR + ); + None + } + }; + + let mut last_heartbeat = Instant::now() - HEARTBEAT_INTERVAL; + loop { + if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL { + let _ = state::write_heartbeat(pid, started_at); + last_heartbeat = Instant::now(); + } + if let Some(listener) = &listener { + settings_server::accept_once(listener, &store); + } + std::thread::sleep(ACCEPT_POLL_INTERVAL); + } +} +``` + +Bind failure (e.g., port already in use by another `ledgrrr-service` instance) degrades to heartbeat-only rather than crashing the process — matches PRD-11 §7's "never a silent no-op or fabricated success" only in the sense that the failure is logged to stderr, not swallowed; the service still provides its one previously-guaranteed function (liveness heartbeat) rather than dying entirely over a secondary feature. + +- [ ] **Step 3: Build** + +Run: `cargo build -p ledgerr-desktop-agent --bin ledgrrr-service` +Expected: compiles clean. + +- [ ] **Step 4: Manual smoke test** + +Run: `LEDGRRR_STATE_DIR=$(mktemp -d) ./target/debug/ledgrrr-service &` +Then: `sleep 1 && curl -s http://127.0.0.1:15116/settings | head -c 200` +Expected: JSON starting with `{"schema_version":"v2",...}` (or similar — the actual `AppSettings` default JSON). Kill the background process afterward: `kill %1`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs +git commit -m "feat(ledgerr-desktop-agent): serve settings HTTP endpoint from ledgrrr-service's main loop" +``` + +--- + +### Task 8: Switch `host-tauri` to be an HTTP client of the settings endpoint + +**Files:** +- Modify: `crates/ledgerr-host/src/bin/tauri/state.rs` +- Modify: `crates/ledgerr-host/src/bin/tauri/main.rs` +- Modify: `crates/ledgerr-host/src/bin/tauri/commands.rs` +- Create: `crates/ledgerr-host/src/bin/tauri/settings_client.rs` + +**Interfaces:** +- Consumes: `ledgrrr_settings::AppSettings` (Task 4), `settings_server::SETTINGS_SERVER_ADDR` (Task 6, for the default URL — reused as a constant, not a runtime dependency on the crate itself, to avoid `ledgerr-host` depending on `ledgerr-desktop-agent`; the literal `127.0.0.1:15116` is duplicated as a `const` here, matching how `INTERNAL_OPENAI_ADDR` is already a freestanding const in `internal_openai.rs` rather than shared across crates). +- Produces: `settings_client::{SettingsClient, SettingsClientError}` with `load() -> Result` and `save(&AppSettings) -> Result<(), SettingsClientError>` — same two operations `AppState.store` exposed before, so `commands.rs` call sites change their receiver but not their call shape. + +- [ ] **Step 1: Write the failing test** + +`crates/ledgerr-host/src/bin/tauri/settings_client.rs`: +```rust +//! HTTP client for `ledgrrr-service`'s settings endpoint (see +//! `ledgerr_desktop_agent::settings_server` for the server side). Replaces +//! the local `SettingsStore` this binary used to own directly — settings +//! are now `ledgrrr-service`'s state, not `host-tauri`'s. + +use ledgrrr_settings::AppSettings; + +const DEFAULT_SETTINGS_SERVER_URL: &str = "http://127.0.0.1:15116"; + +#[derive(Debug, thiserror::Error)] +pub enum SettingsClientError { + #[error("request to ledgrrr-service failed: {0}")] + Request(#[from] reqwest::Error), + #[error("ledgrrr-service returned an error: {0}")] + Server(String), +} + +pub struct SettingsClient { + base_url: String, + client: reqwest::blocking::Client, +} + +impl SettingsClient { + pub fn new() -> Self { + Self::with_base_url(DEFAULT_SETTINGS_SERVER_URL.to_string()) + } + + pub fn with_base_url(base_url: String) -> Self { + Self { + base_url, + client: reqwest::blocking::Client::new(), + } + } + + pub fn load(&self) -> Result { + let response = self + .client + .get(format!("{}/settings", self.base_url)) + .send()?; + if !response.status().is_success() { + return Err(SettingsClientError::Server(response.status().to_string())); + } + Ok(response.json()?) + } + + pub fn save(&self, settings: &AppSettings) -> Result<(), SettingsClientError> { + let response = self + .client + .post(format!("{}/settings", self.base_url)) + .json(settings) + .send()?; + if !response.status().is_success() { + return Err(SettingsClientError::Server(response.status().to_string())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// Minimal fake server: accepts exactly one connection, replies with a + /// fixed body, then stops. Enough to test the client without depending + /// on ledgerr-desktop-agent (which would create a dependency cycle risk + /// — ledgerr-host must not depend on ledgerr-desktop-agent). + fn fake_server_returning(body: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0_u8; 1024]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + }); + format!("http://{addr}") + } + + #[test] + fn load_parses_settings_from_server_response() { + let defaults = AppSettings::default(); + let body = serde_json::to_string(&defaults).unwrap(); + let base_url = fake_server_returning(Box::leak(body.into_boxed_str())); + let client = SettingsClient::with_base_url(base_url); + let loaded = client.load().unwrap(); + assert_eq!(loaded, defaults); + } +} +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: `cargo test -p ledgerr-host --bin host-tauri load_parses_settings_from_server_response` +Expected: FAIL to compile — module not wired into `main.rs` yet, and `AppSettings` needs `PartialEq` derived (confirm via `grep -n "derive" crates/ledgrrr-settings/src/schema.rs` after Task 4 — the source `schema.rs` already derives `PartialEq, Eq` on `AppSettings` per the version read during this plan's research, so no change needed there; if Task 4's copy dropped it, add it back). + +- [ ] **Step 3: Wire the module in and update `AppState`** + +Add `mod settings_client;` to `crates/ledgerr-host/src/bin/tauri/main.rs` (near its other `mod` declarations). + +`crates/ledgerr-host/src/bin/tauri/state.rs` — replace the `store` field's type: +```rust +use std::sync::{Arc, Mutex}; + +use ledgerr_host::chat::{ChatTurn, ReviewLog}; +use ledgerr_host::evidence::EvidenceState; +use ledgerr_host::internal_openai::InternalOpenAiHandle; + +use crate::settings_client::SettingsClient; + +pub struct AppState { + pub store: Arc, + pub history: Arc>>, + pub review_log: Arc>, + pub internal_endpoint: Arc>>, + pub evidence: Arc>, +} +``` + +`crates/ledgerr-host/src/bin/tauri/main.rs:58` — the construction site: +```rust +let store = Arc::new(crate::settings_client::SettingsClient::new()); +``` +(Removes the `default_settings_path()` import/call if nothing else in `main.rs` uses it — check with `grep -n "default_settings_path" crates/ledgerr-host/src/bin/tauri/main.rs` after this edit.) + +`crates/ledgerr-host/src/bin/tauri/commands.rs` — every `state.store.load()` / `state.store.save(&settings)` / `state.store.path().display()` call site changes shape slightly: `load()`/`save()` keep the same names and now return `Result<_, SettingsClientError>` instead of `Result<_, SettingsError>` (the `.map_err(|e| e.to_string())` pattern already used at every call site absorbs this transparently — no call-site signature change needed there). `state.store.path().display()` has no equivalent on `SettingsClient` (there's no longer a local file path to display, since state lives in `ledgrrr-service` now) — at `commands.rs:151` and `:192`, replace: +```rust +let status_text = format!("Editing {}", state.store.path().display()); +``` +with: +```rust +let status_text = "Editing settings via ledgrrr-service".to_string(); +``` +and similarly at line 192's `state.store.path().display()` usage. + +- [ ] **Step 4: Build and run the test** + +Run: `cargo test -p ledgerr-host --bin host-tauri load_parses_settings_from_server_response` +Expected: PASS. + +Run: `cargo check -p ledgerr-host --all-features` +Expected: clean — this will surface any remaining `state.store.path()` call site this plan's research didn't catch; fix forward. + +- [ ] **Step 5: Manual end-to-end smoke test** + +```bash +LEDGRRR_STATE_DIR=$(mktemp -d) ./target/debug/ledgrrr-service & +sleep 1 +cargo run -p ledgerr-host --bin host-tauri & +# Confirm in the Tauri window that settings load without error (no "Editing ..." panic, +# no connection-refused toast). Then: +kill %1 %2 +``` + +- [ ] **Step 6: Commit** + +```bash +git add crates/ledgerr-host/src/bin/tauri/settings_client.rs crates/ledgerr-host/src/bin/tauri/state.rs \ + crates/ledgerr-host/src/bin/tauri/main.rs crates/ledgerr-host/src/bin/tauri/commands.rs +git commit -m "feat(host-tauri): settings now served by ledgrrr-service over HTTP, not a local SettingsStore" +``` + +--- + +### Task 9: Switch `host-tray` to the same HTTP client + +**Files:** +- Modify: `crates/ledgerr-host/src/bin/host-tray.rs` +- Modify: `crates/ledgerr-host/src/tray/runtime.rs` + +**Interfaces:** +- Consumes: same `SettingsClient` shape as Task 8 — but `host-tray.rs` is a separate binary target from `host-tauri`'s `bin/tauri/*.rs` module tree, so it needs its own copy of `settings_client.rs` (binary targets in this crate don't share modules with each other, only with the library crate root `ledgerr_host::*`). Given that duplication concern, this task instead promotes `settings_client.rs` from a `host-tauri`-local module to a `ledgerr-host` library module, shared by both binaries. + +Confirmed by reading `crates/ledgerr-host/src/tray/runtime.rs` in full (342 lines) during this plan's writing: `SettingsStore` is threaded through as a type annotation in exactly two places — `pub fn run(store: SettingsStore) -> Result<(), Box>` (line 21) and its helper `fn handle_command(command: TrayCommand, store: &SettingsStore, state: &Arc>, control_tx: &mpsc::Sender) -> Result>` (line 120-125), called as `handle_command(command, &store, &state, &tray.control_tx)?` (line 109). Every other call site in the file (~9 `store.load()?` / `store.save(&settings)?` pairs, one per `TrayCommand` variant, e.g. lines 128-130, 136-138, 144-154, 160-202, 215) calls `.load()`/`.save()` by method name only. Since `SettingsClient::load(&self) -> Result` and `SettingsClient::save(&self, settings: &AppSettings) -> Result<(), SettingsClientError>` (Task 8) share identical method names and argument shapes with `SettingsStore::load`/`save`, and `SettingsClientError` derives `thiserror::Error` (so `?` still coerces into `Box` at every one of those call sites) — **no trait, no adapter, and no changes to any of the ~9 command-handler bodies are needed.** This is a pure type-annotation swap in exactly two places. + +- [ ] **Step 1: Promote `settings_client` to a library module** + +Move `crates/ledgerr-host/src/bin/tauri/settings_client.rs` to `crates/ledgerr-host/src/settings_client.rs` (no content changes to the struct/impl — only its module path moves). Add `pub mod settings_client;` to `crates/ledgerr-host/src/lib.rs`. In `crates/ledgerr-host/src/bin/tauri/main.rs`, remove `mod settings_client;` and change every `crate::settings_client::SettingsClient` reference (in `state.rs`) to `ledgerr_host::settings_client::SettingsClient`. + +`reqwest = { workspace = true }` is already present in `crates/ledgerr-host/Cargo.toml`'s `[dependencies]` (confirmed during this plan's research) — no new dependency needed for the promoted module. + +- [ ] **Step 2: Run Task 8's test again from its new location** + +Run: `cargo test -p ledgerr-host load_parses_settings_from_server_response` +Expected: PASS (same test, now compiled as part of the library crate instead of the `host-tauri` binary — confirms the promotion didn't change behavior). + +- [ ] **Step 3: Swap the type in `tray/runtime.rs`** + +Line 10, change: +```rust +use crate::settings::{AppSettings, SettingsStore}; +``` +to: +```rust +use crate::settings::AppSettings; +use crate::settings_client::SettingsClient; +``` + +Line 21, change: +```rust +pub fn run(store: SettingsStore) -> Result<(), Box> { +``` +to: +```rust +pub fn run(store: SettingsClient) -> Result<(), Box> { +``` + +Line 122, inside `fn handle_command(...)`, change: +```rust + store: &SettingsStore, +``` +to: +```rust + store: &SettingsClient, +``` + +No other line in this file changes — every `store.load()?` / `store.save(&settings)?` call site keeps compiling unchanged because the method names and `?`-compatible error types match. + +- [ ] **Step 4: Update `host-tray.rs`** + +Current content (confirmed by reading the file during this plan's writing): +```rust +#![cfg_attr(windows, windows_subsystem = "windows")] + +#[cfg(windows)] +fn main() -> Result<(), Box> { + let store = + ledgerr_host::settings::SettingsStore::new(ledgerr_host::settings::default_settings_path()); + ledgerr_host::tray::runtime::run(store) +} + +#[cfg(not(windows))] +fn main() { + eprintln!("host-tray is currently supported on Windows builds only"); + std::process::exit(1); +} +``` + +Replace the `#[cfg(windows)]` block's body: +```rust +#[cfg(windows)] +fn main() -> Result<(), Box> { + let client = ledgerr_host::settings_client::SettingsClient::new(); + ledgerr_host::tray::runtime::run(client) +} +``` +The `#[cfg(not(windows))]` block is unchanged. The whole binary only does real work behind `#[cfg(windows)]` — `cargo build -p ledgerr-host --bin host-tray` on non-Windows still compiles both arms (proves the code is syntactically/type-correct) but only the Windows arm exercises `tray::runtime::run`. + +- [ ] **Step 5: Build** + +Run: `cargo build -p ledgerr-host --bin host-tray` +Expected: compiles clean on any platform (per Step 4's note, only the `#[cfg(windows)]` arm touches `runtime::run`, but both arms must still type-check). + +- [ ] **Step 6: Commit** + +```bash +git add crates/ledgerr-host/src/settings_client.rs crates/ledgerr-host/src/lib.rs \ + crates/ledgerr-host/src/bin/tauri/state.rs crates/ledgerr-host/src/bin/tauri/main.rs \ + crates/ledgerr-host/src/bin/host-tray.rs crates/ledgerr-host/src/tray/runtime.rs +git commit -m "feat(host-tray): settings now served by ledgrrr-service over HTTP, shared SettingsClient with host-tauri" +``` + +--- + +### Task 10: Full workspace verification + +**Files:** none (verification-only task). + +- [ ] **Step 1: Full workspace build** + +Run: `cargo check --workspace --all-targets --all-features` +Expected: clean. (Known pre-existing unrelated failure: none, assuming gh#162 has merged by the time this plan executes — if it hasn't, `ledger-core` won't compile at all and this whole plan is blocked on that landing first, per this plan's own research findings from the gh#162 work earlier this session.) + +- [ ] **Step 2: Full workspace test** + +Run: `cargo test --workspace --all-features` +Expected: same pass/fail set as `main` had before this plan started, plus the new tests from Tasks 5, 6, and 8 passing. No regressions. + +- [ ] **Step 3: Clippy** + +Run: `cargo clippy --workspace --all-targets --all-features` +Expected: clean (workspace lints deny `unsafe_code`; nothing in this plan introduces any). + +- [ ] **Step 4: Manual three-process smoke test** + +```bash +LEDGRRR_STATE_DIR=$(mktemp -d) ./target/debug/ledgrrr-service & +sleep 1 +curl -s http://127.0.0.1:15116/settings | python3 -m json.tool # confirm valid JSON +cargo run -p ledgerr-host --bin host-tauri & +sleep 2 +# In the Tauri window: change a setting, save it, close and reopen the settings panel, +# confirm the change persisted (proves the round-trip through ledgrrr-service, not +# just that the UI didn't crash). +kill %1 %2 +``` + +- [ ] **Step 5: Update `docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md`'s subsystem 4 row** + +This file exists in git history (commits `c829368`, `2e8d3ad`) but not in the current working tree. Restore it and mark subsystem 4 as Phase A complete: +```bash +git show 2e8d3ad:docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md > docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md +``` +Then edit the subsystem 4 row's "Plan file" column from `backlogged — issue #118` to reference this plan's filename, and update its status. Commit alongside. + +- [ ] **Step 6: Final commit** + +```bash +git add docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md +git commit -m "docs: mark gh#118 Phase A (settings unification) complete in the integration roadmap" +``` From e0bb2d60b1baca860c7a30c941c00c34a4a9fd09 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 19:26:20 +1000 Subject: [PATCH 03/16] feat(ledgrrr-settings): scaffold new crate, move settings_backend --- Cargo.toml | 1 + crates/ledgrrr-settings/Cargo.toml | 20 +++ .../ledgrrr-settings/src/backend/json_file.rs | 123 ++++++++++++++++++ crates/ledgrrr-settings/src/backend/mod.rs | 71 ++++++++++ .../src/backend/windows_registry.rs | 79 +++++++++++ crates/ledgrrr-settings/src/lib.rs | 12 ++ crates/ledgrrr-settings/src/model_provider.rs | 5 + crates/ledgrrr-settings/src/notification.rs | 11 ++ crates/ledgrrr-settings/src/path.rs | 9 ++ crates/ledgrrr-settings/src/schema.rs | 14 ++ crates/ledgrrr-settings/src/store.rs | 8 ++ 11 files changed, 353 insertions(+) create mode 100644 crates/ledgrrr-settings/Cargo.toml create mode 100644 crates/ledgrrr-settings/src/backend/json_file.rs create mode 100644 crates/ledgrrr-settings/src/backend/mod.rs create mode 100644 crates/ledgrrr-settings/src/backend/windows_registry.rs create mode 100644 crates/ledgrrr-settings/src/lib.rs create mode 100644 crates/ledgrrr-settings/src/model_provider.rs create mode 100644 crates/ledgrrr-settings/src/notification.rs create mode 100644 crates/ledgrrr-settings/src/path.rs create mode 100644 crates/ledgrrr-settings/src/schema.rs create mode 100644 crates/ledgrrr-settings/src/store.rs diff --git a/Cargo.toml b/Cargo.toml index b2ad984..2444b28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/ledger-core", "crates/ledger-attest", "crates/ledgerr-focus", + "crates/ledgrrr-settings", "crates/ledgerr-host", "crates/ledgerr-mcp", "crates/ledgerr-mcp-core", diff --git a/crates/ledgrrr-settings/Cargo.toml b/crates/ledgrrr-settings/Cargo.toml new file mode 100644 index 0000000..960ca7a --- /dev/null +++ b/crates/ledgrrr-settings/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ledgrrr-settings" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows-registry = "0.6" + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/ledgrrr-settings/src/backend/json_file.rs b/crates/ledgrrr-settings/src/backend/json_file.rs new file mode 100644 index 0000000..78d12ca --- /dev/null +++ b/crates/ledgrrr-settings/src/backend/json_file.rs @@ -0,0 +1,123 @@ +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; + +use super::{SettingsBackend, SettingsBackendError}; + +/// A settings backend that stores key-value pairs as a JSON object in a file. +/// +/// The file path is platform-dependent and determined by [`crate::settings::default_settings_path`]. +/// Each key is a top-level field in the JSON object. The primary key used by +/// `SettingsStore` is `"app_settings"`, which holds the serialized `AppSettings`. +pub struct JsonFileBackend { + path: PathBuf, +} + +impl JsonFileBackend { + /// Create a new JSON file backend backed by the given path. + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// Read the entire key-value map from disk. Returns an empty map on `NotFound`. + fn read_map(&self) -> Result, SettingsBackendError> { + match std::fs::read_to_string(&self.path) { + Ok(raw) => Ok(serde_json::from_str(&raw).unwrap_or_default()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()), + Err(e) => Err(SettingsBackendError::Io(e)), + } + } + + /// Atomically write the key-value map to disk using a temp-file + rename strategy. + fn write_map(&self, map: &HashMap) -> Result<(), SettingsBackendError> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + + let temp_path = self.path.with_extension("json.tmp"); + let json = serde_json::to_vec_pretty(map)?; + let mut temp = std::fs::File::create(&temp_path)?; + temp.write_all(&json)?; + temp.flush()?; + drop(temp); + + // On Windows, `std::fs::rename` does not overwrite an existing destination. + #[cfg(windows)] + if self.path.exists() { + std::fs::remove_file(&self.path)?; + } + + std::fs::rename(temp_path, &self.path)?; + Ok(()) + } +} + +impl SettingsBackend for JsonFileBackend { + fn get(&self, key: &str) -> Result, SettingsBackendError> { + let map = self.read_map()?; + Ok(map.get(key).cloned()) + } + + fn set(&mut self, key: &str, value: &str) -> Result<(), SettingsBackendError> { + let mut map = self.read_map()?; + map.insert(key.to_owned(), value.to_owned()); + self.write_map(&map) + } + + fn delete(&mut self, key: &str) -> Result<(), SettingsBackendError> { + let mut map = self.read_map()?; + map.remove(key); + self.write_map(&map) + } + + fn get_all(&self) -> Result, SettingsBackendError> { + self.read_map() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn missing_file_returns_empty_get() { + let dir = tempdir().unwrap(); + let backend = JsonFileBackend::new(dir.path().join("nonexistent.json")); + assert_eq!(backend.get("any").unwrap(), None); + } + + #[test] + fn set_then_get_roundtrips() { + let dir = tempdir().unwrap(); + let path = dir.path().join("test.json"); + let mut backend = JsonFileBackend::new(path.clone()); + backend.set("greeting", "hello").unwrap(); + assert_eq!(backend.get("greeting").unwrap(), Some("hello".into())); + } + + #[test] + fn delete_removes_key() { + let dir = tempdir().unwrap(); + let path = dir.path().join("test.json"); + let mut backend = JsonFileBackend::new(path.clone()); + backend.set("k1", "v1").unwrap(); + backend.set("k2", "v2").unwrap(); + backend.delete("k1").unwrap(); + assert_eq!(backend.get("k1").unwrap(), None); + assert_eq!(backend.get("k2").unwrap(), Some("v2".into())); + } + + #[test] + fn get_all_returns_all_keys() { + let dir = tempdir().unwrap(); + let path = dir.path().join("test.json"); + let mut backend = JsonFileBackend::new(path.clone()); + backend.set("a", "1").unwrap(); + backend.set("b", "2").unwrap(); + let all = backend.get_all().unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all.get("a"), Some(&"1".into())); + assert_eq!(all.get("b"), Some(&"2".into())); + } +} diff --git a/crates/ledgrrr-settings/src/backend/mod.rs b/crates/ledgrrr-settings/src/backend/mod.rs new file mode 100644 index 0000000..f22d2d0 --- /dev/null +++ b/crates/ledgrrr-settings/src/backend/mod.rs @@ -0,0 +1,71 @@ +//! Platform-agnostic settings storage abstraction. +//! +//! On Windows, settings are stored in the registry at +//! `HKEY_CURRENT_USER\Software\b00t\settings` via the `windows-registry` crate. +//! On other platforms, settings are stored in a JSON file (see `json_file`). +//! +//! The trait is intentionally simple: string-keyed, string-valued. +//! `SettingsStore` in the parent `settings` module handles serialization of +//! the structured `AppSettings` type into the single `"app_settings"` key. + +use std::collections::HashMap; + +mod json_file; +#[cfg(windows)] +mod windows_registry; + +pub use json_file::JsonFileBackend; +#[cfg(windows)] +pub use windows_registry::WindowsRegistryBackend; + +use thiserror::Error; + +/// Errors that can occur during settings storage operations. +#[derive(Debug, Error)] +pub enum SettingsBackendError { + /// An I/O error occurred during file operations. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + /// A JSON serialization/deserialization error. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + /// A platform-specific error (e.g., registry access failure). + #[error("{0}")] + Platform(String), +} + +/// Key-value settings storage abstraction. +/// +/// Each implementation provides platform-appropriate persistence. +/// Methods are thread-safe through external synchronization. +pub trait SettingsBackend: Send { + /// Read a value by key. Returns `None` if the key does not exist. + fn get(&self, key: &str) -> Result, SettingsBackendError>; + /// Write a value by key. Overwrites any existing value for the key. + fn set(&mut self, key: &str, value: &str) -> Result<(), SettingsBackendError>; + /// Remove a key-value pair. No-op if the key does not exist. + fn delete(&mut self, key: &str) -> Result<(), SettingsBackendError>; + /// Return all key-value pairs in the store. + fn get_all(&self) -> Result, SettingsBackendError>; +} + +/// Create the platform-appropriate settings backend with JSON file fallback. +/// +/// On Windows, tries the registry first and falls back to the JSON file +/// on failure (e.g., permission denied). On other platforms, always uses +/// the JSON file backend. +pub fn create_backend(path: &std::path::Path) -> Box { + #[cfg(windows)] + { + match WindowsRegistryBackend::new() { + Ok(backend) => return Box::new(backend), + Err(e) => { + eprintln!( + "ledgerr-host: failed to open registry, \ + falling back to JSON file: {e}" + ); + } + } + } + Box::new(JsonFileBackend::new(path.to_path_buf())) +} diff --git a/crates/ledgrrr-settings/src/backend/windows_registry.rs b/crates/ledgrrr-settings/src/backend/windows_registry.rs new file mode 100644 index 0000000..27b5468 --- /dev/null +++ b/crates/ledgrrr-settings/src/backend/windows_registry.rs @@ -0,0 +1,79 @@ +//! Windows Registry settings backend. +//! +//! Stores key-value pairs under `HKEY_CURRENT_USER\Software\b00t\settings`. +//! Each setting is a `REG_SZ` value named by the key. +//! +//! This module is only compiled on Windows targets (see `#[cfg(windows)]` on +//! the `mod windows_registry` declaration in the parent module). + +use std::collections::HashMap; + +use windows_registry::*; + +use super::{SettingsBackend, SettingsBackendError}; + +/// Registry path under HKCU where settings are stored. +const SETTINGS_PATH: &str = r"software\b00t\settings"; + +/// A settings backend backed by the Windows Registry. +/// +/// Opens and closes the registry key on each operation. This avoids holding +/// a `Key` handle (which is `!Send + !Sync`) across thread boundaries and +/// keeps the struct trivially `Send`. +pub struct WindowsRegistryBackend; + +impl WindowsRegistryBackend { + /// Create a new registry backend. Validates the key can be opened/created. + pub fn new() -> Result { + let _key = Self::open_key()?; + Ok(Self) + } + + fn open_key() -> Result { + CURRENT_USER + .options() + .read() + .write() + .create() + .open(SETTINGS_PATH) + .map_err(|e| { + SettingsBackendError::Platform(format!("failed to open registry key: {e}")) + }) + } +} + +impl SettingsBackend for WindowsRegistryBackend { + fn get(&self, key: &str) -> Result, SettingsBackendError> { + let k = Self::open_key()?; + match k.get_string(key) { + Ok(v) => Ok(Some(v)), + Err(_) => Ok(None), + } + } + + fn set(&mut self, key: &str, value: &str) -> Result<(), SettingsBackendError> { + let k = Self::open_key()?; + k.set_string(key, value) + .map_err(|e| SettingsBackendError::Platform(format!("registry write failed: {e}")))?; + Ok(()) + } + + fn delete(&mut self, key: &str) -> Result<(), SettingsBackendError> { + let k = Self::open_key()?; + k.remove_value(key) + .map_err(|e| SettingsBackendError::Platform(format!("registry delete failed: {e}")))?; + Ok(()) + } + + fn get_all(&self) -> Result, SettingsBackendError> { + let mut map = HashMap::new(); + + // Query known settings keys. Currently only "app_settings" is used + // by SettingsStore. This can be extended as more keys are added. + if let Some(val) = self.get("app_settings")? { + map.insert("app_settings".to_owned(), val); + } + + Ok(map) + } +} diff --git a/crates/ledgrrr-settings/src/lib.rs b/crates/ledgrrr-settings/src/lib.rs new file mode 100644 index 0000000..5b03f52 --- /dev/null +++ b/crates/ledgrrr-settings/src/lib.rs @@ -0,0 +1,12 @@ +pub mod backend; +pub mod model_provider; +pub mod notification; +pub mod path; +pub mod schema; +pub mod store; + +pub use model_provider::ModelProviderLabel; +pub use notification::{NotificationBackend, NotificationStatus, NotificationTestResult}; +pub use path::default_settings_path; +pub use schema::{AppSettings, ChatSettings, SettingsSchemaVersion, ShowNotificationsFor}; +pub use store::{SettingsError, SettingsStore}; diff --git a/crates/ledgrrr-settings/src/model_provider.rs b/crates/ledgrrr-settings/src/model_provider.rs new file mode 100644 index 0000000..f1d2f39 --- /dev/null +++ b/crates/ledgrrr-settings/src/model_provider.rs @@ -0,0 +1,5 @@ +//! Model provider configuration and types. +//! (To be populated by a later task) + +/// Model provider label (stub - to be populated in later task) +pub struct ModelProviderLabel; diff --git a/crates/ledgrrr-settings/src/notification.rs b/crates/ledgrrr-settings/src/notification.rs new file mode 100644 index 0000000..350cc3c --- /dev/null +++ b/crates/ledgrrr-settings/src/notification.rs @@ -0,0 +1,11 @@ +//! Notification types and configuration. +//! (To be populated by a later task) + +/// Notification backend (stub - to be populated in later task) +pub struct NotificationBackend; + +/// Notification status (stub - to be populated in later task) +pub struct NotificationStatus; + +/// Notification test result (stub - to be populated in later task) +pub struct NotificationTestResult; diff --git a/crates/ledgrrr-settings/src/path.rs b/crates/ledgrrr-settings/src/path.rs new file mode 100644 index 0000000..b86cf7b --- /dev/null +++ b/crates/ledgrrr-settings/src/path.rs @@ -0,0 +1,9 @@ +//! Settings file path utilities. +//! (To be populated by a later task) + +use std::path::PathBuf; + +/// Return the default settings path (stub - to be populated in later task) +pub fn default_settings_path() -> PathBuf { + PathBuf::new() +} diff --git a/crates/ledgrrr-settings/src/schema.rs b/crates/ledgrrr-settings/src/schema.rs new file mode 100644 index 0000000..9200f2d --- /dev/null +++ b/crates/ledgrrr-settings/src/schema.rs @@ -0,0 +1,14 @@ +//! Application settings schema and types. +//! (To be populated by a later task) + +/// Application settings (stub - to be populated in later task) +pub struct AppSettings; + +/// Chat settings (stub - to be populated in later task) +pub struct ChatSettings; + +/// Settings schema version (stub - to be populated in later task) +pub struct SettingsSchemaVersion; + +/// Show notifications for (stub - to be populated in later task) +pub struct ShowNotificationsFor; diff --git a/crates/ledgrrr-settings/src/store.rs b/crates/ledgrrr-settings/src/store.rs new file mode 100644 index 0000000..b522b82 --- /dev/null +++ b/crates/ledgrrr-settings/src/store.rs @@ -0,0 +1,8 @@ +//! Settings storage and management. +//! (To be populated by a later task) + +/// Settings error (stub - to be populated in later task) +pub struct SettingsError; + +/// Settings store (stub - to be populated in later task) +pub struct SettingsStore; From 7d6ad272eeea91e23e0b0c229e511a0d7ebecd27 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 19:31:24 +1000 Subject: [PATCH 04/16] fix(ledgrrr-settings): remove fabricated stub modules, scope lib.rs to Task 1's actual deliverable (backend only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes five stub files that Task 1's implementer created to satisfy the given lib.rs template (model_provider.rs, notification.rs, path.rs, schema.rs, store.rs). These stubs were placeholders for real modules that Tasks 2–4 will deliver, and their presence forces unnecessary overwrite/conflict resolution in those tasks. lib.rs is now scoped to only Task 1's actual deliverable: the backend module and its public API, with no synthetic re-exports. Later tasks (2, 3, 4) will add their own modules incrementally as they land their real implementations. Verification: - cargo check -p ledgrrr-settings --all-targets: PASS - cargo test -p ledgrrr-settings: 4/4 backend tests pass - cargo check --workspace: PASS Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 12 ++++++++++++ crates/ledgrrr-settings/src/lib.rs | 11 ----------- crates/ledgrrr-settings/src/model_provider.rs | 5 ----- crates/ledgrrr-settings/src/notification.rs | 11 ----------- crates/ledgrrr-settings/src/path.rs | 9 --------- crates/ledgrrr-settings/src/schema.rs | 14 -------------- crates/ledgrrr-settings/src/store.rs | 8 -------- 7 files changed, 12 insertions(+), 58 deletions(-) delete mode 100644 crates/ledgrrr-settings/src/model_provider.rs delete mode 100644 crates/ledgrrr-settings/src/notification.rs delete mode 100644 crates/ledgrrr-settings/src/path.rs delete mode 100644 crates/ledgrrr-settings/src/schema.rs delete mode 100644 crates/ledgrrr-settings/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index 43c3efa..6152719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5920,6 +5920,18 @@ dependencies = [ "url", ] +[[package]] +name = "ledgrrr-settings" +version = "1.9.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "windows-registry", +] + [[package]] name = "lexical-core" version = "1.0.6" diff --git a/crates/ledgrrr-settings/src/lib.rs b/crates/ledgrrr-settings/src/lib.rs index 5b03f52..fceb141 100644 --- a/crates/ledgrrr-settings/src/lib.rs +++ b/crates/ledgrrr-settings/src/lib.rs @@ -1,12 +1 @@ pub mod backend; -pub mod model_provider; -pub mod notification; -pub mod path; -pub mod schema; -pub mod store; - -pub use model_provider::ModelProviderLabel; -pub use notification::{NotificationBackend, NotificationStatus, NotificationTestResult}; -pub use path::default_settings_path; -pub use schema::{AppSettings, ChatSettings, SettingsSchemaVersion, ShowNotificationsFor}; -pub use store::{SettingsError, SettingsStore}; diff --git a/crates/ledgrrr-settings/src/model_provider.rs b/crates/ledgrrr-settings/src/model_provider.rs deleted file mode 100644 index f1d2f39..0000000 --- a/crates/ledgrrr-settings/src/model_provider.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Model provider configuration and types. -//! (To be populated by a later task) - -/// Model provider label (stub - to be populated in later task) -pub struct ModelProviderLabel; diff --git a/crates/ledgrrr-settings/src/notification.rs b/crates/ledgrrr-settings/src/notification.rs deleted file mode 100644 index 350cc3c..0000000 --- a/crates/ledgrrr-settings/src/notification.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Notification types and configuration. -//! (To be populated by a later task) - -/// Notification backend (stub - to be populated in later task) -pub struct NotificationBackend; - -/// Notification status (stub - to be populated in later task) -pub struct NotificationStatus; - -/// Notification test result (stub - to be populated in later task) -pub struct NotificationTestResult; diff --git a/crates/ledgrrr-settings/src/path.rs b/crates/ledgrrr-settings/src/path.rs deleted file mode 100644 index b86cf7b..0000000 --- a/crates/ledgrrr-settings/src/path.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Settings file path utilities. -//! (To be populated by a later task) - -use std::path::PathBuf; - -/// Return the default settings path (stub - to be populated in later task) -pub fn default_settings_path() -> PathBuf { - PathBuf::new() -} diff --git a/crates/ledgrrr-settings/src/schema.rs b/crates/ledgrrr-settings/src/schema.rs deleted file mode 100644 index 9200f2d..0000000 --- a/crates/ledgrrr-settings/src/schema.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Application settings schema and types. -//! (To be populated by a later task) - -/// Application settings (stub - to be populated in later task) -pub struct AppSettings; - -/// Chat settings (stub - to be populated in later task) -pub struct ChatSettings; - -/// Settings schema version (stub - to be populated in later task) -pub struct SettingsSchemaVersion; - -/// Show notifications for (stub - to be populated in later task) -pub struct ShowNotificationsFor; diff --git a/crates/ledgrrr-settings/src/store.rs b/crates/ledgrrr-settings/src/store.rs deleted file mode 100644 index b522b82..0000000 --- a/crates/ledgrrr-settings/src/store.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Settings storage and management. -//! (To be populated by a later task) - -/// Settings error (stub - to be populated in later task) -pub struct SettingsError; - -/// Settings store (stub - to be populated in later task) -pub struct SettingsStore; From 324e4c38c538433afc6e96f6d951d2b276f92597 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 20:09:08 +1000 Subject: [PATCH 05/16] refactor(ledgerr-host): move NotificationBackend/Status/TestResult to ledgrrr-settings Move three notification types from ledgerr-host to the new ledgrrr-settings crate: - NotificationBackend enum - NotificationStatus enum - NotificationTestResult struct These types are now re-exported from ledgrrr_settings in both ledgerr-host and ledgrrr-settings for downstream consumption. Co-Authored-By: Claude Sonnet 5 --- crates/ledgerr-host/Cargo.toml | 1 + crates/ledgerr-host/src/notify/types.rs | 28 +------------------- crates/ledgrrr-settings/src/lib.rs | 3 +++ crates/ledgrrr-settings/src/notification.rs | 29 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 27 deletions(-) create mode 100644 crates/ledgrrr-settings/src/notification.rs diff --git a/crates/ledgerr-host/Cargo.toml b/crates/ledgerr-host/Cargo.toml index 1118c01..378d2bc 100644 --- a/crates/ledgerr-host/Cargo.toml +++ b/crates/ledgerr-host/Cargo.toml @@ -33,6 +33,7 @@ chrono = { workspace = true } reqwest = { workspace = true } rig-core = "0.35.0" ledger-core = { workspace = true } +ledgrrr-settings = { path = "../ledgrrr-settings" } arc-kit-au = { path = "../arc-kit-au" } holon-viz = { path = "../holon-viz" } ledgerr-desktop-agent = { path = "../ledgerr-desktop-agent" } diff --git a/crates/ledgerr-host/src/notify/types.rs b/crates/ledgerr-host/src/notify/types.rs index d70bcf5..4eb6f4d 100644 --- a/crates/ledgerr-host/src/notify/types.rs +++ b/crates/ledgerr-host/src/notify/types.rs @@ -1,24 +1,7 @@ -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use thiserror::Error; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum NotificationBackend { - Auto, - PowerShell, - Noop, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum NotificationStatus { - Disabled, - Unknown, - Ready, - Degraded, - Failed, -} +pub use ledgrrr_settings::{NotificationBackend, NotificationStatus, NotificationTestResult}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -31,15 +14,6 @@ pub enum NotificationEvent { Test { title: String, body: String }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct NotificationTestResult { - pub status: NotificationStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timestamp: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NotificationSettings { pub enabled: bool, diff --git a/crates/ledgrrr-settings/src/lib.rs b/crates/ledgrrr-settings/src/lib.rs index fceb141..b04c275 100644 --- a/crates/ledgrrr-settings/src/lib.rs +++ b/crates/ledgrrr-settings/src/lib.rs @@ -1 +1,4 @@ pub mod backend; +pub mod notification; + +pub use notification::{NotificationBackend, NotificationStatus, NotificationTestResult}; diff --git a/crates/ledgrrr-settings/src/notification.rs b/crates/ledgrrr-settings/src/notification.rs new file mode 100644 index 0000000..f4b7764 --- /dev/null +++ b/crates/ledgrrr-settings/src/notification.rs @@ -0,0 +1,29 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationBackend { + Auto, + PowerShell, + Noop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationStatus { + Disabled, + Unknown, + Ready, + Degraded, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotificationTestResult { + pub status: NotificationStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} From 2623051a8f42fbc23d96277341e47a2c44155481 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 20:24:02 +1000 Subject: [PATCH 06/16] refactor(ledgerr-host): move ModelProviderLabel to ledgrrr-settings Move ModelProviderLabel enum and ProviderReadiness to ledgrrr-settings/model_provider. Implement display_name() and description() as inherent methods. Methods requiring ledgerr-host types (chat_settings, readiness) are provided via ModelProviderExt trait to avoid circular dependencies. - Creates crates/ledgrrr-settings/src/model_provider.rs with ModelProviderLabel and ProviderReadiness enums plus basic inherent methods - Updates internal_openai.rs to re-export types and implement extension trait - Maintains API compatibility: trait is automatically in scope via wildcard imports Co-Authored-By: Claude Sonnet 5 --- crates/ledgerr-host/src/internal_openai.rs | 67 +++---------------- crates/ledgrrr-settings/Cargo.toml | 1 + crates/ledgrrr-settings/src/lib.rs | 2 + crates/ledgrrr-settings/src/model_provider.rs | 59 ++++++++++++++++ 4 files changed, 71 insertions(+), 58 deletions(-) create mode 100644 crates/ledgrrr-settings/src/model_provider.rs diff --git a/crates/ledgerr-host/src/internal_openai.rs b/crates/ledgerr-host/src/internal_openai.rs index 4e62bfd..e2e8a58 100644 --- a/crates/ledgerr-host/src/internal_openai.rs +++ b/crates/ledgerr-host/src/internal_openai.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::settings::ChatSettings; +pub use ledgrrr_settings::{ModelProviderLabel, ProviderReadiness}; pub const INTERNAL_OPENAI_ADDR: &str = "127.0.0.1:15115"; pub const INTERNAL_OPENAI_CHAT_URL: &str = "http://127.0.0.1:15115/v1/chat/completions"; @@ -37,39 +38,14 @@ pub const FOUNDRY_LOCAL_MODEL: &str = "phi-4-mini"; pub const FOUNDRY_LOCAL_API_KEY: &str = "local-foundry"; pub const FOUNDRY_LOCAL_DEFAULT_CHAT_URL: &str = "http://localhost:5272/v1/chat/completions"; -/// Operator-facing model provider label. -/// -/// This label is shown in the host UI instead of the technical backend name. -/// Each label maps to a readiness state and a setup path. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] -#[serde(rename_all = "snake_case")] -pub enum ModelProviderLabel { - /// Private local inference. Works immediately. May use a deterministic stub if no GGUF is configured. - LocalDemo, - /// Private local inference via Windows AI / Foundry Local. Requires setup first. - WindowsAi, - /// Explicit external API call. Requires operator-supplied endpoint and key. - Cloud, -} - -impl ModelProviderLabel { - pub fn display_name(&self) -> &'static str { - match self { - Self::LocalDemo => "Local Demo", - Self::WindowsAi => "Windows AI", - Self::Cloud => "Cloud", - } - } - - pub fn description(&self) -> &'static str { - match self { - Self::LocalDemo => "Works immediately. Private. May use a deterministic fallback if no GGUF model is configured.", - Self::WindowsAi => "Private. Requires Windows AI / Foundry Local setup first.", - Self::Cloud => "Explicit external call. Requires endpoint and API key.", - } - } +/// Extension trait for ModelProviderLabel methods that depend on this module's types. +pub trait ModelProviderExt { + fn chat_settings(&self, system_prompt: impl Into) -> Result; + fn readiness(&self, settings: &crate::settings::AppSettings) -> ProviderReadiness; +} - pub fn chat_settings(&self, system_prompt: impl Into) -> Result { +impl ModelProviderExt for ModelProviderLabel { + fn chat_settings(&self, system_prompt: impl Into) -> Result { match self { Self::LocalDemo => Ok(local_demo_chat_settings(system_prompt)), Self::WindowsAi => windows_ai_chat_settings(system_prompt), @@ -77,8 +53,7 @@ impl ModelProviderLabel { } } - /// Readiness for this provider. Requires AppSettings for accurate cloud detection. - pub fn readiness(&self, settings: &crate::settings::AppSettings) -> ProviderReadiness { + fn readiness(&self, settings: &crate::settings::AppSettings) -> ProviderReadiness { match self { Self::LocalDemo => local_demo_readiness(), Self::WindowsAi => windows_ai_readiness(), @@ -87,30 +62,6 @@ impl ModelProviderLabel { } } -/// Readiness state for a model provider. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] -#[serde(rename_all = "snake_case")] -pub enum ProviderReadiness { - /// Provider can send requests now. - Ready, - /// Provider needs one setup step before use. - SetupNeeded { next_command: String }, - /// Provider cannot be used in the current environment. - Unavailable { reason: String }, - /// Provider endpoint exists but a smoke test or model load failed. - Diagnostic { reason: String }, -} - -impl std::fmt::Display for ProviderReadiness { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Ready => write!(f, "Ready"), - Self::SetupNeeded { next_command } => write!(f, "Setup Needed — run: {next_command}"), - Self::Unavailable { reason } => write!(f, "Unavailable — {reason}"), - Self::Diagnostic { reason } => write!(f, "Diagnostic — {reason}"), - } - } -} /// Combined provider info for the host UI. /// diff --git a/crates/ledgrrr-settings/Cargo.toml b/crates/ledgrrr-settings/Cargo.toml index 960ca7a..7fb8d99 100644 --- a/crates/ledgrrr-settings/Cargo.toml +++ b/crates/ledgrrr-settings/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true chrono = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +specta = { version = "=2.0.0-rc.25", features = ["derive"] } thiserror = { workspace = true } [target.'cfg(windows)'.dependencies] diff --git a/crates/ledgrrr-settings/src/lib.rs b/crates/ledgrrr-settings/src/lib.rs index b04c275..ce855d6 100644 --- a/crates/ledgrrr-settings/src/lib.rs +++ b/crates/ledgrrr-settings/src/lib.rs @@ -1,4 +1,6 @@ pub mod backend; +pub mod model_provider; pub mod notification; +pub use model_provider::{ModelProviderLabel, ProviderReadiness}; pub use notification::{NotificationBackend, NotificationStatus, NotificationTestResult}; diff --git a/crates/ledgrrr-settings/src/model_provider.rs b/crates/ledgrrr-settings/src/model_provider.rs new file mode 100644 index 0000000..1a911a3 --- /dev/null +++ b/crates/ledgrrr-settings/src/model_provider.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +/// Operator-facing model provider label. +/// +/// This label is shown in the host UI instead of the technical backend name. +/// Each label maps to a readiness state and a setup path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "snake_case")] +pub enum ModelProviderLabel { + /// Private local inference. Works immediately. May use a deterministic stub if no GGUF is configured. + LocalDemo, + /// Private local inference via Windows AI / Foundry Local. Requires setup first. + WindowsAi, + /// Explicit external API call. Requires operator-supplied endpoint and key. + Cloud, +} + +impl ModelProviderLabel { + pub fn display_name(&self) -> &'static str { + match self { + Self::LocalDemo => "Local Demo", + Self::WindowsAi => "Windows AI", + Self::Cloud => "Cloud", + } + } + + pub fn description(&self) -> &'static str { + match self { + Self::LocalDemo => "Works immediately. Private. May use a deterministic fallback if no GGUF model is configured.", + Self::WindowsAi => "Private. Requires Windows AI / Foundry Local setup first.", + Self::Cloud => "Explicit external call. Requires endpoint and API key.", + } + } +} + +/// Readiness state for a model provider. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "snake_case")] +pub enum ProviderReadiness { + /// Provider can send requests now. + Ready, + /// Provider needs one setup step before use. + SetupNeeded { next_command: String }, + /// Provider cannot be used in the current environment. + Unavailable { reason: String }, + /// Provider endpoint exists but a smoke test or model load failed. + Diagnostic { reason: String }, +} + +impl std::fmt::Display for ProviderReadiness { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ready => write!(f, "Ready"), + Self::SetupNeeded { next_command } => write!(f, "Setup Needed — run: {next_command}"), + Self::Unavailable { reason } => write!(f, "Unavailable — {reason}"), + Self::Diagnostic { reason } => write!(f, "Diagnostic — {reason}"), + } + } +} From 45dcff8ac7901e1b39079b964d55f1cd9e3922e1 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 20:44:44 +1000 Subject: [PATCH 07/16] refactor(ledgerr-host): move AppSettings/SettingsStore/default_settings_path to ledgrrr-settings --- crates/ledgerr-host/src/internal_openai.rs | 11 ++ crates/ledgerr-host/src/lib.rs | 1 - crates/ledgerr-host/src/settings/mod.rs | 11 +- crates/ledgerr-host/src/settings_backend.rs | 71 ---------- .../src/settings_backend/json_file.rs | 123 ------------------ .../src/settings_backend/windows_registry.rs | 79 ----------- crates/ledgrrr-settings/src/lib.rs | 6 + .../settings => ledgrrr-settings/src}/path.rs | 0 .../src}/schema.rs | 20 +-- .../src}/store.rs | 4 +- 10 files changed, 25 insertions(+), 301 deletions(-) delete mode 100644 crates/ledgerr-host/src/settings_backend.rs delete mode 100644 crates/ledgerr-host/src/settings_backend/json_file.rs delete mode 100644 crates/ledgerr-host/src/settings_backend/windows_registry.rs rename crates/{ledgerr-host/src/settings => ledgrrr-settings/src}/path.rs (100%) rename crates/{ledgerr-host/src/settings => ledgrrr-settings/src}/schema.rs (82%) rename crates/{ledgerr-host/src/settings => ledgrrr-settings/src}/store.rs (97%) diff --git a/crates/ledgerr-host/src/internal_openai.rs b/crates/ledgerr-host/src/internal_openai.rs index e2e8a58..9bfa239 100644 --- a/crates/ledgerr-host/src/internal_openai.rs +++ b/crates/ledgerr-host/src/internal_openai.rs @@ -1710,6 +1710,17 @@ mod tests { } } +/// Resolve ChatSettings from the operator's model_provider choice. +/// +/// Returns (resolved_settings, Option) where the second +/// element is Some when a fallback occurred (e.g., WindowsAi selected but +/// Foundry not installed). The caller decides whether to surface the warning. +pub fn resolve_chat( + settings: &ledgrrr_settings::AppSettings, +) -> (ChatSettings, Option) { + resolve_chat_settings(settings) +} + /// Resolve active ChatSettings from the AppSettings model_provider field. /// /// Returns the resolved settings and an optional warning if a fallback occurred. diff --git a/crates/ledgerr-host/src/lib.rs b/crates/ledgerr-host/src/lib.rs index 3c3ea40..baaae2f 100644 --- a/crates/ledgerr-host/src/lib.rs +++ b/crates/ledgerr-host/src/lib.rs @@ -9,7 +9,6 @@ pub mod local_llm; pub mod local_llm_mistral; pub mod notify; pub mod settings; -pub mod settings_backend; pub mod tray; pub use evidence::{EvidenceState, TodayQueue}; pub use internal_openai::{ diff --git a/crates/ledgerr-host/src/settings/mod.rs b/crates/ledgerr-host/src/settings/mod.rs index 54de044..ecbcf0e 100644 --- a/crates/ledgerr-host/src/settings/mod.rs +++ b/crates/ledgerr-host/src/settings/mod.rs @@ -1,7 +1,4 @@ -mod path; -mod schema; -mod store; - -pub use path::default_settings_path; -pub use schema::{AppSettings, ChatSettings, SettingsSchemaVersion, ShowNotificationsFor}; -pub use store::{SettingsError, SettingsStore}; +pub use ledgrrr_settings::{ + default_settings_path, AppSettings, ChatSettings, SettingsError, SettingsSchemaVersion, + SettingsStore, ShowNotificationsFor, +}; diff --git a/crates/ledgerr-host/src/settings_backend.rs b/crates/ledgerr-host/src/settings_backend.rs deleted file mode 100644 index f22d2d0..0000000 --- a/crates/ledgerr-host/src/settings_backend.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Platform-agnostic settings storage abstraction. -//! -//! On Windows, settings are stored in the registry at -//! `HKEY_CURRENT_USER\Software\b00t\settings` via the `windows-registry` crate. -//! On other platforms, settings are stored in a JSON file (see `json_file`). -//! -//! The trait is intentionally simple: string-keyed, string-valued. -//! `SettingsStore` in the parent `settings` module handles serialization of -//! the structured `AppSettings` type into the single `"app_settings"` key. - -use std::collections::HashMap; - -mod json_file; -#[cfg(windows)] -mod windows_registry; - -pub use json_file::JsonFileBackend; -#[cfg(windows)] -pub use windows_registry::WindowsRegistryBackend; - -use thiserror::Error; - -/// Errors that can occur during settings storage operations. -#[derive(Debug, Error)] -pub enum SettingsBackendError { - /// An I/O error occurred during file operations. - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - /// A JSON serialization/deserialization error. - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), - /// A platform-specific error (e.g., registry access failure). - #[error("{0}")] - Platform(String), -} - -/// Key-value settings storage abstraction. -/// -/// Each implementation provides platform-appropriate persistence. -/// Methods are thread-safe through external synchronization. -pub trait SettingsBackend: Send { - /// Read a value by key. Returns `None` if the key does not exist. - fn get(&self, key: &str) -> Result, SettingsBackendError>; - /// Write a value by key. Overwrites any existing value for the key. - fn set(&mut self, key: &str, value: &str) -> Result<(), SettingsBackendError>; - /// Remove a key-value pair. No-op if the key does not exist. - fn delete(&mut self, key: &str) -> Result<(), SettingsBackendError>; - /// Return all key-value pairs in the store. - fn get_all(&self) -> Result, SettingsBackendError>; -} - -/// Create the platform-appropriate settings backend with JSON file fallback. -/// -/// On Windows, tries the registry first and falls back to the JSON file -/// on failure (e.g., permission denied). On other platforms, always uses -/// the JSON file backend. -pub fn create_backend(path: &std::path::Path) -> Box { - #[cfg(windows)] - { - match WindowsRegistryBackend::new() { - Ok(backend) => return Box::new(backend), - Err(e) => { - eprintln!( - "ledgerr-host: failed to open registry, \ - falling back to JSON file: {e}" - ); - } - } - } - Box::new(JsonFileBackend::new(path.to_path_buf())) -} diff --git a/crates/ledgerr-host/src/settings_backend/json_file.rs b/crates/ledgerr-host/src/settings_backend/json_file.rs deleted file mode 100644 index 78d12ca..0000000 --- a/crates/ledgerr-host/src/settings_backend/json_file.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::collections::HashMap; -use std::io::Write; -use std::path::PathBuf; - -use super::{SettingsBackend, SettingsBackendError}; - -/// A settings backend that stores key-value pairs as a JSON object in a file. -/// -/// The file path is platform-dependent and determined by [`crate::settings::default_settings_path`]. -/// Each key is a top-level field in the JSON object. The primary key used by -/// `SettingsStore` is `"app_settings"`, which holds the serialized `AppSettings`. -pub struct JsonFileBackend { - path: PathBuf, -} - -impl JsonFileBackend { - /// Create a new JSON file backend backed by the given path. - pub fn new(path: PathBuf) -> Self { - Self { path } - } - - /// Read the entire key-value map from disk. Returns an empty map on `NotFound`. - fn read_map(&self) -> Result, SettingsBackendError> { - match std::fs::read_to_string(&self.path) { - Ok(raw) => Ok(serde_json::from_str(&raw).unwrap_or_default()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()), - Err(e) => Err(SettingsBackendError::Io(e)), - } - } - - /// Atomically write the key-value map to disk using a temp-file + rename strategy. - fn write_map(&self, map: &HashMap) -> Result<(), SettingsBackendError> { - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent)?; - } - - let temp_path = self.path.with_extension("json.tmp"); - let json = serde_json::to_vec_pretty(map)?; - let mut temp = std::fs::File::create(&temp_path)?; - temp.write_all(&json)?; - temp.flush()?; - drop(temp); - - // On Windows, `std::fs::rename` does not overwrite an existing destination. - #[cfg(windows)] - if self.path.exists() { - std::fs::remove_file(&self.path)?; - } - - std::fs::rename(temp_path, &self.path)?; - Ok(()) - } -} - -impl SettingsBackend for JsonFileBackend { - fn get(&self, key: &str) -> Result, SettingsBackendError> { - let map = self.read_map()?; - Ok(map.get(key).cloned()) - } - - fn set(&mut self, key: &str, value: &str) -> Result<(), SettingsBackendError> { - let mut map = self.read_map()?; - map.insert(key.to_owned(), value.to_owned()); - self.write_map(&map) - } - - fn delete(&mut self, key: &str) -> Result<(), SettingsBackendError> { - let mut map = self.read_map()?; - map.remove(key); - self.write_map(&map) - } - - fn get_all(&self) -> Result, SettingsBackendError> { - self.read_map() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn missing_file_returns_empty_get() { - let dir = tempdir().unwrap(); - let backend = JsonFileBackend::new(dir.path().join("nonexistent.json")); - assert_eq!(backend.get("any").unwrap(), None); - } - - #[test] - fn set_then_get_roundtrips() { - let dir = tempdir().unwrap(); - let path = dir.path().join("test.json"); - let mut backend = JsonFileBackend::new(path.clone()); - backend.set("greeting", "hello").unwrap(); - assert_eq!(backend.get("greeting").unwrap(), Some("hello".into())); - } - - #[test] - fn delete_removes_key() { - let dir = tempdir().unwrap(); - let path = dir.path().join("test.json"); - let mut backend = JsonFileBackend::new(path.clone()); - backend.set("k1", "v1").unwrap(); - backend.set("k2", "v2").unwrap(); - backend.delete("k1").unwrap(); - assert_eq!(backend.get("k1").unwrap(), None); - assert_eq!(backend.get("k2").unwrap(), Some("v2".into())); - } - - #[test] - fn get_all_returns_all_keys() { - let dir = tempdir().unwrap(); - let path = dir.path().join("test.json"); - let mut backend = JsonFileBackend::new(path.clone()); - backend.set("a", "1").unwrap(); - backend.set("b", "2").unwrap(); - let all = backend.get_all().unwrap(); - assert_eq!(all.len(), 2); - assert_eq!(all.get("a"), Some(&"1".into())); - assert_eq!(all.get("b"), Some(&"2".into())); - } -} diff --git a/crates/ledgerr-host/src/settings_backend/windows_registry.rs b/crates/ledgerr-host/src/settings_backend/windows_registry.rs deleted file mode 100644 index d5301b6..0000000 --- a/crates/ledgerr-host/src/settings_backend/windows_registry.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Windows Registry settings backend. -//! -//! Stores key-value pairs under `HKEY_CURRENT_USER\Software\b00t\settings`. -//! Each setting is a `REG_SZ` value named by the key. -//! -//! This module is only compiled on Windows targets (see `#[cfg(windows)]` on -//! the `mod windows_registry` declaration in the parent module). - -use std::collections::HashMap; - -use windows_registry::{CURRENT_USER, Key}; - -use super::{SettingsBackend, SettingsBackendError}; - -/// Registry path under HKCU where settings are stored. -const SETTINGS_PATH: &str = r"software\b00t\settings"; - -/// A settings backend backed by the Windows Registry. -/// -/// Opens and closes the registry key on each operation. This avoids holding -/// a `Key` handle (which is `!Send + !Sync`) across thread boundaries and -/// keeps the struct trivially `Send`. -pub struct WindowsRegistryBackend; - -impl WindowsRegistryBackend { - /// Create a new registry backend. Validates the key can be opened/created. - pub fn new() -> Result { - let _key = Self::open_key()?; - Ok(Self) - } - - fn open_key() -> Result { - CURRENT_USER - .options() - .read() - .write() - .create() - .open(SETTINGS_PATH) - .map_err(|e| { - SettingsBackendError::Platform(format!("failed to open registry key: {e}")) - }) - } -} - -impl SettingsBackend for WindowsRegistryBackend { - fn get(&self, key: &str) -> Result, SettingsBackendError> { - let k = Self::open_key()?; - match k.get_string(key) { - Ok(v) => Ok(Some(v)), - Err(_) => Ok(None), - } - } - - fn set(&mut self, key: &str, value: &str) -> Result<(), SettingsBackendError> { - let k = Self::open_key()?; - k.set_string(key, value) - .map_err(|e| SettingsBackendError::Platform(format!("registry write failed: {e}")))?; - Ok(()) - } - - fn delete(&mut self, key: &str) -> Result<(), SettingsBackendError> { - let k = Self::open_key()?; - k.remove_value(key) - .map_err(|e| SettingsBackendError::Platform(format!("registry delete failed: {e}")))?; - Ok(()) - } - - fn get_all(&self) -> Result, SettingsBackendError> { - let mut map = HashMap::new(); - - // Query known settings keys. Currently only "app_settings" is used - // by SettingsStore. This can be extended as more keys are added. - if let Some(val) = self.get("app_settings")? { - map.insert("app_settings".to_owned(), val); - } - - Ok(map) - } -} diff --git a/crates/ledgrrr-settings/src/lib.rs b/crates/ledgrrr-settings/src/lib.rs index ce855d6..05746b8 100644 --- a/crates/ledgrrr-settings/src/lib.rs +++ b/crates/ledgrrr-settings/src/lib.rs @@ -1,6 +1,12 @@ pub mod backend; pub mod model_provider; pub mod notification; +pub mod path; +pub mod schema; +pub mod store; pub use model_provider::{ModelProviderLabel, ProviderReadiness}; pub use notification::{NotificationBackend, NotificationStatus, NotificationTestResult}; +pub use path::default_settings_path; +pub use schema::{AppSettings, ChatSettings, SettingsSchemaVersion, ShowNotificationsFor}; +pub use store::{SettingsError, SettingsStore}; diff --git a/crates/ledgerr-host/src/settings/path.rs b/crates/ledgrrr-settings/src/path.rs similarity index 100% rename from crates/ledgerr-host/src/settings/path.rs rename to crates/ledgrrr-settings/src/path.rs diff --git a/crates/ledgerr-host/src/settings/schema.rs b/crates/ledgrrr-settings/src/schema.rs similarity index 82% rename from crates/ledgerr-host/src/settings/schema.rs rename to crates/ledgrrr-settings/src/schema.rs index 2d75423..34fd8e6 100644 --- a/crates/ledgerr-host/src/settings/schema.rs +++ b/crates/ledgrrr-settings/src/schema.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; -use crate::internal_openai::ModelProviderLabel; -use crate::notify::{NotificationBackend, NotificationTestResult}; +use crate::model_provider::ModelProviderLabel; +use crate::notification::{NotificationBackend, NotificationTestResult}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -113,19 +113,3 @@ impl Default for AppSettings { } } } - -impl AppSettings { - /// Resolve ChatSettings from the operator's model_provider choice. - /// - /// Returns (resolved_settings, Option) where the second - /// element is Some when a fallback occurred (e.g., WindowsAi selected but - /// Foundry not installed). The caller decides whether to surface the warning. - pub fn resolve_chat( - &self, - ) -> ( - ChatSettings, - Option, - ) { - crate::internal_openai::resolve_chat_settings(self) - } -} diff --git a/crates/ledgerr-host/src/settings/store.rs b/crates/ledgrrr-settings/src/store.rs similarity index 97% rename from crates/ledgerr-host/src/settings/store.rs rename to crates/ledgrrr-settings/src/store.rs index 6273dcd..5aa01b9 100644 --- a/crates/ledgerr-host/src/settings/store.rs +++ b/crates/ledgrrr-settings/src/store.rs @@ -4,8 +4,8 @@ use std::sync::Mutex; use thiserror::Error; -use super::schema::{AppSettings, SettingsSchemaVersion}; -use crate::settings_backend::{create_backend, SettingsBackend, SettingsBackendError}; +use crate::backend::{create_backend, SettingsBackend, SettingsBackendError}; +use crate::schema::{AppSettings, SettingsSchemaVersion}; /// Errors that can occur during settings loading and saving. #[derive(Debug, Error)] From 03b248b4bf0660676b6881d0ff8d79206bb9562e Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 20:59:15 +1000 Subject: [PATCH 08/16] fix(ledgerr-desktop-agent): TRAY_CANDIDATES named a binary (ledgerr-tauri) that doesn't exist Co-Authored-By: Claude Sonnet 5 --- crates/ledgerr-desktop-agent/src/status.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ledgerr-desktop-agent/src/status.rs b/crates/ledgerr-desktop-agent/src/status.rs index 2e1c186..e173361 100644 --- a/crates/ledgerr-desktop-agent/src/status.rs +++ b/crates/ledgerr-desktop-agent/src/status.rs @@ -198,7 +198,6 @@ const TRAY_CANDIDATES: &[&str] = &[ "host-tauri.exe", "host-tray.exe", "host-tray", - "ledgerr-tauri", ]; /// Finds a tray binary next to this controller's own executable, then on @@ -287,3 +286,16 @@ pub fn collect() -> LedgrrrStatus { }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tray_candidates_matches_the_real_host_tauri_binary_name() { + assert!( + TRAY_CANDIDATES.contains(&"host-tauri"), + "TRAY_CANDIDATES must list the real host-tauri bin target, not a nonexistent ledgerr-tauri binary: {TRAY_CANDIDATES:?}" + ); + } +} From 2ca54f313ecb29d1136c7dabd6c88adb5539fb6e Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 21:02:35 +1000 Subject: [PATCH 09/16] feat(ledgerr-desktop-agent): add settings HTTP server (GET/POST /settings) --- crates/ledgerr-desktop-agent/Cargo.toml | 4 + crates/ledgerr-desktop-agent/src/lib.rs | 1 + .../src/settings_server.rs | 187 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 crates/ledgerr-desktop-agent/src/settings_server.rs diff --git a/crates/ledgerr-desktop-agent/Cargo.toml b/crates/ledgerr-desktop-agent/Cargo.toml index 0b4c67a..c990e7a 100644 --- a/crates/ledgerr-desktop-agent/Cargo.toml +++ b/crates/ledgerr-desktop-agent/Cargo.toml @@ -7,11 +7,15 @@ license.workspace = true [dependencies] blake3 = { workspace = true } getrandom = "0.3" +ledgrrr-settings = { path = "../ledgrrr-settings" } schemars = { version = "0.8", features = ["derive"] } serde = { workspace = true } serde_json = { workspace = true } sysinfo = { version = "0.33", default-features = false, features = ["system"] } thiserror = { workspace = true } +[dev-dependencies] +tempfile = { workspace = true } + [lints] workspace = true diff --git a/crates/ledgerr-desktop-agent/src/lib.rs b/crates/ledgerr-desktop-agent/src/lib.rs index dec29b5..1825912 100644 --- a/crates/ledgerr-desktop-agent/src/lib.rs +++ b/crates/ledgerr-desktop-agent/src/lib.rs @@ -13,6 +13,7 @@ pub mod playbook; pub mod render; pub mod runtime_client; pub mod service_control; +pub mod settings_server; pub mod simulate; pub mod state; pub mod status; diff --git a/crates/ledgerr-desktop-agent/src/settings_server.rs b/crates/ledgerr-desktop-agent/src/settings_server.rs new file mode 100644 index 0000000..70bfd65 --- /dev/null +++ b/crates/ledgerr-desktop-agent/src/settings_server.rs @@ -0,0 +1,187 @@ +//! HTTP settings server for `ledgrrr-service` — same hand-rolled, +//! nonblocking-`TcpListener` style as `ledgerr-host`'s `internal_openai.rs` +//! endpoint. GET /settings returns the current `AppSettings` as JSON; +//! POST /settings replaces them. No async runtime. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::time::Duration; + +use ledgrrr_settings::{AppSettings, SettingsStore}; + +pub const SETTINGS_SERVER_ADDR: &str = "127.0.0.1:15116"; + +fn json_response(status: u16, payload: &impl serde::Serialize) -> String { + let body = serde_json::to_string(payload) + .unwrap_or_else(|_| "{\"error\":\"serialization failure\"}".to_string()); + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "OK", + }; + format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn parse_content_length(headers: &str) -> Option { + headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().ok()) + .flatten() + }) +} + +fn route_request(raw: &[u8], store: &SettingsStore) -> String { + let Some(header_end) = find_header_end(raw) else { + return json_response(400, &serde_json::json!({ "error": "invalid request" })); + }; + let headers = String::from_utf8_lossy(&raw[..header_end]); + let request_line = headers.lines().next().unwrap_or_default(); + let body = &raw[header_end + 4..]; + + if request_line.starts_with("GET /settings ") || request_line.starts_with("GET /settings HTTP") { + return match store.load() { + Ok(settings) => json_response(200, &settings), + Err(error) => json_response(500, &serde_json::json!({ "error": error.to_string() })), + }; + } + + if request_line.starts_with("POST /settings ") || request_line.starts_with("POST /settings HTTP") { + let settings: AppSettings = match serde_json::from_slice(body) { + Ok(settings) => settings, + Err(error) => { + return json_response( + 400, + &serde_json::json!({ "error": format!("invalid settings body: {error}") }), + ); + } + }; + return match store.save(&settings) { + Ok(()) => json_response(200, &settings), + Err(error) => json_response(500, &serde_json::json!({ "error": error.to_string() })), + }; + } + + json_response(404, &serde_json::json!({ "error": "not found" })) +} + +fn request_complete(buffer: &[u8]) -> bool { + let Some(header_end) = find_header_end(buffer) else { + return false; + }; + let headers = String::from_utf8_lossy(&buffer[..header_end]); + let content_length = parse_content_length(&headers).unwrap_or_default(); + buffer.len() >= header_end + 4 + content_length +} + +fn handle_stream(mut stream: TcpStream, store: &SettingsStore) { + let mut buffer = Vec::with_capacity(4096); + let mut chunk = [0_u8; 2048]; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + buffer.extend_from_slice(&chunk[..n]); + if request_complete(&buffer) { + break; + } + } + Err(_) => break, + } + } + let response = route_request(&buffer, store); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +/// Bind the settings server and return the live listener, already set to +/// nonblocking so the caller can interleave `accept()` polling with other +/// periodic work (the heartbeat write in `ledgrrr-service`'s main loop). +pub fn bind() -> std::io::Result { + let listener = TcpListener::bind(SETTINGS_SERVER_ADDR)?; + listener.set_nonblocking(true)?; + Ok(listener) +} + +/// Poll the listener once. Call this in a loop; returns immediately if no +/// connection is pending (`WouldBlock`) rather than blocking, so the caller +/// stays free to also run heartbeat writes on the same thread. +pub fn accept_once(listener: &TcpListener, store: &SettingsStore) { + match listener.accept() { + Ok((stream, _)) => handle_stream(stream, store), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store_with_defaults() -> SettingsStore { + let dir = tempfile::tempdir().unwrap(); + SettingsStore::new(dir.path().join("settings.json")) + } + + #[test] + fn get_settings_returns_defaults_as_json() { + let store = store_with_defaults(); + let response = route_request(b"GET /settings HTTP/1.1\r\n\r\n", &store); + assert!(response.starts_with("HTTP/1.1 200 OK")); + let body_start = response.find("\r\n\r\n").unwrap() + 4; + let parsed: AppSettings = serde_json::from_str(&response[body_start..]).unwrap(); + assert!(parsed.toast_enabled); + } + + #[test] + fn post_settings_persists_and_get_reflects_it() { + let store = store_with_defaults(); + let mut updated = store.load().unwrap(); + updated.toast_enabled = false; + let body = serde_json::to_string(&updated).unwrap(); + let request = format!( + "POST /settings HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + + let post_response = route_request(request.as_bytes(), &store); + assert!(post_response.starts_with("HTTP/1.1 200 OK")); + + let get_response = route_request(b"GET /settings HTTP/1.1\r\n\r\n", &store); + let body_start = get_response.find("\r\n\r\n").unwrap() + 4; + let parsed: AppSettings = serde_json::from_str(&get_response[body_start..]).unwrap(); + assert!(!parsed.toast_enabled); + } + + #[test] + fn post_settings_rejects_malformed_json() { + let store = store_with_defaults(); + let body = "{not json"; + let request = format!( + "POST /settings HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let response = route_request(request.as_bytes(), &store); + assert!(response.starts_with("HTTP/1.1 400 Bad Request")); + } + + #[test] + fn unknown_route_returns_404() { + let store = store_with_defaults(); + let response = route_request(b"GET /nope HTTP/1.1\r\n\r\n", &store); + assert!(response.starts_with("HTTP/1.1 404 Not Found")); + } +} From 1c9720961e118ee98f466a033846cb1fd3f3caf4 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 21:07:05 +1000 Subject: [PATCH 10/16] feat(ledgerr-desktop-agent): serve settings HTTP endpoint from ledgrrr-service's main loop --- .../src/bin/ledgrrr-service.rs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs b/crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs index ad8e952..e95ee5d 100644 --- a/crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs +++ b/crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs @@ -2,9 +2,10 @@ use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; -use std::time::Duration; +use std::time::{Duration, Instant}; -use ledgerr_desktop_agent::state; +use ledgerr_desktop_agent::{settings_server, state}; +use ledgrrr_settings::{default_settings_path, SettingsStore}; const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); @@ -93,13 +94,24 @@ fn main() { return; } let _ = listener.set_nonblocking(true); + let settings_store = SettingsStore::new(default_settings_path()); + let settings_listener = match settings_server::bind() { + Ok(listener) => Some(listener), + Err(error) => { + state::audit("runtime", "settings_bind", "unavailable", error.to_string()); + None + } + }; state::audit("runtime", "start", "ok", format!("mode={mode}")); - let mut last_heartbeat = std::time::Instant::now() - HEARTBEAT_INTERVAL; + let mut last_heartbeat = Instant::now() - HEARTBEAT_INTERVAL; loop { if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL { let _ = state::write_heartbeat(pid, started_at); - last_heartbeat = std::time::Instant::now(); + last_heartbeat = Instant::now(); + } + if let Some(settings_listener) = &settings_listener { + settings_server::accept_once(settings_listener, &settings_store); } match listener.accept() { Ok((stream, _)) => { From aa83c855c399d58d37255f2ddf6907ef629f6a99 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 21:15:57 +1000 Subject: [PATCH 11/16] feat(host-tauri): settings now served by ledgrrr-service over HTTP, not a local SettingsStore --- crates/ledgerr-host/src/bin/tauri/commands.rs | 7 +- crates/ledgerr-host/src/bin/tauri/main.rs | 4 +- .../src/bin/tauri/settings_client.rs | 95 +++++++++++++++++++ crates/ledgerr-host/src/bin/tauri/state.rs | 5 +- 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 crates/ledgerr-host/src/bin/tauri/settings_client.rs diff --git a/crates/ledgerr-host/src/bin/tauri/commands.rs b/crates/ledgerr-host/src/bin/tauri/commands.rs index f0e8a21..222da0c 100644 --- a/crates/ledgerr-host/src/bin/tauri/commands.rs +++ b/crates/ledgerr-host/src/bin/tauri/commands.rs @@ -200,7 +200,7 @@ pub fn get_initial_state(state: tauri::State<'_, AppState>) -> Result>> = Arc::new(Mutex::new(Vec::new())); let review_log: Arc> = Arc::new(Mutex::new(ReviewLog::default())); let internal_endpoint: Arc>> = Arc::new(Mutex::new(None)); diff --git a/crates/ledgerr-host/src/bin/tauri/settings_client.rs b/crates/ledgerr-host/src/bin/tauri/settings_client.rs new file mode 100644 index 0000000..304b80a --- /dev/null +++ b/crates/ledgerr-host/src/bin/tauri/settings_client.rs @@ -0,0 +1,95 @@ +//! HTTP client for `ledgrrr-service`'s settings endpoint (see +//! `ledgerr_desktop_agent::settings_server` for the server side). Replaces +//! the local `SettingsStore` this binary used to own directly — settings +//! are now `ledgrrr-service`'s state, not `host-tauri`'s. + +use ledgrrr_settings::AppSettings; + +const DEFAULT_SETTINGS_SERVER_URL: &str = "http://127.0.0.1:15116"; + +#[derive(Debug, thiserror::Error)] +pub enum SettingsClientError { + #[error("request to ledgrrr-service failed: {0}")] + Request(#[from] reqwest::Error), + #[error("ledgrrr-service returned an error: {0}")] + Server(String), +} + +pub struct SettingsClient { + base_url: String, + client: reqwest::blocking::Client, +} + +impl SettingsClient { + pub fn new() -> Self { + Self::with_base_url(DEFAULT_SETTINGS_SERVER_URL.to_string()) + } + + pub fn with_base_url(base_url: String) -> Self { + Self { + base_url, + client: reqwest::blocking::Client::new(), + } + } + + pub fn load(&self) -> Result { + let response = self + .client + .get(format!("{}/settings", self.base_url)) + .send()?; + if !response.status().is_success() { + return Err(SettingsClientError::Server(response.status().to_string())); + } + Ok(response.json()?) + } + + pub fn save(&self, settings: &AppSettings) -> Result<(), SettingsClientError> { + let response = self + .client + .post(format!("{}/settings", self.base_url)) + .json(settings) + .send()?; + if !response.status().is_success() { + return Err(SettingsClientError::Server(response.status().to_string())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// Minimal fake server: accepts exactly one connection, replies with a + /// fixed body, then stops. Enough to test the client without depending + /// on ledgerr-desktop-agent (which would create a dependency cycle risk + /// — ledgerr-host must not depend on ledgerr-desktop-agent). + fn fake_server_returning(body: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0_u8; 1024]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + }); + format!("http://{addr}") + } + + #[test] + fn load_parses_settings_from_server_response() { + let defaults = AppSettings::default(); + let body = serde_json::to_string(&defaults).unwrap(); + let base_url = fake_server_returning(Box::leak(body.into_boxed_str())); + let client = SettingsClient::with_base_url(base_url); + let loaded = client.load().unwrap(); + assert_eq!(loaded, defaults); + } +} diff --git a/crates/ledgerr-host/src/bin/tauri/state.rs b/crates/ledgerr-host/src/bin/tauri/state.rs index 1c2768a..b8feb67 100644 --- a/crates/ledgerr-host/src/bin/tauri/state.rs +++ b/crates/ledgerr-host/src/bin/tauri/state.rs @@ -3,10 +3,11 @@ use std::sync::{Arc, Mutex}; use ledgerr_host::chat::{ChatTurn, ReviewLog}; use ledgerr_host::evidence::EvidenceState; use ledgerr_host::internal_openai::InternalOpenAiHandle; -use ledgerr_host::settings::SettingsStore; + +use crate::settings_client::SettingsClient; pub struct AppState { - pub store: Arc, + pub store: Arc, pub history: Arc>>, pub review_log: Arc>, pub internal_endpoint: Arc>>, From d04b6e4f7a41a5b0f49e481e87dd2b96fd335f27 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 21:22:38 +1000 Subject: [PATCH 12/16] feat(host-tray): settings now served by ledgrrr-service over HTTP, shared SettingsClient with host-tauri --- crates/ledgerr-host/src/bin/host-tray.rs | 5 ++--- crates/ledgerr-host/src/bin/tauri/main.rs | 3 +-- crates/ledgerr-host/src/bin/tauri/state.rs | 2 +- crates/ledgerr-host/src/lib.rs | 1 + crates/ledgerr-host/src/{bin/tauri => }/settings_client.rs | 0 crates/ledgerr-host/src/tray/runtime.rs | 7 ++++--- 6 files changed, 9 insertions(+), 9 deletions(-) rename crates/ledgerr-host/src/{bin/tauri => }/settings_client.rs (100%) diff --git a/crates/ledgerr-host/src/bin/host-tray.rs b/crates/ledgerr-host/src/bin/host-tray.rs index 9373da0..ce1210a 100644 --- a/crates/ledgerr-host/src/bin/host-tray.rs +++ b/crates/ledgerr-host/src/bin/host-tray.rs @@ -2,9 +2,8 @@ #[cfg(windows)] fn main() -> Result<(), Box> { - let store = - ledgerr_host::settings::SettingsStore::new(ledgerr_host::settings::default_settings_path()); - ledgerr_host::tray::runtime::run(store) + let client = ledgerr_host::settings_client::SettingsClient::new(); + ledgerr_host::tray::runtime::run(client) } #[cfg(not(windows))] diff --git a/crates/ledgerr-host/src/bin/tauri/main.rs b/crates/ledgerr-host/src/bin/tauri/main.rs index 2d86ae5..01990a6 100644 --- a/crates/ledgerr-host/src/bin/tauri/main.rs +++ b/crates/ledgerr-host/src/bin/tauri/main.rs @@ -5,7 +5,6 @@ #[cfg(target_os = "windows")] mod commands; -mod settings_client; #[cfg(target_os = "windows")] mod state; #[cfg(target_os = "windows")] @@ -57,7 +56,7 @@ fn main() { use state::AppState; use std::sync::{Arc, Mutex}; - let store = Arc::new(crate::settings_client::SettingsClient::new()); + let store = Arc::new(ledgerr_host::settings_client::SettingsClient::new()); let history: Arc>> = Arc::new(Mutex::new(Vec::new())); let review_log: Arc> = Arc::new(Mutex::new(ReviewLog::default())); let internal_endpoint: Arc>> = Arc::new(Mutex::new(None)); diff --git a/crates/ledgerr-host/src/bin/tauri/state.rs b/crates/ledgerr-host/src/bin/tauri/state.rs index b8feb67..d3506a7 100644 --- a/crates/ledgerr-host/src/bin/tauri/state.rs +++ b/crates/ledgerr-host/src/bin/tauri/state.rs @@ -4,7 +4,7 @@ use ledgerr_host::chat::{ChatTurn, ReviewLog}; use ledgerr_host::evidence::EvidenceState; use ledgerr_host::internal_openai::InternalOpenAiHandle; -use crate::settings_client::SettingsClient; +use ledgerr_host::settings_client::SettingsClient; pub struct AppState { pub store: Arc, diff --git a/crates/ledgerr-host/src/lib.rs b/crates/ledgerr-host/src/lib.rs index baaae2f..5475bc1 100644 --- a/crates/ledgerr-host/src/lib.rs +++ b/crates/ledgerr-host/src/lib.rs @@ -9,6 +9,7 @@ pub mod local_llm; pub mod local_llm_mistral; pub mod notify; pub mod settings; +pub mod settings_client; pub mod tray; pub use evidence::{EvidenceState, TodayQueue}; pub use internal_openai::{ diff --git a/crates/ledgerr-host/src/bin/tauri/settings_client.rs b/crates/ledgerr-host/src/settings_client.rs similarity index 100% rename from crates/ledgerr-host/src/bin/tauri/settings_client.rs rename to crates/ledgerr-host/src/settings_client.rs diff --git a/crates/ledgerr-host/src/tray/runtime.rs b/crates/ledgerr-host/src/tray/runtime.rs index 4906f80..8ff7cc6 100644 --- a/crates/ledgerr-host/src/tray/runtime.rs +++ b/crates/ledgerr-host/src/tray/runtime.rs @@ -8,7 +8,8 @@ use crate::notify::{ NotificationBackend, NotificationEvent, NotificationSettings, NotificationStatus, NotificationTestResult, Notifier, NotifyError, PowerShellBurntToastNotifier, }; -use crate::settings::{AppSettings, SettingsStore}; +use crate::settings::AppSettings; +use crate::settings_client::SettingsClient; use super::native::{ make_icon_data, NativeTrayPlatform, TrayControl, TrayEvent, CMD_CYCLE_BACKEND, @@ -18,7 +19,7 @@ use super::native::{ }; use super::{tray_menu_labels, TrayCommand, TrayState}; -pub fn run(store: SettingsStore) -> Result<(), Box> { +pub fn run(store: SettingsClient) -> Result<(), Box> { let settings = store.load()?; let state = Arc::new(Mutex::new(TrayState::from_settings(&settings))); let labels = tray_menu_labels(&state.lock().expect("tray state poisoned")); @@ -119,7 +120,7 @@ pub fn run(store: SettingsStore) -> Result<(), Box> { fn handle_command( command: TrayCommand, - store: &SettingsStore, + store: &SettingsClient, state: &Arc>, control_tx: &mpsc::Sender, ) -> Result> { From 15cb1ada645c4b9e72dde4baf3327860565d1a43 Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 21:43:11 +1000 Subject: [PATCH 13/16] docs: mark gh#118 Phase A (settings unification) complete in the integration roadmap --- .../2026-07-25-ledgrrr-integration-roadmap.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md diff --git a/docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md b/docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md new file mode 100644 index 0000000..f053d50 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md @@ -0,0 +1,87 @@ +# ledgrrr Integration Roadmap + +**Status:** living document. Written 2026-07-25 as the output of an architecture-integration audit +requested against the vision: *ledgrrr as a smart, auditable MCP gateway that records agent tool +use and enforces business-process constraints via constraint generation; a swiss-army-knife +toolkit with a tray, a Claude `.mcpb` extension, and a shared background server that the tray and +MCP surfaces both connect through; a CLIF-based diagram/process engine with isometric +visualization and symbolic iconography; deterministic, composable pipeline execution instead of +LLM-improvised command sequences.* + +This document records what the audit found (with exact file:line evidence), and breaks the gap +between "current state" and "vision" into independent subsystems, each with its own plan file so +each can ship and be tested on its own. Do not treat this as one monolithic project — the five +subsystems below have almost no shared code and can be built/reviewed in parallel once the first +is done. + +## Audit findings (verified against code, not docs) + +1. **The MCP gateway does not audit tool calls today.** `crates/ledgerr-mcp/src/bin/ledgerr-mcp-server.rs` + builds *two* independent `TurboLedgerService` instances in `build_service()` (line ~269): one is + wrapped by `service.spawn_actor()` (the "new" actor/gate dispatch system, per + `mcp_adapter.rs:1-10`'s own header comment: *"New code should route through `actor::ServiceHandle` + instead... replaced by the actor/gate channel system in PRD-7 Phase 0-4"*), the other is boxed, + leaked, and used as `global_raw_service()`. The `"tools/call"` match in `handle_request` (line + ~86) dispatches **exclusively** through `mcp_adapter::handle_*_tool(global_raw_service(), ...)` + — the legacy direct-call path. The actor/gate instance and its spawned thread are constructed + and then never used again. `legacy` is in `ledgerr-mcp`'s `default` feature set + (`Cargo.toml:38`), so this isn't a config mistake — it is the only path that currently compiles + into the shipped binary. Net effect: there is no single choke point today through which every + tool call passes, which is the prerequisite for "records agent tool use." +2. **No constraint/policy enforcement exists at the gateway layer.** The Kasuari/Z3 "legal + intelligence" layer (PRD-7 Phase 0) is a domain-specific tax-rule solver (AU GST/FBT, US + Schedule C/FBAR/FEIE) invoked from within specific `ledgerr_tax` operations — it does not gate + *which tools an agent may call, in what order, or with what arguments* at the transport layer. + "Constraint generation enforcement of business processes" as a cross-cutting gateway concern is + 0% built. +3. **Tool-contract documentation has already drifted once**, which is exactly the kind of + inconsistency a consolidated control plane is supposed to prevent: `AGENTS.md:44` and + `AGENTS.md:292` say the default catalog is 8 top-level tools; `crates/ledgerr-mcp/src/contract.rs:58` + publishes `PUBLISHED_TOOLS: [ToolContractSpec; 9]`, i.e. `ledgerr_evidence` shipped without the + docs being updated. +4. **CLIF does not exist anywhere in this repository.** `grep -ril clif` across `*.rs *.md *.toml` + returns zero hits. No parser, AST, serializer, or logical-form-to-diagram lowering exists. The + diagram system that does exist (`crates/ledgerr-desktop-agent/src/render.rs`, the workflow-TOML + compiler referenced in `AGENTS.md`, `mdbook-rhai-mermaid`) produces Mermaid from Rhai-FSM/TOML + workflow definitions — a different, narrower input format than CLIF. Isometric rendering exists + as a docs-editor feature (`book/` live-sync tooling) but is not connected to a general symbolic + process-diagram authoring pipeline. +5. **Three separate desktop/control surfaces exist and do not share one background server:** + `ledgerr-tauri` (current primary shell, native win32 tray via `crates/ledgerr-host/src/tray/native.rs` + wired in through `crates/ledgerr-tauri/src/tray.rs`), `ledgerr-host`'s legacy Slint + `host-tray`/`host-window` binaries (still built via the `wsl2-pwsh-*` Justfile recipes), and + `ledgerr-desktop-agent`'s `ledgrrr-service`/`ledgrrr-mcp` controller (PRD-10 Phase 1, its own + stdio MCP server). None of the three currently proxy through a shared background server process + with its own MCP client/server fan-out, as the vision describes. +6. **`LedgerOperation` dispatcher (`crates/ledger-core/src/ledger_ops.rs`) is a real trait with a + real dispatcher, but the operations that matter most — `IngestStatementOp`, `ClassifyTransactionsOp`, + and others — return `Err(LedgerOpError::NotImplemented(...))` today** (confirmed at + `ledger_ops.rs:342,372,499,572,608`, and asserted as *current, expected* behavior by + `integration_tests.rs:105-110,178-182`). "Deterministic execution as a composable pipeline of + steps" has the right interface shape already; most steps behind it are stubs. + +## Subsystems (independent plans) + +| # | Subsystem | Plan file | Why this order | +|---|-----------|-----------|-----------------| +| 1 | **Gateway call audit log** — single choke point in `ledgerr-mcp-server.rs` records every `tools/call`, persisted locally, queryable via `ledgerr_audit`. | `2026-07-25-mcp-gateway-audit-log.md` — **shipped** (`gateway_audit.rs`, wired into dispatch, e2e-tested) | Smallest, most tractable, and it is the literal foundation the user asked for first ("records agent tool use"). Every later constraint-enforcement gate hooks into this same choke point, so building it first avoids rework. | +| 2 | **Gateway policy gate** — a `GatewayPolicy` trait evaluated pre-dispatch at the same choke point (allow/deny), seeded with a real sequencing constraint (`ledgerr_reconciliation/commit` requires a prior successful `validate`). | **shipped** (`gateway_policy.rs`, e2e-tested) | Note (2026-07-25, post-ship): the operator clarified "constraint generation" in the original ask was actually **"constrained generation"** — the xgrammar/grammar-constrained-decoding sense (forcing LLM token generation to conform to a formal grammar), not this after-the-fact policy gate. This subsystem remains legitimate as defense-in-depth (catches anything that reaches the gateway regardless of how it was generated) but is a different mechanism than what "constrained generation" now refers to — see issue #116. | +| 3 | **Actor/legacy dispatch consolidation** — retire the orphaned second `TurboLedgerService` instance; route `handle_request` through `ServiceHandle` (the actor) so there is exactly one live service instance. | **partially shipped**: dead actor-spawn removed, misleading module doc corrected. Remainder (routing all 28 `mcp_adapter` handlers through the actor, or retiring `actor`/`gate` instead) filed as issue #119 — real regression risk, needs its own TDD-per-handler plan. | Housekeeping that makes subsystems 1 and 2 architecturally honest instead of a second parallel wrapper. The waste (idle thread, unused service instance) was safe to fix immediately; the larger dispatch migration is not. | +| 4 | **Shared background server + tray/MCP proxy unification** — pick one of the three existing surfaces as the long-lived background process and make `ledgerr-tauri`'s tray and any Claude-facing `.mcpb` controller talk to it instead of each owning independent state. | `2026-08-06-unify-desktop-background-server.md` — **Phase A complete** (issue #118): new `ledgrrr-settings` crate is the shared settings model, an HTTP settings server (`ledgrrr-service`, `crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs`) is the long-lived background process, and `host-tauri`/`host-tray` were switched to HTTP clients of it instead of each owning local state. Verified with `cargo check`/`test`/`clippy` on the non-Windows-only surfaces plus a live `/settings` smoke test. **Caveat: the Windows-only surfaces** (`host-tauri`'s `state.rs`/`commands.rs`, `host-tray`'s `tray/runtime.rs`) **are implemented but compiler-unverified** — this Linux/WSL environment cfg-strips them before typecheck, so they've only had manual/static code review, never a compiler or test pass; a real Windows build is required before merge for compiler-backed confidence in that portion. | Large, cross-crate, UI-visible change; a genuine architecture decision (which of the 3 surfaces is the survivor), not a mechanical fix. Operator direction 2026-07-25: defer, not a "next task" priority right now. | +| 5 | **CLIF diagram/process engine** — CLIF is confirmed to mean the literal ISO/IEC 24707 standard (not an informal DSL). Net-new: CLIF parser/AST in Rust, lowering to a diagram IR and to an executable pipeline, isometric renderer hookup, symbolic icon set per operation family. | **backlogged** — issues #114 (CLIF AST/interpreter), #115 (Rhai vs Monty, deferred as options not a decision), #116 (constrained generation / xgrammar), #117 (RDF/triple-store + semantic vector search cognitive-assistance layer, the longer-range destination this is heading toward) | Biggest, most novel, longest-lead subsystem — explicitly deferred by the operator 2026-07-25 ("we don't need to solve the CLIF today; put all this on the backlog in gh issues"). Do not resume without the operator opening one of these issues as the next task. | +| 6 | **`LedgerOperation` NotImplemented burn-down** — originally scoped as "wire the stubs to already-working implementations." | **backlogged, and rescoped** — issue #119. Investigation found `OperationDispatcher`/`LedgerOperation` have **zero production callers** anywhere in the workspace (only self-referential in `ledger_ops.rs`'s own tests). `ClassifyTransactionsOp`'s stub also references a "ledger store" that doesn't exist as a type in `ledger-core` — filling the stub body would mean inventing an unvalidated storage abstraction, not plumbing to existing tested code as originally assumed. | Corrected finding, not just deferred: this needs a decision (does the calendar-scheduled-operation abstraction become a live trigger path, or stay a documented-intent skeleton) before any stub-filling, not just engineering time. | + +## Status as of 2026-07-25 (session end) + +Subsystems 1–2 shipped and tested (7 commits, all `cargo test -p ledgerr-mcp --all-targets --all-features` green throughout). Subsystem 3's safe half shipped. Subsystems 3's remainder, 4, 5, and 6 are filed as GitHub issues #114–120 per explicit operator direction to stop implementation work and backlog the rest — this was a deliberate scope narrowing mid-session, not an assessment that the work is unimportant. Resume from the relevant issue, not from this roadmap's original "not yet written" framing. + +## Working agreement for this effort + +- Each plan file is independently executable and independently testable — per `writing-plans`' + scope check, do not merge these into one giant plan. +- Every task closes with the project's evidence-line convention: run the declared test/verification + command and paste the `PASS`/`FAIL` output verbatim before marking a task done (see root + `CLAUDE.md` "Trace-or-filler"). +- No `unwrap`/unchecked indexing in any new financial-path code (root `CLAUDE.md` Safety Bar). +- Subsystem 4 (control-plane unification) and subsystem 5 (CLIF) are architecture decisions, not + mechanical fixes — brainstorm with the user before writing their task plans. From 1ce5284cae0803e90ae2ae47d7374f13f42b830e Mon Sep 17 00:00:00 2001 From: brianh Date: Thu, 6 Aug 2026 22:00:34 +1000 Subject: [PATCH 14/16] fix: address final whole-branch review findings (dead code, Default impl, doc gap) --- crates/ledgerr-host/src/internal_openai.rs | 11 ----------- crates/ledgerr-host/src/settings_client.rs | 6 ++++++ .../plans/2026-07-25-ledgrrr-integration-roadmap.md | 2 +- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/ledgerr-host/src/internal_openai.rs b/crates/ledgerr-host/src/internal_openai.rs index 9bfa239..e2e8a58 100644 --- a/crates/ledgerr-host/src/internal_openai.rs +++ b/crates/ledgerr-host/src/internal_openai.rs @@ -1710,17 +1710,6 @@ mod tests { } } -/// Resolve ChatSettings from the operator's model_provider choice. -/// -/// Returns (resolved_settings, Option) where the second -/// element is Some when a fallback occurred (e.g., WindowsAi selected but -/// Foundry not installed). The caller decides whether to surface the warning. -pub fn resolve_chat( - settings: &ledgrrr_settings::AppSettings, -) -> (ChatSettings, Option) { - resolve_chat_settings(settings) -} - /// Resolve active ChatSettings from the AppSettings model_provider field. /// /// Returns the resolved settings and an optional warning if a fallback occurred. diff --git a/crates/ledgerr-host/src/settings_client.rs b/crates/ledgerr-host/src/settings_client.rs index 304b80a..8302558 100644 --- a/crates/ledgerr-host/src/settings_client.rs +++ b/crates/ledgerr-host/src/settings_client.rs @@ -56,6 +56,12 @@ impl SettingsClient { } } +impl Default for SettingsClient { + fn default() -> Self { + Self::new() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md b/docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md index f053d50..5ff1642 100644 --- a/docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md +++ b/docs/superpowers/plans/2026-07-25-ledgrrr-integration-roadmap.md @@ -67,7 +67,7 @@ is done. | 1 | **Gateway call audit log** — single choke point in `ledgerr-mcp-server.rs` records every `tools/call`, persisted locally, queryable via `ledgerr_audit`. | `2026-07-25-mcp-gateway-audit-log.md` — **shipped** (`gateway_audit.rs`, wired into dispatch, e2e-tested) | Smallest, most tractable, and it is the literal foundation the user asked for first ("records agent tool use"). Every later constraint-enforcement gate hooks into this same choke point, so building it first avoids rework. | | 2 | **Gateway policy gate** — a `GatewayPolicy` trait evaluated pre-dispatch at the same choke point (allow/deny), seeded with a real sequencing constraint (`ledgerr_reconciliation/commit` requires a prior successful `validate`). | **shipped** (`gateway_policy.rs`, e2e-tested) | Note (2026-07-25, post-ship): the operator clarified "constraint generation" in the original ask was actually **"constrained generation"** — the xgrammar/grammar-constrained-decoding sense (forcing LLM token generation to conform to a formal grammar), not this after-the-fact policy gate. This subsystem remains legitimate as defense-in-depth (catches anything that reaches the gateway regardless of how it was generated) but is a different mechanism than what "constrained generation" now refers to — see issue #116. | | 3 | **Actor/legacy dispatch consolidation** — retire the orphaned second `TurboLedgerService` instance; route `handle_request` through `ServiceHandle` (the actor) so there is exactly one live service instance. | **partially shipped**: dead actor-spawn removed, misleading module doc corrected. Remainder (routing all 28 `mcp_adapter` handlers through the actor, or retiring `actor`/`gate` instead) filed as issue #119 — real regression risk, needs its own TDD-per-handler plan. | Housekeeping that makes subsystems 1 and 2 architecturally honest instead of a second parallel wrapper. The waste (idle thread, unused service instance) was safe to fix immediately; the larger dispatch migration is not. | -| 4 | **Shared background server + tray/MCP proxy unification** — pick one of the three existing surfaces as the long-lived background process and make `ledgerr-tauri`'s tray and any Claude-facing `.mcpb` controller talk to it instead of each owning independent state. | `2026-08-06-unify-desktop-background-server.md` — **Phase A complete** (issue #118): new `ledgrrr-settings` crate is the shared settings model, an HTTP settings server (`ledgrrr-service`, `crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs`) is the long-lived background process, and `host-tauri`/`host-tray` were switched to HTTP clients of it instead of each owning local state. Verified with `cargo check`/`test`/`clippy` on the non-Windows-only surfaces plus a live `/settings` smoke test. **Caveat: the Windows-only surfaces** (`host-tauri`'s `state.rs`/`commands.rs`, `host-tray`'s `tray/runtime.rs`) **are implemented but compiler-unverified** — this Linux/WSL environment cfg-strips them before typecheck, so they've only had manual/static code review, never a compiler or test pass; a real Windows build is required before merge for compiler-backed confidence in that portion. | Large, cross-crate, UI-visible change; a genuine architecture decision (which of the 3 surfaces is the survivor), not a mechanical fix. Operator direction 2026-07-25: defer, not a "next task" priority right now. | +| 4 | **Shared background server + tray/MCP proxy unification** — pick one of the three existing surfaces as the long-lived background process and make `ledgerr-tauri`'s tray and any Claude-facing `.mcpb` controller talk to it instead of each owning independent state. | `2026-08-06-unify-desktop-background-server.md` — **Phase A complete** (issue #118): new `ledgrrr-settings` crate is the shared settings model, an HTTP settings server (`ledgrrr-service`, `crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs`) is the long-lived background process, and `host-tauri`/`host-tray` were switched to HTTP clients of it instead of each owning local state. Verified with `cargo check`/`test`/`clippy` on the non-Windows-only surfaces plus a live `/settings` smoke test. **Caveat: the Windows-only surfaces** (`host-tauri`'s `state.rs`/`commands.rs`, `host-tray`'s `tray/runtime.rs`) **are implemented but compiler-unverified** — this Linux/WSL environment cfg-strips them before typecheck, so they've only had manual/static code review, never a compiler or test pass; a real Windows build is required before merge for compiler-backed confidence in that portion. `host-window` (legacy Slint binary) and `notify-test` (dev utility) still construct their own `SettingsStore` directly, bypassing `ledgrrr-service` — deferred, not yet migrated. | Large, cross-crate, UI-visible change; a genuine architecture decision (which of the 3 surfaces is the survivor), not a mechanical fix. Operator direction 2026-07-25: defer, not a "next task" priority right now. | | 5 | **CLIF diagram/process engine** — CLIF is confirmed to mean the literal ISO/IEC 24707 standard (not an informal DSL). Net-new: CLIF parser/AST in Rust, lowering to a diagram IR and to an executable pipeline, isometric renderer hookup, symbolic icon set per operation family. | **backlogged** — issues #114 (CLIF AST/interpreter), #115 (Rhai vs Monty, deferred as options not a decision), #116 (constrained generation / xgrammar), #117 (RDF/triple-store + semantic vector search cognitive-assistance layer, the longer-range destination this is heading toward) | Biggest, most novel, longest-lead subsystem — explicitly deferred by the operator 2026-07-25 ("we don't need to solve the CLIF today; put all this on the backlog in gh issues"). Do not resume without the operator opening one of these issues as the next task. | | 6 | **`LedgerOperation` NotImplemented burn-down** — originally scoped as "wire the stubs to already-working implementations." | **backlogged, and rescoped** — issue #119. Investigation found `OperationDispatcher`/`LedgerOperation` have **zero production callers** anywhere in the workspace (only self-referential in `ledger_ops.rs`'s own tests). `ClassifyTransactionsOp`'s stub also references a "ledger store" that doesn't exist as a type in `ledger-core` — filling the stub body would mean inventing an unvalidated storage abstraction, not plumbing to existing tested code as originally assumed. | Corrected finding, not just deferred: this needs a decision (does the calendar-scheduled-operation abstraction become a live trigger path, or stay a documented-intent skeleton) before any stub-filling, not just engineering time. | From f53056e844406d6a6bbca5ce41a3009f2f58d6fb Mon Sep 17 00:00:00 2001 From: brianh Date: Fri, 7 Aug 2026 18:58:59 +1000 Subject: [PATCH 15/16] fix(ledgerr-host): resolve clippy violations in internal_openai.rs Move resolve_chat_settings above the #[cfg(test)] mod tests block (clippy::items_after_test_module) and drop the unnecessary ::default() call on the Phi4LocalFallbackBackend unit struct (clippy::default_constructed_unit_structs). --- crates/ledgerr-host/src/internal_openai.rs | 54 +++++++++++----------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/crates/ledgerr-host/src/internal_openai.rs b/crates/ledgerr-host/src/internal_openai.rs index e2e8a58..6354851 100644 --- a/crates/ledgerr-host/src/internal_openai.rs +++ b/crates/ledgerr-host/src/internal_openai.rs @@ -1234,6 +1234,32 @@ fn default_phi4_model_path() -> Option { d_drive_model.exists().then_some(d_drive_model) } +/// Resolve active ChatSettings from the AppSettings model_provider field. +/// +/// Returns the resolved settings and an optional warning if a fallback occurred. +/// The caller decides whether to surface the warning or swallow it. +pub fn resolve_chat_settings( + settings: &crate::settings::AppSettings, +) -> (ChatSettings, Option) { + match settings + .model_provider + .chat_settings(settings.chat.system_prompt.clone()) + { + Ok(cs) => (cs, None), + Err(_) => { + let fallback = local_demo_chat_settings(settings.chat.system_prompt.clone()); + let warning = Some(ProviderReadiness::Diagnostic { + reason: format!( + "{} unavailable, fell back to Local Demo. {}", + settings.model_provider.display_name(), + settings.model_provider.readiness(settings), + ), + }); + (fallback, warning) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1434,7 +1460,7 @@ mod tests { stream: false, }; - let response = Phi4LocalFallbackBackend::default() + let response = Phi4LocalFallbackBackend .complete(&request) .expect("fallback should respond"); @@ -1709,29 +1735,3 @@ mod tests { assert!(warning.is_some()); } } - -/// Resolve active ChatSettings from the AppSettings model_provider field. -/// -/// Returns the resolved settings and an optional warning if a fallback occurred. -/// The caller decides whether to surface the warning or swallow it. -pub fn resolve_chat_settings( - settings: &crate::settings::AppSettings, -) -> (ChatSettings, Option) { - match settings - .model_provider - .chat_settings(settings.chat.system_prompt.clone()) - { - Ok(cs) => (cs, None), - Err(_) => { - let fallback = local_demo_chat_settings(settings.chat.system_prompt.clone()); - let warning = Some(ProviderReadiness::Diagnostic { - reason: format!( - "{} unavailable, fell back to Local Demo. {}", - settings.model_provider.display_name(), - settings.model_provider.readiness(settings), - ), - }); - (fallback, warning) - } - } -} From 79af8b1abafdf20f912a350066e4ae4e16e93c41 Mon Sep 17 00:00:00 2001 From: brianh Date: Sun, 16 Aug 2026 11:01:02 +1000 Subject: [PATCH 16/16] fix(desktop-agent): retain host-tauri tray candidate --- Cargo.lock | 4 ++++ crates/ledgerr-desktop-agent/src/status.rs | 1 + 2 files changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6152719..9eae650 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5770,10 +5770,12 @@ version = "1.9.0" dependencies = [ "blake3", "getrandom 0.3.4", + "ledgrrr-settings", "schemars 0.8.22", "serde", "serde_json", "sysinfo 0.33.1", + "tempfile", "thiserror 2.0.18", ] @@ -5806,6 +5808,7 @@ dependencies = [ "holon-viz", "ledger-core", "ledgerr-desktop-agent", + "ledgrrr-settings", "mistralrs", "reqwest 0.12.28", "rig-core", @@ -5927,6 +5930,7 @@ dependencies = [ "chrono", "serde", "serde_json", + "specta", "tempfile", "thiserror 2.0.18", "windows-registry", diff --git a/crates/ledgerr-desktop-agent/src/status.rs b/crates/ledgerr-desktop-agent/src/status.rs index e173361..94d5ace 100644 --- a/crates/ledgerr-desktop-agent/src/status.rs +++ b/crates/ledgerr-desktop-agent/src/status.rs @@ -196,6 +196,7 @@ fn detect_package() -> PackageStatus { const TRAY_CANDIDATES: &[&str] = &[ "ledgrrr-tray.exe", "host-tauri.exe", + "host-tauri", "host-tray.exe", "host-tray", ];