Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9cb7b7d
fix(ledger-core): repair committed conflict fragments
promptexecutionerr Jul 30, 2026
2f81719
docs: add implementation plan for gh#118 Phase A (settings unification)
promptexecutionerr Aug 6, 2026
e0bb2d6
feat(ledgrrr-settings): scaffold new crate, move settings_backend
promptexecutionerr Aug 6, 2026
7d6ad27
fix(ledgrrr-settings): remove fabricated stub modules, scope lib.rs t…
promptexecutionerr Aug 6, 2026
324e4c3
refactor(ledgerr-host): move NotificationBackend/Status/TestResult to…
promptexecutionerr Aug 6, 2026
2623051
refactor(ledgerr-host): move ModelProviderLabel to ledgrrr-settings
promptexecutionerr Aug 6, 2026
45dcff8
refactor(ledgerr-host): move AppSettings/SettingsStore/default_settin…
promptexecutionerr Aug 6, 2026
03b248b
fix(ledgerr-desktop-agent): TRAY_CANDIDATES named a binary (ledgerr-t…
promptexecutionerr Aug 6, 2026
2ca54f3
feat(ledgerr-desktop-agent): add settings HTTP server (GET/POST /sett…
promptexecutionerr Aug 6, 2026
1c97209
feat(ledgerr-desktop-agent): serve settings HTTP endpoint from ledgrr…
promptexecutionerr Aug 6, 2026
aa83c85
feat(host-tauri): settings now served by ledgrrr-service over HTTP, n…
promptexecutionerr Aug 6, 2026
d04b6e4
feat(host-tray): settings now served by ledgrrr-service over HTTP, sh…
promptexecutionerr Aug 6, 2026
15cb1ad
docs: mark gh#118 Phase A (settings unification) complete in the inte…
promptexecutionerr Aug 6, 2026
1ce5284
fix: address final whole-branch review findings (dead code, Default i…
promptexecutionerr Aug 6, 2026
f53056e
fix(ledgerr-host): resolve clippy violations in internal_openai.rs
promptexecutionerr Aug 7, 2026
79af8b1
fix(desktop-agent): retain host-tauri tray candidate
promptexecutionerr Aug 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion crates/ledger-core/src/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/ledgerr-desktop-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 16 additions & 4 deletions crates/ledgerr-desktop-agent/src/bin/ledgrrr-service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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, _)) => {
Expand Down
1 change: 1 addition & 0 deletions crates/ledgerr-desktop-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
187 changes: 187 additions & 0 deletions crates/ledgerr-desktop-agent/src/settings_server.rs
Original file line number Diff line number Diff line change
@@ -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<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}

fn parse_content_length(headers: &str) -> Option<usize> {
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<TcpListener> {
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"));
}
}
15 changes: 14 additions & 1 deletion crates/ledgerr-desktop-agent/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,9 @@ fn detect_package() -> PackageStatus {
const TRAY_CANDIDATES: &[&str] = &[
"ledgrrr-tray.exe",
"host-tauri.exe",
"host-tauri",
"host-tray.exe",
"host-tray",
"ledgerr-tauri",
];

/// Finds a tray binary next to this controller's own executable, then on
Expand Down Expand Up @@ -287,3 +287,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:?}"
);
}
}
1 change: 1 addition & 0 deletions crates/ledgerr-host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
5 changes: 2 additions & 3 deletions crates/ledgerr-host/src/bin/host-tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

#[cfg(windows)]
fn main() -> Result<(), Box<dyn std::error::Error>> {
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))]
Expand Down
7 changes: 2 additions & 5 deletions crates/ledgerr-host/src/bin/tauri/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ pub fn get_initial_state(state: tauri::State<'_, AppState>) -> Result<InitialSta
settings.chat = internal_phi_chat_settings(settings.chat.system_prompt.clone());
}

let status_text = format!("Editing {}", state.store.path().display());
let status_text = "Editing settings via ledgrrr-service".to_string();

Ok(InitialState {
version_text: format!("Version {}", env!("CARGO_PKG_VERSION")),
Expand Down Expand Up @@ -239,10 +239,7 @@ pub fn save_settings(

state.store.save(&settings).map_err(|e| e.to_string())?;

Ok(format!(
"Saved chat settings to {}",
state.store.path().display()
))
Ok("Saved chat settings via ledgrrr-service".to_string())
}

#[tauri::command]
Expand Down
3 changes: 1 addition & 2 deletions crates/ledgerr-host/src/bin/tauri/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,10 @@ fn main() {
use ledgerr_host::chat::{ChatTurn, ReviewLog};
use ledgerr_host::evidence::EvidenceState;
use ledgerr_host::internal_openai::InternalOpenAiHandle;
use ledgerr_host::settings::{default_settings_path, SettingsStore};
use state::AppState;
use std::sync::{Arc, Mutex};

let store = Arc::new(SettingsStore::new(default_settings_path()));
let store = Arc::new(ledgerr_host::settings_client::SettingsClient::new());
let history: Arc<Mutex<Vec<ChatTurn>>> = Arc::new(Mutex::new(Vec::new()));
let review_log: Arc<Mutex<ReviewLog>> = Arc::new(Mutex::new(ReviewLog::default()));
let internal_endpoint: Arc<Mutex<Option<InternalOpenAiHandle>>> = Arc::new(Mutex::new(None));
Expand Down
Loading
Loading