feat(harness_init): eager first-run setup with init overlay; pin managed Python to 3.13 (#4021)

This commit is contained in:
Steven Enamakel
2026-06-23 17:32:29 -07:00
committed by GitHub
parent 39fd83e866
commit fb720fd305
40 changed files with 1728 additions and 4 deletions
+2
View File
@@ -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 && <LocalAIDownloadSnackbar />}
{!onMobile && <AppUpdatePrompt />}
<KeyringConsentOverlay />
<HarnessInitOverlay />
<SecretPromptDialog />
</ServiceBlockingGate>
</CommandProvider>
@@ -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<HarnessInitSnapshot | null>(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 (
<InitProgressScreen
snapshot={snapshot}
onRetry={handleRetry}
onContinue={handleContinue}
retrying={retrying}
/>
);
}
@@ -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> = {}): 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(
<InitProgressScreen snapshot={snapshot()} onRetry={vi.fn()} onContinue={vi.fn()} />
);
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(
<InitProgressScreen snapshot={snapshot()} onRetry={vi.fn()} onContinue={onContinue} />
);
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(
<InitProgressScreen snapshot={failed} onRetry={onRetry} onContinue={onContinue} />
);
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);
});
});
@@ -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 = <Spinner className="h-4 w-4 text-primary-500" />;
stateText = t('harnessInit.stateRunning');
break;
case 'done':
icon = <CheckIcon className="h-4 w-4 text-sage-500" />;
stateText = t('harnessInit.stateDone');
break;
case 'failed':
icon = <CloseIcon className="h-4 w-4 text-coral-400" />;
stateText = t('harnessInit.stateFailed');
break;
case 'skipped':
icon = <CloseIcon className="h-4 w-4 text-amber-400" />;
stateText = t('harnessInit.stateSkipped');
break;
case 'pending':
default:
icon = <span className="block h-2 w-2 rounded-full bg-stone-500/60" />;
stateText = t('harnessInit.statePending');
break;
}
return (
<li className="flex items-center gap-3 py-2">
<span className="flex h-5 w-5 items-center justify-center" aria-hidden>
{icon}
</span>
<span className="flex-1 text-sm text-stone-200">{label}</span>
<span className="text-xs text-stone-400">{stateText}</span>
</li>
);
}
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 (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-stone-950/90 p-4 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="harness-init-title"
className="w-full max-w-md rounded-2xl border border-stone-700/60 bg-stone-900 p-6 shadow-2xl">
<h2 id="harness-init-title" className="text-lg font-semibold text-white">
{t('harnessInit.title')}
</h2>
<p className="mt-1 text-sm text-stone-400">{t('harnessInit.subtitle')}</p>
<ul className="mt-5 divide-y divide-stone-800">
{snapshot.steps.map(step => (
<StepRow key={step.id} step={step} />
))}
</ul>
{!failed && (
<div className="mt-5 flex items-center justify-between gap-3">
<p className="text-xs text-stone-500">{t('harnessInit.backgroundHint')}</p>
<button
type="button"
onClick={onContinue}
className="shrink-0 rounded-lg border border-stone-700 px-3 py-1.5 text-sm text-stone-300 hover:bg-stone-800 hover:text-white">
{t('harnessInit.runInBackground')}
</button>
</div>
)}
{failed && (
<div className="mt-5">
<div className="rounded-xl border border-coral-500/20 bg-coral-500/10 p-3">
<p className="text-xs text-coral-300">{t('harnessInit.failedMessage')}</p>
{failedStep?.message && (
<p className="mt-1 break-words text-[11px] text-coral-400/80">
{failedStep.message}
</p>
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={onContinue}
className="rounded-lg px-3 py-1.5 text-sm text-stone-300 hover:text-white">
{t('harnessInit.continueAnyway')}
</button>
<button
type="button"
onClick={onRetry}
disabled={retrying}
className="inline-flex items-center gap-2 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-500/90 disabled:opacity-60">
{retrying && <Spinner className="h-3 w-3" />}
{t('harnessInit.retry')}
</button>
</div>
</div>
)}
</div>
</div>
);
}
+17
View File
@@ -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 إلى إذنك لتخزين الأسرار باستخدام التخزين المشفّر المحلي بدلاً من ذلك.',
+18
View File
@@ -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-এর পরিবর্তে স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান ব্যবহার করে গোপনীয়তা সংরক্ষণ করতে আপনার অনুমতি প্রয়োজন।',
+18
View File
@@ -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.',
+18
View File
@@ -6143,6 +6143,24 @@ const en: TranslationMap = {
'chat.files.error.download_failed': 'Download failed. Please try again.',
'chat.files.error.delete_failed': 'Couldnt 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':
+18
View File
@@ -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.',
+19
View File
@@ -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é.",
+17
View File
@@ -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 को इसके बजाय स्थानीय एन्क्रिप्टेड भंडारण का उपयोग करके रहस्य संग्रहीत करने के लिए आपकी अनुमति चाहिए।',
+18
View File
@@ -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.',
+18
View File
@@ -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.",
+17
View File
@@ -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이 로컬 암호화 저장소를 사용하여 비밀을 저장하려면 귀하의 허가가 필요합니다.',
+18
View File
@@ -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.',
+18
View File
@@ -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.',
+18
View File
@@ -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 необходимо ваше разрешение на хранение секретов в локальном зашифрованном хранилище.',
+17
View File
@@ -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 需要您的许可,以便使用本地加密存储来保存密钥。',
@@ -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);
});
});
+102
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<HarnessInitSnapshot | null> {
const payload = await callCoreRpc<unknown>({ 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<HarnessInitSnapshot | null> {
const payload = await callCoreRpc<unknown>({
method: 'openhuman.harness_init_run',
params: { force },
// Provisioning can download Python / Node / spaCy — allow a long budget.
timeoutMs: 10 * 60 * 1000,
});
return parseHarnessInitSnapshot(payload);
}
+1
View File
@@ -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
+3
View File
@@ -136,6 +136,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
.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<ControllerSchema> {
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());
+19 -1
View File
@@ -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<String>,
percent: Option<u8>,
},
/// 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",
+14
View File
@@ -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.
+24
View File
@@ -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);
}
_ => {}
}
}
@@ -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(),
+6
View File
@@ -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).
+26
View File
@@ -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,
};
+117
View File
@@ -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<String, Value>) -> 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<String, Value>) -> 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<Value, String> {
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());
}
}
+193
View File
@@ -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<Box<dyn Future<Output = T> + 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<HarnessInitStep> {
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
);
}
}
}
+206
View File
@@ -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<ControllerSchema> {
vec![schemas("status"), schemas("run")]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
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 0100 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"]);
}
}
+115
View File
@@ -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<Mutex<HarnessInitSnapshot>> = OnceLock::new();
fn state() -> &'static Mutex<HarnessInitSnapshot> {
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<String>, percent: Option<u8>) {
{
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());
}
}
+81
View File
@@ -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<String>,
/// Optional 0100 progress hint; `None` for indeterminate steps.
pub percent: Option<u8>,
/// RFC3339 timestamp of the last state change.
pub updated_at: Option<String>,
}
/// 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<StepStatus>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
}
+1 -1
View File
@@ -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::{
@@ -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) {
+1
View File
@@ -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;
+5 -1
View File
@@ -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?;
+19 -1
View File
@@ -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<PythonDistribution> {
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::<Vec<_>>();
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
);
}
@@ -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());
}
+45
View File
@@ -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();