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> {