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.
This commit is contained in:
Steven Enamakel
2026-03-31 13:59:08 -07:00
committed by GitHub
parent 2b33afeefe
commit e5e09221b2
5 changed files with 234 additions and 36 deletions
+11
View File
@@ -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.
+86 -1
View File
@@ -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<std::path::PathBuf, String> {
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<String>) -> Result<String, String> {
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<String, String> {
run_core_cli(vec!["service".into(), "install".into()]).await
}
#[tauri::command]
async fn service_start_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "start".into()]).await
}
#[tauri::command]
async fn service_stop_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "stop".into()]).await
}
#[tauri::command]
async fn service_status_direct() -> Result<String, String> {
run_core_cli(vec!["service".into(), "status".into()]).await
}
#[tauri::command]
async fn service_uninstall_direct() -> Result<String, String> {
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 {
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(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<unknown>) => {
const opName = op.name || 'anonymous-operation';
console.info('[ServiceBlockingGate] Running operation', { operation: opName });
async (label: string, op: () => Promise<unknown>) => {
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 (
<div className="h-screen w-screen flex items-center justify-center bg-[#0a0d12] text-white px-6">
<div className="w-full max-w-xl rounded-2xl border border-white/15 bg-black/30 p-6 space-y-4">
<h1 className="text-xl font-semibold">OpenHuman Service Required</h1>
<p className="text-sm text-white/70">
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.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
@@ -180,7 +192,28 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
</div>
</div>
{error ? (
{isOperating ? (
<div className="rounded-lg border border-blue-500/40 bg-blue-900/20 p-3 text-sm text-blue-300 flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{operatingLabel ? `${operatingLabel}...` : 'Working...'}
</div>
) : null}
{error && !isOperating ? (
<div className="rounded-lg border border-red-500/40 bg-red-900/20 p-3 text-sm text-red-300">
{error}
</div>
@@ -188,44 +221,75 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
<div className="flex flex-wrap gap-2">
<button
disabled={isOperating || installed}
onClick={() => void runOperation(() => openhumanServiceInstall())}
className="px-3 py-2 rounded-lg text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-400">
Install Service
onClick={() => {
console.warn('[ServiceGate] INSTALL clicked', { isOperating, serviceStateText });
if (isOperating) return;
void runOperation('Installing service', () => openhumanServiceInstall());
}}
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-blue-600 hover:bg-blue-500 text-white cursor-pointer">
{isOperating && operatingLabel === 'Installing service'
? 'Installing...'
: 'Install Service'}
</button>
<button
disabled={isOperating || !installed || serviceRunning}
onClick={() => void runOperation(() => openhumanServiceStart())}
className="px-3 py-2 rounded-lg text-sm bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:text-gray-400">
Start Service
onClick={() => {
console.warn('[ServiceGate] START clicked', { isOperating, serviceStateText });
if (isOperating) return;
void runOperation('Starting service', () => openhumanServiceStart());
}}
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-green-600 hover:bg-green-500 text-white cursor-pointer">
{isOperating && operatingLabel === 'Starting service' ? 'Starting...' : 'Start Service'}
</button>
<button
disabled={isOperating || !installed || !serviceRunning}
onClick={() => void runOperation(() => openhumanServiceStop())}
className="px-3 py-2 rounded-lg text-sm bg-red-600 hover:bg-red-700 disabled:bg-gray-700 disabled:text-gray-400">
disabled={!canStop}
onClick={() => {
console.warn('[ServiceGate] STOP clicked', { isOperating, canStop });
void runOperation('Stopping service', () => openhumanServiceStop());
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
canStop
? 'bg-red-600 hover:bg-red-500 text-white cursor-pointer'
: 'bg-white/5 text-white/30 cursor-not-allowed'
}`}>
Stop Service
</button>
<button
disabled={isOperating || !installed}
onClick={() => void runOperation(restartService)}
className="px-3 py-2 rounded-lg text-sm bg-cyan-700 hover:bg-cyan-800 disabled:bg-gray-700 disabled:text-gray-400">
disabled={!canRestart}
onClick={() => {
console.warn('[ServiceGate] RESTART clicked', { isOperating, canRestart });
void runOperation('Restarting service', restartService);
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
canRestart
? 'bg-cyan-700 hover:bg-cyan-600 text-white cursor-pointer'
: 'bg-white/5 text-white/30 cursor-not-allowed'
}`}>
Restart Service
</button>
<button
disabled={isOperating || !installed}
onClick={() => void runOperation(() => openhumanServiceUninstall())}
className="px-3 py-2 rounded-lg text-sm bg-amber-700 hover:bg-amber-800 disabled:bg-gray-700 disabled:text-gray-400">
disabled={!canUninstall}
onClick={() => {
console.warn('[ServiceGate] UNINSTALL clicked', { isOperating, canUninstall });
void runOperation('Uninstalling service', () => openhumanServiceUninstall());
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
canUninstall
? 'bg-amber-700 hover:bg-amber-600 text-white cursor-pointer'
: 'bg-white/5 text-white/30 cursor-not-allowed'
}`}>
Uninstall Service
</button>
<button
disabled={isOperating}
onClick={() => void refreshStatus({ showChecking: true, clearError: true })}
className="px-3 py-2 rounded-lg text-sm bg-gray-700 hover:bg-gray-600 disabled:bg-gray-700/60 disabled:text-gray-400">
onClick={() => {
console.warn('[ServiceGate] REFRESH clicked');
void refreshStatus({ showChecking: true, clearError: true });
}}
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-white/10 hover:bg-white/20 text-white cursor-pointer">
Refresh
</button>
</div>
+1
View File
@@ -51,6 +51,7 @@
background-repeat: repeat;
background-position: top right;
background-size: 510px auto;
pointer-events: none;
}
}
+44 -7
View File
@@ -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<ServiceStatus> {
const parsed = JSON.parse(raw) as CommandResponse<ServiceStatus>;
return parsed;
}
export async function openhumanServiceInstall(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_install' });
try {
return await callCoreRpc<CommandResponse<ServiceStatus>>({
method: 'openhuman.service_install',
});
} catch {
const raw = await invoke<string>('service_install_direct');
return parseServiceCliOutput(raw);
}
}
export async function openhumanServiceStart(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_start' });
try {
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_start' });
} catch {
const raw = await invoke<string>('service_start_direct');
return parseServiceCliOutput(raw);
}
}
export async function openhumanServiceStop(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_stop' });
try {
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_stop' });
} catch {
const raw = await invoke<string>('service_stop_direct');
return parseServiceCliOutput(raw);
}
}
export async function openhumanServiceStatus(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_status' });
try {
return await callCoreRpc<CommandResponse<ServiceStatus>>({
method: 'openhuman.service_status',
});
} catch {
const raw = await invoke<string>('service_status_direct');
return parseServiceCliOutput(raw);
}
}
export async function openhumanServiceUninstall(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ServiceStatus>>({
method: 'openhuman.service_uninstall',
});
try {
return await callCoreRpc<CommandResponse<ServiceStatus>>({
method: 'openhuman.service_uninstall',
});
} catch {
const raw = await invoke<string>('service_uninstall_direct');
return parseServiceCliOutput(raw);
}
}
export async function openhumanAgentServerStatus(): Promise<CommandResponse<AgentServerStatus>> {