mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(harness_init): eager first-run setup with init overlay; pin managed Python to 3.13 (#4021)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 إلى إذنك لتخزين الأسرار باستخدام التخزين المشفّر المحلي بدلاً من ذلك.',
|
||||
|
||||
@@ -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-এর পরিবর্তে স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান ব্যবহার করে গোপনীয়তা সংরক্ষণ করতে আপনার অনুমতি প্রয়োজন।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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 को इसके बजाय स्थानीय एन्क्रिप्टेड भंडारण का उपयोग करके रहस्य संग्रहीत करने के लिए आपकी अनुमति चाहिए।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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이 로컬 암호화 저장소를 사용하여 비밀을 저장하려면 귀하의 허가가 필요합니다.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 необходимо ваше разрешение на хранение секретов в локальном зашифрованном хранилище.',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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).
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 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"]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 0–100 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>,
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user