mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
a40272ef45
commit
9376ffc19f
@@ -151,7 +151,14 @@ const ar4: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingProfile': 'جارٍ بناء ملفك الشخصي...',
|
||||
'onboarding.contextGathering.continueToChat': 'المتابعة إلى المحادثة',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'لم نتمكّن من بناء ملفك الشخصي بالكامل الآن، لكن لا بأس — يمكنك المتابعة وسيكتمل ملفك تدريجيًا مع مرور الوقت.',
|
||||
'تعذّر إنشاء ملفك الكامل الآن، لكن لا بأس — يمكنك المتابعة وسيُبنى ملفك مع الوقت.',
|
||||
'onboarding.contextGathering.coreAlive': 'النواة متاحة — قد يستغرق التشغيل الأول دقيقة.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'يجري التحقق من اتصال النواة…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'النواة لا تستجيب. يمكنك المتابعة والمحاولة لاحقًا.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'قد يستغرق التشغيل الأول 30–60 ثانية أثناء تهيئة نموذجك المحلي والأدوات. يمكنك المتابعة إلى المحادثة في أي وقت — يستمر بناء الملف الشخصي في الخلفية.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'لا يزال العمل جاريًا على ملفك الشخصي…',
|
||||
'onboarding.contextGathering.title': 'جمع السياق',
|
||||
'openhuman.team_list_teams': 'قائمة الفرق',
|
||||
'overlay.ariaAttention': 'رسالة انتباه',
|
||||
|
||||
@@ -152,7 +152,14 @@ const bn4: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingProfile': 'আপনার প্রোফাইল তৈরি হচ্ছে...',
|
||||
'onboarding.contextGathering.continueToChat': 'চ্যাটে চালিয়ে যান',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'আমরা এই মুহূর্তে আপনার সম্পূর্ণ প্রোফাইল তৈরি করতে পারিনি, কিন্তু সমস্যা নেই — আপনি চালিয়ে যেতে পারেন, আপনার প্রোফাইল সময়ের সাথে তৈরি হতে থাকবে।',
|
||||
'আমরা এখনই আপনার পূর্ণ প্রোফাইল তৈরি করতে পারিনি, কিন্তু সমস্যা নেই — আপনি চালিয়ে যেতে পারেন এবং আপনার প্রোফাইল সময়ের সাথে তৈরি হবে।',
|
||||
'onboarding.contextGathering.coreAlive': 'কোর সংযোগযোগ্য — প্রথম লঞ্চ এক মিনিট সময় নিতে পারে।',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'কোর সংযোগ যাচাই করা হচ্ছে…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'কোর সাড়া দিচ্ছে না। আপনি চালিয়ে যেতে পারেন এবং পরে আবার চেষ্টা করতে পারেন।',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'আমরা আপনার লোকাল মডেল এবং টুলস প্রস্তুত করছি, প্রথম লঞ্চ ৩০–৬০ সেকেন্ড সময় নিতে পারে। আপনি যেকোনো সময় চ্যাটে যেতে পারেন — প্রোফাইল তৈরি ব্যাকগ্রাউন্ডে চলতে থাকবে।',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'এখনও আপনার প্রোফাইলে কাজ চলছে…',
|
||||
'onboarding.contextGathering.title': 'কন্টেক্সট সংগ্রহ',
|
||||
'openhuman.team_list_teams': 'টিম তালিকা',
|
||||
'overlay.ariaAttention': 'মনোযোগের বার্তা',
|
||||
|
||||
@@ -153,6 +153,13 @@ const en4: TranslationMap = {
|
||||
'onboarding.contextGathering.continueToChat': 'Continue to chat',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
"Your chat is ready. We'll keep building your full profile in the background, so you can continue now and refine it over time.",
|
||||
'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'Core is not responding. You can continue and try again later.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…',
|
||||
'onboarding.contextGathering.title': 'Context Gathering',
|
||||
'openhuman.team_list_teams': 'Team list teams',
|
||||
'overlay.ariaAttention': 'Attention message',
|
||||
|
||||
@@ -152,7 +152,15 @@ const es4: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingProfile': 'Construyendo tu perfil...',
|
||||
'onboarding.contextGathering.continueToChat': 'Continuar al chat',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'No pudimos construir tu perfil completo en este momento, pero no pasa nada: puedes continuar y tu perfil se irá construyendo con el tiempo.',
|
||||
'No pudimos construir tu perfil completo ahora mismo, pero no pasa nada — puedes continuar y tu perfil se construirá con el tiempo.',
|
||||
'onboarding.contextGathering.coreAlive':
|
||||
'El núcleo está disponible — el primer arranque puede tardar un minuto.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Comprobando la conexión con el núcleo…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'El núcleo no responde. Puedes continuar e intentarlo más tarde.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'El primer arranque puede tardar 30–60 segundos mientras preparamos tu modelo local y tus herramientas. Puedes continuar al chat en cualquier momento — la construcción del perfil sigue en segundo plano.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Seguimos trabajando en tu perfil…',
|
||||
'onboarding.contextGathering.title': 'Recopilación de contexto',
|
||||
'openhuman.team_list_teams': 'Lista de equipos',
|
||||
'overlay.ariaAttention': 'Mensaje de atención',
|
||||
|
||||
@@ -152,7 +152,15 @@ const fr4: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingProfile': 'Construction de ton profil…',
|
||||
'onboarding.contextGathering.continueToChat': 'Accéder au chat',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
"Nous n'avons pas pu construire ton profil complet pour le moment, mais ce n'est pas grave — tu peux continuer et ton profil se construira au fil du temps.",
|
||||
"Nous n'avons pas pu créer votre profil complet pour l'instant, mais ce n'est pas grave — vous pouvez continuer et votre profil se construira au fil du temps.",
|
||||
'onboarding.contextGathering.coreAlive':
|
||||
'Le cœur est accessible — le premier lancement peut prendre une minute.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Vérification de la connexion au cœur…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'Le cœur ne répond pas. Tu peux continuer et réessayer plus tard.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'Le premier lancement peut prendre 30 à 60 secondes pendant que nous préparons ton modèle local et tes outils. Tu peux accéder au chat à tout moment — la construction du profil continue en arrière-plan.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Construction de ton profil en cours…',
|
||||
'onboarding.contextGathering.title': 'Collecte de contexte',
|
||||
'openhuman.team_list_teams': 'Liste des équipes',
|
||||
'overlay.ariaAttention': "Message d'attention",
|
||||
|
||||
@@ -153,6 +153,14 @@ const hi4: TranslationMap = {
|
||||
'onboarding.contextGathering.continueToChat': 'चैट पर जाएं',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'हम अभी आपकी पूरी प्रोफ़ाइल नहीं बना सके, लेकिन कोई बात नहीं — आप जारी रख सकते हैं और आपकी प्रोफ़ाइल समय के साथ बनती जाएगी।',
|
||||
'onboarding.contextGathering.coreAlive':
|
||||
'कोर पहुँच योग्य है — पहली बार लॉन्च करने में एक मिनट लग सकता है।',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'कोर कनेक्शन की जाँच की जा रही है…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'कोर प्रतिक्रिया नहीं दे रहा है। आप जारी रख सकते हैं और बाद में पुनः प्रयास कर सकते हैं।',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'हम आपके स्थानीय मॉडल और टूल्स को तैयार कर रहे हैं, पहली बार लॉन्च करने में 30–60 सेकंड लग सकते हैं। आप कभी भी चैट पर जा सकते हैं — प्रोफ़ाइल पृष्ठभूमि में बनती रहेगी।',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'आपकी प्रोफ़ाइल पर अब भी काम चल रहा है…',
|
||||
'onboarding.contextGathering.title': 'कॉन्टेक्स्ट गैदरिंग',
|
||||
'openhuman.team_list_teams': 'टीम सूची टीमें',
|
||||
'overlay.ariaAttention': 'ध्यान संदेश',
|
||||
|
||||
@@ -152,7 +152,15 @@ const id4: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingProfile': 'Membangun profil Anda...',
|
||||
'onboarding.contextGathering.continueToChat': 'Lanjutkan ke chat',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'Kami belum bisa membangun profil lengkap Anda sekarang, tetapi tidak apa-apa — Anda bisa melanjutkan dan profil Anda akan terbangun seiring waktu.',
|
||||
'Kami tidak bisa membangun profil lengkap Anda sekarang, tapi tidak apa-apa — Anda bisa lanjut dan profil Anda akan terbentuk seiring waktu.',
|
||||
'onboarding.contextGathering.coreAlive':
|
||||
'Core dapat diakses — peluncuran pertama bisa memakan waktu satu menit.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Memeriksa koneksi core…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'Core tidak merespons. Anda bisa melanjutkan dan coba lagi nanti.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'Peluncuran pertama bisa memakan waktu 30–60 detik sementara kami menyiapkan model dan alat lokal Anda. Anda bisa melanjutkan ke chat kapan saja — pembuatan profil tetap berjalan di latar belakang.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Masih membangun profil Anda…',
|
||||
'onboarding.contextGathering.title': 'Pengumpulan Konteks',
|
||||
'openhuman.team_list_teams': 'Daftar tim',
|
||||
'overlay.ariaAttention': 'Pesan perhatian',
|
||||
|
||||
@@ -153,6 +153,14 @@ const it4: TranslationMap = {
|
||||
'onboarding.contextGathering.continueToChat': 'Continua alla chat',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'Non siamo riusciti a costruire il tuo profilo completo ora, ma va bene — puoi continuare e il tuo profilo si svilupperà nel tempo.',
|
||||
'onboarding.contextGathering.coreAlive':
|
||||
'Il core è raggiungibile — il primo avvio può richiedere un minuto.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Verifica della connessione al core…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'Il core non risponde. Puoi continuare e riprovare più tardi.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'Il primo avvio può richiedere 30–60 secondi mentre prepariamo il tuo modello locale e gli strumenti. Puoi continuare la chat in qualsiasi momento — la costruzione del profilo continua in background.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Stiamo ancora preparando il tuo profilo…',
|
||||
'onboarding.contextGathering.title': 'Raccolta del contesto',
|
||||
'openhuman.team_list_teams': 'Elenco team',
|
||||
'overlay.ariaAttention': 'Messaggio di attenzione',
|
||||
|
||||
@@ -153,7 +153,15 @@ const pt4: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingProfile': 'Construindo seu perfil...',
|
||||
'onboarding.contextGathering.continueToChat': 'Continuar para o chat',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'Não conseguimos construir seu perfil completo agora, mas tudo bem — você pode continuar e seu perfil será construído com o tempo.',
|
||||
'Não conseguimos montar seu perfil completo agora, mas tudo bem — você pode continuar e seu perfil será construído ao longo do tempo.',
|
||||
'onboarding.contextGathering.coreAlive':
|
||||
'Núcleo acessível — a primeira inicialização pode demorar um minuto.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Verificando a conexão com o núcleo…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'O núcleo não está respondendo. Você pode continuar e tentar novamente mais tarde.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'A primeira inicialização pode levar 30–60 segundos enquanto preparamos seu modelo local e ferramentas. Você pode continuar para o chat a qualquer momento — a construção do perfil continua em segundo plano.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Ainda construindo seu perfil…',
|
||||
'onboarding.contextGathering.title': 'Coleta de Contexto',
|
||||
'openhuman.team_list_teams': 'Listar equipes',
|
||||
'overlay.ariaAttention': 'Mensagem de atenção',
|
||||
|
||||
@@ -152,7 +152,14 @@ const ru4: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingProfile': 'Составление профиля...',
|
||||
'onboarding.contextGathering.continueToChat': 'Перейти в чат',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'Сейчас не удалось построить ваш полный профиль, но это нормально — вы можете продолжить, и ваш профиль будет формироваться со временем.',
|
||||
'Мы не смогли построить ваш полный профиль прямо сейчас, но это нормально — вы можете продолжить, и профиль будет дополняться со временем.',
|
||||
'onboarding.contextGathering.coreAlive': 'Ядро доступно — первый запуск может занять минуту.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Проверка соединения с ядром…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'Ядро не отвечает. Можно продолжить и попробовать позже.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'Первый запуск может занять 30–60 секунд, пока мы прогреваем локальную модель и инструменты. Вы можете перейти в чат в любой момент — построение профиля продолжится в фоне.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Профиль ещё составляется…',
|
||||
'onboarding.contextGathering.title': 'Сбор контекста',
|
||||
'openhuman.team_list_teams': 'Список команд',
|
||||
'overlay.ariaAttention': 'Сообщение',
|
||||
|
||||
@@ -149,7 +149,13 @@ const zhCN4: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingProfile': '正在构建你的档案...',
|
||||
'onboarding.contextGathering.continueToChat': '前往对话',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
'暂时无法完整构建你的档案,但没关系——你可以继续,档案会随时间逐步完善。',
|
||||
'我们暂时无法构建你的完整资料,但没关系——你可以继续,资料会随时间逐步完善。',
|
||||
'onboarding.contextGathering.coreAlive': '核心可访问 — 首次启动可能需要一分钟。',
|
||||
'onboarding.contextGathering.coreAliveProbing': '正在检查核心连接…',
|
||||
'onboarding.contextGathering.coreUnreachable': '核心未响应。你可以继续,稍后再试。',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'我们正在预热本地模型与工具,首次启动可能需要 30–60 秒。你可以随时进入对话 — 档案构建会在后台继续进行。',
|
||||
'onboarding.contextGathering.stillWorkingTitle': '仍在生成你的档案…',
|
||||
'onboarding.contextGathering.title': '上下文收集',
|
||||
'openhuman.team_list_teams': '团队列表',
|
||||
'overlay.ariaAttention': '注意消息',
|
||||
|
||||
@@ -1493,8 +1493,15 @@ const en: TranslationMap = {
|
||||
'onboarding.contextGathering.buildingDesc': 'Gathering context from your connected accounts…',
|
||||
'onboarding.contextGathering.buildingProfile': 'Building your profile...',
|
||||
'onboarding.contextGathering.continueToChat': 'Continue to chat',
|
||||
'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'Core is not responding. You can continue and try again later.',
|
||||
'onboarding.contextGathering.errorDesc':
|
||||
"Your chat is ready. We'll keep building your full profile in the background, so you can continue now and refine it over time.",
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…',
|
||||
'onboarding.contextGathering.title': 'Context Gathering',
|
||||
'openhuman.team_list_teams': 'Team list teams',
|
||||
'overlay.ariaAttention': 'Attention message',
|
||||
|
||||
@@ -15,9 +15,30 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { callCoreRpc } from '../../../services/coreRpcClient';
|
||||
import { callCoreRpc, getCoreRpcUrl, testCoreRpcConnection } from '../../../services/coreRpcClient';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
|
||||
/**
|
||||
* Threshold after which the building-profile UI swaps to the "still working"
|
||||
* staged copy (#2156). Real first-launch completions on slow M-series Macs
|
||||
* can take 30–40s, so users would otherwise see what looks like a stall at
|
||||
* the global RPC timeout boundary.
|
||||
*/
|
||||
const STILL_WORKING_THRESHOLD_MS = 30_000;
|
||||
|
||||
/** How often we probe `core.ping` after entering the still-working state. */
|
||||
const ALIVE_PROBE_INTERVAL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Per-probe network budget. `testCoreRpcConnection` uses raw `fetch()` with
|
||||
* no built-in timeout, so we have to bound the probe ourselves — otherwise a
|
||||
* TCP black-hole (firewall drop, suspended laptop wake) lets the first probe
|
||||
* hang forever and the single-flight guard blocks every subsequent tick.
|
||||
*/
|
||||
const PROBE_TIMEOUT_MS = 3_000;
|
||||
|
||||
type AliveState = 'unknown' | 'probing' | 'alive' | 'unreachable';
|
||||
|
||||
interface ContextGatheringStepProps {
|
||||
connectedSources: string[];
|
||||
onNext: () => void | Promise<void>;
|
||||
@@ -138,10 +159,20 @@ async function findLinkedInUrlViaComposio(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* First-launch profile compression on slow hardware (#2156) can run past the
|
||||
* global 30s RPC timeout while the core LLM compressor finishes. Use a
|
||||
* longer-but-still-bounded budget so users with a slow-but-alive core are
|
||||
* not parked on the post-login fallback when the call would otherwise
|
||||
* succeed. Real failures still abort within `SAVE_PROFILE_TIMEOUT_MS`.
|
||||
*/
|
||||
const SAVE_PROFILE_TIMEOUT_MS = 90_000;
|
||||
|
||||
async function saveProfile(markdown: string): Promise<void> {
|
||||
await callCoreRpc<unknown>({
|
||||
method: 'openhuman.learning_save_profile',
|
||||
params: { markdown, summarize: true },
|
||||
timeoutMs: SAVE_PROFILE_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,6 +189,10 @@ const ContextGatheringStep = ({
|
||||
);
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
// Staged "still working" mode kicks in after STILL_WORKING_THRESHOLD_MS so
|
||||
// a slow-but-alive first launch no longer looks like a stall (#2156).
|
||||
const [stillWorking, setStillWorking] = useState(false);
|
||||
const [aliveState, setAliveState] = useState<AliveState>('unknown');
|
||||
const backgroundClickedRef = useRef(false);
|
||||
const ranRef = useRef(false);
|
||||
|
||||
@@ -271,6 +306,66 @@ const ContextGatheringStep = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Staged "still working" trigger (#2156). After STILL_WORKING_THRESHOLD_MS
|
||||
// of pipeline runtime, swap to the calmer copy and start probing
|
||||
// `core.ping` so we can tell users whether the core is slow-but-alive vs
|
||||
// truly unreachable. Cleared on finish/error so completed runs never flip
|
||||
// to the slow-path UI retroactively.
|
||||
useEffect(() => {
|
||||
if (finished || !hasGmail || hasError) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setStillWorking(true);
|
||||
}, STILL_WORKING_THRESHOLD_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [finished, hasGmail, hasError]);
|
||||
|
||||
// Periodic alive probe while in still-working state. `core.ping` resolves
|
||||
// quickly even when the busy snapshot RPC is holding the worker, so a
|
||||
// green ping during a slow snapshot is exactly the alive-but-slow signal
|
||||
// users need to see. On cold start the bearer token may not be resolved
|
||||
// yet (IPC race) and the probe will come back 401 — that means auth is
|
||||
// still warming up, not that the core is down, so we treat 401 as `alive`.
|
||||
useEffect(() => {
|
||||
if (!stillWorking || finished || hasError) return;
|
||||
let cancelled = false;
|
||||
// Single-flight guard so a slow probe never gets shadowed by the next
|
||||
// 5s tick. Each probe also bounds itself with its own AbortController
|
||||
// (PROBE_TIMEOUT_MS) so a TCP-black-hole core (firewall, suspended
|
||||
// laptop) cannot leave us with a permanently in-flight fetch and a
|
||||
// permanently blocked guard — that would re-create exactly the
|
||||
// "Checking core connection…" forever failure mode this UI exists to
|
||||
// fix.
|
||||
let inFlight = false;
|
||||
|
||||
const probe = async () => {
|
||||
if (cancelled || inFlight) return;
|
||||
inFlight = true;
|
||||
setAliveState(prev => (prev === 'unknown' ? 'probing' : prev));
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const url = await getCoreRpcUrl();
|
||||
const response = await testCoreRpcConnection(url, undefined, { signal: controller.signal });
|
||||
if (!cancelled) {
|
||||
// 401 = auth not ready yet (cold-start IPC race), not a dead core.
|
||||
setAliveState(response.ok || response.status === 401 ? 'alive' : 'unreachable');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setAliveState('unreachable');
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
void probe();
|
||||
const interval = window.setInterval(probe, ALIVE_PROBE_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [stillWorking, finished, hasError]);
|
||||
|
||||
// Auto-navigate on successful completion (skip if user already clicked background link)
|
||||
useEffect(() => {
|
||||
if (finished && !hasError && !backgroundClickedRef.current) {
|
||||
@@ -305,6 +400,24 @@ const ContextGatheringStep = ({
|
||||
);
|
||||
}
|
||||
|
||||
// The slow-path UI must vanish as soon as the pipeline resolves, even
|
||||
// during the 800ms auto-advance window — otherwise a slow-success user
|
||||
// sees "still working…" copy after the work has actually finished.
|
||||
const showStillWorking = stillWorking && !finished && !hasError;
|
||||
const titleKey = showStillWorking
|
||||
? 'onboarding.contextGathering.stillWorkingTitle'
|
||||
: 'onboarding.contextGathering.buildingProfile';
|
||||
const descKey = showStillWorking
|
||||
? 'onboarding.contextGathering.stillWorkingDesc'
|
||||
: 'onboarding.contextGathering.buildingDesc';
|
||||
|
||||
const aliveLabelKey: Record<AliveState, string> = {
|
||||
unknown: 'onboarding.contextGathering.coreAliveProbing',
|
||||
probing: 'onboarding.contextGathering.coreAliveProbing',
|
||||
alive: 'onboarding.contextGathering.coreAlive',
|
||||
unreachable: 'onboarding.contextGathering.coreUnreachable',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 shadow-soft animate-fade-up">
|
||||
<div className="flex flex-col items-center justify-center gap-6 py-8">
|
||||
@@ -312,11 +425,15 @@ const ContextGatheringStep = ({
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-r from-stone-300 via-stone-100 to-stone-300 bg-[length:200%_100%] animate-shimmer" />
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold text-stone-900 dark:text-neutral-100 animate-pulse">
|
||||
{t('onboarding.contextGathering.buildingProfile')}
|
||||
<h1
|
||||
className="text-xl font-bold text-stone-900 dark:text-neutral-100 animate-pulse"
|
||||
data-testid="context-gathering-title">
|
||||
{t(titleKey)}
|
||||
</h1>
|
||||
<p className="text-sm text-stone-500 dark:text-neutral-400 leading-relaxed">
|
||||
{t('onboarding.contextGathering.buildingDesc')}
|
||||
<p
|
||||
className="text-sm text-stone-500 dark:text-neutral-400 leading-relaxed text-center max-w-sm"
|
||||
data-testid="context-gathering-desc">
|
||||
{t(descKey)}
|
||||
</p>
|
||||
|
||||
{/* Skeleton bars */}
|
||||
@@ -326,6 +443,28 @@ const ContextGatheringStep = ({
|
||||
<div className="h-3 w-1/2 rounded-full bg-gradient-to-r from-stone-300 via-stone-100 to-stone-300 bg-[length:200%_100%] animate-shimmer [animation-delay:300ms]" />
|
||||
</div>
|
||||
|
||||
{/* Alive indicator — only while still-working state is active AND
|
||||
the pipeline hasn't finished/errored. Avoids flashing the probe
|
||||
during the 800ms auto-advance window after a slow success. */}
|
||||
{showStillWorking && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-stone-500 dark:text-neutral-400"
|
||||
data-testid="core-alive-indicator"
|
||||
data-alive-state={aliveState}>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
aliveState === 'alive'
|
||||
? 'bg-emerald-500'
|
||||
: aliveState === 'unreachable'
|
||||
? 'bg-red-500'
|
||||
: 'bg-stone-300 dark:bg-neutral-600 animate-pulse'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t(aliveLabelKey[aliveState])}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasGmail && !finished && (
|
||||
<OnboardingNextButton
|
||||
label={t('onboarding.contextGathering.continueToChat')}
|
||||
|
||||
@@ -5,11 +5,22 @@ import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import ContextGatheringStep from '../ContextGatheringStep';
|
||||
|
||||
const callCoreRpc = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc }));
|
||||
const getCoreRpcUrl = vi.hoisted(() => vi.fn(async () => 'http://127.0.0.1:7788/rpc'));
|
||||
const testCoreRpcConnection = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ ok: true, status: 200 }) as Response)
|
||||
);
|
||||
vi.mock('../../../../services/coreRpcClient', () => ({
|
||||
callCoreRpc,
|
||||
getCoreRpcUrl,
|
||||
testCoreRpcConnection,
|
||||
}));
|
||||
|
||||
describe('ContextGatheringStep', () => {
|
||||
beforeEach(() => {
|
||||
callCoreRpc.mockReset();
|
||||
getCoreRpcUrl.mockClear();
|
||||
testCoreRpcConnection.mockClear();
|
||||
testCoreRpcConnection.mockResolvedValue({ ok: true, status: 200 } as Response);
|
||||
});
|
||||
|
||||
it('no-Gmail branch: auto-navigates without any RPC', async () => {
|
||||
@@ -277,4 +288,228 @@ describe('ContextGatheringStep', () => {
|
||||
// fireEvent not needed — onNext is available via the button but user can also
|
||||
// just verify the friendly message is shown
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// #2156 — slow-but-alive snapshot / save_profile path
|
||||
// --------------------------------------------------------------------------
|
||||
describe('staged still-working UI (#2156)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes a 90s timeoutMs override on learning_save_profile so slow first launches finish', async () => {
|
||||
const onNext = vi.fn().mockResolvedValue(undefined);
|
||||
callCoreRpc.mockImplementation(async (req: { method: string }) => {
|
||||
if (req.method === 'openhuman.tools_composio_execute') {
|
||||
return {
|
||||
successful: true,
|
||||
data: { messages: [{ messageText: 'https://www.linkedin.com/in/jane-doe' }] },
|
||||
};
|
||||
}
|
||||
if (req.method === 'openhuman.learning_save_profile') {
|
||||
return { path: '/tmp/PROFILE.md', bytes: 1 };
|
||||
}
|
||||
throw new Error(`unexpected RPC ${req.method}`);
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={onNext} />
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onNext).toHaveBeenCalled(), { timeout: 5000 });
|
||||
|
||||
const saveCall = callCoreRpc.mock.calls.find(
|
||||
(c: Array<{ method: string; timeoutMs?: number }>) =>
|
||||
c[0].method === 'openhuman.learning_save_profile'
|
||||
);
|
||||
expect(saveCall![0].timeoutMs).toBeGreaterThan(30_000);
|
||||
expect(saveCall![0].timeoutMs).toBeLessThanOrEqual(10 * 60 * 1_000);
|
||||
});
|
||||
|
||||
it('swaps to the still-working copy after 30s while the pipeline is still pending', async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveGmail!: (v: unknown) => void;
|
||||
callCoreRpc.mockImplementation(
|
||||
() =>
|
||||
new Promise(res => {
|
||||
resolveGmail = res;
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
|
||||
);
|
||||
|
||||
// Initial copy — fast happy path.
|
||||
expect(screen.getByTestId('context-gathering-title').textContent).toMatch(
|
||||
/building your profile/i
|
||||
);
|
||||
expect(screen.queryByTestId('core-alive-indicator')).not.toBeInTheDocument();
|
||||
|
||||
// Cross the 30s threshold.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_500);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('context-gathering-title').textContent).toMatch(/still working/i);
|
||||
// Indicator appears so users see whether core is alive or unreachable.
|
||||
expect(screen.getByTestId('core-alive-indicator')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveGmail({ successful: true, data: { messages: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the core as alive when core.ping returns ok', async () => {
|
||||
// Fake timers must be active before render so the 30s still-working
|
||||
// setTimeout registers against the fake clock.
|
||||
vi.useFakeTimers();
|
||||
let resolveGmail!: (v: unknown) => void;
|
||||
callCoreRpc.mockImplementation(
|
||||
() =>
|
||||
new Promise(res => {
|
||||
resolveGmail = res;
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
|
||||
);
|
||||
|
||||
// Drive the still-working transition under fake timers, then flip
|
||||
// back to real timers so waitFor can poll the React commit for the
|
||||
// resolved indicator state (waitFor doesn't observe fake-time
|
||||
// microtasks reliably).
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_500);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
const indicator = await screen.findByTestId('core-alive-indicator');
|
||||
await waitFor(() => {
|
||||
expect(indicator.getAttribute('data-alive-state')).toBe('alive');
|
||||
});
|
||||
expect(testCoreRpcConnection).toHaveBeenCalled();
|
||||
expect(indicator.textContent ?? '').toMatch(/core is reachable/i);
|
||||
|
||||
await act(async () => {
|
||||
resolveGmail({ successful: true, data: { messages: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
it('treats HTTP 401 as alive (auth not ready yet, core is up)', async () => {
|
||||
vi.useFakeTimers();
|
||||
testCoreRpcConnection.mockResolvedValue({ ok: false, status: 401 } as Response);
|
||||
|
||||
let resolveGmail!: (v: unknown) => void;
|
||||
callCoreRpc.mockImplementation(
|
||||
() =>
|
||||
new Promise(res => {
|
||||
resolveGmail = res;
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_500);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
const indicator = await screen.findByTestId('core-alive-indicator');
|
||||
await waitFor(() => {
|
||||
expect(indicator.getAttribute('data-alive-state')).toBe('alive');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveGmail({ successful: true, data: { messages: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an AbortSignal so a TCP black-hole probe cannot hang forever', async () => {
|
||||
vi.useFakeTimers();
|
||||
// Hang the probe indefinitely so the only way it can resolve is via
|
||||
// the controller-driven abort path. Capture whether the signal was
|
||||
// forwarded.
|
||||
testCoreRpcConnection.mockImplementation(
|
||||
((_url: string, _token?: string, init?: { signal?: AbortSignal }) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError'))
|
||||
);
|
||||
})) as never
|
||||
);
|
||||
|
||||
let resolveGmail!: (v: unknown) => void;
|
||||
callCoreRpc.mockImplementation(
|
||||
() =>
|
||||
new Promise(res => {
|
||||
resolveGmail = res;
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
|
||||
);
|
||||
|
||||
// Enter still-working state.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_500);
|
||||
});
|
||||
// Drive the per-probe 3s timeout to fire the abort.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(testCoreRpcConnection).toHaveBeenCalled();
|
||||
const lastCall = testCoreRpcConnection.mock.calls[
|
||||
testCoreRpcConnection.mock.calls.length - 1
|
||||
] as unknown as [string, string | undefined, { signal?: AbortSignal } | undefined];
|
||||
expect(lastCall[2]?.signal).toBeInstanceOf(AbortSignal);
|
||||
|
||||
const indicator = await screen.findByTestId('core-alive-indicator');
|
||||
await waitFor(() => {
|
||||
expect(indicator.getAttribute('data-alive-state')).toBe('unreachable');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveGmail({ successful: true, data: { messages: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unreachable when core.ping rejects', async () => {
|
||||
vi.useFakeTimers();
|
||||
testCoreRpcConnection.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
let resolveGmail!: (v: unknown) => void;
|
||||
callCoreRpc.mockImplementation(
|
||||
() =>
|
||||
new Promise(res => {
|
||||
resolveGmail = res;
|
||||
})
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_500);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
const indicator = await screen.findByTestId('core-alive-indicator');
|
||||
await waitFor(() => {
|
||||
expect(indicator.getAttribute('data-alive-state')).toBe('unreachable');
|
||||
});
|
||||
expect(indicator.textContent ?? '').toMatch(/core is not responding/i);
|
||||
|
||||
await act(async () => {
|
||||
resolveGmail({ successful: true, data: { messages: [] } });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,6 +342,131 @@ describe('coreRpcClient', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('honors per-call timeoutMs override instead of the global default (#2156)', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockImplementationOnce(
|
||||
(_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = (init as RequestInit).signal as AbortSignal | undefined;
|
||||
if (!signal) return;
|
||||
const onAbort = () => {
|
||||
const err = new Error('The operation was aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
})
|
||||
);
|
||||
|
||||
const pending = callCoreRpc({ method: 'openhuman.app_state_snapshot', timeoutMs: 60_000 });
|
||||
let settled = false;
|
||||
pending
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
// 30s passes — global default would have aborted by now, but the
|
||||
// per-call 60s override keeps the request alive. Assert the pending
|
||||
// promise is still in flight so an early-abort regression on the
|
||||
// override path cannot slip through (CodeRabbit #2179 review).
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
// Advance to the override boundary — now the abort fires.
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await expect(pending).rejects.toThrow(
|
||||
'Core RPC openhuman.app_state_snapshot timed out after 60000ms'
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('clamps an oversize timeoutMs to the MAX bound (10 minutes)', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockImplementationOnce(
|
||||
(_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = (init as RequestInit).signal as AbortSignal | undefined;
|
||||
if (!signal) return;
|
||||
const onAbort = () => {
|
||||
const err = new Error('The operation was aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
})
|
||||
);
|
||||
|
||||
const pending = callCoreRpc({
|
||||
method: 'openhuman.app_state_snapshot',
|
||||
// 2 hours — far beyond the 10 minute clamp; should be reduced.
|
||||
timeoutMs: 2 * 60 * 60 * 1_000,
|
||||
});
|
||||
let settled = false;
|
||||
pending
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
const MAX_MS = 10 * 60 * 1_000;
|
||||
// 1ms before the clamp boundary: still pending. Guards against an
|
||||
// off-by-one where the clamp accidentally lowers the budget further
|
||||
// (CodeRabbit #2179 review).
|
||||
await vi.advanceTimersByTimeAsync(MAX_MS - 1);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
// Cross the clamp boundary — abort fires.
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
|
||||
await expect(pending).rejects.toThrow(
|
||||
`Core RPC openhuman.app_state_snapshot timed out after ${MAX_MS}ms`
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to the global default when timeoutMs is undefined', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockImplementationOnce(
|
||||
(_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = (init as RequestInit).signal as AbortSignal | undefined;
|
||||
if (!signal) return;
|
||||
const onAbort = () => {
|
||||
const err = new Error('The operation was aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
})
|
||||
);
|
||||
|
||||
const pending = callCoreRpc({ method: 'openhuman.threads_list' });
|
||||
pending.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CORE_RPC_TIMEOUT_MS + 1);
|
||||
await expect(pending).rejects.toThrow(
|
||||
`Core RPC openhuman.threads_list timed out after ${CORE_RPC_TIMEOUT_MS}ms`
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not trigger the timeout path when fetch resolves promptly', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -12,6 +12,27 @@ interface CoreRpcRelayRequest {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
serviceManaged?: boolean;
|
||||
/**
|
||||
* Per-call timeout override in milliseconds. When omitted, defaults to the
|
||||
* global `CORE_RPC_TIMEOUT_MS` (30s). Use for slow-but-alive RPCs such as
|
||||
* first-launch `openhuman.app_state_snapshot` (#2156). Clamped to the same
|
||||
* [MIN, MAX] window as the global default.
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Mirror of `parseCoreRpcTimeoutMs` bounds in `utils/config.ts`. */
|
||||
const PER_CALL_TIMEOUT_MIN_MS = 1_000;
|
||||
const PER_CALL_TIMEOUT_MAX_MS = 10 * 60 * 1_000;
|
||||
|
||||
function resolvePerCallTimeoutMs(override: number | undefined): number {
|
||||
if (override === undefined) return CORE_RPC_TIMEOUT_MS;
|
||||
if (!Number.isFinite(override)) return CORE_RPC_TIMEOUT_MS;
|
||||
const clamped = Math.min(
|
||||
Math.max(Math.round(override), PER_CALL_TIMEOUT_MIN_MS),
|
||||
PER_CALL_TIMEOUT_MAX_MS
|
||||
);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
interface JsonRpcRequestBody {
|
||||
@@ -344,7 +365,8 @@ export async function getCoreRpcToken(): Promise<string | null> {
|
||||
*/
|
||||
export async function testCoreRpcConnection(
|
||||
url: string,
|
||||
tokenOverride?: string
|
||||
tokenOverride?: string,
|
||||
init?: { signal?: AbortSignal }
|
||||
): Promise<Response> {
|
||||
const token = tokenOverride?.trim() || (await getCoreRpcToken());
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
@@ -355,6 +377,7 @@ export async function testCoreRpcConnection(
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }),
|
||||
signal: init?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -393,6 +416,7 @@ export async function callCoreRpc<T>({
|
||||
method,
|
||||
params,
|
||||
serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing.
|
||||
timeoutMs,
|
||||
}: CoreRpcRelayRequest): Promise<T> {
|
||||
void serviceManaged;
|
||||
|
||||
@@ -401,6 +425,7 @@ export async function callCoreRpc<T>({
|
||||
}
|
||||
|
||||
const normalizedMethod = normalizeRpcMethod(method);
|
||||
const effectiveTimeoutMs = resolvePerCallTimeoutMs(timeoutMs);
|
||||
const payload: JsonRpcRequestBody = {
|
||||
jsonrpc: '2.0',
|
||||
id: nextJsonRpcId++,
|
||||
@@ -419,12 +444,14 @@ export async function callCoreRpc<T>({
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// Bound the fetch to CORE_RPC_TIMEOUT_MS. Without this a hung core
|
||||
// sidecar will block every caller (and the UI) forever. We use a
|
||||
// manual AbortController + setTimeout rather than AbortSignal.timeout()
|
||||
// so test fake timers can drive the abort deterministically.
|
||||
// Bound the fetch. Without this a hung core sidecar would block every
|
||||
// caller (and the UI) forever. We use a manual AbortController +
|
||||
// setTimeout rather than AbortSignal.timeout() so test fake timers can
|
||||
// drive the abort deterministically. Per-call `timeoutMs` (clamped) lets
|
||||
// legitimately-slow RPCs such as first-launch `app_state_snapshot`
|
||||
// (#2156) opt into a longer-but-still-bounded budget.
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CORE_RPC_TIMEOUT_MS);
|
||||
const timeoutId = setTimeout(() => controller.abort(), effectiveTimeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(rpcUrl, {
|
||||
@@ -437,9 +464,11 @@ export async function callCoreRpc<T>({
|
||||
if (controller.signal.aborted) {
|
||||
// Throw a fully-classified `CoreRpcError` here so the outer catch
|
||||
// doesn't re-wrap a bare `Error` and so callers can branch on
|
||||
// `err.kind === 'timeout'` (Sentry filter, soft toast skip).
|
||||
// `err.kind === 'timeout'` (Sentry filter, soft toast skip). Use
|
||||
// the per-call `effectiveTimeoutMs` so the message reflects the
|
||||
// actual budget (#2156 raised the snapshot path to 90s).
|
||||
throw new CoreRpcError(
|
||||
`Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`,
|
||||
`Core RPC ${payload.method} timed out after ${effectiveTimeoutMs}ms`,
|
||||
'timeout'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,13 +67,21 @@ describe('coreStateApi.fetchCoreAppSnapshot', () => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
it('calls the correct RPC method', async () => {
|
||||
it('calls the correct RPC method with the slow-snapshot timeout override (#2156)', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: makeSnapshotResult() });
|
||||
|
||||
const { fetchCoreAppSnapshot } = await import('./coreStateApi');
|
||||
const { fetchCoreAppSnapshot, SNAPSHOT_TIMEOUT_MS } = await import('./coreStateApi');
|
||||
await fetchCoreAppSnapshot();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.app_state_snapshot' });
|
||||
// The first-launch snapshot legitimately runs past the global 30s default
|
||||
// on slow M-series machines; verify the longer-but-still-bounded budget
|
||||
// is threaded through to callCoreRpc instead of relying on the default.
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.app_state_snapshot',
|
||||
timeoutMs: SNAPSHOT_TIMEOUT_MS,
|
||||
});
|
||||
expect(SNAPSHOT_TIMEOUT_MS).toBeGreaterThan(30_000);
|
||||
expect(SNAPSHOT_TIMEOUT_MS).toBeLessThanOrEqual(10 * 60 * 1_000);
|
||||
});
|
||||
|
||||
it('returns the inner result from the RPC envelope', async () => {
|
||||
|
||||
@@ -48,9 +48,21 @@ interface AppStateSnapshotResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* First-launch `app_state_snapshot` can take 30–40s on M-series Macs while
|
||||
* memory tree init, Composio registry warmup, and other boot work compete
|
||||
* for the snapshot critical path (#2156). The global `CORE_RPC_TIMEOUT_MS`
|
||||
* default of 30s caused users with merely slow-but-alive cores to be parked
|
||||
* on the post-login fallback. Use a longer-but-still-bounded budget here so
|
||||
* legitimate slow-success completes inline, while real failures still abort
|
||||
* within `SNAPSHOT_TIMEOUT_MS` rather than hanging forever.
|
||||
*/
|
||||
export const SNAPSHOT_TIMEOUT_MS = 90_000;
|
||||
|
||||
export const fetchCoreAppSnapshot = async (): Promise<AppStateSnapshotResult> => {
|
||||
const response = await callCoreRpc<{ result: AppStateSnapshotResult }>({
|
||||
method: 'openhuman.app_state_snapshot',
|
||||
timeoutMs: SNAPSHOT_TIMEOUT_MS,
|
||||
});
|
||||
// Normalise the optional #1299 field at the API boundary so older core
|
||||
// builds without `meetAutoOrchestratorHandoff` still surface the
|
||||
|
||||
Reference in New Issue
Block a user