From e5e09221b21868048d672e1ef627e8cf18803dc3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:59:08 -0700 Subject: [PATCH] fix: service gate buttons unclickable on startup (#139) * refactor: enhance service command handling and improve service state checks - Updated the ServiceBlockingGate component to include additional checks for service installation status, ensuring it accounts for 'Unknown' states. - Refactored tauriCommands to implement direct service command invocations with error handling, allowing fallback to CLI parsing for service operations (install, start, stop, status, uninstall). - Introduced a new utility function to parse CLI JSON output into the expected CommandResponse format, improving robustness in service command responses. - Added new Tauri commands for direct service interactions, enhancing the application's ability to manage service states effectively. * docs: add debug logging guidelines to CLAUDE.md and enhance ServiceBlockingGate component - Introduced a new section in CLAUDE.md outlining best practices for debug logging, emphasizing verbose diagnostics, critical checkpoints, structured context, and safety measures. - Updated the ServiceBlockingGate component to improve operation handling by adding an operating label state, enhancing user feedback during service operations, and refining error handling and logging for better traceability. * style: add pointer-events none to CSS for improved interaction handling - Updated index.css to include pointer-events: none; for specific elements, enhancing user experience by preventing unintended interactions. * style: improve text formatting and readability in ServiceBlockingGate and tauriCommands - Refactored text in the ServiceBlockingGate component for better readability by consolidating lines. - Enhanced the formatting of the openhumanServiceStart function in tauriCommands for improved clarity in the method call structure. --- CLAUDE.md | 11 ++ app/src-tauri/src/lib.rs | 87 ++++++++++++- .../components/daemon/ServiceBlockingGate.tsx | 120 ++++++++++++++---- app/src/index.css | 1 + app/src/utils/tauriCommands.ts | 51 +++++++- 5 files changed, 234 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5d87d825f..48f04d8d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -300,6 +300,17 @@ In the parent **OpenHuman** desktop app, **Tauri / Rust is a delivery vehicle**: --- +## Debug logging rule (must follow) + +- **Default to verbose diagnostics on new/changed flows**: Add substantial, development-oriented logs while implementing features or fixes so issues are easy to trace end-to-end. +- **Log critical checkpoints**: Include logs at entry/exit points, branch decisions, external calls, retries/timeouts, state transitions, and error handling paths. +- **Use structured, grep-friendly context**: Prefer stable prefixes (for example `[domain]`, `[rpc]`, `[ui-flow]`) and include correlation fields such as request IDs, method names, and entity IDs when available. +- **Platform conventions**: In Rust, use `log` / `tracing` at `debug` or `trace`; in `app/`, use namespaced `debug` logs and dev-only detail as needed. +- **Keep logs safe**: Never log secrets or sensitive payloads (API keys, JWTs, credentials, full PII). Redact or omit sensitive fields. +- **Treat debuggability as a deliverable**: Changes lacking sufficient logging for diagnosis are incomplete and should be updated before handoff. + +--- + ## Feature design workflow (new capabilities) Follow this order so behavior is **specified**, **proven in Rust**, **proven over RPC**, then **surfaced in the UI** with matching tests. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 19d6951aa..f05e61c6f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -18,6 +18,84 @@ fn core_rpc_url() -> String { .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string()) } +/// Resolve the core binary, preferring the staged sidecar. +fn resolve_core_bin() -> Result { + if let Some(bin) = core_process::default_core_bin() { + return Ok(bin); + } + std::env::current_exe().map_err(|e| format!("cannot resolve executable: {e}")) +} + +/// Run the core binary with the given CLI args and return its stdout. +async fn run_core_cli(args: Vec) -> Result { + tokio::task::spawn_blocking(move || { + let bin = resolve_core_bin()?; + let is_self = { + let current = std::env::current_exe().ok(); + current + .as_ref() + .and_then(|c| std::fs::canonicalize(c).ok()) + .zip(std::fs::canonicalize(&bin).ok()) + .map_or(false, |(a, b)| a == b) + }; + + let mut cmd = std::process::Command::new(&bin); + if is_self { + cmd.arg("core"); + } + cmd.args(&args); + + log::info!( + "[service-direct] running {:?} {}{}", + bin, + if is_self { "core " } else { "" }, + args.join(" ") + ); + + let output = cmd + .output() + .map_err(|e| format!("failed to execute core binary: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "core binary exited with {}: {}", + output.status, + stderr.trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + }) + .await + .map_err(|e| format!("task join error: {e}"))? +} + +#[tauri::command] +async fn service_install_direct() -> Result { + run_core_cli(vec!["service".into(), "install".into()]).await +} + +#[tauri::command] +async fn service_start_direct() -> Result { + run_core_cli(vec!["service".into(), "start".into()]).await +} + +#[tauri::command] +async fn service_stop_direct() -> Result { + run_core_cli(vec!["service".into(), "stop".into()]).await +} + +#[tauri::command] +async fn service_status_direct() -> Result { + run_core_cli(vec!["service".into(), "status".into()]).await +} + +#[tauri::command] +async fn service_uninstall_direct() -> Result { + run_core_cli(vec!["service".into(), "uninstall".into()]).await +} + fn is_daemon_mode() -> bool { std::env::args().any(|arg| arg == "daemon" || arg == "--daemon") } @@ -138,7 +216,14 @@ pub fn run() { Ok(()) }) - .invoke_handler(tauri::generate_handler![core_rpc_url]) + .invoke_handler(tauri::generate_handler![ + core_rpc_url, + service_install_direct, + service_start_direct, + service_stop_direct, + service_status_direct, + service_uninstall_direct + ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(move |app_handle, event| match event { diff --git a/app/src/components/daemon/ServiceBlockingGate.tsx b/app/src/components/daemon/ServiceBlockingGate.tsx index 24eb6cdc7..ff58ea575 100644 --- a/app/src/components/daemon/ServiceBlockingGate.tsx +++ b/app/src/components/daemon/ServiceBlockingGate.tsx @@ -34,6 +34,7 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => { const [serviceStateText, setServiceStateText] = useState('Unknown'); const [agentRunning, setAgentRunning] = useState(false); const [isOperating, setIsOperating] = useState(false); + const [operatingLabel, setOperatingLabel] = useState(null); const [error, setError] = useState(null); const refreshStatus = useCallback(async (options: RefreshOptions = {}) => { @@ -124,27 +125,31 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => { }; }, [refreshStatus]); - const installed = useMemo(() => serviceStateText !== 'NotInstalled', [serviceStateText]); + const installed = useMemo( + () => serviceStateText !== 'NotInstalled' && !serviceStateText.startsWith('Unknown'), + [serviceStateText] + ); const serviceRunning = useMemo(() => serviceStateText === 'Running', [serviceStateText]); const runOperation = useCallback( - async (op: () => Promise) => { - const opName = op.name || 'anonymous-operation'; - console.info('[ServiceBlockingGate] Running operation', { operation: opName }); + async (label: string, op: () => Promise) => { + console.info('[ServiceBlockingGate] Running operation', { operation: label }); setIsOperating(true); + setOperatingLabel(label); setError(null); try { await op(); - console.info('[ServiceBlockingGate] Operation completed', { operation: opName }); + console.info('[ServiceBlockingGate] Operation completed', { operation: label }); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message); console.error('[ServiceBlockingGate] Operation failed', { - operation: opName, + operation: label, error: message, }); } finally { setIsOperating(false); + setOperatingLabel(null); await refreshStatus(); } }, @@ -161,12 +166,19 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => { return <>{children}; } + // Stop/Restart/Uninstall require the service to be installed. + // Install and Start are always available as recovery actions (never disabled). + const canStop = !isOperating && installed && serviceRunning; + const canRestart = !isOperating && installed; + const canUninstall = !isOperating && installed; + return (

OpenHuman Service Required

- The desktop service must be installed and running before the app can continue. + The desktop service must be installed and running before the app can continue. Use the + buttons below to set up or restart the service.

@@ -180,7 +192,28 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
- {error ? ( + {isOperating ? ( +
+ + + + + {operatingLabel ? `${operatingLabel}...` : 'Working...'} +
+ ) : null} + + {error && !isOperating ? (
{error}
@@ -188,44 +221,75 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
diff --git a/app/src/index.css b/app/src/index.css index ab0607de8..ced9d289a 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -51,6 +51,7 @@ background-repeat: repeat; background-position: top right; background-size: 510px auto; + pointer-events: none; } } diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts index a0ad48a77..9f0d67921 100644 --- a/app/src/utils/tauriCommands.ts +++ b/app/src/utils/tauriCommands.ts @@ -1515,41 +1515,78 @@ export async function openhumanHardwareIntrospect( }); } +/** + * Parse CLI JSON output from a direct service command into CommandResponse shape. + */ +function parseServiceCliOutput(raw: string): CommandResponse { + const parsed = JSON.parse(raw) as CommandResponse; + return parsed; +} + export async function openhumanServiceInstall(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc>({ method: 'openhuman.service_install' }); + try { + return await callCoreRpc>({ + method: 'openhuman.service_install', + }); + } catch { + const raw = await invoke('service_install_direct'); + return parseServiceCliOutput(raw); + } } export async function openhumanServiceStart(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc>({ method: 'openhuman.service_start' }); + try { + return await callCoreRpc>({ method: 'openhuman.service_start' }); + } catch { + const raw = await invoke('service_start_direct'); + return parseServiceCliOutput(raw); + } } export async function openhumanServiceStop(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc>({ method: 'openhuman.service_stop' }); + try { + return await callCoreRpc>({ method: 'openhuman.service_stop' }); + } catch { + const raw = await invoke('service_stop_direct'); + return parseServiceCliOutput(raw); + } } export async function openhumanServiceStatus(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc>({ method: 'openhuman.service_status' }); + try { + return await callCoreRpc>({ + method: 'openhuman.service_status', + }); + } catch { + const raw = await invoke('service_status_direct'); + return parseServiceCliOutput(raw); + } } export async function openhumanServiceUninstall(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc>({ - method: 'openhuman.service_uninstall', - }); + try { + return await callCoreRpc>({ + method: 'openhuman.service_uninstall', + }); + } catch { + const raw = await invoke('service_uninstall_direct'); + return parseServiceCliOutput(raw); + } } export async function openhumanAgentServerStatus(): Promise> {