mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(recovery): prompt users to download latest build after crash loops (#778)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
1e3743f28e
commit
44fda19734
@@ -1,3 +1,6 @@
|
||||
import { LATEST_APP_DOWNLOAD_URL } from '../utils/config';
|
||||
import { openUrl } from '../utils/openUrl';
|
||||
|
||||
/**
|
||||
* ErrorFallbackScreen
|
||||
*
|
||||
@@ -55,6 +58,9 @@ export default function ErrorFallbackScreen({
|
||||
<p className="text-sm text-stone-400 text-center mb-6">
|
||||
The application encountered an unexpected error and could not recover.
|
||||
</p>
|
||||
<p className="text-xs text-stone-500 text-center mb-6">
|
||||
If this keeps happening after restart, install the latest version.
|
||||
</p>
|
||||
|
||||
{/* Error details */}
|
||||
<div className="bg-stone-800/50 border border-stone-700/50 rounded-xl p-4 mb-6">
|
||||
@@ -73,10 +79,10 @@ export default function ErrorFallbackScreen({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex-1 bg-stone-700 hover:bg-stone-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
|
||||
className="bg-stone-700 hover:bg-stone-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
|
||||
Try to Recover
|
||||
</button>
|
||||
<button
|
||||
@@ -84,9 +90,14 @@ export default function ErrorFallbackScreen({
|
||||
window.location.hash = '#/home';
|
||||
window.location.reload();
|
||||
}}
|
||||
className="flex-1 bg-coral-500 hover:bg-coral-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
|
||||
className="bg-coral-500 hover:bg-coral-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
|
||||
Reload App
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openUrl(LATEST_APP_DOWNLOAD_URL)}
|
||||
className="bg-stone-800 hover:bg-stone-700 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors border border-stone-600">
|
||||
Download Latest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useDaemonHealth } from '../../hooks/useDaemonHealth';
|
||||
import { useDaemonLifecycle } from '../../hooks/useDaemonLifecycle';
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { LATEST_APP_DOWNLOAD_URL } from '../../utils/config';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
|
||||
interface ServiceBlockingGateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
|
||||
return <>{children}</>;
|
||||
const { snapshot } = useCoreState();
|
||||
const daemonHealth = useDaemonHealth();
|
||||
const daemonLifecycle = useDaemonLifecycle();
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const hasSession = Boolean(snapshot.sessionToken);
|
||||
const shouldShowRecoveryPrompt =
|
||||
hasSession && daemonLifecycle.maxAttemptsReached && daemonHealth.status !== 'running';
|
||||
|
||||
const handleRetry = async () => {
|
||||
setIsRestarting(true);
|
||||
setRestartError(null);
|
||||
try {
|
||||
const restarted = await daemonHealth.restartDaemon();
|
||||
if (restarted) {
|
||||
daemonLifecycle.resetRetries();
|
||||
return;
|
||||
}
|
||||
setRestartError('Retry failed. Download the latest app build and try again.');
|
||||
} catch {
|
||||
setRestartError('Retry failed. Download the latest app build and try again.');
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadLatest = async () => {
|
||||
await openUrl(LATEST_APP_DOWNLOAD_URL);
|
||||
};
|
||||
|
||||
if (!shouldShowRecoveryPrompt) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div className="fixed inset-0 z-[10000] bg-stone-950/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-xl rounded-2xl border border-coral-500/30 bg-stone-900 p-6 shadow-2xl">
|
||||
<h2 className="text-xl font-semibold text-white">OpenHuman core is unavailable</h2>
|
||||
<p className="mt-2 text-sm text-stone-300">
|
||||
We could not recover the local core service after multiple attempts. This can happen
|
||||
after a critical crash or binary mismatch.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-stone-400">
|
||||
Download the latest app build to restore a healthy core runtime.
|
||||
</p>
|
||||
{restartError && <p className="mt-3 text-sm text-coral-300">{restartError}</p>}
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={isRestarting}
|
||||
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
|
||||
{isRestarting ? 'Retrying...' : 'Retry Core'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadLatest}
|
||||
className="rounded-lg bg-coral-500 px-4 py-2 text-sm font-medium text-white hover:bg-coral-600">
|
||||
Download Latest Version
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceBlockingGate;
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, type Mock } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import * as tauriCommands from '../../../utils/tauriCommands';
|
||||
import ServiceBlockingGate from '../ServiceBlockingGate';
|
||||
|
||||
const mockOpenUrl = vi.fn();
|
||||
const mockUseCoreState = vi.fn();
|
||||
const mockUseDaemonHealth = vi.fn();
|
||||
const mockUseDaemonLifecycle = vi.fn();
|
||||
|
||||
vi.mock('../../../utils/openUrl', () => ({
|
||||
openUrl: (...args: unknown[]) => mockOpenUrl(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/config', () => ({
|
||||
LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest',
|
||||
}));
|
||||
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() }));
|
||||
|
||||
vi.mock('../../../hooks/useDaemonHealth', () => ({ useDaemonHealth: () => mockUseDaemonHealth() }));
|
||||
|
||||
vi.mock('../../../hooks/useDaemonLifecycle', () => ({
|
||||
useDaemonLifecycle: () => mockUseDaemonLifecycle(),
|
||||
}));
|
||||
|
||||
describe('ServiceBlockingGate', () => {
|
||||
const mockIsTauri = tauriCommands.isTauri as Mock;
|
||||
const mockServiceStatus = tauriCommands.openhumanServiceStatus as Mock;
|
||||
const mockAgentStatus = tauriCommands.openhumanAgentServerStatus as Mock;
|
||||
const mockInstall = tauriCommands.openhumanServiceInstall as Mock;
|
||||
const mockStart = tauriCommands.openhumanServiceStart as Mock;
|
||||
|
||||
it('renders children directly outside Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
<div>App Content</div>
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
|
||||
beforeEach(() => {
|
||||
mockOpenUrl.mockReset();
|
||||
mockUseCoreState.mockReturnValue({ snapshot: { sessionToken: 'token' } });
|
||||
mockUseDaemonHealth.mockReturnValue({ status: 'running', restartDaemon: vi.fn() });
|
||||
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: false, resetRetries: vi.fn() });
|
||||
});
|
||||
|
||||
it('renders children even when service is not installed', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] });
|
||||
mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
|
||||
|
||||
it('renders children and does not show recovery prompt when daemon is healthy', async () => {
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
<div>App Content</div>
|
||||
@@ -35,15 +40,12 @@ describe('ServiceBlockingGate', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
|
||||
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('OpenHuman core is unavailable')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not expose forced install actions from the app shell', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] });
|
||||
mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
|
||||
mockInstall.mockResolvedValue({ result: { state: 'Stopped' }, logs: [] });
|
||||
mockStart.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
|
||||
it('shows recovery prompt when daemon retries are exhausted', async () => {
|
||||
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: vi.fn() });
|
||||
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() });
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
@@ -51,16 +53,13 @@ describe('ServiceBlockingGate', () => {
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: 'Install Service' })).not.toBeInTheDocument();
|
||||
expect(mockInstall).not.toHaveBeenCalled();
|
||||
expect(mockStart).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('OpenHuman core is unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download Latest Version' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when service is running even if agent is not running', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockServiceStatus.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
|
||||
mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
|
||||
it('opens latest release page from recovery prompt', async () => {
|
||||
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: vi.fn() });
|
||||
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() });
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
@@ -68,14 +67,23 @@ describe('ServiceBlockingGate', () => {
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
|
||||
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download Latest Version' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
'https://github.com/tinyhumansai/openhuman/releases/latest'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders children when service is running and agent probe fails', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockServiceStatus.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
|
||||
mockAgentStatus.mockRejectedValue(new Error('agent status unavailable'));
|
||||
it('calls restartDaemon and resetRetries on successful retry', async () => {
|
||||
const mockRestartDaemon = vi.fn().mockResolvedValue(true);
|
||||
const mockResetRetries = vi.fn();
|
||||
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon });
|
||||
mockUseDaemonLifecycle.mockReturnValue({
|
||||
maxAttemptsReached: true,
|
||||
resetRetries: mockResetRetries,
|
||||
});
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
@@ -83,14 +91,18 @@ describe('ServiceBlockingGate', () => {
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
|
||||
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry Core' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestartDaemon).toHaveBeenCalled();
|
||||
expect(mockResetRetries).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders children when service is stopped but agent server is running (soft pass)', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockServiceStatus.mockResolvedValue({ result: { state: 'Stopped' }, logs: [] });
|
||||
mockAgentStatus.mockResolvedValue({ result: { running: true }, logs: [] });
|
||||
it('shows error message when restartDaemon returns false', async () => {
|
||||
const mockRestartDaemon = vi.fn().mockResolvedValue(false);
|
||||
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon });
|
||||
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() });
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
@@ -98,14 +110,17 @@ describe('ServiceBlockingGate', () => {
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
|
||||
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry Core' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Retry failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders children when service probe fails but agent server is running (soft pass)', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockServiceStatus.mockRejectedValue(new Error('service status unavailable'));
|
||||
mockAgentStatus.mockResolvedValue({ result: { running: true }, logs: [] });
|
||||
it('shows error message when restartDaemon throws', async () => {
|
||||
const mockRestartDaemon = vi.fn().mockRejectedValue(new Error('daemon error'));
|
||||
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon });
|
||||
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() });
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
@@ -113,7 +128,10 @@ describe('ServiceBlockingGate', () => {
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
|
||||
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry Core' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Retry failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ export const SENTRY_RELEASE = BUILD_SHA
|
||||
export const MINIMUM_SUPPORTED_APP_VERSION =
|
||||
(import.meta.env.VITE_MINIMUM_SUPPORTED_APP_VERSION as string | undefined)?.trim() ?? '';
|
||||
|
||||
/** Recovery link opened when OAuth is blocked due to an outdated app build (build-time default; override via CI). */
|
||||
/** URL for the latest app release download page. Used for OAuth version-gate recovery and crash-recovery prompts. Override via VITE_LATEST_APP_DOWNLOAD_URL for deployment-specific download pages. */
|
||||
export const LATEST_APP_DOWNLOAD_URL =
|
||||
(import.meta.env.VITE_LATEST_APP_DOWNLOAD_URL as string | undefined)?.trim() ||
|
||||
'https://github.com/tinyhumansai/openhuman/releases/latest';
|
||||
|
||||
+10
-3
@@ -255,7 +255,12 @@ PY
|
||||
ASSET_URL="$(echo "${parsed}" | sed -n '3p')"
|
||||
ASSET_SHA256="$(echo "${parsed}" | sed -n '4p')"
|
||||
|
||||
[ -n "${ASSET_URL}" ]
|
||||
# Exit 0 on success, 2 when API responded but no compatible asset was found.
|
||||
# Callers can distinguish "no asset" (2) from network/parse errors (1).
|
||||
if [ -n "${ASSET_URL}" ]; then
|
||||
return 0
|
||||
fi
|
||||
return 2
|
||||
}
|
||||
|
||||
resolve_release_digest() {
|
||||
@@ -292,8 +297,10 @@ if resolve_from_latest_json; then
|
||||
log_ok "Resolved latest release via latest.json (${LATEST_VERSION})"
|
||||
else
|
||||
log_warn "latest.json lookup failed. Falling back to releases API."
|
||||
if ! resolve_from_release_api; then
|
||||
if [ "${DRY_RUN}" = true ]; then
|
||||
resolve_from_release_api
|
||||
resolve_rc=$?
|
||||
if [ "${resolve_rc}" -ne 0 ]; then
|
||||
if [ "${DRY_RUN}" = true ] && [ "${resolve_rc}" -eq 2 ]; then
|
||||
if [ "${OS}" = "linux" ]; then
|
||||
log_warn "No Linux release asset is currently published. Dry-run will skip install steps."
|
||||
echo "DRY RUN: no compatible asset available for ${OS}/${ARCH}"
|
||||
|
||||
Reference in New Issue
Block a user