mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -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': 'الشبكة',
|
||||
|
||||
@@ -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': 'নেটওয়ার্ক',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'नेटवर्क',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '네트워크',
|
||||
|
||||
@@ -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ć',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Сеть',
|
||||
|
||||
@@ -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': '网络',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | null;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<SecurityPolicy>, jobs: Vec<CronJob>) {
|
||||
let max_concurrent = config.scheduler.max_concurrent.max(1);
|
||||
let mut in_flight = stream::iter(jobs.into_iter().map(|job| {
|
||||
|
||||
@@ -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<crate::core::socketio::WebChannelEvent>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user