mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish Shared design system: CSS custom properties for spacing, typography, transitions, and color tokens used across web UI and boot screen. Boot screen: compact feature-tags line showing enabled subsystems (db, tools, routines, heartbeat, skills, sandbox, embeddings) at a glance. Downgrade startup info logs (libSQL, webhook, workspace seed) to debug level since the boot screen now covers this. Onboarding wizard: model picker with live API fetch, provider-aware auth flow, improved error recovery and progress display. Web UI: ARIA attributes, welcome card, streaming debounce, connection status banner, skeleton loaders, send cooldown. CLI: doctor command enhancements, status command cleanup, REPL banner consolidation, shared fmt module. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish Merge staging theme support (dark/light/system toggle) and layer UX polish on top: spring-physics motion, glass morphism depth, chat experience improvements, and responsive mobile refinements. Design system: - Restore and extend design token system (spacing, typography, timing, easing) with legacy aliases for theme compatibility - Add shadow tiers, accent glow, glass morphism, spring easing tokens - Tokens defined in both dark (:root) and light ([data-theme="light"]) Micro-interactions (Phase 2): - Spring-overshoot message entry animation (slideUp) - Spring-scale button press on all interactive buttons - Tab crossfade animation, tool card smooth accordion (max-height) - Modal scale(0.95) + blur(8px) entry, toast spring slide - Sidebar width crossfade, card hover lift Visual depth (Phase 3): - Tab bar glass morphism + surface highlight + sliding indicator - Active tab accent background pill - Assistant message accent left border, user message bubble tail - Floating input area (rounded + shadow + margin) Chat polish (Phase 4): - Smooth streaming cursor (cursorPulse), message hover timestamps - Time separators (Today/Yesterday/date) - Textarea smooth auto-expand, send button glow Settings & forms (Phase 5): - iOS-style toggle switches for boolean settings - Input focus glow, save feedback spring animation - Welcome card with gradient background + proper spacing - Sticky settings group headers with glass backdrop Accessibility & mobile (Phase 6): - Animated focus ring, prefers-reduced-motion global kill-switch - Touch target audit (44px min), mobile bottom-sheet modals - Mobile bottom tab bar, toast redesign (icon + border + countdown) - Thread hover translateX, badge in_progress pulse Bug fixes: - Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500) - Connection lost banner as fixed top bar instead of flex child - Sidebar collapse keeps toggle + new thread buttons visible - Downgrade noisy startup logs (db, webhook, vector) to debug - Remove green dot pulse animation on connected status - Deduplicate confirm-modal in HTML, add tab-indicator div Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish - Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed, add backdrop overlay, auto-close on thread select, outside-click dismiss - Settings: replace cramped horizontal tabs with drill-down navigation (category list → detail view → back button) - Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator to top edge - Keep thread toggle button visible in collapsed 36px sidebar strip Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): interactive approval selector and transient status lines - Replace ASCII-art approval box with clean horizontal rule card - Add inquire-based interactive selector for tool approvals (↑↓ + Enter) - Selector runs directly from send_status via spawn_blocking, with stdin_locked flag to prevent readline from competing for stdin - Transient thinking/tool-started lines: each replaces the previous, all erased before final output (no clutter left in scrollback) - Esc in selector sends denial so agent never gets stuck Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: widen TurnCost token fields to u64 and remove unused variable - Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost, SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on large conversations - Remove unused _routine_engine_for_loop binding in agent_loop.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: reduce startup log noise — demote info to debug Demote routine startup messages (builder, WASM tools, tunnel, WASM channels) from info to debug so the default log output stays clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): allow CDN scripts in CSP connect-src directive Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the browser can fetch marked.js and DOMPurify without CSP violations. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix cargo fmt in repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): gate turn_cost SSE handler on current thread Prevents cost badge from attaching to the wrong message when switching threads or receiving events from background threads. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: retrigger CI * fix: add missing extension_manager to webhook EngineContext The webhook trigger path added in #736 was missing the extension_manager field introduced by #1453. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory Low impact — requires compromised CA to exploit. Tracked for upstream rustls-webpki upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(routines): use fields.join for cron normalization Use split_whitespace fields instead of re-trimming the original string to avoid preserving extra internal whitespace in cron expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): Apple-style approval card — clean vertical flow - Drop verbose tool description (the command IS the decision surface) - Unified vertical pipe layout: ◆ header → │ params → │ selector - Selector options show keyboard shortcuts inline: Approve (y) - Compact help message, answered state uses └ to close the flow - No horizontal rules, no blank-line padding — just breathing room Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(repl): replace inquire with crossterm for approval selector Drop the inquire dependency (which pulled in crossterm 0.25, duplicating the existing 0.28). The 3-option approval selector is now built directly with crossterm raw mode — same UX, zero new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication termimad (via crokey) uses crossterm 0.29. Upgrading our direct dependency from 0.28 to 0.29 collapses to a single crossterm version in the dependency tree. Also migrated termimad::crossterm:: references to the direct crossterm import. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle - Fix box_top() fill calculation: was off-by-one, producing boxes 1 char too wide (fmt.rs) - Fix smart_truncate(): account for "..." in the budget so output never exceeds max_chars (repl.rs) - Move theme toggle to settings sidebar on mobile instead of display:none, so mobile users can still switch themes (style.css, index.html, app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: cargo fmt repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — retry duplication, CSP connect-src, deny color - Remove failed message before retry to prevent duplicate user messages - Revert connect-src to 'self' — CDN hosts only need script-src - Use red for Deny confirmation in REPL approval selector Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
343 lines
11 KiB
Rust
343 lines
11 KiB
Rust
//! System health and diagnostics CLI command.
|
|
//!
|
|
//! Checks database connectivity, session validity, embeddings,
|
|
//! WASM runtime, tool count, and channel availability.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use crate::bootstrap::ironclaw_base_dir;
|
|
use crate::cli::fmt;
|
|
use crate::settings::Settings;
|
|
|
|
/// Load settings from JSON and TOML config files, matching the runtime
|
|
/// priority: TOML overlay > settings.json > defaults.
|
|
///
|
|
/// This mirrors the loading chain in `Config::from_env_with_toml()` but
|
|
/// without resolving the full `Config` (which requires async + secrets).
|
|
fn load_settings() -> Settings {
|
|
load_settings_from(&Settings::default_path(), &Settings::default_toml_path())
|
|
}
|
|
|
|
/// Inner implementation with injectable paths (testable).
|
|
fn load_settings_from(json_path: &std::path::Path, toml_path: &std::path::Path) -> Settings {
|
|
let mut settings = Settings::load_from(json_path);
|
|
|
|
match Settings::load_toml(toml_path) {
|
|
Ok(Some(toml_settings)) => {
|
|
settings.merge_from(&toml_settings);
|
|
}
|
|
Ok(None) => {} // File not found — fine for default path
|
|
Err(e) => {
|
|
eprintln!("Warning: failed to parse {}: {}", toml_path.display(), e);
|
|
}
|
|
}
|
|
|
|
settings
|
|
}
|
|
|
|
/// Run the status command, printing system health info.
|
|
pub async fn run_status_command() -> anyhow::Result<()> {
|
|
let settings = load_settings();
|
|
|
|
println!();
|
|
println!(" {}IronClaw Status{}", fmt::bold(), fmt::reset());
|
|
println!();
|
|
|
|
// Version
|
|
println!(
|
|
"{}",
|
|
fmt::kv_line(
|
|
"Version",
|
|
&format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
|
|
12,
|
|
)
|
|
);
|
|
|
|
// Database
|
|
let db_backend = std::env::var("DATABASE_BACKEND")
|
|
.ok()
|
|
.unwrap_or_else(|| "postgres".to_string());
|
|
let db_value = match db_backend.as_str() {
|
|
"libsql" | "turso" | "sqlite" => {
|
|
let path = std::env::var("LIBSQL_PATH")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(|_| crate::config::default_libsql_path());
|
|
if path.exists() {
|
|
let turso = if std::env::var("LIBSQL_URL").is_ok() {
|
|
" + Turso sync"
|
|
} else {
|
|
""
|
|
};
|
|
format!("libSQL ({}{})", path.display(), turso)
|
|
} else {
|
|
format!("libSQL (file missing: {})", path.display())
|
|
}
|
|
}
|
|
_ => {
|
|
if std::env::var("DATABASE_URL").is_ok() {
|
|
match check_database().await {
|
|
Ok(()) => "connected (PostgreSQL)".to_string(),
|
|
Err(e) => format!("error ({})", e),
|
|
}
|
|
} else {
|
|
"not configured".to_string()
|
|
}
|
|
}
|
|
};
|
|
println!("{}", fmt::kv_line("Database", &db_value, 12));
|
|
|
|
// Session / Auth
|
|
let session_path = crate::config::llm::default_session_path();
|
|
let session_value = if session_path.exists() {
|
|
format!("found ({})", session_path.display())
|
|
} else {
|
|
"not found (run `ironclaw onboard`)".to_string()
|
|
};
|
|
println!("{}", fmt::kv_line("Session", &session_value, 12));
|
|
|
|
// Secrets (auto-detect from env only; skip keychain probe to avoid
|
|
// triggering macOS system password dialogs on a simple status check)
|
|
let secrets_value = if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
|
"configured (env)".to_string()
|
|
} else {
|
|
// We don't probe the keychain here because get_generic_password()
|
|
// triggers macOS unlock+authorization dialogs, which is bad UX for
|
|
// a read-only status command. If onboarding completed with keychain
|
|
// storage, the key is there; we just can't cheaply verify it.
|
|
"env not set (keychain may be configured)".to_string()
|
|
};
|
|
println!("{}", fmt::kv_line("Secrets", &secrets_value, 12));
|
|
|
|
// Embeddings
|
|
let emb_enabled = settings.embeddings.enabled
|
|
|| std::env::var("OPENAI_API_KEY").is_ok()
|
|
|| std::env::var("EMBEDDING_ENABLED")
|
|
.map(|v| v == "true")
|
|
.unwrap_or(false);
|
|
let emb_value = if emb_enabled {
|
|
format!(
|
|
"enabled (provider: {}, model: {})",
|
|
settings.embeddings.provider, settings.embeddings.model
|
|
)
|
|
} else {
|
|
"disabled".to_string()
|
|
};
|
|
println!("{}", fmt::kv_line("Embeddings", &emb_value, 12));
|
|
|
|
// WASM tools
|
|
let tools_dir = settings
|
|
.wasm
|
|
.tools_dir
|
|
.clone()
|
|
.unwrap_or_else(default_tools_dir);
|
|
let tools_value = if tools_dir.exists() {
|
|
let count = count_wasm_files(&tools_dir);
|
|
format!("{} installed ({})", count, tools_dir.display())
|
|
} else {
|
|
format!("directory not found ({})", tools_dir.display())
|
|
};
|
|
println!("{}", fmt::kv_line("WASM Tools", &tools_value, 12));
|
|
|
|
// WASM channels
|
|
let channels_dir = settings
|
|
.channels
|
|
.wasm_channels_dir
|
|
.clone()
|
|
.unwrap_or_else(default_channels_dir);
|
|
let mut channel_info = vec!["cli".to_string()];
|
|
if settings.channels.http_enabled {
|
|
channel_info.push(format!(
|
|
"http:{}",
|
|
settings.channels.http_port.unwrap_or(3000)
|
|
));
|
|
}
|
|
if channels_dir.exists() {
|
|
let wasm_count = count_wasm_files(&channels_dir);
|
|
if wasm_count > 0 {
|
|
channel_info.push(format!("{} wasm", wasm_count));
|
|
}
|
|
}
|
|
println!("{}", fmt::kv_line("Channels", &channel_info.join(", "), 12));
|
|
|
|
// Heartbeat
|
|
let hb_enabled = settings.heartbeat.enabled
|
|
|| std::env::var("HEARTBEAT_ENABLED")
|
|
.map(|v| v == "true")
|
|
.unwrap_or(false);
|
|
let hb_value = if hb_enabled {
|
|
format!("enabled (interval: {}s)", settings.heartbeat.interval_secs)
|
|
} else {
|
|
"disabled".to_string()
|
|
};
|
|
println!("{}", fmt::kv_line("Heartbeat", &hb_value, 12));
|
|
|
|
// MCP servers
|
|
let mcp_value = match crate::tools::mcp::config::load_mcp_servers().await {
|
|
Ok(servers) => {
|
|
let enabled = servers.servers.iter().filter(|s| s.enabled).count();
|
|
let total = servers.servers.len();
|
|
format!("{} enabled / {} configured", enabled, total)
|
|
}
|
|
Err(_) => "none configured".to_string(),
|
|
};
|
|
println!("{}", fmt::kv_line("MCP Servers", &mcp_value, 12));
|
|
|
|
// Config path
|
|
println!();
|
|
println!(
|
|
"{}",
|
|
fmt::kv_line(
|
|
"Config",
|
|
&crate::bootstrap::ironclaw_env_path().display().to_string(),
|
|
12,
|
|
)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(feature = "postgres")]
|
|
async fn check_database() -> anyhow::Result<()> {
|
|
let url = std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL not set"))?;
|
|
|
|
let config: deadpool_postgres::Config = deadpool_postgres::Config {
|
|
url: Some(url),
|
|
..Default::default()
|
|
};
|
|
let pool = crate::db::tls::create_pool(&config, crate::config::SslMode::from_env())
|
|
.map_err(|e| anyhow::anyhow!("pool error: {}", e))?;
|
|
|
|
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("timeout"))?
|
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
|
|
|
client
|
|
.execute("SELECT 1", &[])
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(feature = "postgres"))]
|
|
async fn check_database() -> anyhow::Result<()> {
|
|
// For non-postgres backends, just report configured
|
|
Ok(())
|
|
}
|
|
|
|
fn count_wasm_files(dir: &std::path::Path) -> usize {
|
|
std::fs::read_dir(dir)
|
|
.map(|entries| {
|
|
entries
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.path().extension().is_some_and(|ext| ext == "wasm"))
|
|
.count()
|
|
})
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn default_tools_dir() -> PathBuf {
|
|
ironclaw_base_dir().join("tools")
|
|
}
|
|
|
|
fn default_channels_dir() -> PathBuf {
|
|
ironclaw_base_dir().join("channels")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::load_settings_from;
|
|
|
|
/// Regression test for #354: load_settings_from must read config.toml.
|
|
#[test]
|
|
fn reads_toml_heartbeat_enabled() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let json_path = dir.path().join("settings.json");
|
|
let toml_path = dir.path().join("config.toml");
|
|
|
|
// No JSON file — only TOML
|
|
std::fs::write(
|
|
&toml_path,
|
|
"[heartbeat]\nenabled = true\ninterval_secs = 600",
|
|
)
|
|
.expect("write toml");
|
|
|
|
let settings = load_settings_from(&json_path, &toml_path);
|
|
assert!(settings.heartbeat.enabled);
|
|
assert_eq!(settings.heartbeat.interval_secs, 600);
|
|
}
|
|
|
|
/// Without any config files, defaults are returned.
|
|
#[test]
|
|
fn defaults_without_config_files() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let settings = load_settings_from(
|
|
&dir.path().join("nonexistent.json"),
|
|
&dir.path().join("nonexistent.toml"),
|
|
);
|
|
assert!(!settings.heartbeat.enabled);
|
|
}
|
|
|
|
/// settings.json is respected.
|
|
#[test]
|
|
fn reads_json_heartbeat_enabled() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let json_path = dir.path().join("settings.json");
|
|
let toml_path = dir.path().join("nonexistent.toml");
|
|
|
|
std::fs::write(
|
|
&json_path,
|
|
r#"{"heartbeat":{"enabled":true,"interval_secs":900}}"#,
|
|
)
|
|
.expect("write json");
|
|
|
|
let settings = load_settings_from(&json_path, &toml_path);
|
|
assert!(settings.heartbeat.enabled);
|
|
assert_eq!(settings.heartbeat.interval_secs, 900);
|
|
}
|
|
|
|
/// TOML overlay wins over JSON settings.
|
|
#[test]
|
|
fn toml_overlay_wins_over_json() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let json_path = dir.path().join("settings.json");
|
|
let toml_path = dir.path().join("config.toml");
|
|
|
|
std::fs::write(
|
|
&json_path,
|
|
r#"{"heartbeat":{"enabled":false,"interval_secs":100}}"#,
|
|
)
|
|
.expect("write json");
|
|
std::fs::write(
|
|
&toml_path,
|
|
"[heartbeat]\nenabled = true\ninterval_secs = 200",
|
|
)
|
|
.expect("write toml");
|
|
|
|
let settings = load_settings_from(&json_path, &toml_path);
|
|
assert!(settings.heartbeat.enabled);
|
|
assert_eq!(settings.heartbeat.interval_secs, 200);
|
|
}
|
|
|
|
/// Invalid TOML is warned but doesn't crash; falls back to JSON/defaults.
|
|
#[test]
|
|
fn invalid_toml_falls_back_gracefully() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let json_path = dir.path().join("settings.json");
|
|
let toml_path = dir.path().join("config.toml");
|
|
|
|
std::fs::write(
|
|
&json_path,
|
|
r#"{"heartbeat":{"enabled":true,"interval_secs":500}}"#,
|
|
)
|
|
.expect("write json");
|
|
std::fs::write(&toml_path, "this is not valid toml [[[").expect("write bad toml");
|
|
|
|
let settings = load_settings_from(&json_path, &toml_path);
|
|
// Should fall back to JSON values, not crash
|
|
assert!(settings.heartbeat.enabled);
|
|
assert_eq!(settings.heartbeat.interval_secs, 500);
|
|
}
|
|
}
|