diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index bed03e21c..9f23f3fb3 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -6121,7 +6121,11 @@ const messages: TranslationMap = { 'userErrors.budgetExceeded.body': 'نفدت الميزانية المُدارة. أضف ميزانية أو غيّر خطتك.', 'userErrors.insufficientCredits.title': 'مطلوب رصيد المزود', 'userErrors.insufficientCredits.body': 'نفد رصيد المزود. أعد الشحن أو حدّث مفتاح API.', + 'userErrors.apiKeyMissing.title': 'مطلوب مفتاح API', + 'userErrors.apiKeyMissing.body': + 'لا يوجد مفتاح API لمزوّد الذكاء الاصطناعي. أضِفه في إعدادات المزوّد للمتابعة.', 'userErrors.scope.chat': 'الدردشة', + 'userErrors.scope.cron': 'مهمة مجدوَلة', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'المبلغ', 'agentWorld.trading.networkLabel': 'الشبكة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index e4ab9e61d..87f5d64ca 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -6249,7 +6249,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'প্রদানকারীর ক্রেডিট প্রয়োজন', 'userErrors.insufficientCredits.body': 'AI প্রদানকারীর ক্রেডিট শেষ। রিচার্জ করুন বা API কী বদলান।', + 'userErrors.apiKeyMissing.title': 'API কী প্রয়োজন', + 'userErrors.apiKeyMissing.body': + 'আপনার AI প্রদানকারীর কোনো API কী সেট নেই। চালিয়ে যেতে প্রদানকারী সেটিংসে একটি যোগ করুন।', 'userErrors.scope.chat': 'চ্যাট', + 'userErrors.scope.cron': 'নির্ধারিত কাজ', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'পরিমাণ', 'agentWorld.trading.networkLabel': 'নেটওয়ার্ক', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index d7e681f62..2bf0e8f2e 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -6422,7 +6422,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'Anbieter-Guthaben erforderlich', 'userErrors.insufficientCredits.body': 'Deinem KI-Anbieter ist das Guthaben ausgegangen. Lade es auf oder aktualisiere den Schlüssel.', + 'userErrors.apiKeyMissing.title': 'API-Schlüssel erforderlich', + 'userErrors.apiKeyMissing.body': + 'Für deinen KI-Anbieter ist kein API-Schlüssel hinterlegt. Füge in den Anbietereinstellungen einen hinzu, um fortzufahren.', 'userErrors.scope.chat': 'Chat', + 'userErrors.scope.cron': 'Geplante Aufgabe', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Betrag', 'agentWorld.trading.networkLabel': 'Netzwerk', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 6a84623ae..628deb542 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -6559,7 +6559,11 @@ const en: TranslationMap = { 'userErrors.insufficientCredits.title': 'Provider credits required', 'userErrors.insufficientCredits.body': 'Your AI provider is out of credits. Top it up or update its API key to continue.', + 'userErrors.apiKeyMissing.title': 'API key required', + 'userErrors.apiKeyMissing.body': + 'Your AI provider has no API key set. Add one in provider settings to continue.', 'userErrors.scope.chat': 'Chat', + 'userErrors.scope.cron': 'Scheduled job', }; export default en; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 7290b6232..49c5ac814 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -6381,7 +6381,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'Se requieren créditos del proveedor', 'userErrors.insufficientCredits.body': 'Tu proveedor de IA se quedó sin créditos. Recárgalo o actualiza su clave de API.', + 'userErrors.apiKeyMissing.title': 'Se requiere clave de API', + 'userErrors.apiKeyMissing.body': + 'Tu proveedor de IA no tiene una clave de API configurada. Añade una en los ajustes del proveedor para continuar.', 'userErrors.scope.chat': 'Chat', + 'userErrors.scope.cron': 'Tarea programada', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Importe', 'agentWorld.trading.networkLabel': 'Red', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 15a0c5014..a1a2c5079 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -6401,7 +6401,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'Crédits du fournisseur requis', 'userErrors.insufficientCredits.body': "Votre fournisseur IA n'a plus de crédits. Rechargez-le ou mettez à jour sa clé API.", + 'userErrors.apiKeyMissing.title': 'Clé API requise', + 'userErrors.apiKeyMissing.body': + "Aucune clé API n'est définie pour votre fournisseur d'IA. Ajoutez-en une dans les paramètres du fournisseur pour continuer.", 'userErrors.scope.chat': 'Chat', + 'userErrors.scope.cron': 'Tâche planifiée', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Montant', 'agentWorld.trading.networkLabel': 'Réseau', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index d155476f5..c3bc28f17 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -6253,7 +6253,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'प्रदाता क्रेडिट आवश्यक', 'userErrors.insufficientCredits.body': 'AI प्रदाता के क्रेडिट समाप्त। रिचार्ज करें या API कुंजी बदलें।', + 'userErrors.apiKeyMissing.title': 'API कुंजी आवश्यक', + 'userErrors.apiKeyMissing.body': + 'आपके AI प्रदाता के लिए कोई API कुंजी सेट नहीं है। जारी रखने के लिए प्रदाता सेटिंग्स में एक जोड़ें।', 'userErrors.scope.chat': 'चैट', + 'userErrors.scope.cron': 'निर्धारित कार्य', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'राशि', 'agentWorld.trading.networkLabel': 'नेटवर्क', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e06cba750..db07382e0 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -6275,7 +6275,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'Kredit penyedia diperlukan', 'userErrors.insufficientCredits.body': 'Penyedia AI Anda kehabisan kredit. Isi ulang atau perbarui kunci API-nya.', + 'userErrors.apiKeyMissing.title': 'Kunci API diperlukan', + 'userErrors.apiKeyMissing.body': + 'Penyedia AI Anda belum memiliki kunci API. Tambahkan satu di pengaturan penyedia untuk melanjutkan.', 'userErrors.scope.chat': 'Obrolan', + 'userErrors.scope.cron': 'Tugas terjadwal', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Jumlah', 'agentWorld.trading.networkLabel': 'Jaringan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index b3f22eab9..dd6b302d8 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -6368,7 +6368,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'Crediti del provider necessari', 'userErrors.insufficientCredits.body': 'Il tuo provider IA ha esaurito i crediti. Ricaricalo o aggiorna la chiave API.', + 'userErrors.apiKeyMissing.title': 'Chiave API richiesta', + 'userErrors.apiKeyMissing.body': + 'Il tuo provider IA non ha una chiave API impostata. Aggiungine una nelle impostazioni del provider per continuare.', 'userErrors.scope.chat': 'Chat', + 'userErrors.scope.cron': 'Attività pianificata', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Importo', 'agentWorld.trading.networkLabel': 'Rete', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index a0d7d097c..f97469c21 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -6189,7 +6189,11 @@ const messages: TranslationMap = { 'userErrors.budgetExceeded.body': '관리형 AI 예산이 모두 소진되었습니다.', 'userErrors.insufficientCredits.title': '제공업체 크레딧 필요', 'userErrors.insufficientCredits.body': 'AI 제공업체 크레딧이 소진되었습니다.', + 'userErrors.apiKeyMissing.title': 'API 키 필요', + 'userErrors.apiKeyMissing.body': + 'AI 제공업체에 API 키가 설정되지 않았습니다. 제공업체 설정에서 추가하세요.', 'userErrors.scope.chat': '채팅', + 'userErrors.scope.cron': '예약된 작업', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': '금액', 'agentWorld.trading.networkLabel': '네트워크', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f1aafcb6e..28fa9c809 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -6343,7 +6343,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'Wymagane środki u dostawcy', 'userErrors.insufficientCredits.body': 'Twój dostawca AI nie ma już środków. Doładuj je lub zaktualizuj klucz API.', + 'userErrors.apiKeyMissing.title': 'Wymagany klucz API', + 'userErrors.apiKeyMissing.body': + 'Twój dostawca AI nie ma ustawionego klucza API. Dodaj go w ustawieniach dostawcy, aby kontynuować.', 'userErrors.scope.chat': 'Czat', + 'userErrors.scope.cron': 'Zaplanowane zadanie', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Kwota', 'agentWorld.trading.networkLabel': 'Sieć', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index ec5d1fe37..f79901498 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -6360,7 +6360,11 @@ const messages: TranslationMap = { 'userErrors.insufficientCredits.title': 'Créditos do provedor necessários', 'userErrors.insufficientCredits.body': 'Seu provedor de IA ficou sem créditos. Recarregue-o ou atualize a chave de API.', + 'userErrors.apiKeyMissing.title': 'Chave de API necessária', + 'userErrors.apiKeyMissing.body': + 'Seu provedor de IA não tem uma chave de API definida. Adicione uma nas configurações do provedor para continuar.', 'userErrors.scope.chat': 'Chat', + 'userErrors.scope.cron': 'Tarefa agendada', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Valor', 'agentWorld.trading.networkLabel': 'Rede', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index f58a8fdca..bf9bdb580 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -6316,7 +6316,11 @@ const messages: TranslationMap = { 'userErrors.budgetExceeded.body': 'Управляемый бюджет ИИ исчерпан. Измените план.', 'userErrors.insufficientCredits.title': 'Требуются кредиты провайдера', 'userErrors.insufficientCredits.body': 'У провайдера закончились кредиты. Пополните их.', + 'userErrors.apiKeyMissing.title': 'Требуется ключ API', + 'userErrors.apiKeyMissing.body': + 'У провайдера ИИ не задан ключ API. Добавьте его в настройках провайдера.', 'userErrors.scope.chat': 'Чат', + 'userErrors.scope.cron': 'Запланированная задача', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': 'Сумма', 'agentWorld.trading.networkLabel': 'Сеть', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 684b162b2..585d42dba 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -5931,7 +5931,10 @@ const messages: TranslationMap = { 'userErrors.budgetExceeded.body': '托管 AI 预算已用尽,请增加预算或更改套餐。', 'userErrors.insufficientCredits.title': '需要提供商额度', 'userErrors.insufficientCredits.body': '提供商额度已用完,请充值或更新 API 密钥。', + 'userErrors.apiKeyMissing.title': '需要 API 密钥', + 'userErrors.apiKeyMissing.body': '您的 AI 提供商未设置 API 密钥,请在提供商设置中添加以继续。', 'userErrors.scope.chat': '聊天', + 'userErrors.scope.cron': '定时任务', // Agent World — Identity trading (confirm-before-spend + balance gate) 'agentWorld.trading.amountLabel': '金额', 'agentWorld.trading.networkLabel': '网络', diff --git a/app/src/lib/userErrors/__tests__/classify.test.ts b/app/src/lib/userErrors/__tests__/classify.test.ts index 7b9309336..771840985 100644 --- a/app/src/lib/userErrors/__tests__/classify.test.ts +++ b/app/src/lib/userErrors/__tests__/classify.test.ts @@ -35,6 +35,31 @@ describe('classifyUserActionableError', () => { expect(a?.kind).toBe('budget_exceeded'); }); + it('classifies a configured provider with no API key (cron user_error kind token)', () => { + // Core emits the stable kind token in error_type — classify must accept it. + const a = classifyUserActionableError({ errorType: 'api_key_missing', scope: 'cron' }); + expect(a?.kind).toBe('api_key_missing'); + expect(a?.scope).toBe('cron'); + expect(a?.action).toBe('open_provider_settings'); + expect(a?.titleKey).toBe('userErrors.apiKeyMissing.title'); + + // …and the verbatim credential-guard prose (mirrors Rust is_api_key_unset_message). + for (const msg of [ + 'openrouter: API key not set', + 'Missing API key for provider', + 'No API key is configured', + 'no api key supplied', + ]) { + expect(classifyUserActionableError({ message: msg })?.kind).toBe('api_key_missing'); + } + }); + + it('does NOT classify an invalid/rejected API key (401) as missing-key', () => { + // A present-but-rejected key is a different state — must not be promoted. + expect(classifyUserActionableError({ message: 'Invalid API key (401)' })).toBeNull(); + expect(classifyUserActionableError({ message: 'Incorrect API key provided' })).toBeNull(); + }); + it('returns null for generic / non-actionable errors and empty input', () => { expect(classifyUserActionableError({ message: GENERIC_MSG })).toBeNull(); expect(classifyUserActionableError({ message: '', errorType: 'inference' })).toBeNull(); diff --git a/app/src/lib/userErrors/classify.ts b/app/src/lib/userErrors/classify.ts index 0fac9cc1b..8f7b6f95c 100644 --- a/app/src/lib/userErrors/classify.ts +++ b/app/src/lib/userErrors/classify.ts @@ -100,5 +100,31 @@ export function classifyUserActionableError( }; } + // Provider configured but no API key set — a deterministic credential-guard + // failure (no HTTP). Matches the stable `api_key_missing` kind token emitted + // by core (e.g. cron `user_error`) AND the verbatim guard prose, mirroring + // the Rust single-source matcher `is_api_key_unset_message` (observability.rs) + // so a wording drift on either side fails its own test rather than silently + // dropping the signal (TAURI-RUST-HCK / #4165). + const isApiKeyMissing = + text.includes('api_key_missing') || + text.includes('api key not set') || + text.includes('missing api key') || + text.includes('no api key is configured') || + text.includes('no api key supplied'); + if (isApiKeyMissing) { + return { + id: userErrorId('api_key_missing', scope, signal.provider), + kind: 'api_key_missing', + severity: 'warning', + scope, + sourceDomain: signal.sourceDomain, + provider: signal.provider, + titleKey: 'userErrors.apiKeyMissing.title', + bodyKey: 'userErrors.apiKeyMissing.body', + action: 'open_provider_settings', + }; + } + return null; } diff --git a/app/src/services/__tests__/socketService.events.test.ts b/app/src/services/__tests__/socketService.events.test.ts index d83ad364a..cea4b144d 100644 --- a/app/src/services/__tests__/socketService.events.test.ts +++ b/app/src/services/__tests__/socketService.events.test.ts @@ -53,6 +53,12 @@ vi.mock('../coreRpcClient', () => ({ getCoreRpcToken: vi.fn(async () => 'mock-core-bearer'), })); +// Capture the metadata-only ingest the `user_error` handler routes through. +const ingestRuntimeErrorSignalMock = vi.fn(); +vi.mock('../../lib/userErrors/report', () => ({ + ingestRuntimeErrorSignal: (...args: unknown[]) => ingestRuntimeErrorSignalMock(...args), +})); + /** Build a mock socket that captures event handlers in `handlers`. */ function buildMockSocket(): { handlers: EventHandlerMap; mockSocket: object } { const handlers: EventHandlerMap = {}; @@ -333,4 +339,73 @@ describe('socketService — agent_meetings event handlers (lines 428-480)', () = expect.objectContaining({ payload: { error: 'bot crashed' } }) ); }); + + it('routes a "user_error" broadcast through the metadata-only ingest (#4165)', async () => { + const { handlers, mockSocket } = buildMockSocket(); + + vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); + getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + ingestRuntimeErrorSignalMock.mockClear(); + + const { socketService } = await import('../socketService'); + socketService.connect('jwt-test-user-error'); + + await pollUntil(() => expect(handlers['user_error']).toBeDefined()); + + // Stable kind token + scope only — never a raw provider body. The fixture + // deliberately includes a raw `message` to prove the handler drops it + // (no-leak contract), not just that it omits it by default. + handlers['user_error']!({ + error_type: 'api_key_missing', + error_source: 'cron', + error_provider: 'openrouter', + message: 'raw upstream provider text', + }); + + expect(ingestRuntimeErrorSignalMock).toHaveBeenCalledTimes(1); + const signal = ingestRuntimeErrorSignalMock.mock.calls[0]?.[1] as Record; + expect(signal).toMatchObject({ + errorType: 'api_key_missing', + scope: 'cron', + sourceDomain: 'cron', + provider: 'openrouter', + }); + // No-leak contract: the raw provider `message` must NEVER be forwarded. + expect(signal.message).toBeUndefined(); + }); + + it('defaults "user_error" sourceDomain to cron when error_source is absent (#4165)', async () => { + const { handlers, mockSocket } = buildMockSocket(); + + vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); + getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + ingestRuntimeErrorSignalMock.mockClear(); + + const { socketService } = await import('../socketService'); + socketService.connect('jwt-test-user-error-default'); + + await pollUntil(() => expect(handlers['user_error']).toBeDefined()); + handlers['user_error']!({ error_type: 'insufficient_credits' }); + + expect(ingestRuntimeErrorSignalMock).toHaveBeenCalledWith( + storeMock.dispatch, + expect.objectContaining({ errorType: 'insufficient_credits', sourceDomain: 'cron' }) + ); + }); + + it('drops an empty "user_error" payload without ingesting (#4165)', async () => { + const { handlers, mockSocket } = buildMockSocket(); + + vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); + getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + ingestRuntimeErrorSignalMock.mockClear(); + + const { socketService } = await import('../socketService'); + socketService.connect('jwt-test-user-error-empty'); + + await pollUntil(() => expect(handlers['user_error']).toBeDefined()); + handlers['user_error']!(null); + + expect(ingestRuntimeErrorSignalMock).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts index c39ea0bd6..450d251b7 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -3,6 +3,7 @@ import { type Socket } from 'socket.io-client'; import { getCoreStateSnapshot } from '../lib/coreState/store'; import { SocketIOMCPTransportImpl } from '../lib/mcp'; +import { ingestRuntimeErrorSignal } from '../lib/userErrors/report'; import { store } from '../store'; import { setBackendMeetError, @@ -430,6 +431,37 @@ class SocketService { store.dispatch(setCompanionState(event)); }); + // Permanent user-config / billing failures surfaced from background jobs + // (e.g. cron) — core broadcasts these to the "system" room as a + // metadata-only `user_error` event carrying a stable kind token in + // `error_type` (never a raw provider body). Routed through the same + // classifier the chat runtime uses so the UserErrorCenter renders them + // durably with a deep-link action, even though no chat thread is active. + // Producer: core cron scheduler `publish_cron_user_error` (#4165 / + // TAURI-RUST-HCK follow-up). + this.socket.on('user_error', (data: unknown) => { + const obj = data as Record | null; + if (!obj) { + socketWarn('user_error dropped — empty payload'); + return; + } + const errorType = typeof obj.error_type === 'string' ? obj.error_type : undefined; + const provider = typeof obj.error_provider === 'string' ? obj.error_provider : undefined; + const sourceDomain = typeof obj.error_source === 'string' ? obj.error_source : 'cron'; + socketLog('user_error kind=%s source=%s', errorType ?? 'none', sourceDomain); + // Metadata-only ingest: forward the stable kind token + scope ONLY, never + // a raw `message` body. The cron producer already omits it, but we drop + // any `obj.message` here too so a future/buggy broadcast can't leak raw + // provider text into the UI — classify() keys on `errorType` for this + // path. Locks the no-leak contract FE-side (CodeRabbit #4169). + ingestRuntimeErrorSignal(store.dispatch, { + errorType, + scope: 'cron', + sourceDomain, + provider, + }); + }); + // Backend Meet bot events — forwarded from core's DomainEvent bus this.socket.on('agent_meetings:joined', (data: unknown) => { const obj = data as Record | null; diff --git a/app/src/types/userError.ts b/app/src/types/userError.ts index d8007c54e..87e4f9c6c 100644 --- a/app/src/types/userError.ts +++ b/app/src/types/userError.ts @@ -14,7 +14,7 @@ */ /** Stable discriminator the UI branches on. Extend as new states are added. */ -export type UserErrorKind = 'insufficient_credits' | 'budget_exceeded'; +export type UserErrorKind = 'insufficient_credits' | 'budget_exceeded' | 'api_key_missing'; /** Where the failure originated, for grouping/labelling (privacy-safe). */ export type UserErrorScope = 'chat' | 'cron' | 'provider' | 'integration' | 'workspace'; diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 3a2dd4154..b31cdf592 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -579,12 +579,12 @@ async fn execute_job_with_retry( // Suppressed the retries-exhausted Sentry report for a permanent // user-config / billing state. Metadata-only breadcrumb so the // suppression is diagnosable in production without the raw provider body. - let reason = if credits_exhausted { - "insufficient_credits_402" + let (reason, user_error_kind) = if credits_exhausted { + ("insufficient_credits_402", "insufficient_credits") } else if budget_exhausted { - "budget_exhausted_400" + ("budget_exhausted_400", "budget_exceeded") } else { - "api_key_unset" + ("api_key_unset", "api_key_missing") }; log::debug!( "[cron] action=suppress_retries_exhausted_report reason={reason} job_id={} retries={}", @@ -598,6 +598,11 @@ async fn execute_job_with_retry( // surfaced here — only the `&'static str` constants from // `permanent_halt_message`. last_output = permanent_halt_message(credits_exhausted, budget_exhausted).to_string(); + // Also surface the actionable state to the UserErrorCenter so the user + // can fix it (add an API key / top up credits / raise the budget) even + // with no chat thread open. Broadcast-only + metadata-only — see + // `publish_cron_user_error` (#4165 / TAURI-RUST-HCK follow-up). + publish_cron_user_error(user_error_kind); } (false, last_output) @@ -618,6 +623,33 @@ fn permanent_halt_message(credits_exhausted: bool, budget_exhausted: bool) -> &' } } +/// Surface a permanent cron user-config / billing halt to every connected +/// client's UserErrorCenter. +/// +/// Broadcasts a metadata-only `user_error` web-channel event to the `"system"` +/// room (which every socket auto-joins). The payload carries only the stable +/// `kind` token in `error_type` — one of `api_key_missing` / `insufficient_credits` +/// / `budget_exceeded`, mirroring the frontend `UserErrorKind` discriminator — +/// plus `error_source = "cron"`. It NEVER carries the raw provider body (see the +/// metadata-only rule in CLAUDE.md), so no secrets / PII leave the core. +/// +/// The frontend `socketService` listens for `user_error` and routes it through +/// the same classifier the chat runtime uses, so a background (no-delivery) job +/// failure is no longer silent — it lands in the shell's UserErrorCenter with a +/// deep-link action even though no chat thread is active. +fn publish_cron_user_error(kind: &str) { + log::debug!("[cron] action=surface_user_error kind={kind}"); + crate::openhuman::channels::providers::web::publish_web_channel_event( + crate::core::socketio::WebChannelEvent { + event: "user_error".to_string(), + client_id: "system".to_string(), + error_type: Some(kind.to_string()), + error_source: Some("cron".to_string()), + ..Default::default() + }, + ); +} + async fn process_due_jobs(config: &Config, security: &Arc, jobs: Vec) { let max_concurrent = config.scheduler.max_concurrent.max(1); let mut in_flight = stream::iter(jobs.into_iter().map(|job| { diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index 73e99aa40..98faf9c11 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -1680,3 +1680,62 @@ async fn deliver_if_configured_empty_success_skips_chat_and_alert() { assert!(deliver_if_configured(&config, &job, "", true).await.is_ok()); assert_eq!(cron_alerts(&config).await, 0); } + +/// Receive the next `user_error` broadcast on `rx` carrying `kind`, skipping any +/// unrelated events. The web-channel bus is a process-global broadcast, so a +/// sibling test running concurrently may interleave its own `user_error` (a +/// different kind) onto the same channel — filtering on `kind` keeps each test +/// deterministic regardless of ordering. +/// +/// A concurrent flood can also push our event past the channel capacity before +/// we read it, surfacing as `Lagged` (the receiver fell behind, not a real +/// absence). We treat `Lagged` as recoverable and keep scanning (CodeRabbit +/// #4169); only a terminal `Empty`/`Closed` — the matching event genuinely was +/// not published — panics. +fn next_user_error( + rx: &mut tokio::sync::broadcast::Receiver, + kind: &str, +) -> crate::core::socketio::WebChannelEvent { + use tokio::sync::broadcast::error::TryRecvError; + loop { + match rx.try_recv() { + Ok(ev) if ev.event == "user_error" && ev.error_type.as_deref() == Some(kind) => { + break ev + } + Ok(_) => continue, + // Receiver fell behind a concurrent flood — the dropped slots can't + // have held *our* just-published event before this point, so skip + // ahead and keep scanning rather than failing spuriously. + Err(TryRecvError::Lagged(_)) => continue, + Err(e) => panic!("expected a user_error broadcast for kind={kind}, bus said: {e:?}"), + } + } +} + +#[test] +fn publish_cron_user_error_broadcasts_metadata_only_for_each_kind() { + use crate::openhuman::channels::providers::web::subscribe_web_channel_events; + + // Folded from two tests that both published `api_key_missing` to the + // process-global bus and could false-pass off each other's broadcast under + // parallel execution (CodeRabbit #4169). One subscription + serialized + // publishes means each assertion can only be satisfied by THIS test's own + // emission, so a regression in `publish_cron_user_error` actually fails. + // The three tokens are exactly the `UserErrorKind` values classify.ts accepts. + let mut rx = subscribe_web_channel_events(); + for kind in ["insufficient_credits", "budget_exceeded", "api_key_missing"] { + publish_cron_user_error(kind); + let ev = next_user_error(&mut rx, kind); + // Broadcast to the "system" room every connected socket auto-joins. + assert_eq!(ev.client_id, "system"); + // Stable kind token mirrors the frontend `UserErrorKind` discriminator. + assert_eq!(ev.error_type.as_deref(), Some(kind)); + assert_eq!(ev.error_source.as_deref(), Some("cron")); + // Metadata-only: a `user_error` NEVER carries the raw provider body + // (CLAUDE.md) and is thread-less (no chat context). + assert!(ev.message.is_none(), "user_error must not carry a raw body"); + assert!(ev.full_response.is_none()); + assert!(ev.thread_id.is_empty(), "cron user_error is thread-less"); + assert!(ev.request_id.is_empty()); + } +}