From fb720fd3053a64125144ecebd6eede5b369de169 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:32:29 -0700 Subject: [PATCH] feat(harness_init): eager first-run setup with init overlay; pin managed Python to 3.13 (#4021) --- app/src/App.tsx | 2 + .../InitProgressScreen/HarnessInitOverlay.tsx | 116 ++++++++++ .../InitProgressScreen.test.tsx | 102 +++++++++ .../InitProgressScreen/InitProgressScreen.tsx | 144 ++++++++++++ app/src/lib/i18n/ar.ts | 17 ++ app/src/lib/i18n/bn.ts | 18 ++ app/src/lib/i18n/de.ts | 18 ++ app/src/lib/i18n/en.ts | 18 ++ app/src/lib/i18n/es.ts | 18 ++ app/src/lib/i18n/fr.ts | 19 ++ app/src/lib/i18n/hi.ts | 17 ++ app/src/lib/i18n/id.ts | 18 ++ app/src/lib/i18n/it.ts | 18 ++ app/src/lib/i18n/ko.ts | 17 ++ app/src/lib/i18n/pl.ts | 18 ++ app/src/lib/i18n/pt.ts | 18 ++ app/src/lib/i18n/ru.ts | 18 ++ app/src/lib/i18n/zh-CN.ts | 17 ++ app/src/services/harnessInitService.test.ts | 72 ++++++ app/src/services/harnessInitService.ts | 102 +++++++++ docs/TEST-COVERAGE-MATRIX.md | 1 + src/core/all.rs | 3 + src/core/event_bus/events.rs | 20 +- src/core/jsonrpc.rs | 14 ++ src/core/socketio.rs | 24 ++ src/openhuman/config/schema/runtime_python.rs | 14 ++ src/openhuman/harness_init/bus.rs | 6 + src/openhuman/harness_init/mod.rs | 26 +++ src/openhuman/harness_init/ops.rs | 117 ++++++++++ src/openhuman/harness_init/registry.rs | 193 ++++++++++++++++ src/openhuman/harness_init/schemas.rs | 206 ++++++++++++++++++ src/openhuman/harness_init/store.rs | 115 ++++++++++ src/openhuman/harness_init/types.rs | 81 +++++++ src/openhuman/memory_tree/nlp/mod.rs | 2 +- src/openhuman/memory_tree/nlp/provision.rs | 12 + src/openhuman/mod.rs | 1 + src/openhuman/runtime_python/bootstrap.rs | 6 +- src/openhuman/runtime_python/downloader.rs | 20 +- .../runtime_python/downloader_tests.rs | 39 ++++ tests/json_rpc_e2e.rs | 45 ++++ 40 files changed, 1728 insertions(+), 4 deletions(-) create mode 100644 app/src/components/InitProgressScreen/HarnessInitOverlay.tsx create mode 100644 app/src/components/InitProgressScreen/InitProgressScreen.test.tsx create mode 100644 app/src/components/InitProgressScreen/InitProgressScreen.tsx create mode 100644 app/src/services/harnessInitService.test.ts create mode 100644 app/src/services/harnessInitService.ts create mode 100644 src/openhuman/harness_init/bus.rs create mode 100644 src/openhuman/harness_init/mod.rs create mode 100644 src/openhuman/harness_init/ops.rs create mode 100644 src/openhuman/harness_init/registry.rs create mode 100644 src/openhuman/harness_init/schemas.rs create mode 100644 src/openhuman/harness_init/store.rs create mode 100644 src/openhuman/harness_init/types.rs diff --git a/app/src/App.tsx b/app/src/App.tsx index b63663a4f..990eea384 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -18,6 +18,7 @@ import CommandProvider from './components/commands/CommandProvider'; import ServiceBlockingGate from './components/daemon/ServiceBlockingGate'; import DictationHotkeyManager from './components/DictationHotkeyManager'; import ErrorFallbackScreen from './components/ErrorFallbackScreen'; +import HarnessInitOverlay from './components/InitProgressScreen/HarnessInitOverlay'; import KeyringConsentOverlay from './components/keyring/KeyringConsentOverlay'; import AppSidebar from './components/layout/shell/AppSidebar'; import RootShellLayout from './components/layout/shell/RootShellLayout'; @@ -154,6 +155,7 @@ function App() { {!onMobile && } {!onMobile && } + diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx new file mode 100644 index 000000000..eeed28a19 --- /dev/null +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx @@ -0,0 +1,116 @@ +import debugFactory from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + fetchHarnessInitStatus, + type HarnessInitSnapshot, + runHarnessInit, +} from '../../services/harnessInitService'; +import InitProgressScreen from './InitProgressScreen'; + +const log = debugFactory('harness-init'); + +const POLL_MS = 2000; + +/** + * Blocking first-run initialization gate. + * + * Polls `openhuman.harness_init_status` and, while the run is in progress, + * covers the app with a full-screen overlay showing per-step progress. The + * overlay offers a "Run in background" action so the user can dismiss it and + * keep working while setup continues — the core runs init as a background task + * regardless of whether the overlay is shown. On a warm host every step is + * already provisioned, so the snapshot reports `done` on the first poll and + * this renders nothing. On a terminal `failed` it offers Retry / Continue — + * failures are non-fatal (the core degrades to a fallback). + * + * Polling-based (not socket) to sidestep the cold-start race where the socket + * is not yet connected when init begins. + */ +export default function HarnessInitOverlay() { + const [snapshot, setSnapshot] = useState(null); + const [dismissed, setDismissed] = useState(false); + const [retrying, setRetrying] = useState(false); + const cancelledRef = useRef(false); + // Mirrors `dismissed` so the poll loop can stop without re-running the effect. + const dismissedRef = useRef(false); + + useEffect(() => { + cancelledRef.current = false; + let timeoutId: number | null = null; + + const poll = async () => { + try { + const next = await fetchHarnessInitStatus(); + if (cancelledRef.current || dismissedRef.current) { + return; + } + if (next) { + setSnapshot(next); + // Stop polling once the run is terminal; a `failed` snapshot stays + // on screen (with Retry) but does not need further polling. + if (next.overall === 'done' || next.overall === 'failed') { + return; + } + } + } catch (err) { + // Status can fail while the core is still coming up — keep polling. + log('status poll failed: %O', err); + } + if (!cancelledRef.current && !dismissedRef.current) { + timeoutId = window.setTimeout(() => void poll(), POLL_MS); + } + }; + + void poll(); + + return () => { + cancelledRef.current = true; + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + }; + }, []); + + const handleRetry = useCallback(async () => { + setRetrying(true); + try { + const next = await runHarnessInit(false); + if (next) { + setSnapshot(next); + } + } catch (err) { + log('retry failed: %O', err); + } finally { + setRetrying(false); + } + }, []); + + const handleContinue = useCallback(() => { + // Hide the overlay and stop polling; the core keeps running init as a + // background task regardless. + dismissedRef.current = true; + setDismissed(true); + }, []); + + if (dismissed || !snapshot) { + return null; + } + + // Block only while a run is actively in progress, or hold a failed run on + // screen until the user explicitly continues. `idle` (no run started yet) + // and `done` never block. + const shouldShow = snapshot.overall === 'running' || snapshot.overall === 'failed'; + if (!shouldShow) { + return null; + } + + return ( + + ); +} diff --git a/app/src/components/InitProgressScreen/InitProgressScreen.test.tsx b/app/src/components/InitProgressScreen/InitProgressScreen.test.tsx new file mode 100644 index 000000000..994875921 --- /dev/null +++ b/app/src/components/InitProgressScreen/InitProgressScreen.test.tsx @@ -0,0 +1,102 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { HarnessInitSnapshot } from '../../services/harnessInitService'; +import { renderWithProviders } from '../../test/test-utils'; +import InitProgressScreen from './InitProgressScreen'; + +function snapshot(overrides: Partial = {}): HarnessInitSnapshot { + return { + overall: 'running', + startedAt: null, + finishedAt: null, + steps: [ + { + id: 'python_runtime', + label: 'Python runtime', + required: false, + state: 'done', + message: null, + percent: 100, + updatedAt: null, + }, + { + id: 'spacy', + label: 'spaCy', + required: false, + state: 'running', + message: null, + percent: null, + updatedAt: null, + }, + { + id: 'node_runtime', + label: 'Node.js runtime', + required: false, + state: 'pending', + message: null, + percent: null, + updatedAt: null, + }, + ], + ...overrides, + }; +} + +describe('InitProgressScreen', () => { + it('renders each step with its localized label and state', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Python runtime')).toBeInTheDocument(); + expect(screen.getByText('Language model')).toBeInTheDocument(); + expect(screen.getByText('Node.js runtime')).toBeInTheDocument(); + expect(screen.getByText('Ready')).toBeInTheDocument(); + expect(screen.getByText('Installing…')).toBeInTheDocument(); + expect(screen.getByText('Waiting')).toBeInTheDocument(); + // No failure actions while running. + expect(screen.queryByText('Retry')).not.toBeInTheDocument(); + }); + + it('offers a Run in background action while running', () => { + const onContinue = vi.fn(); + renderWithProviders( + + ); + + fireEvent.click(screen.getByText('Run in background')); + expect(onContinue).toHaveBeenCalledTimes(1); + }); + + it('shows the failing message and Retry / Continue on a failed run', () => { + const onRetry = vi.fn(); + const onContinue = vi.fn(); + const failed = snapshot({ + overall: 'failed', + steps: [ + { + id: 'spacy', + label: 'spaCy', + required: false, + state: 'failed', + message: 'pip install timed out', + percent: null, + updatedAt: null, + }, + ], + }); + + renderWithProviders( + + ); + + expect(screen.getByText('pip install timed out')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Retry')); + expect(onRetry).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText('Continue anyway')); + expect(onContinue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/InitProgressScreen/InitProgressScreen.tsx b/app/src/components/InitProgressScreen/InitProgressScreen.tsx new file mode 100644 index 000000000..eaa9dce99 --- /dev/null +++ b/app/src/components/InitProgressScreen/InitProgressScreen.tsx @@ -0,0 +1,144 @@ +import { useT } from '../../lib/i18n/I18nContext'; +import type { HarnessInitSnapshot, HarnessInitStep } from '../../services/harnessInitService'; +import { CheckIcon, CloseIcon, Spinner } from '../ui/icons'; + +/** Map a known step id to its i18n label key; fall back to the server label. */ +function stepLabel(t: (key: string) => string, step: HarnessInitStep): string { + switch (step.id) { + case 'python_runtime': + return t('harnessInit.stepPython'); + case 'spacy': + return t('harnessInit.stepSpacy'); + case 'node_runtime': + return t('harnessInit.stepNode'); + default: + return step.label; + } +} + +function StepRow({ step }: { step: HarnessInitStep }) { + const { t } = useT(); + const label = stepLabel(t, step); + + let icon: React.ReactNode; + let stateText: string; + switch (step.state) { + case 'running': + icon = ; + stateText = t('harnessInit.stateRunning'); + break; + case 'done': + icon = ; + stateText = t('harnessInit.stateDone'); + break; + case 'failed': + icon = ; + stateText = t('harnessInit.stateFailed'); + break; + case 'skipped': + icon = ; + stateText = t('harnessInit.stateSkipped'); + break; + case 'pending': + default: + icon = ; + stateText = t('harnessInit.statePending'); + break; + } + + return ( + + + {icon} + + {label} + {stateText} + + ); +} + +export interface InitProgressScreenProps { + snapshot: HarnessInitSnapshot; + onRetry: () => void; + onContinue: () => void; + retrying?: boolean; +} + +/** + * Presentational first-run initialization screen. Renders the step list with + * per-step status. On a terminal `failed` overall it surfaces the failing step + * and offers Retry / Continue (failures are non-fatal — the app degrades to a + * fallback). + */ +export default function InitProgressScreen({ + snapshot, + onRetry, + onContinue, + retrying = false, +}: InitProgressScreenProps) { + const { t } = useT(); + const failed = snapshot.overall === 'failed'; + const failedStep = snapshot.steps.find(s => s.state === 'failed'); + + return ( + + + + {t('harnessInit.title')} + + {t('harnessInit.subtitle')} + + + {snapshot.steps.map(step => ( + + ))} + + + {!failed && ( + + {t('harnessInit.backgroundHint')} + + {t('harnessInit.runInBackground')} + + + )} + + {failed && ( + + + {t('harnessInit.failedMessage')} + {failedStep?.message && ( + + {failedStep.message} + + )} + + + + {t('harnessInit.continueAnyway')} + + + {retrying && } + {t('harnessInit.retry')} + + + + )} + + + ); +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 80c328341..0604658a5 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5542,6 +5542,23 @@ const messages: TranslationMap = { 'graphCohesion.title': 'تماسك الرسم البياني', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'جارٍ الإعداد', + 'harnessInit.subtitle': 'يقوم OpenHuman بتجهيز المكونات التي يحتاجها عند التشغيل الأول.', + 'harnessInit.stepPython': 'بيئة تشغيل Python', + 'harnessInit.stepSpacy': 'النموذج اللغوي', + 'harnessInit.stepNode': 'بيئة تشغيل Node.js', + 'harnessInit.statePending': 'في الانتظار', + 'harnessInit.stateRunning': 'جارٍ التثبيت…', + 'harnessInit.stateDone': 'جاهز', + 'harnessInit.stateSkipped': 'تم التخطي', + 'harnessInit.stateFailed': 'فشل', + 'harnessInit.failedMessage': + 'لم تكتمل بعض خطوات الإعداد. يمكنك إعادة المحاولة أو المتابعة — سيستخدم OpenHuman بديلاً مدمجاً.', + 'harnessInit.retry': 'إعادة المحاولة', + 'harnessInit.continueAnyway': 'المتابعة على أي حال', + 'harnessInit.runInBackground': 'التشغيل في الخلفية', + 'harnessInit.backgroundHint': 'يمكنك مواصلة استخدام OpenHuman حتى ينتهي هذا.', + 'keyring.consent.title': 'التخزين الآمن غير متاح', 'keyring.consent.description': 'سلسلة مفاتيح نظام التشغيل غير متاحة. يحتاج OpenHuman إلى إذنك لتخزين الأسرار باستخدام التخزين المشفّر المحلي بدلاً من ذلك.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a54f5cda6..c048c85ad 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5657,6 +5657,24 @@ const messages: TranslationMap = { 'memory.tab.cohesion': 'Cohesion', // Keyring consent & security + 'harnessInit.title': 'সেটআপ করা হচ্ছে', + 'harnessInit.subtitle': 'প্রথম চালুর সময় OpenHuman প্রয়োজনীয় উপাদানগুলো প্রস্তুত করছে।', + 'harnessInit.stepPython': 'Python রানটাইম', + 'harnessInit.stepSpacy': 'ভাষা মডেল', + 'harnessInit.stepNode': 'Node.js রানটাইম', + 'harnessInit.statePending': 'অপেক্ষমাণ', + 'harnessInit.stateRunning': 'ইনস্টল করা হচ্ছে…', + 'harnessInit.stateDone': 'প্রস্তুত', + 'harnessInit.stateSkipped': 'এড়িয়ে যাওয়া হয়েছে', + 'harnessInit.stateFailed': 'ব্যর্থ', + 'harnessInit.failedMessage': + 'কিছু সেটআপ ধাপ সম্পন্ন হয়নি। আপনি পুনরায় চেষ্টা করতে পারেন, অথবা চালিয়ে যেতে পারেন — OpenHuman একটি অন্তর্নির্মিত ফলব্যাক ব্যবহার করবে।', + 'harnessInit.retry': 'পুনরায় চেষ্টা করুন', + 'harnessInit.continueAnyway': 'তবুও চালিয়ে যান', + 'harnessInit.runInBackground': 'ব্যাকগ্রাউন্ডে চালান', + 'harnessInit.backgroundHint': + 'এটি শেষ হওয়া পর্যন্ত আপনি OpenHuman ব্যবহার করা চালিয়ে যেতে পারেন।', + 'keyring.consent.title': 'নিরাপদ সঞ্চয়স্থান অনুপলব্ধ', 'keyring.consent.description': 'আপনার অপারেটিং সিস্টেমের কিচেন অ্যাক্সেসযোগ্য নয়। OpenHuman-এর পরিবর্তে স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান ব্যবহার করে গোপনীয়তা সংরক্ষণ করতে আপনার অনুমতি প্রয়োজন।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 3acd62571..ee95d1bee 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5802,6 +5802,24 @@ const messages: TranslationMap = { 'graphCohesion.title': 'Graph-Kohäsion', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'Einrichtung läuft', + 'harnessInit.subtitle': 'OpenHuman bereitet beim ersten Start benötigte Komponenten vor.', + 'harnessInit.stepPython': 'Python-Laufzeitumgebung', + 'harnessInit.stepSpacy': 'Sprachmodell', + 'harnessInit.stepNode': 'Node.js-Laufzeitumgebung', + 'harnessInit.statePending': 'Wartet', + 'harnessInit.stateRunning': 'Wird installiert…', + 'harnessInit.stateDone': 'Bereit', + 'harnessInit.stateSkipped': 'Übersprungen', + 'harnessInit.stateFailed': 'Fehlgeschlagen', + 'harnessInit.failedMessage': + 'Einige Einrichtungsschritte wurden nicht abgeschlossen. Du kannst es erneut versuchen oder fortfahren — OpenHuman verwendet dann eine integrierte Ausweichlösung.', + 'harnessInit.retry': 'Erneut versuchen', + 'harnessInit.continueAnyway': 'Trotzdem fortfahren', + 'harnessInit.runInBackground': 'Im Hintergrund ausführen', + 'harnessInit.backgroundHint': + 'Du kannst OpenHuman weiter verwenden, während dies abgeschlossen wird.', + 'keyring.consent.title': 'Sicherer Speicher nicht verfügbar', 'keyring.consent.description': 'Der Schlüsselbund Ihres Betriebssystems ist nicht erreichbar. OpenHuman benötigt Ihre Erlaubnis, Geheimnisse stattdessen in einem lokal verschlüsselten Speicher abzulegen.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 2a2139974..b3fc74342 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -6143,6 +6143,24 @@ const en: TranslationMap = { 'chat.files.error.download_failed': 'Download failed. Please try again.', 'chat.files.error.delete_failed': 'Couldn’t delete the file. Please try again.', + // First-run initialization (harness_init) + 'harnessInit.title': 'Setting things up', + 'harnessInit.subtitle': 'OpenHuman is preparing components it needs on first launch.', + 'harnessInit.stepPython': 'Python runtime', + 'harnessInit.stepSpacy': 'Language model', + 'harnessInit.stepNode': 'Node.js runtime', + 'harnessInit.statePending': 'Waiting', + 'harnessInit.stateRunning': 'Installing…', + 'harnessInit.stateDone': 'Ready', + 'harnessInit.stateSkipped': 'Skipped', + 'harnessInit.stateFailed': 'Failed', + 'harnessInit.failedMessage': + 'Some setup steps did not finish. You can retry, or continue — OpenHuman will use a built-in fallback.', + 'harnessInit.retry': 'Retry', + 'harnessInit.continueAnyway': 'Continue anyway', + 'harnessInit.runInBackground': 'Run in background', + 'harnessInit.backgroundHint': 'You can keep using OpenHuman while this finishes.', + // Keyring consent & security 'keyring.consent.title': 'Secure Storage Unavailable', 'keyring.consent.description': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 8b3519d3f..46f3fdc16 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5767,6 +5767,24 @@ const messages: TranslationMap = { 'graphCohesion.title': 'Cohesión del grafo', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'Preparando todo', + 'harnessInit.subtitle': + 'OpenHuman está preparando los componentes que necesita en el primer inicio.', + 'harnessInit.stepPython': 'Entorno de ejecución de Python', + 'harnessInit.stepSpacy': 'Modelo de lenguaje', + 'harnessInit.stepNode': 'Entorno de ejecución de Node.js', + 'harnessInit.statePending': 'En espera', + 'harnessInit.stateRunning': 'Instalando…', + 'harnessInit.stateDone': 'Listo', + 'harnessInit.stateSkipped': 'Omitido', + 'harnessInit.stateFailed': 'Error', + 'harnessInit.failedMessage': + 'Algunos pasos de la configuración no se completaron. Puedes reintentar o continuar — OpenHuman usará una alternativa integrada.', + 'harnessInit.retry': 'Reintentar', + 'harnessInit.continueAnyway': 'Continuar de todos modos', + 'harnessInit.runInBackground': 'Ejecutar en segundo plano', + 'harnessInit.backgroundHint': 'Puedes seguir usando OpenHuman mientras esto termina.', + 'keyring.consent.title': 'Almacenamiento seguro no disponible', 'keyring.consent.description': 'El llavero de su sistema operativo no está accesible. OpenHuman necesita su permiso para almacenar secretos usando almacenamiento local cifrado.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 80bc3dafd..bffb86b2f 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5785,6 +5785,25 @@ const messages: TranslationMap = { 'graphCohesion.title': 'Cohésion du graphe', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'Configuration en cours', + 'harnessInit.subtitle': + 'OpenHuman prépare les composants dont il a besoin lors du premier lancement.', + 'harnessInit.stepPython': "Environnement d'exécution Python", + 'harnessInit.stepSpacy': 'Modèle de langage', + 'harnessInit.stepNode': "Environnement d'exécution Node.js", + 'harnessInit.statePending': 'En attente', + 'harnessInit.stateRunning': 'Installation…', + 'harnessInit.stateDone': 'Prêt', + 'harnessInit.stateSkipped': 'Ignoré', + 'harnessInit.stateFailed': 'Échec', + 'harnessInit.failedMessage': + 'Certaines étapes de configuration ne se sont pas terminées. Vous pouvez réessayer ou continuer — OpenHuman utilisera une solution de secours intégrée.', + 'harnessInit.retry': 'Réessayer', + 'harnessInit.continueAnyway': 'Continuer quand même', + 'harnessInit.runInBackground': 'Exécuter en arrière-plan', + 'harnessInit.backgroundHint': + 'Vous pouvez continuer à utiliser OpenHuman pendant que cela se termine.', + 'keyring.consent.title': 'Stockage sécurisé indisponible', 'keyring.consent.description': "Le trousseau de votre système d'exploitation n'est pas accessible. OpenHuman a besoin de votre autorisation pour stocker les secrets en utilisant un stockage local chiffré.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index d650f39b4..9bd80a35c 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5660,6 +5660,23 @@ const messages: TranslationMap = { 'graphCohesion.title': 'ग्राफ संसक्ति', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'सेटअप किया जा रहा है', + 'harnessInit.subtitle': 'OpenHuman पहली बार शुरू होने पर आवश्यक घटक तैयार कर रहा है।', + 'harnessInit.stepPython': 'Python रनटाइम', + 'harnessInit.stepSpacy': 'भाषा मॉडल', + 'harnessInit.stepNode': 'Node.js रनटाइम', + 'harnessInit.statePending': 'प्रतीक्षारत', + 'harnessInit.stateRunning': 'इंस्टॉल हो रहा है…', + 'harnessInit.stateDone': 'तैयार', + 'harnessInit.stateSkipped': 'छोड़ा गया', + 'harnessInit.stateFailed': 'विफल', + 'harnessInit.failedMessage': + 'कुछ सेटअप चरण पूरे नहीं हुए। आप पुनः प्रयास कर सकते हैं, या जारी रख सकते हैं — OpenHuman एक अंतर्निहित फॉलबैक का उपयोग करेगा।', + 'harnessInit.retry': 'पुनः प्रयास करें', + 'harnessInit.continueAnyway': 'फिर भी जारी रखें', + 'harnessInit.runInBackground': 'पृष्ठभूमि में चलाएँ', + 'harnessInit.backgroundHint': 'जब तक यह पूरा होता है, आप OpenHuman का उपयोग जारी रख सकते हैं।', + 'keyring.consent.title': 'सुरक्षित भंडारण अनुपलब्ध', 'keyring.consent.description': 'आपके ऑपरेटिंग सिस्टम का कीचेन सुलभ नहीं है। OpenHuman को इसके बजाय स्थानीय एन्क्रिप्टेड भंडारण का उपयोग करके रहस्य संग्रहीत करने के लिए आपकी अनुमति चाहिए।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 889b1c12e..28b7a6f3b 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5672,6 +5672,24 @@ const messages: TranslationMap = { 'graphCohesion.title': 'Kohesi Graf', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'Menyiapkan semuanya', + 'harnessInit.subtitle': + 'OpenHuman sedang menyiapkan komponen yang dibutuhkan saat pertama kali dijalankan.', + 'harnessInit.stepPython': 'Runtime Python', + 'harnessInit.stepSpacy': 'Model bahasa', + 'harnessInit.stepNode': 'Runtime Node.js', + 'harnessInit.statePending': 'Menunggu', + 'harnessInit.stateRunning': 'Memasang…', + 'harnessInit.stateDone': 'Siap', + 'harnessInit.stateSkipped': 'Dilewati', + 'harnessInit.stateFailed': 'Gagal', + 'harnessInit.failedMessage': + 'Beberapa langkah penyiapan tidak selesai. Anda dapat mencoba lagi atau melanjutkan — OpenHuman akan menggunakan cadangan bawaan.', + 'harnessInit.retry': 'Coba lagi', + 'harnessInit.continueAnyway': 'Lanjutkan saja', + 'harnessInit.runInBackground': 'Jalankan di latar belakang', + 'harnessInit.backgroundHint': 'Anda dapat terus menggunakan OpenHuman saat proses ini selesai.', + 'keyring.consent.title': 'Penyimpanan aman tidak tersedia', 'keyring.consent.description': 'Keychain sistem operasi Anda tidak dapat diakses. OpenHuman memerlukan izin Anda untuk menyimpan rahasia menggunakan penyimpanan lokal terenkripsi.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 483bb0279..119947c8a 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5752,6 +5752,24 @@ const messages: TranslationMap = { 'graphCohesion.title': 'Coesione del grafo', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'Preparazione in corso', + 'harnessInit.subtitle': 'OpenHuman sta preparando i componenti necessari al primo avvio.', + 'harnessInit.stepPython': 'Ambiente di esecuzione Python', + 'harnessInit.stepSpacy': 'Modello linguistico', + 'harnessInit.stepNode': 'Ambiente di esecuzione Node.js', + 'harnessInit.statePending': 'In attesa', + 'harnessInit.stateRunning': 'Installazione…', + 'harnessInit.stateDone': 'Pronto', + 'harnessInit.stateSkipped': 'Saltato', + 'harnessInit.stateFailed': 'Non riuscito', + 'harnessInit.failedMessage': + "Alcuni passaggi di configurazione non sono stati completati. Puoi riprovare o continuare — OpenHuman userà un'alternativa integrata.", + 'harnessInit.retry': 'Riprova', + 'harnessInit.continueAnyway': 'Continua comunque', + 'harnessInit.runInBackground': 'Esegui in background', + 'harnessInit.backgroundHint': + 'Puoi continuare a usare OpenHuman mentre questa operazione termina.', + 'keyring.consent.title': 'Archivio sicuro non disponibile', 'keyring.consent.description': "Il portachiavi del sistema operativo non è accessibile. OpenHuman necessita del tuo permesso per archiviare i segreti utilizzando l'archiviazione locale crittografata.", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index d54f9593b..5bd9fb7be 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5604,6 +5604,23 @@ const messages: TranslationMap = { 'graphCohesion.title': '그래프 응집도', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': '설정하는 중', + 'harnessInit.subtitle': 'OpenHuman이 처음 실행에 필요한 구성 요소를 준비하고 있습니다.', + 'harnessInit.stepPython': 'Python 런타임', + 'harnessInit.stepSpacy': '언어 모델', + 'harnessInit.stepNode': 'Node.js 런타임', + 'harnessInit.statePending': '대기 중', + 'harnessInit.stateRunning': '설치 중…', + 'harnessInit.stateDone': '준비됨', + 'harnessInit.stateSkipped': '건너뜀', + 'harnessInit.stateFailed': '실패', + 'harnessInit.failedMessage': + '일부 설정 단계가 완료되지 않았습니다. 다시 시도하거나 계속 진행할 수 있습니다 — OpenHuman이 내장 대체 기능을 사용합니다.', + 'harnessInit.retry': '다시 시도', + 'harnessInit.continueAnyway': '그래도 계속', + 'harnessInit.runInBackground': '백그라운드에서 실행', + 'harnessInit.backgroundHint': '이 작업이 완료되는 동안 계속 OpenHuman을 사용할 수 있습니다.', + 'keyring.consent.title': '보안 저장소를 사용할 수 없음', 'keyring.consent.description': '운영 체제 키체인에 접근할 수 없습니다. OpenHuman이 로컬 암호화 저장소를 사용하여 비밀을 저장하려면 귀하의 허가가 필요합니다.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 3e69c89f6..92b190eda 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5742,6 +5742,24 @@ const messages: TranslationMap = { 'chat.files.error.download_failed': 'Pobieranie nie powiodło się. Prosimy spróbować ponownie.', 'chat.files.error.delete_failed': 'Nie udało się usunąć pliku. Prosimy spróbować ponownie.', + 'harnessInit.title': 'Trwa konfiguracja', + 'harnessInit.subtitle': + 'OpenHuman przygotowuje komponenty potrzebne przy pierwszym uruchomieniu.', + 'harnessInit.stepPython': 'Środowisko uruchomieniowe Python', + 'harnessInit.stepSpacy': 'Model językowy', + 'harnessInit.stepNode': 'Środowisko uruchomieniowe Node.js', + 'harnessInit.statePending': 'Oczekiwanie', + 'harnessInit.stateRunning': 'Instalowanie…', + 'harnessInit.stateDone': 'Gotowe', + 'harnessInit.stateSkipped': 'Pominięto', + 'harnessInit.stateFailed': 'Niepowodzenie', + 'harnessInit.failedMessage': + 'Niektóre kroki konfiguracji nie zostały ukończone. Możesz spróbować ponownie lub kontynuować — OpenHuman użyje wbudowanego rozwiązania zastępczego.', + 'harnessInit.retry': 'Spróbuj ponownie', + 'harnessInit.continueAnyway': 'Kontynuuj mimo to', + 'harnessInit.runInBackground': 'Uruchom w tle', + 'harnessInit.backgroundHint': 'Możesz dalej korzystać z OpenHuman, dopóki to się nie zakończy.', + 'keyring.consent.title': 'Bezpieczne przechowywanie niedostępne', 'keyring.consent.description': 'Pęk kluczy systemu operacyjnego jest niedostępny. OpenHuman potrzebuje Twojej zgody na przechowywanie sekretów w lokalnym zaszyfrowanym magazynie.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 309f0575d..8e8fb1a79 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5748,6 +5748,24 @@ const messages: TranslationMap = { 'graphCohesion.title': 'Coesão do grafo', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'Preparando tudo', + 'harnessInit.subtitle': + 'O OpenHuman está preparando os componentes necessários na primeira inicialização.', + 'harnessInit.stepPython': 'Ambiente de execução Python', + 'harnessInit.stepSpacy': 'Modelo de linguagem', + 'harnessInit.stepNode': 'Ambiente de execução Node.js', + 'harnessInit.statePending': 'Aguardando', + 'harnessInit.stateRunning': 'Instalando…', + 'harnessInit.stateDone': 'Pronto', + 'harnessInit.stateSkipped': 'Ignorado', + 'harnessInit.stateFailed': 'Falhou', + 'harnessInit.failedMessage': + 'Algumas etapas de configuração não foram concluídas. Você pode tentar novamente ou continuar — o OpenHuman usará uma alternativa integrada.', + 'harnessInit.retry': 'Tentar novamente', + 'harnessInit.continueAnyway': 'Continuar mesmo assim', + 'harnessInit.runInBackground': 'Executar em segundo plano', + 'harnessInit.backgroundHint': 'Você pode continuar usando o OpenHuman enquanto isso termina.', + 'keyring.consent.title': 'Armazenamento seguro indisponível', 'keyring.consent.description': 'O chaveiro do sistema operacional não está acessível. O OpenHuman precisa da sua permissão para armazenar segredos usando armazenamento local criptografado.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index fbaa9146f..8f55907d7 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5712,6 +5712,24 @@ const messages: TranslationMap = { 'graphCohesion.title': 'Связность графа', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': 'Идёт настройка', + 'harnessInit.subtitle': 'OpenHuman готовит компоненты, необходимые при первом запуске.', + 'harnessInit.stepPython': 'Среда выполнения Python', + 'harnessInit.stepSpacy': 'Языковая модель', + 'harnessInit.stepNode': 'Среда выполнения Node.js', + 'harnessInit.statePending': 'Ожидание', + 'harnessInit.stateRunning': 'Установка…', + 'harnessInit.stateDone': 'Готово', + 'harnessInit.stateSkipped': 'Пропущено', + 'harnessInit.stateFailed': 'Ошибка', + 'harnessInit.failedMessage': + 'Некоторые шаги настройки не завершились. Вы можете повторить попытку или продолжить — OpenHuman использует встроенный резервный вариант.', + 'harnessInit.retry': 'Повторить', + 'harnessInit.continueAnyway': 'Всё равно продолжить', + 'harnessInit.runInBackground': 'Запустить в фоновом режиме', + 'harnessInit.backgroundHint': + 'Вы можете продолжать пользоваться OpenHuman, пока это завершается.', + 'keyring.consent.title': 'Безопасное хранилище недоступно', 'keyring.consent.description': 'Связка ключей вашей операционной системы недоступна. OpenHuman необходимо ваше разрешение на хранение секретов в локальном зашифрованном хранилище.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 82e2a4af4..9d7a8cdf2 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -5371,6 +5371,23 @@ const messages: TranslationMap = { 'graphCohesion.title': '图的凝聚度', 'memory.tab.cohesion': 'Cohesion', + 'harnessInit.title': '正在进行设置', + 'harnessInit.subtitle': 'OpenHuman 正在准备首次启动所需的组件。', + 'harnessInit.stepPython': 'Python 运行时', + 'harnessInit.stepSpacy': '语言模型', + 'harnessInit.stepNode': 'Node.js 运行时', + 'harnessInit.statePending': '等待中', + 'harnessInit.stateRunning': '正在安装…', + 'harnessInit.stateDone': '就绪', + 'harnessInit.stateSkipped': '已跳过', + 'harnessInit.stateFailed': '失败', + 'harnessInit.failedMessage': + '部分设置步骤未完成。你可以重试或继续 — OpenHuman 将使用内置的后备方案。', + 'harnessInit.retry': '重试', + 'harnessInit.continueAnyway': '仍然继续', + 'harnessInit.runInBackground': '在后台运行', + 'harnessInit.backgroundHint': '在此完成期间,你可以继续使用 OpenHuman。', + 'keyring.consent.title': '安全存储不可用', 'keyring.consent.description': '您的操作系统密钥链不可访问。OpenHuman 需要您的许可,以便使用本地加密存储来保存密钥。', diff --git a/app/src/services/harnessInitService.test.ts b/app/src/services/harnessInitService.test.ts new file mode 100644 index 000000000..fec0c31e4 --- /dev/null +++ b/app/src/services/harnessInitService.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { parseHarnessInitSnapshot } from './harnessInitService'; + +describe('parseHarnessInitSnapshot', () => { + it('parses a well-formed snapshot envelope', () => { + const snap = parseHarnessInitSnapshot({ + snapshot: { + overall: 'running', + started_at: '2026-06-23T00:00:00Z', + finished_at: null, + steps: [ + { + id: 'python_runtime', + label: 'Python runtime', + required: false, + state: 'done', + message: 'already provisioned', + percent: 100, + updated_at: '2026-06-23T00:00:01Z', + }, + { + id: 'spacy', + label: 'spaCy', + required: false, + state: 'running', + message: null, + percent: null, + updated_at: null, + }, + ], + }, + }); + + expect(snap).not.toBeNull(); + expect(snap?.overall).toBe('running'); + expect(snap?.startedAt).toBe('2026-06-23T00:00:00Z'); + expect(snap?.steps).toHaveLength(2); + expect(snap?.steps[0]).toMatchObject({ id: 'python_runtime', state: 'done', percent: 100 }); + expect(snap?.steps[1]).toMatchObject({ id: 'spacy', state: 'running', percent: null }); + }); + + it('returns null when the envelope has no snapshot', () => { + expect(parseHarnessInitSnapshot({})).toBeNull(); + expect(parseHarnessInitSnapshot(null)).toBeNull(); + expect(parseHarnessInitSnapshot({ snapshot: 'nope' })).toBeNull(); + }); + + it('returns null on an unknown overall state', () => { + expect(parseHarnessInitSnapshot({ snapshot: { overall: 'bogus', steps: [] } })).toBeNull(); + }); + + it('drops malformed step entries but keeps valid ones', () => { + const snap = parseHarnessInitSnapshot({ + snapshot: { + overall: 'done', + steps: [ + { id: 'node_runtime', state: 'done' }, + { id: 'missing_state' }, + { state: 'done' }, + { id: 'bad_state', state: 'exploded' }, + ], + }, + }); + + expect(snap?.steps).toHaveLength(1); + expect(snap?.steps[0].id).toBe('node_runtime'); + // Defaults applied for absent optional fields. + expect(snap?.steps[0].label).toBe('node_runtime'); + expect(snap?.steps[0].required).toBe(false); + }); +}); diff --git a/app/src/services/harnessInitService.ts b/app/src/services/harnessInitService.ts new file mode 100644 index 000000000..fe2d087ec --- /dev/null +++ b/app/src/services/harnessInitService.ts @@ -0,0 +1,102 @@ +/** + * Harness Init Service + * + * Thin RPC wrappers around the core `harness_init` controller plus parsing into + * typed snapshots. The initialization overlay polls `fetchHarnessInitStatus` + * (read-only) and calls `runHarnessInit` to retry after a failure. + * + * Mirrors the polling contract used by `daemonHealthService`, but stays a plain + * data layer — the React overlay owns the interval and visibility. + */ +import { callCoreRpc } from './coreRpcClient'; + +export type HarnessInitStepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'; +export type HarnessInitOverall = 'idle' | 'running' | 'done' | 'failed'; + +export interface HarnessInitStep { + id: string; + label: string; + required: boolean; + state: HarnessInitStepState; + message: string | null; + percent: number | null; + updatedAt: string | null; +} + +export interface HarnessInitSnapshot { + overall: HarnessInitOverall; + steps: HarnessInitStep[]; + startedAt: string | null; + finishedAt: string | null; +} + +interface RawSnapshotEnvelope { + snapshot?: unknown; +} + +const STEP_STATES: HarnessInitStepState[] = ['pending', 'running', 'done', 'failed', 'skipped']; +const OVERALL_STATES: HarnessInitOverall[] = ['idle', 'running', 'done', 'failed']; + +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function parseStep(raw: unknown): HarnessInitStep | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const data = raw as Record; + const id = asString(data.id); + const state = asString(data.state) as HarnessInitStepState | null; + if (!id || !state || !STEP_STATES.includes(state)) { + return null; + } + return { + id, + label: asString(data.label) ?? id, + required: data.required === true, + state, + message: asString(data.message), + percent: typeof data.percent === 'number' ? data.percent : null, + updatedAt: asString(data.updated_at), + }; +} + +/** Parse the `{ snapshot: {...} }` RPC result envelope into a typed snapshot. */ +export function parseHarnessInitSnapshot(payload: unknown): HarnessInitSnapshot | null { + const envelope = (payload ?? {}) as RawSnapshotEnvelope; + const raw = envelope.snapshot; + if (!raw || typeof raw !== 'object') { + return null; + } + const data = raw as Record; + const overall = asString(data.overall) as HarnessInitOverall | null; + if (!overall || !OVERALL_STATES.includes(overall)) { + return null; + } + const stepsRaw = Array.isArray(data.steps) ? data.steps : []; + const steps = stepsRaw.map(parseStep).filter((s): s is HarnessInitStep => s !== null); + return { + overall, + steps, + startedAt: asString(data.started_at), + finishedAt: asString(data.finished_at), + }; +} + +/** Read the current init progress. Read-only — never triggers provisioning. */ +export async function fetchHarnessInitStatus(): Promise { + const payload = await callCoreRpc({ method: 'openhuman.harness_init_status' }); + return parseHarnessInitSnapshot(payload); +} + +/** Re-run init (retry). `force` re-runs even already-satisfied steps. */ +export async function runHarnessInit(force = false): Promise { + const payload = await callCoreRpc({ + method: 'openhuman.harness_init_run', + params: { force }, + // Provisioning can download Python / Node / spaCy — allow a long budget. + timeoutMs: 10 * 60 * 1000, + }); + return parseHarnessInitSnapshot(payload); +} diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 1366e9f72..f8b75cb87 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -43,6 +43,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 0.2.2 | Gatekeeper Validation | MS | release-manual-smoke | 🚫 | OS-level signature check | | 0.2.3 | Code Signing Verification | MS | release-manual-smoke | 🚫 | `codesign --verify` capture in checklist | | 0.2.4 | First Launch Permissions Prompt | MS | release-manual-smoke | 🚫 | TCC prompts non-driver-automatable | +| 0.2.5 | First-Run Harness Init (Python/spaCy/Node) | RU+RI+VU | `src/openhuman/harness_init/*` (`#[cfg(test)]`), `src/openhuman/runtime_python/downloader_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/services/harnessInitService.test.ts`, `app/src/components/InitProgressScreen/InitProgressScreen.test.tsx` | ✅ | Eager startup provisioning + `harness_init_status`/`_run` RPC + blocking init overlay; managed CPython pinned to 3.13.x. Live download/model fetch is `MS` (network) | ### 0.3 Updates & Reinstallation diff --git a/src/core/all.rs b/src/core/all.rs index 15881dbf3..0d73440b4 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -136,6 +136,8 @@ fn build_registered_controllers() -> Vec { .extend(crate::openhuman::agent_experience::all_agent_experience_registered_controllers()); // System and process health monitoring controllers.extend(crate::openhuman::health::all_health_registered_controllers()); + // One-time first-run initialization (Python/spaCy/Node provisioning) + controllers.extend(crate::openhuman::harness_init::all_harness_init_registered_controllers()); // Diagnostic tools controllers.extend(crate::openhuman::doctor::all_doctor_registered_controllers()); // Secret storage and encryption @@ -360,6 +362,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::agent_registry::all_agent_registry_controller_schemas()); schemas.extend(crate::openhuman::agent_experience::all_agent_experience_controller_schemas()); schemas.extend(crate::openhuman::health::all_health_controller_schemas()); + schemas.extend(crate::openhuman::harness_init::all_harness_init_controller_schemas()); schemas.extend(crate::openhuman::doctor::all_doctor_controller_schemas()); schemas.extend(crate::openhuman::encryption::all_encryption_controller_schemas()); schemas.extend(crate::openhuman::keyring_consent::all_keyring_consent_controller_schemas()); diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index 29f1d610d..e9551aff3 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -925,6 +925,20 @@ pub enum DomainEvent { }, /// A component restart was observed. HealthRestarted { component: String }, + /// A one-time harness-init step changed state (pending → running → done / + /// failed / skipped). Surfaced to the frontend initialization screen. + HarnessInitProgress { + step_id: String, + state: String, + message: Option, + percent: Option, + }, + /// The harness-init run reached a terminal state. `failed_required` is true + /// only when a *required* step failed (no required steps today). + HarnessInitCompleted { + overall: String, + failed_required: bool, + }, // ── Keyring ───────────────────────────────────────────────────────── /// The OS keyring is unavailable and no user consent for local fallback @@ -1214,7 +1228,9 @@ impl DomainEvent { | Self::AutonomyConfigChanged | Self::AgentPathsChanged | Self::HealthChanged { .. } - | Self::HealthRestarted { .. } => "system", + | Self::HealthRestarted { .. } + | Self::HarnessInitProgress { .. } + | Self::HarnessInitCompleted { .. } => "system", Self::KeyringConsentRequired | Self::KeyringDecryptFailed { .. } => "keyring", @@ -1352,6 +1368,8 @@ impl DomainEvent { Self::AgentPathsChanged => "AgentPathsChanged", Self::HealthChanged { .. } => "HealthChanged", Self::HealthRestarted { .. } => "HealthRestarted", + Self::HarnessInitProgress { .. } => "HarnessInitProgress", + Self::HarnessInitCompleted { .. } => "HarnessInitCompleted", Self::KeyringConsentRequired => "KeyringConsentRequired", Self::KeyringDecryptFailed { .. } => "KeyringDecryptFailed", Self::SessionExpired { .. } => "SessionExpired", diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 91aade58e..255529f2d 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2374,6 +2374,20 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { // Uses a Once guard so repeated calls to bootstrap_core_runtime() // cannot double-subscribe. register_domain_subscribers(workspace_dir.clone(), cfg.clone(), embedded_core); + + // One-time first-run initialization (managed Python runtime, spaCy model, + // managed Node runtime). Spawned AFTER subscribers are live but does NOT + // block the ready signal — the core becomes RPC-ready immediately and the + // frontend watches per-step progress via `openhuman.harness_init_status`. + // On a warm host every step's `is_done` probe passes and this settles + // instantly. See `crate::openhuman::harness_init`. + { + let cfg_for_init = cfg.clone(); + tokio::spawn(async move { + crate::openhuman::harness_init::run_harness_init(cfg_for_init).await; + }); + } + // Warm the remote skills catalog on every core load. This updates the // cached registry used by skill discovery/search, but runs best-effort in // the background so Hermes/network latency cannot block core readiness. diff --git a/src/core/socketio.rs b/src/core/socketio.rs index 38f264c6c..1ca34c6ee 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -944,6 +944,30 @@ pub fn spawn_web_channel_bridge(io: SocketIo) { }); let _ = io_memory_sync.emit("memory:build_progress", &payload); } + crate::core::event_bus::DomainEvent::HarnessInitProgress { + step_id, + state, + message, + percent, + } => { + let payload = serde_json::json!({ + "step_id": step_id, + "state": state, + "message": message, + "percent": percent, + }); + let _ = io_memory_sync.emit("init:progress", &payload); + } + crate::core::event_bus::DomainEvent::HarnessInitCompleted { + overall, + failed_required, + } => { + let payload = serde_json::json!({ + "overall": overall, + "failed_required": failed_required, + }); + let _ = io_memory_sync.emit("init:completed", &payload); + } _ => {} } } diff --git a/src/openhuman/config/schema/runtime_python.rs b/src/openhuman/config/schema/runtime_python.rs index d910714ea..e1d87465f 100644 --- a/src/openhuman/config/schema/runtime_python.rs +++ b/src/openhuman/config/schema/runtime_python.rs @@ -22,6 +22,15 @@ pub struct RuntimePythonConfig { /// rejected even when present on `PATH`. #[serde(default = "default_minimum_version")] pub minimum_version: String, + /// Exclusive upper bound for the managed-runtime selector. The + /// `python-build-standalone` "latest" release ships every supported series + /// (incl. pre-release lines like 3.15.x betas, whose `bN` suffix the + /// version parser drops — so they look like a stable `3.15.0`). Capping + /// selection below this keeps us on a stable line with prebuilt wheels for + /// dependencies such as spaCy. Empty string disables the cap. Default + /// `3.14.0` → selects the latest `3.13.x`. + #[serde(default = "default_maximum_version")] + pub maximum_version: String, /// Absolute path to a directory reserved for future managed Python /// installs. Empty string means "use the runtime default cache dir". #[serde(default)] @@ -49,6 +58,10 @@ fn default_minimum_version() -> String { "3.12.0".to_string() } +fn default_maximum_version() -> String { + "3.14.0".to_string() +} + fn default_prefer_system() -> bool { false } @@ -58,6 +71,7 @@ impl Default for RuntimePythonConfig { Self { enabled: default_enabled(), minimum_version: default_minimum_version(), + maximum_version: default_maximum_version(), cache_dir: String::new(), managed_release_tag: String::new(), prefer_system: default_prefer_system(), diff --git a/src/openhuman/harness_init/bus.rs b/src/openhuman/harness_init/bus.rs new file mode 100644 index 000000000..6e85f408d --- /dev/null +++ b/src/openhuman/harness_init/bus.rs @@ -0,0 +1,6 @@ +//! Event-bus glue for `harness_init`. +//! +//! Progress events are published directly from [`super::store::update_step`], +//! so no subscriber is required today. This module is kept for canonical +//! module shape and as the home for any future health re-publishing (e.g. +//! mirroring spaCy/Node readiness into the `health` domain). diff --git a/src/openhuman/harness_init/mod.rs b/src/openhuman/harness_init/mod.rs new file mode 100644 index 000000000..a394e562e --- /dev/null +++ b/src/openhuman/harness_init/mod.rs @@ -0,0 +1,26 @@ +//! `harness_init` — first-class orchestration of one-time, first-run setup. +//! +//! On a fresh install several provisioning steps (managed Python runtime, +//! spaCy + model, managed Node runtime) used to run lazily on first use, with +//! no user-visible feedback. This domain runs them eagerly at core startup +//! (spawned non-blocking after the RPC server is ready), tracks per-step +//! progress in an in-memory snapshot, and exposes it over +//! `openhuman.harness_init_status` / `openhuman.harness_init_run` for the +//! frontend initialization screen. +//! +//! Steps delegate to the existing idempotent provisioning code +//! (`runtime_python`, `memory_tree::nlp`, `runtime_node`) — this module +//! orchestrates and reports, it does not reimplement downloads. + +pub mod bus; +pub mod ops; +pub mod registry; +pub mod schemas; +pub mod store; +pub mod types; + +pub use ops::run_harness_init; +pub use schemas::{ + all_controller_schemas as all_harness_init_controller_schemas, + all_registered_controllers as all_harness_init_registered_controllers, +}; diff --git a/src/openhuman/harness_init/ops.rs b/src/openhuman/harness_init/ops.rs new file mode 100644 index 000000000..a09b7270e --- /dev/null +++ b/src/openhuman/harness_init/ops.rs @@ -0,0 +1,117 @@ +//! Harness-init orchestrator + RPC handlers. +//! +//! `run_harness_init` is spawned (non-blocking) from `bootstrap_core_runtime` +//! after the core is RPC-ready, so the frontend can connect and watch progress. +//! It walks the [`registry`] step list, marking each `Done` instantly when its +//! cheap `is_done` probe passes, otherwise running it and recording the result. + +use serde_json::{json, Map, Value}; + +use super::registry::{self, HarnessInitStep}; +use super::store; +use super::types::{HarnessInitSnapshot, OverallState, StepState}; +use crate::core::all::ControllerFuture; +use crate::openhuman::config::Config; + +/// Run every registered step once. `force` re-runs steps even when their +/// `is_done` probe passes (used by the retry RPC). Failures of non-required +/// steps are recorded as `Skipped`; the overall state is `Failed` only when a +/// *required* step fails. +pub async fn run_harness_init_with(config: Config, force: bool) -> HarnessInitSnapshot { + log::info!("[harness_init] starting one-time init run (force={force})"); + store::set_overall(OverallState::Running); + + let mut failed_required = false; + + for step in registry::all_steps() { + run_one(&config, &step, force, &mut failed_required).await; + } + + let overall = if failed_required { + OverallState::Failed + } else { + OverallState::Done + }; + store::set_overall(overall); + store::publish_completed(overall, failed_required); + log::info!("[harness_init] init run finished overall={overall:?}"); + store::snapshot() +} + +/// Boot entrypoint: run all not-yet-done steps. +pub async fn run_harness_init(config: Config) { + run_harness_init_with(config, false).await; +} + +async fn run_one(config: &Config, step: &HarnessInitStep, force: bool, failed_required: &mut bool) { + if !force && (step.is_done)(config).await { + log::debug!("[harness_init] step {} already satisfied", step.id); + store::update_step( + step.id, + StepState::Done, + Some("already provisioned".to_string()), + Some(100), + ); + return; + } + + store::update_step(step.id, StepState::Running, None, None); + match (step.run)(config).await { + Ok(()) => { + store::update_step(step.id, StepState::Done, None, Some(100)); + } + Err(msg) => { + log::warn!("[harness_init] step {} failed: {msg}", step.id); + if step.required { + *failed_required = true; + store::update_step(step.id, StepState::Failed, Some(msg), None); + } else { + // Non-fatal: the app proceeds with a fallback. + store::update_step(step.id, StepState::Skipped, Some(msg), None); + } + } + } +} + +// ── RPC handlers ──────────────────────────────────────────────────────────── + +/// `openhuman.harness_init_status` — return the current snapshot. +pub fn handle_status(_params: Map) -> ControllerFuture { + Box::pin(async move { snapshot_response(store::snapshot()) }) +} + +/// `openhuman.harness_init_run` — re-run init (retry). `force` re-runs even +/// already-satisfied steps; defaults to false (only retries pending/failed). +pub fn handle_run(params: Map) -> ControllerFuture { + Box::pin(async move { + let force = params + .get("force") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let config = crate::openhuman::config::rpc::load_config_with_timeout().await?; + let snapshot = run_harness_init_with(config, force).await; + snapshot_response(snapshot) + }) +} + +fn snapshot_response(snapshot: HarnessInitSnapshot) -> Result { + let value = serde_json::to_value(snapshot) + .map_err(|e| format!("failed to serialize harness-init snapshot: {e}"))?; + Ok(json!({ "snapshot": value })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn status_response_wraps_snapshot_key() { + let resp = handle_status(Map::new()).await.unwrap(); + assert!(resp.get("snapshot").is_some()); + let overall = resp["snapshot"]["overall"].as_str().unwrap(); + // Idle before any run, or a later state if a boot run already executed + // in this process — both are valid, we only assert the shape. + assert!(["idle", "running", "done", "failed"].contains(&overall)); + assert!(resp["snapshot"]["steps"].is_array()); + } +} diff --git a/src/openhuman/harness_init/registry.rs b/src/openhuman/harness_init/registry.rs new file mode 100644 index 000000000..a551b091c --- /dev/null +++ b/src/openhuman/harness_init/registry.rs @@ -0,0 +1,193 @@ +//! The set of one-time initialization steps run eagerly at core startup. +//! +//! Each [`HarnessInitStep`] is a thin descriptor of function pointers that +//! delegate to the existing, already-idempotent provisioning code — this module +//! orchestrates and reports; it does not reimplement any download logic. +//! +//! Current steps (all non-required — failure degrades to a fallback): +//! 1. `python_runtime` — managed CPython (prerequisite for spaCy). +//! 2. `spacy` — spaCy venv + `en_core_web_sm` model. +//! 3. `node_runtime` — managed Node.js (skills / MCP). +//! +//! Voice models (Whisper, Piper) and Ollama stay lazy/opt-in and are +//! intentionally NOT registered here; they can be added later as steps. + +use std::future::Future; +use std::pin::Pin; + +use crate::openhuman::config::Config; + +/// Future returned by a step's probe/run hooks. Borrows the `Config` for the +/// duration of the call. +pub type StepFuture<'a, T> = Pin + Send + 'a>>; + +/// A single startup provisioning step. +pub struct HarnessInitStep { + /// Stable identifier used as the dedupe key and the UI i18n key. + pub id: &'static str, + /// Default human-readable label. + pub label: &'static str, + /// When true, a failure blocks the app. All steps are non-required today. + pub required: bool, + /// Cheap, network-free probe: is this step already satisfied on this host? + /// When true the orchestrator marks it `Done` without invoking `run`. + pub is_done: for<'a> fn(&'a Config) -> StepFuture<'a, bool>, + /// Perform the work. `Ok(())` → done; `Err(msg)` → failed/skipped. + pub run: for<'a> fn(&'a Config) -> StepFuture<'a, Result<(), String>>, +} + +/// The ordered list of mandatory eager steps. +pub fn all_steps() -> Vec { + vec![python_runtime_step(), spacy_step(), node_runtime_step()] +} + +// ── python_runtime ────────────────────────────────────────────────────────── + +fn python_runtime_step() -> HarnessInitStep { + HarnessInitStep { + id: "python_runtime", + label: "Python runtime", + required: false, + is_done: |config| Box::pin(python_is_done(config)), + run: |config| Box::pin(python_run(config)), + } +} + +async fn python_is_done(config: &Config) -> bool { + if !config.runtime_python.enabled { + // Disabled → nothing to provision; treat as satisfied. + return true; + } + // Memoised within this process. Across restarts this is None at boot and + // the (fast) `resolve()` probe in `run` settles it quickly. + use crate::openhuman::runtime_python::PythonBootstrap; + PythonBootstrap::new(config.runtime_python.clone()) + .try_cached() + .is_some() +} + +async fn python_run(config: &Config) -> Result<(), String> { + if !config.runtime_python.enabled { + return Ok(()); + } + use crate::openhuman::runtime_python::PythonBootstrap; + PythonBootstrap::new(config.runtime_python.clone()) + .resolve() + .await + .map(|resolved| { + log::info!( + "[harness_init] python runtime ready version={} source={:?}", + resolved.version, + resolved.source + ); + }) + .map_err(|e| format!("{e:#}")) +} + +// ── spacy ───────────────────────────────────────────────────────────────── + +fn spacy_step() -> HarnessInitStep { + HarnessInitStep { + id: "spacy", + label: "spaCy language model", + required: false, + is_done: |config| Box::pin(spacy_is_done(config)), + run: |config| Box::pin(spacy_run(config)), + } +} + +async fn spacy_is_done(config: &Config) -> bool { + if !config.runtime_python.enabled || !config.memory_tree.spacy_enabled { + return true; + } + crate::openhuman::memory_tree::nlp::spacy_provisioned(config) +} + +async fn spacy_run(config: &Config) -> Result<(), String> { + if !config.runtime_python.enabled || !config.memory_tree.spacy_enabled { + return Ok(()); + } + crate::openhuman::memory_tree::nlp::ensure_spacy(config) + .await + .map(|_| { + log::info!("[harness_init] spaCy provisioned"); + }) + .map_err(|e| format!("{e:#}")) +} + +// ── node_runtime ──────────────────────────────────────────────────────────── + +fn node_runtime_step() -> HarnessInitStep { + HarnessInitStep { + id: "node_runtime", + label: "Node.js runtime", + required: false, + is_done: |config| Box::pin(node_is_done(config)), + run: |config| Box::pin(node_run(config)), + } +} + +fn build_node_bootstrap(config: &Config) -> crate::openhuman::runtime_node::NodeBootstrap { + crate::openhuman::runtime_node::NodeBootstrap::new( + config.node.clone(), + config.workspace_dir.clone(), + reqwest::Client::new(), + ) +} + +async fn node_is_done(config: &Config) -> bool { + if !config.node.enabled { + return true; + } + build_node_bootstrap(config).try_cached().is_some() +} + +async fn node_run(config: &Config) -> Result<(), String> { + if !config.node.enabled { + return Ok(()); + } + build_node_bootstrap(config) + .resolve() + .await + .map(|resolved| { + log::info!( + "[harness_init] node runtime ready version={} source={:?}", + resolved.version, + resolved.source + ); + }) + .map_err(|e| format!("{e:#}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_steps_have_stable_ids_and_are_non_required() { + let steps = all_steps(); + let ids: Vec<_> = steps.iter().map(|s| s.id).collect(); + assert_eq!(ids, vec!["python_runtime", "spacy", "node_runtime"]); + assert!(steps.iter().all(|s| !s.required)); + assert!(steps.iter().all(|s| !s.label.is_empty())); + } + + #[tokio::test] + async fn disabled_runtimes_report_done_without_work() { + let mut config = Config::default(); + config.runtime_python.enabled = false; + config.node.enabled = false; + for step in all_steps() { + assert!( + (step.is_done)(&config).await, + "step {} should be done when its runtime is disabled", + step.id + ); + assert!( + (step.run)(&config).await.is_ok(), + "step {} run should no-op when disabled", + step.id + ); + } + } +} diff --git a/src/openhuman/harness_init/schemas.rs b/src/openhuman/harness_init/schemas.rs new file mode 100644 index 000000000..8963b5e07 --- /dev/null +++ b/src/openhuman/harness_init/schemas.rs @@ -0,0 +1,206 @@ +//! Controller schemas for the `harness_init` namespace. +//! +//! Two functions, both returning a `HarnessInitSnapshot` under the `snapshot` +//! output key: +//! - `openhuman.harness_init_status` — read the current init progress. +//! - `openhuman.harness_init_run` — re-run init (retry), optionally forced. + +use crate::core::all::RegisteredController; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +use super::ops::{handle_run, handle_status}; + +pub fn all_controller_schemas() -> Vec { + vec![schemas("status"), schemas("run")] +} + +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("status"), + handler: handle_status, + }, + RegisteredController { + schema: schemas("run"), + handler: handle_run, + }, + ] +} + +/// The shape of a single init step in the snapshot output. +fn step_status_type() -> TypeSchema { + TypeSchema::Object { + fields: vec![ + FieldSchema { + name: "id", + ty: TypeSchema::String, + comment: "Stable step identifier (e.g. 'python_runtime').", + required: true, + }, + FieldSchema { + name: "label", + ty: TypeSchema::String, + comment: "Human-readable step label.", + required: true, + }, + FieldSchema { + name: "required", + ty: TypeSchema::Bool, + comment: "Whether a failure blocks the app.", + required: true, + }, + FieldSchema { + name: "state", + ty: TypeSchema::Enum { + variants: vec!["pending", "running", "done", "failed", "skipped"], + }, + comment: "Current step lifecycle state.", + required: true, + }, + FieldSchema { + name: "message", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional detail (error string or note).", + required: false, + }, + FieldSchema { + name: "percent", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Optional 0–100 progress hint.", + required: false, + }, + FieldSchema { + name: "updated_at", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "RFC3339 timestamp of the last state change.", + required: false, + }, + ], + } +} + +/// The `snapshot` output field shared by both functions. +fn snapshot_output() -> FieldSchema { + FieldSchema { + name: "snapshot", + ty: TypeSchema::Object { + fields: vec![ + FieldSchema { + name: "overall", + ty: TypeSchema::Enum { + variants: vec!["idle", "running", "done", "failed"], + }, + comment: "Overall init lifecycle state.", + required: true, + }, + FieldSchema { + name: "steps", + ty: TypeSchema::Array(Box::new(step_status_type())), + comment: "Per-step status, in run order.", + required: true, + }, + FieldSchema { + name: "started_at", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "RFC3339 timestamp when the run started.", + required: false, + }, + FieldSchema { + name: "finished_at", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "RFC3339 timestamp when the run finished.", + required: false, + }, + ], + }, + comment: "Current harness-init progress snapshot.", + required: true, + } +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "status" => ControllerSchema { + namespace: "harness_init", + function: "status", + description: "Read the current one-time initialization progress.", + inputs: vec![], + outputs: vec![snapshot_output()], + }, + "run" => ControllerSchema { + namespace: "harness_init", + function: "run", + description: "Re-run one-time initialization (retry failed/pending steps).", + inputs: vec![FieldSchema { + name: "force", + ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), + comment: "Re-run even steps already marked done. Defaults to false.", + required: false, + }], + outputs: vec![snapshot_output()], + }, + _other => ControllerSchema { + namespace: "harness_init", + function: "unknown", + description: "Unknown harness_init controller function.", + inputs: vec![FieldSchema { + name: "function", + ty: TypeSchema::String, + comment: "Unknown function requested for schema lookup.", + required: true, + }], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_has_no_inputs_and_snapshot_output() { + let s = schemas("status"); + assert_eq!(s.namespace, "harness_init"); + assert_eq!(s.function, "status"); + assert!(s.inputs.is_empty()); + assert_eq!(s.outputs.len(), 1); + assert_eq!(s.outputs[0].name, "snapshot"); + } + + #[test] + fn run_has_optional_force_input() { + let s = schemas("run"); + let force = s.inputs.iter().find(|f| f.name == "force").unwrap(); + assert!(!force.required); + } + + #[test] + fn unknown_function_returns_placeholder_with_error_output() { + let s = schemas("does-not-exist"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.outputs[0].name, "error"); + } + + #[test] + fn all_controller_schemas_covers_supported_functions() { + let names: Vec<_> = all_controller_schemas() + .into_iter() + .map(|s| s.function) + .collect(); + assert_eq!(names, vec!["status", "run"]); + } + + #[test] + fn all_registered_controllers_has_handler_per_schema() { + let controllers = all_registered_controllers(); + assert_eq!(controllers.len(), 2); + let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); + assert_eq!(names, vec!["status", "run"]); + } +} diff --git a/src/openhuman/harness_init/store.rs b/src/openhuman/harness_init/store.rs new file mode 100644 index 000000000..3985a5ba5 --- /dev/null +++ b/src/openhuman/harness_init/store.rs @@ -0,0 +1,115 @@ +//! In-memory status holder for the harness-init run. +//! +//! Mirrors the `STATUS_TABLE` pattern used by the local-inference installers: +//! a process-lifetime singleton guarded by a mutex. `update_step` is the single +//! mutation point and also publishes the matching `DomainEvent` so the event +//! bus stays in lockstep with the snapshot the RPC returns. + +use std::sync::{Mutex, OnceLock}; + +use chrono::Utc; + +use super::registry; +use super::types::{HarnessInitSnapshot, OverallState, StepState, StepStatus}; +use crate::core::event_bus::{publish_global, DomainEvent}; + +static STATE: OnceLock> = OnceLock::new(); + +fn state() -> &'static Mutex { + STATE.get_or_init(|| Mutex::new(seed_snapshot())) +} + +/// Build the initial snapshot: every registered step `Pending`, overall `Idle`. +fn seed_snapshot() -> HarnessInitSnapshot { + let steps = registry::all_steps() + .into_iter() + .map(|s| StepStatus { + id: s.id.to_string(), + label: s.label.to_string(), + required: s.required, + state: StepState::Pending, + message: None, + percent: None, + updated_at: None, + }) + .collect(); + HarnessInitSnapshot { + overall: OverallState::Idle, + steps, + started_at: None, + finished_at: None, + } +} + +/// Clone the current snapshot for the RPC layer. +pub fn snapshot() -> HarnessInitSnapshot { + state().lock().unwrap().clone() +} + +/// Set the overall lifecycle state, stamping start/finish timestamps once. +pub fn set_overall(overall: OverallState) { + let mut guard = state().lock().unwrap(); + let now = Utc::now().to_rfc3339(); + match overall { + OverallState::Running if guard.started_at.is_none() => { + guard.started_at = Some(now.clone()); + } + OverallState::Done | OverallState::Failed => { + guard.finished_at = Some(now.clone()); + } + _ => {} + } + guard.overall = overall; +} + +/// Update one step's state and publish a `HarnessInitProgress` event. +pub fn update_step(id: &str, step_state: StepState, message: Option, percent: Option) { + { + let mut guard = state().lock().unwrap(); + if let Some(step) = guard.steps.iter_mut().find(|s| s.id == id) { + step.state = step_state; + step.message = message.clone(); + step.percent = percent; + step.updated_at = Some(Utc::now().to_rfc3339()); + } else { + log::warn!("[harness_init] update_step for unknown id={id}"); + return; + } + } + publish_global(DomainEvent::HarnessInitProgress { + step_id: id.to_string(), + state: serde_json::to_value(step_state) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_default(), + message, + percent, + }); +} + +/// Publish the terminal event once the run finishes. +pub fn publish_completed(overall: OverallState, failed_required: bool) { + let overall_str = serde_json::to_value(overall) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_default(); + publish_global(DomainEvent::HarnessInitCompleted { + overall: overall_str, + failed_required, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seed_snapshot_starts_idle_with_pending_steps() { + let snap = seed_snapshot(); + assert_eq!(snap.overall, OverallState::Idle); + assert!(!snap.steps.is_empty()); + assert!(snap.steps.iter().all(|s| s.state == StepState::Pending)); + assert!(snap.started_at.is_none()); + assert!(snap.finished_at.is_none()); + } +} diff --git a/src/openhuman/harness_init/types.rs b/src/openhuman/harness_init/types.rs new file mode 100644 index 000000000..fd5468662 --- /dev/null +++ b/src/openhuman/harness_init/types.rs @@ -0,0 +1,81 @@ +//! Serde types for the harness-init status snapshot. +//! +//! These are the wire shapes returned by `openhuman.harness_init_status` / +//! `openhuman.harness_init_run` and consumed by the frontend initialization +//! screen. All enums serialize `snake_case` so the TypeScript side can match +//! on plain string literals. + +use serde::{Deserialize, Serialize}; + +/// State of a single init step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StepState { + /// Not started yet. + Pending, + /// Work in progress. + Running, + /// Completed successfully (or already provisioned). + Done, + /// A required step failed. + Failed, + /// A non-required step failed; the app proceeds with a fallback. + Skipped, +} + +impl StepState { + /// A step in `Done`/`Failed`/`Skipped` will not change again this run. + pub fn is_terminal(self) -> bool { + matches!(self, Self::Done | Self::Failed | Self::Skipped) + } +} + +/// Per-step status surfaced to the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepStatus { + /// Stable identifier, e.g. `"python_runtime"`. + pub id: String, + /// Human-readable label (the UI may prefer its own i18n key by `id`). + pub label: String, + /// Whether a failure of this step blocks the app. All steps are currently + /// non-required (their absence degrades to a fallback). + pub required: bool, + /// Current lifecycle state. + pub state: StepState, + /// Optional detail (error string, "already provisioned", etc.). + pub message: Option, + /// Optional 0–100 progress hint; `None` for indeterminate steps. + pub percent: Option, + /// RFC3339 timestamp of the last state change. + pub updated_at: Option, +} + +/// Overall init lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OverallState { + /// No run has started. + Idle, + /// At least one step is pending/running. + Running, + /// All steps reached a terminal state and no required step failed. + Done, + /// A required step failed. + Failed, +} + +impl OverallState { + /// The UI stops polling / unblocks once the run is terminal. + pub fn is_terminal(self) -> bool { + matches!(self, Self::Done | Self::Failed) + } +} + +/// Full snapshot returned over RPC. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HarnessInitSnapshot { + pub overall: OverallState, + pub steps: Vec, + pub started_at: Option, + pub finished_at: Option, +} diff --git a/src/openhuman/memory_tree/nlp/mod.rs b/src/openhuman/memory_tree/nlp/mod.rs index abab366d5..6789e1fdc 100644 --- a/src/openhuman/memory_tree/nlp/mod.rs +++ b/src/openhuman/memory_tree/nlp/mod.rs @@ -16,7 +16,7 @@ mod client; mod provision; pub use client::{shared_ner, SpacyNer}; -pub use provision::{ensure_spacy, SpacyRuntime, SPACY_MODEL}; +pub use provision::{ensure_spacy, spacy_provisioned, SpacyRuntime, SPACY_MODEL}; use crate::openhuman::config::Config; use crate::openhuman::memory_tree::score::extract::{ diff --git a/src/openhuman/memory_tree/nlp/provision.rs b/src/openhuman/memory_tree/nlp/provision.rs index 1d7373ea1..11bb4535a 100644 --- a/src/openhuman/memory_tree/nlp/provision.rs +++ b/src/openhuman/memory_tree/nlp/provision.rs @@ -180,6 +180,18 @@ async fn run_step(python_bin: &Path, args: &[&str], timeout: Duration, label: &s Ok(()) } +/// Cheap, network-free probe: is spaCy already provisioned on this host? +/// +/// Mirrors the early-return guard in [`ensure_spacy`] (marker file + venv +/// interpreter present) without creating directories, writing the service +/// script, or resolving Python. Used by the harness-init orchestrator to mark +/// the spaCy step `Done` instantly on a warm host. +pub fn spacy_provisioned(config: &Config) -> bool { + let venv_dir = nlp_cache_root(config).join("spacy-venv"); + let marker = venv_dir.join(".openhuman-spacy-ready"); + marker.exists() && venv_python_path(&venv_dir).exists() +} + /// Resolve the venv's python executable across platforms. fn venv_python_path(venv_dir: &Path) -> PathBuf { if cfg!(windows) { diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index a4c4bda89..5fef2e5fa 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -49,6 +49,7 @@ pub mod doctor; pub mod embeddings; pub mod encryption; pub mod file_state; +pub mod harness_init; pub mod health; pub mod heartbeat; pub mod http_host; diff --git a/src/openhuman/runtime_python/bootstrap.rs b/src/openhuman/runtime_python/bootstrap.rs index beba97d29..cfffb2a0a 100644 --- a/src/openhuman/runtime_python/bootstrap.rs +++ b/src/openhuman/runtime_python/bootstrap.rs @@ -129,7 +129,11 @@ impl PythonBootstrap { empty_to_none(&self.config.managed_release_tag), ) .await?; - let dist = select_distribution(&release, &self.config.minimum_version)?; + let dist = select_distribution( + &release, + &self.config.minimum_version, + &self.config.maximum_version, + )?; let install_dir = cache_root.join(dist.install_dir_name()); let _install_lock = acquire_install_lock(&install_dir).await?; diff --git a/src/openhuman/runtime_python/downloader.rs b/src/openhuman/runtime_python/downloader.rs index 7396789d1..880ef3826 100644 --- a/src/openhuman/runtime_python/downloader.rs +++ b/src/openhuman/runtime_python/downloader.rs @@ -81,10 +81,21 @@ pub(crate) async fn fetch_release_metadata_from_base( pub fn select_distribution( release: &GithubRelease, minimum_version: &str, + maximum_version: &str, ) -> Result { let Some(minimum) = parse_python_version(minimum_version) else { bail!("invalid runtime_python.minimum_version `{minimum_version}`"); }; + // Empty string disables the upper bound. A non-empty but unparseable value + // is a config error worth surfacing rather than silently ignoring. + let maximum = if maximum_version.trim().is_empty() { + None + } else { + match parse_python_version(maximum_version) { + Some(v) => Some(v), + None => bail!("invalid runtime_python.maximum_version `{maximum_version}`"), + } + }; let target_suffix = host_asset_suffix()?; let mut candidates = release @@ -93,12 +104,19 @@ pub fn select_distribution( .filter_map(|asset| parse_distribution_asset(asset, &release.tag_name)) .filter(|dist| asset_matches_target(&dist.asset_name, target_suffix)) .filter(|dist| dist.version >= minimum) + // Exclusive upper bound — keeps selection off newer pre-release series + // (e.g. 3.15.x betas, which parse as a bare `3.15.0`). + .filter(|dist| maximum.as_ref().map_or(true, |max| dist.version < *max)) .collect::>(); if candidates.is_empty() { bail!( - "no managed python-build-standalone asset found for host suffix `{target_suffix}` with version >= {} in release {}", + "no managed python-build-standalone asset found for host suffix `{target_suffix}` with version >= {}{} in release {}", minimum.display(), + maximum + .as_ref() + .map(|m| format!(" and < {}", m.display())) + .unwrap_or_default(), release.tag_name ); } diff --git a/src/openhuman/runtime_python/downloader_tests.rs b/src/openhuman/runtime_python/downloader_tests.rs index 73c6c1e02..c2dcede3e 100644 --- a/src/openhuman/runtime_python/downloader_tests.rs +++ b/src/openhuman/runtime_python/downloader_tests.rs @@ -22,3 +22,42 @@ fn ignores_non_install_only_assets() { }; assert!(parse_distribution_asset(&asset, "20260510").is_none()); } + +/// Build a release with one `install_only` asset per supplied version, named +/// for the current host so `select_distribution` accepts them. +fn release_with_versions(versions: &[&str]) -> GithubRelease { + let suffix = host_asset_suffix().expect("host suffix"); + let assets = versions + .iter() + .map(|v| GithubAsset { + name: format!("cpython-{v}+20260623-{suffix}"), + browser_download_url: format!("https://example.invalid/{v}.tar.gz"), + digest: None, + }) + .collect(); + GithubRelease { + tag_name: "20260623".to_string(), + assets, + } +} + +#[test] +fn maximum_version_caps_selection_to_stable_series() { + // 3.15.0b3 parses to a bare 3.15.0 — the cap is what keeps us off it. + let release = release_with_versions(&["3.12.13", "3.13.5", "3.15.0"]); + let dist = select_distribution(&release, "3.12.0", "3.14.0").expect("dist"); + assert_eq!(dist.version.display(), "3.13.5"); +} + +#[test] +fn empty_maximum_version_disables_the_cap() { + let release = release_with_versions(&["3.13.5", "3.15.0"]); + let dist = select_distribution(&release, "3.12.0", "").expect("dist"); + assert_eq!(dist.version.display(), "3.15.0"); +} + +#[test] +fn invalid_maximum_version_is_an_error() { + let release = release_with_versions(&["3.13.5"]); + assert!(select_distribution(&release, "3.12.0", "not-a-version").is_err()); +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 50e9f4fba..437a62c70 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1111,6 +1111,51 @@ async fn json_rpc_monitor_list_and_read_surface() { rpc_join.abort(); } +#[tokio::test] +async fn json_rpc_harness_init_status_returns_snapshot_envelope() { + let _env_lock = json_rpc_e2e_env_lock(); + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + // status is read-only — no provisioning is triggered, so it is safe in CI + // (we deliberately do NOT call `openhuman.harness_init_run`, which would + // attempt real Python/Node/spaCy downloads). + let resp = post_json_rpc( + &rpc_base, + 4471_1, + "openhuman.harness_init_status", + json!({}), + ) + .await; + let result = assert_no_jsonrpc_error(&resp, "harness_init_status"); + + let snapshot = result + .get("snapshot") + .expect("harness_init_status should return a snapshot object"); + let overall = snapshot + .get("overall") + .and_then(Value::as_str) + .expect("snapshot.overall should be a string"); + assert!( + ["idle", "running", "done", "failed"].contains(&overall), + "overall should be a known lifecycle state, got {overall}" + ); + let steps = snapshot + .get("steps") + .and_then(Value::as_array) + .expect("snapshot.steps should be an array"); + let ids: Vec<&str> = steps + .iter() + .filter_map(|s| s.get("id").and_then(Value::as_str)) + .collect(); + assert!( + ids.contains(&"python_runtime") && ids.contains(&"spacy") && ids.contains(&"node_runtime"), + "snapshot should list the registered steps, got {ids:?}" + ); + + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let _env_lock = json_rpc_e2e_env_lock();
{t('harnessInit.subtitle')}
{t('harnessInit.backgroundHint')}
{t('harnessInit.failedMessage')}
+ {failedStep.message} +