mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix: Sentry triage — resolve 16 actionable issues across tauri-react, tauri-rust, core-rust (#5171)
Co-authored-by: Maciej Myszkiewicz <mmyszkiewicz@bwcoders.com>
This commit is contained in:
co-authored by
Maciej Myszkiewicz
parent
75e212c40b
commit
55d3ab5ccc
@@ -1 +1 @@
|
||||
11ef51edbeadcca4517b18a037fff858a5dfae0f
|
||||
f09d7e746a44a5187d2abc116e8a4cd7a9f94a67
|
||||
|
||||
@@ -2564,6 +2564,24 @@ pub fn run() {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Defense-in-depth: drop Windows `ERROR_FILE_SYSTEM_LIMITATION`
|
||||
// (os error 665) — a persistent host-filesystem condition with
|
||||
// zero local lever and no Sentry remediation path. The Tauri
|
||||
// shell is a separate crate from the core, so the core's emit-site
|
||||
// classifier (`expected_error_kind`) can only catch events that
|
||||
// originate inside the core binary. Any filesystem-error event
|
||||
// that starts in the shell (e.g. file_logging, window_state,
|
||||
// CEF profile I/O) bypasses the core classifier and lands here;
|
||||
// this filter is the only net for those events (TAURI-RUST-QT0:
|
||||
// 6,050 events / 1 user).
|
||||
if openhuman_core::core::observability::is_windows_file_system_limitation_event(&event)
|
||||
{
|
||||
log::debug!(
|
||||
"[sentry-fs-limitation-filter] dropping Windows file-system-limitation event (os error 665) event_id={:?}",
|
||||
event.event_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Strip server_name (hostname) to avoid leaking machine identity.
|
||||
event.server_name = None;
|
||||
// Attach the cached account uid so Sentry can count unique users
|
||||
|
||||
Vendored
+1
-1
Submodule app/src-tauri/vendor/tauri-cef updated: 11ef51edbe...f09d7e746a
@@ -8,4 +8,4 @@ Short UI chimes for the push-to-talk feature (`docs/superpowers/specs/2026-06-02
|
||||
| `ptt-close.wav` | Mic closed (PTT key released). | Generated locally with Python `wave` + sine generator (1200–800 Hz sweep). | CC0 / Public Domain. |
|
||||
| `ptt-error.wav` | Session aborted (empty audio, mic permission denied, etc.). | Generated locally with Python `wave` + sine generator (250 Hz tone). | CC0 / Public Domain. |
|
||||
|
||||
All clips are ~80–120ms, LUFS-normalized to roughly match the in-app notification sound (~ -16 LUFS). Replace freely with better-sounding equivalents — just keep them under 200ms and CC0/MIT-equivalent.
|
||||
All clips are ~~80–120ms, LUFS-normalized to roughly match the in-app notification sound (~ -16 LUFS)~~. Replace freely with better-sounding equivalents — just keep them under 200ms and CC0/MIT-equivalent.
|
||||
|
||||
@@ -16,7 +16,8 @@ export type ChatSendErrorCode =
|
||||
| 'usage_limit_reached'
|
||||
| 'prompt_blocked'
|
||||
| 'prompt_review'
|
||||
| 'attachment_invalid';
|
||||
| 'attachment_invalid'
|
||||
| 'create_thread_failed';
|
||||
|
||||
export interface ChatSendError {
|
||||
code: ChatSendErrorCode;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { ChatSendError } from '../../chat/chatSendError';
|
||||
import type { Attachment } from '../../lib/attachments';
|
||||
@@ -8,6 +8,14 @@ import AttachmentPreview from './AttachmentPreview';
|
||||
/** Max composer height ≈ 8 lines of text-sm + padding. */
|
||||
const COMPOSER_MAX_HEIGHT = 192;
|
||||
|
||||
/**
|
||||
* Render-loop guard threshold: if the component re-renders this many times
|
||||
* within a single microtick, we log a warning to help diagnose "Maximum update
|
||||
* depth exceeded" issues (TAURI-REACT-2G). React's own limit is 50; we warn at
|
||||
* a lower threshold so developers catch it before the crash.
|
||||
*/
|
||||
const RENDER_LOOP_WARN_THRESHOLD = 30;
|
||||
|
||||
export interface ChatComposerProps {
|
||||
inputValue: string;
|
||||
setInputValue: (value: string | ((prev: string) => string)) => void;
|
||||
@@ -101,6 +109,25 @@ export default function ChatComposer({
|
||||
const { t } = useT();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Render-loop detection guard: tracks re-renders within a single microtick
|
||||
// and warns when the count exceeds a safe threshold, helping diagnose
|
||||
// "Maximum update depth exceeded" issues (TAURI-REACT-2G).
|
||||
const renderCountRef = useRef(0);
|
||||
const renderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
renderCountRef.current += 1;
|
||||
if (renderCountRef.current >= RENDER_LOOP_WARN_THRESHOLD) {
|
||||
console.warn(
|
||||
`[ChatComposer] Render-loop detected: ${renderCountRef.current} renders in a single tick. ` +
|
||||
'Check whether a parent onChange/setState cycle is causing cascading re-renders.'
|
||||
);
|
||||
}
|
||||
if (renderTimerRef.current === null) {
|
||||
renderTimerRef.current = setTimeout(() => {
|
||||
renderCountRef.current = 0;
|
||||
renderTimerRef.current = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// While a turn streams (`allowParallelSend`) the composer stays usable for a
|
||||
// queued follow-up / parallel branch, so the in-flight `isSending` spinner
|
||||
// and lock no longer apply — only real typed content gates the send button.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { createRef, useEffect, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Attachment } from '../../../lib/attachments';
|
||||
@@ -242,6 +242,135 @@ describe('ChatComposer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('render-loop detection guard', () => {
|
||||
it('does not warn under normal rendering', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
renderComposer();
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
// Normal rendering should not trigger the render-loop guard.
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Render-loop detected'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('warns when render-loop threshold is exceeded', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const textInputRef = createRef<HTMLTextAreaElement | null>();
|
||||
const fileInputRef = createRef<HTMLInputElement | null>();
|
||||
const isComposingTextRef = { current: false };
|
||||
// This test harness simulates a render loop by calling setState
|
||||
// from a useEffect, exceeding the guard's 30-render threshold
|
||||
// without triggering React's native "too many re-renders" error.
|
||||
function LoopHarness() {
|
||||
const [count, setCount] = useState(0);
|
||||
const [text, setText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (count < 35) {
|
||||
setCount(count + 1);
|
||||
}
|
||||
}, [count, setCount]);
|
||||
|
||||
return (
|
||||
<ChatComposer
|
||||
inputValue={text}
|
||||
setInputValue={v => setText(typeof v === 'function' ? v('') : v)}
|
||||
onSend={vi.fn().mockResolvedValue(undefined)}
|
||||
textInputRef={textInputRef}
|
||||
fileInputRef={fileInputRef}
|
||||
composerInteractionBlocked={false}
|
||||
isSending={false}
|
||||
attachments={[]}
|
||||
onAttachFiles={vi.fn().mockResolvedValue(undefined)}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
attachError={null}
|
||||
onSwitchToMicCloud={vi.fn()}
|
||||
handleInputKeyDown={vi.fn()}
|
||||
inlineCompletionSuffix=""
|
||||
isComposingTextRef={isComposingTextRef}
|
||||
maxAttachments={5}
|
||||
allowedMimeTypes={[]}
|
||||
attachmentsEnabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<LoopHarness />);
|
||||
|
||||
const loopCalls = warnSpy.mock.calls.filter(
|
||||
args => typeof args[0] === 'string' && args[0].includes('Render-loop detected')
|
||||
);
|
||||
expect(loopCalls.length).toBeGreaterThan(0);
|
||||
expect(loopCalls[0][0]).toContain('ChatComposer');
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('recovers from a temporary loop (counter resets between ticks)', async () => {
|
||||
// After a render loop ends, the setTimeout(0) in the guard should reset
|
||||
// the counter, so a subsequent normal render doesn't trigger a warning.
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const textInputRef = createRef<HTMLTextAreaElement | null>();
|
||||
const fileInputRef = createRef<HTMLInputElement | null>();
|
||||
const isComposingTextRef = { current: false };
|
||||
|
||||
// First, render normally.
|
||||
const { rerender } = render(
|
||||
<ChatComposer
|
||||
inputValue=""
|
||||
setInputValue={vi.fn()}
|
||||
onSend={vi.fn().mockResolvedValue(undefined)}
|
||||
textInputRef={textInputRef}
|
||||
fileInputRef={fileInputRef}
|
||||
composerInteractionBlocked={false}
|
||||
isSending={false}
|
||||
attachments={[]}
|
||||
onAttachFiles={vi.fn().mockResolvedValue(undefined)}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
attachError={null}
|
||||
onSwitchToMicCloud={vi.fn()}
|
||||
handleInputKeyDown={vi.fn()}
|
||||
inlineCompletionSuffix=""
|
||||
isComposingTextRef={isComposingTextRef}
|
||||
maxAttachments={5}
|
||||
allowedMimeTypes={[]}
|
||||
attachmentsEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for the setTimeout(0) reset to fire.
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
// Then render again — should not warn because the counter was reset.
|
||||
rerender(
|
||||
<ChatComposer
|
||||
inputValue="hello"
|
||||
setInputValue={vi.fn()}
|
||||
onSend={vi.fn().mockResolvedValue(undefined)}
|
||||
textInputRef={textInputRef}
|
||||
fileInputRef={fileInputRef}
|
||||
composerInteractionBlocked={false}
|
||||
isSending={false}
|
||||
attachments={[]}
|
||||
onAttachFiles={vi.fn().mockResolvedValue(undefined)}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
attachError={null}
|
||||
onSwitchToMicCloud={vi.fn()}
|
||||
handleInputKeyDown={vi.fn()}
|
||||
inlineCompletionSuffix=""
|
||||
isComposingTextRef={isComposingTextRef}
|
||||
maxAttachments={5}
|
||||
allowedMimeTypes={[]}
|
||||
attachmentsEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const loopCalls = warnSpy.mock.calls.filter(
|
||||
args => typeof args[0] === 'string' && args[0].includes('Render-loop detected')
|
||||
);
|
||||
expect(loopCalls).toHaveLength(0);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('follow-up / parallel mode (allowParallelSend during a streaming turn)', () => {
|
||||
it('keeps the textarea editable even while an in-flight turn is sending', () => {
|
||||
renderComposer({
|
||||
|
||||
@@ -266,6 +266,13 @@ const Conversations = ({
|
||||
const [sendError, setSendError] = useState<ChatSendError | null>(null);
|
||||
const [attachError, setAttachError] = useState<ChatSendError | null>(null);
|
||||
const [sendAdvisory, setSendAdvisory] = useState<string | null>(null);
|
||||
// Refs mirroring error/advisory state for effects that read them without
|
||||
// depending on them, preventing the classic "effect→setState→re-fire" cascade
|
||||
// that contributes to "Maximum update depth exceeded" (TAURI-REACT-2G).
|
||||
const sendErrorRef = useRef(sendError);
|
||||
sendErrorRef.current = sendError;
|
||||
const sendAdvisoryRef = useRef(sendAdvisory);
|
||||
sendAdvisoryRef.current = sendAdvisory;
|
||||
const [openRouterStatus, setOpenRouterStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
// Threads whose send is mid-flight (dispatched locally, backend not yet
|
||||
// accepted). A Set so concurrent sends to different threads each track their
|
||||
@@ -477,14 +484,19 @@ const Conversations = ({
|
||||
typeof navigator.mediaDevices.getUserMedia === 'function';
|
||||
|
||||
const handleCreateNewThread = async () => {
|
||||
const thread = await dispatch(createNewThread()).unwrap();
|
||||
dispatch(setSelectedThread(thread.id));
|
||||
void dispatch(loadThreadMessages(thread.id));
|
||||
if (shouldSyncChatRoute) {
|
||||
debug('[chat][route] created thread thread=%s navigate=true', thread.id);
|
||||
navigate(chatThreadPath(thread.id));
|
||||
} else {
|
||||
debug('[chat][route] created thread thread=%s navigate=false', thread.id);
|
||||
try {
|
||||
const thread = await dispatch(createNewThread()).unwrap();
|
||||
dispatch(setSelectedThread(thread.id));
|
||||
void dispatch(loadThreadMessages(thread.id));
|
||||
if (shouldSyncChatRoute) {
|
||||
debug('[chat][route] created thread thread=%s navigate=true', thread.id);
|
||||
navigate(chatThreadPath(thread.id));
|
||||
} else {
|
||||
debug('[chat][route] created thread thread=%s navigate=false', thread.id);
|
||||
}
|
||||
} catch (error) {
|
||||
debug('[chat] create thread failed: %O', error);
|
||||
setSendError(chatSendError('create_thread_failed', t('chat.createThreadFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -716,13 +728,17 @@ const Conversations = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sendError && inputValue.length > 0) {
|
||||
if (sendErrorRef.current && inputValue.length > 0) {
|
||||
setSendError(null);
|
||||
}
|
||||
if (sendAdvisory && inputValue.length > 0) {
|
||||
if (sendAdvisoryRef.current && inputValue.length > 0) {
|
||||
setSendAdvisory(null);
|
||||
}
|
||||
}, [inputValue, sendAdvisory, sendError]);
|
||||
// Reads sendError/sendAdvisory through refs to avoid re-firing when they
|
||||
// are cleared — which would cascade into extra render cycles and contribute
|
||||
// to "Maximum update depth exceeded" (TAURI-REACT-2G).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inputValue]);
|
||||
|
||||
const clearSilenceTimer = useCallback((threadId: string) => {
|
||||
const existing = sendingTimeoutsRef.current.get(threadId);
|
||||
|
||||
@@ -92,14 +92,19 @@ export function useThreadGoal(
|
||||
}
|
||||
}, [api, threadId]);
|
||||
|
||||
// Reset the editor + cached goal when the thread changes. Done during render
|
||||
// (React's sanctioned "reset state on prop change" pattern) rather than in an
|
||||
// effect, so it's synchronous and lint-clean.
|
||||
if (activeThread.current !== threadId) {
|
||||
activeThread.current = threadId;
|
||||
setExpanded(false);
|
||||
setGoal(null);
|
||||
}
|
||||
// Reset the editor + cached goal when the thread changes. Done in a useEffect
|
||||
// rather than during render to avoid React's nested-update detection, which
|
||||
// can cascade with concurrent state changes into a "Maximum update depth
|
||||
// exceeded" error (TAURI-REACT-2G).
|
||||
const prevThreadRef = useRef(threadId);
|
||||
useEffect(() => {
|
||||
if (prevThreadRef.current !== threadId) {
|
||||
prevThreadRef.current = threadId;
|
||||
activeThread.current = threadId; // keep the race guard in sync
|
||||
setExpanded(false);
|
||||
setGoal(null);
|
||||
}
|
||||
}, [threadId]);
|
||||
|
||||
// Fetch on mount/thread-change and poll lightly. `refresh` is async, so its
|
||||
// setState lands in a later microtask (not a synchronous effect write).
|
||||
|
||||
@@ -1018,6 +1018,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'متابعات في قائمة الانتظار',
|
||||
'chat.queuedFollowups.clear': 'مسح',
|
||||
'chat.queuedFollowups.clearFailed': 'تعذّر مسح القائمة: حاول مرة أخرى.',
|
||||
'chat.createThreadFailed': 'تعذّر إنشاء محادثة جديدة: حاول مرة أخرى.',
|
||||
'chat.parallelBranchLabel': 'فرع متوازٍ',
|
||||
'chat.thinking': 'جارٍ التفكير...',
|
||||
'chat.noMessages': 'لا توجد رسائل بعد',
|
||||
|
||||
@@ -1041,6 +1041,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'সারিবদ্ধ ফলো-আপ',
|
||||
'chat.queuedFollowups.clear': 'সাফ করুন',
|
||||
'chat.queuedFollowups.clearFailed': 'সারি সাফ করা যায়নি: আবার চেষ্টা করুন।',
|
||||
'chat.createThreadFailed': 'নতুন থ্রেড তৈরি করা যায়নি: আবার চেষ্টা করুন।',
|
||||
'chat.parallelBranchLabel': 'সমান্তরাল শাখা',
|
||||
'chat.thinking': 'ভাবছে...',
|
||||
'chat.noMessages': 'এখনো কোনো বার্তা নেই',
|
||||
|
||||
@@ -1085,6 +1085,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.clear': 'Löschen',
|
||||
'chat.queuedFollowups.clearFailed':
|
||||
'Warteschlange konnte nicht geleert werden – bitte erneut versuchen.',
|
||||
'chat.createThreadFailed': 'Neuer Thread konnte nicht erstellt werden – bitte erneut versuchen.',
|
||||
'chat.parallelBranchLabel': 'Paralleler Zweig',
|
||||
'chat.thinking': 'Denken...',
|
||||
'chat.noMessages': 'Noch keine Nachrichten',
|
||||
|
||||
@@ -822,6 +822,7 @@ const en: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'Queued follow-ups',
|
||||
'chat.queuedFollowups.clear': 'Clear',
|
||||
'chat.queuedFollowups.clearFailed': "Couldn't clear the queue. Try again.",
|
||||
'chat.createThreadFailed': "Couldn't create a new thread. Please try again.",
|
||||
'chat.parallelBranchLabel': 'Parallel branch',
|
||||
'chat.thinking': 'Thinking...',
|
||||
'chat.noMessages': 'No messages yet',
|
||||
|
||||
@@ -1066,6 +1066,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'Seguimientos en cola',
|
||||
'chat.queuedFollowups.clear': 'Borrar',
|
||||
'chat.queuedFollowups.clearFailed': 'No se pudo vaciar la cola: inténtalo de nuevo.',
|
||||
'chat.createThreadFailed': 'No se pudo crear un nuevo hilo. Inténtalo de nuevo.',
|
||||
'chat.parallelBranchLabel': 'Rama paralela',
|
||||
'chat.thinking': 'Pensando...',
|
||||
'chat.noMessages': 'Sin mensajes aún',
|
||||
|
||||
@@ -1078,6 +1078,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'Suivis en file',
|
||||
'chat.queuedFollowups.clear': 'Effacer',
|
||||
'chat.queuedFollowups.clearFailed': 'Impossible de vider la file: réessayez.',
|
||||
'chat.createThreadFailed': 'Impossible de créer un nouveau fil. Veuillez réessayer.',
|
||||
'chat.parallelBranchLabel': 'Branche parallèle',
|
||||
'chat.thinking': 'En train de réfléchir…',
|
||||
'chat.noMessages': "Aucun message pour l'instant",
|
||||
|
||||
@@ -1040,6 +1040,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'कतारबद्ध फ़ॉलो-अप',
|
||||
'chat.queuedFollowups.clear': 'साफ़ करें',
|
||||
'chat.queuedFollowups.clearFailed': 'कतार साफ़ नहीं हो सकी: फिर से प्रयास करें।',
|
||||
'chat.createThreadFailed': 'नया थ्रेड नहीं बनाया जा सका: कृपया पुनः प्रयास करें।',
|
||||
'chat.parallelBranchLabel': 'समानांतर शाखा',
|
||||
'chat.thinking': 'सोच रहा है...',
|
||||
'chat.noMessages': 'अभी कोई मैसेज नहीं',
|
||||
|
||||
@@ -1052,6 +1052,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'Tindak lanjut dalam antrean',
|
||||
'chat.queuedFollowups.clear': 'Hapus',
|
||||
'chat.queuedFollowups.clearFailed': 'Gagal mengosongkan antrean: coba lagi.',
|
||||
'chat.createThreadFailed': 'Gagal membuat thread baru: coba lagi.',
|
||||
'chat.parallelBranchLabel': 'Cabang paralel',
|
||||
'chat.thinking': 'Berpikir...',
|
||||
'chat.noMessages': 'Belum ada pesan',
|
||||
|
||||
@@ -1068,6 +1068,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'Follow-up in coda',
|
||||
'chat.queuedFollowups.clear': 'Cancella',
|
||||
'chat.queuedFollowups.clearFailed': 'Impossibile svuotare la coda: riprova.',
|
||||
'chat.createThreadFailed': 'Impossibile creare una nuova conversazione. Riprova.',
|
||||
'chat.parallelBranchLabel': 'Ramo parallelo',
|
||||
'chat.thinking': 'Sto pensando...',
|
||||
'chat.noMessages': 'Nessun messaggio',
|
||||
|
||||
@@ -1032,6 +1032,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': '대기 중인 후속 메시지',
|
||||
'chat.queuedFollowups.clear': '지우기',
|
||||
'chat.queuedFollowups.clearFailed': '대기열을 지우지 못했습니다: 다시 시도하세요.',
|
||||
'chat.createThreadFailed': '새 대화를 만들지 못했습니다: 다시 시도하세요.',
|
||||
'chat.parallelBranchLabel': '병렬 분기',
|
||||
'chat.thinking': '생각 중...',
|
||||
'chat.noMessages': '아직 메시지가 없습니다',
|
||||
|
||||
@@ -1060,6 +1060,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'Wiadomości w kolejce',
|
||||
'chat.queuedFollowups.clear': 'Wyczyść',
|
||||
'chat.queuedFollowups.clearFailed': 'Nie udało się wyczyścić kolejki: spróbuj ponownie.',
|
||||
'chat.createThreadFailed': 'Nie udało się utworzyć nowego wątku. Spróbuj ponownie.',
|
||||
'chat.parallelBranchLabel': 'Równoległa gałąź',
|
||||
'chat.thinking': 'Myślę...',
|
||||
'chat.noMessages': 'Brak wiadomości',
|
||||
|
||||
@@ -1062,6 +1062,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'Acompanhamentos na fila',
|
||||
'chat.queuedFollowups.clear': 'Limpar',
|
||||
'chat.queuedFollowups.clearFailed': 'Não foi possível limpar a fila: tente novamente.',
|
||||
'chat.createThreadFailed': 'Não foi possível criar uma nova conversa. Tente novamente.',
|
||||
'chat.parallelBranchLabel': 'Ramificação paralela',
|
||||
'chat.thinking': 'Pensando...',
|
||||
'chat.noMessages': 'Nenhuma mensagem ainda',
|
||||
|
||||
@@ -1053,6 +1053,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': 'Сообщения в очереди',
|
||||
'chat.queuedFollowups.clear': 'Очистить',
|
||||
'chat.queuedFollowups.clearFailed': 'Не удалось очистить очередь: попробуйте ещё раз.',
|
||||
'chat.createThreadFailed': 'Не удалось создать новый диалог. Попробуйте ещё раз.',
|
||||
'chat.parallelBranchLabel': 'Параллельная ветка',
|
||||
'chat.thinking': 'Думаю...',
|
||||
'chat.noMessages': 'Сообщений пока нет',
|
||||
|
||||
@@ -985,6 +985,7 @@ const messages: TranslationMap = {
|
||||
'chat.queuedFollowups.label': '排队的后续消息',
|
||||
'chat.queuedFollowups.clear': '清除',
|
||||
'chat.queuedFollowups.clearFailed': '无法清空队列:请重试。',
|
||||
'chat.createThreadFailed': '无法创建新会话。请重试。',
|
||||
'chat.parallelBranchLabel': '并行分支',
|
||||
'chat.thinking': '思考中...',
|
||||
'chat.noMessages': '暂无消息',
|
||||
|
||||
@@ -475,9 +475,9 @@ class Gradient {
|
||||
e(this, 'addIsLoadedClass', () => {
|
||||
/*this.isIntersecting && */ !this.isLoadedClass &&
|
||||
((this.isLoadedClass = !0),
|
||||
this.el.classList.add('isLoaded'),
|
||||
this.el && this.el.classList.add('isLoaded'),
|
||||
setTimeout(() => {
|
||||
this.el.parentElement.classList.add('isLoaded');
|
||||
this.el && this.el.parentElement && this.el.parentElement.classList.add('isLoaded');
|
||||
}, 3e3));
|
||||
}),
|
||||
e(this, 'pause', () => {
|
||||
@@ -623,11 +623,12 @@ class Gradient {
|
||||
}
|
||||
showGradientLegend() {
|
||||
this.width > this.minWidth &&
|
||||
((this.isGradientLegendVisible = !0), document.body.classList.add('isGradientLegendVisible'));
|
||||
((this.isGradientLegendVisible = !0),
|
||||
document.body && document.body.classList.add('isGradientLegendVisible'));
|
||||
}
|
||||
hideGradientLegend() {
|
||||
((this.isGradientLegendVisible = !1),
|
||||
document.body.classList.remove('isGradientLegendVisible'));
|
||||
document.body && document.body.classList.remove('isGradientLegendVisible'));
|
||||
}
|
||||
init() {
|
||||
try {
|
||||
|
||||
@@ -115,7 +115,15 @@ function destinationForElement(element: HTMLElement): string {
|
||||
function controlState(element: HTMLElement): string {
|
||||
if (element instanceof HTMLInputElement) {
|
||||
if (element.type === 'checkbox' || element.type === 'radio') {
|
||||
return element.checked ? 'checked' : 'unchecked';
|
||||
// Defensive guard: React 19's controlled-component state restoration
|
||||
// can briefly leave an input's DOM node in an inconsistent state where
|
||||
// `instanceof HTMLInputElement` passes but the element is disconnected
|
||||
// and its `checked` property access throws (see issue #5161).
|
||||
try {
|
||||
return element.checked ? 'checked' : 'unchecked';
|
||||
} catch {
|
||||
return 'changed';
|
||||
}
|
||||
}
|
||||
if (element.type === 'range') return 'changed';
|
||||
}
|
||||
|
||||
@@ -204,10 +204,42 @@ describe('desktopDeepLinkListener', () => {
|
||||
expect(storeSession).toHaveBeenCalledWith(
|
||||
'web-token',
|
||||
{},
|
||||
{ allowPendingBackendValidation: true }
|
||||
{ allowPendingBackendValidation: true, timeoutMs: 25_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('retries storeSession on timeout then succeeds on second attempt', async () => {
|
||||
vi.mocked(storeSession)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error('timed out'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const state = registerAuthDeepLinkState();
|
||||
const url = `openhuman://auth?token=retry-token&key=auth&state=${state}`;
|
||||
|
||||
vi.mocked(getCurrent).mockResolvedValue([url]);
|
||||
await setupDesktopDeepLinkListener();
|
||||
await waitForAuthSettled();
|
||||
|
||||
expect(storeSession).toHaveBeenCalledTimes(2);
|
||||
expect(getDeepLinkAuthState().errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT retry storeSession on non-timeout error', async () => {
|
||||
vi.mocked(storeSession).mockReset().mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const state = registerAuthDeepLinkState();
|
||||
const url = `openhuman://auth?token=no-retry-token&key=auth&state=${state}`;
|
||||
|
||||
vi.mocked(getCurrent).mockResolvedValue([url]);
|
||||
await setupDesktopDeepLinkListener();
|
||||
await waitForAuthSettled();
|
||||
|
||||
// Non-timeout errors should not be retried — only one call expected.
|
||||
expect(storeSession).toHaveBeenCalledTimes(1);
|
||||
expect(getDeepLinkAuthState().errorMessage).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an auth deep link whose state nonce does not match a pending one', async () => {
|
||||
registerAuthDeepLinkState('the-real-nonce');
|
||||
vi.mocked(getCurrent).mockResolvedValue([
|
||||
@@ -230,7 +262,11 @@ describe('desktopDeepLinkListener', () => {
|
||||
vi.mocked(getCurrent).mockResolvedValue([url]);
|
||||
await setupDesktopDeepLinkListener();
|
||||
await waitForAuthSettled();
|
||||
expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true });
|
||||
expect(storeSession).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
{},
|
||||
{ allowPendingBackendValidation: true, timeoutMs: 25_000 }
|
||||
);
|
||||
|
||||
// Replay the exact same deep link — the nonce was consumed, so it fails.
|
||||
vi.mocked(storeSession).mockClear();
|
||||
@@ -249,7 +285,8 @@ describe('desktopDeepLinkListener', () => {
|
||||
|
||||
const state = getDeepLinkAuthState();
|
||||
expect(state.requiresAppDataReset).toBe(false);
|
||||
expect(state.errorMessage).toBe('Sign-in failed. Please try again.');
|
||||
expect(state.errorMessage).toContain('did not respond');
|
||||
expect(state.errorMessage).toContain('restart');
|
||||
});
|
||||
|
||||
it('injection #1: store-time /auth/me failure bounces to signin — no session applied, no /home nav', async () => {
|
||||
@@ -275,14 +312,18 @@ describe('desktopDeepLinkListener', () => {
|
||||
await waitForAuthSettled();
|
||||
|
||||
// store WAS attempted (we reached the persistence call)...
|
||||
expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true });
|
||||
expect(storeSession).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
{},
|
||||
{ allowPendingBackendValidation: true, timeoutMs: 25_000 }
|
||||
);
|
||||
// ...but it FAILED, so the session-applied event was never dispatched...
|
||||
expect(sessionTokenUpdated).not.toHaveBeenCalled();
|
||||
// ...and we never navigated to /home (ProtectedRoute/PublicRoute keep signin).
|
||||
expect(window.location.hash).not.toBe('#/home');
|
||||
// Surfaced as the generic toast; processing cleared.
|
||||
const state = getDeepLinkAuthState();
|
||||
expect(state.errorMessage).toBe('Sign-in failed. Please try again.');
|
||||
expect(state.errorMessage).toContain('did not respond');
|
||||
expect(state.isProcessing).toBe(false);
|
||||
} finally {
|
||||
window.removeEventListener('core-state:session-token-updated', sessionTokenUpdated);
|
||||
@@ -315,7 +356,11 @@ describe('desktopDeepLinkListener', () => {
|
||||
resolveReadiness({ ready: true });
|
||||
await waitForAuthSettled();
|
||||
|
||||
expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true });
|
||||
expect(storeSession).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
{},
|
||||
{ allowPendingBackendValidation: true, timeoutMs: 25_000 }
|
||||
);
|
||||
expect(getDeepLinkAuthState().isProcessing).toBe(false);
|
||||
});
|
||||
|
||||
@@ -348,7 +393,11 @@ describe('desktopDeepLinkListener', () => {
|
||||
|
||||
expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1);
|
||||
expect(clearCoreRpcTokenCache).toHaveBeenCalledTimes(1);
|
||||
expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true });
|
||||
expect(storeSession).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
{},
|
||||
{ allowPendingBackendValidation: true, timeoutMs: 25_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT bust RPC caches before storeSession in local mode', async () => {
|
||||
@@ -360,7 +409,11 @@ describe('desktopDeepLinkListener', () => {
|
||||
|
||||
expect(clearCoreRpcUrlCache).not.toHaveBeenCalled();
|
||||
expect(clearCoreRpcTokenCache).not.toHaveBeenCalled();
|
||||
expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true });
|
||||
expect(storeSession).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
{},
|
||||
{ allowPendingBackendValidation: true, timeoutMs: 25_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches suppress-reauth before storeSession and clears it after in cloud mode', async () => {
|
||||
@@ -426,11 +479,13 @@ describe('authStoreFailureUserMessage (issue #3025)', () => {
|
||||
'other',
|
||||
];
|
||||
|
||||
// Local / unset mode always keeps the plain retry message — the failure there
|
||||
// is a transient embedded-core/backend blip that retrying can clear.
|
||||
// Local / unset mode should mention the retry attempt so the user knows
|
||||
// the system tried before giving up (issue #5166).
|
||||
it.each(['local', null] as const)('stays generic for mode=%s', mode => {
|
||||
for (const kind of CLOUD_KINDS) {
|
||||
expect(authStoreFailureUserMessage(kind, mode)).toBe('Sign-in failed. Please try again.');
|
||||
const msg = authStoreFailureUserMessage(kind, mode);
|
||||
expect(msg).toContain('retry');
|
||||
expect(msg).not.toContain('remote');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,66 @@ const focusMainWindow = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// The Rust core `auth_store_session` with `allowPendingBackendValidation: true`
|
||||
// connects to the backend `/auth/me` endpoint. The backend's HTTP client has
|
||||
// a 15s connect timeout + 120s request timeout. The frontend RPC timeout must
|
||||
// be long enough for the backend to establish a connection (15s) before the
|
||||
// first attempt fails, otherwise the retry loop never completes before the
|
||||
// backend responds. Set to 25s to cover the 15s connect window with headroom.
|
||||
const AUTH_STORE_TIMEOUT_MS = 25_000;
|
||||
const AUTH_STORE_RETRIES = 2;
|
||||
const AUTH_STORE_RETRY_BACKOFF_MS = 500;
|
||||
// The suppress-reauth window must cover the full worst-case retry duration:
|
||||
// AUTH_STORE_RETRIES × AUTH_STORE_TIMEOUT_MS + (AUTH_STORE_RETRIES − 1) × backoff
|
||||
// = 2 × 25 000 + 500 = 50 500 ms. Add 5 s of headroom for RPC serialization,
|
||||
// CoreStateProvider polling jitter, and the final try-catch turnaround.
|
||||
const AUTH_STORE_SUPPRESS_REAUTH_MS =
|
||||
AUTH_STORE_RETRIES * AUTH_STORE_TIMEOUT_MS +
|
||||
(AUTH_STORE_RETRIES - 1) * AUTH_STORE_RETRY_BACKOFF_MS +
|
||||
5_000;
|
||||
|
||||
/**
|
||||
* Retry-safe wrapper around `storeSession` for transient backend timeouts.
|
||||
*
|
||||
* The Rust core's `auth_store_session` with `allowPendingBackendValidation: true`
|
||||
* already retries the `/auth/me` call once (150ms delay) and falls through to
|
||||
* deferred validation on a second transient failure *if* the JWT has a live
|
||||
* local exp. However, the frontend coreRpcClient's default timeout (30s) can
|
||||
* fire *before* Rust finishes its slow backend call + retry + deferred-validation
|
||||
* path, producing a `CoreRpcError(kind='timeout')` even though Rust would have
|
||||
* persisted the session successfully. This wrapper retries the whole RPC call
|
||||
* so a transient backend blip doesn't bounce the user back to sign-in.
|
||||
*/
|
||||
const storeSessionWithRetry = async (sessionToken: string): Promise<void> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= AUTH_STORE_RETRIES; attempt++) {
|
||||
try {
|
||||
await storeSession(
|
||||
sessionToken,
|
||||
{},
|
||||
{ allowPendingBackendValidation: true, timeoutMs: AUTH_STORE_TIMEOUT_MS }
|
||||
);
|
||||
return; // success
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
const isTimeout = /timed out|timeout|operation timed out/.test(message);
|
||||
if (isTimeout && attempt < AUTH_STORE_RETRIES) {
|
||||
console.warn(
|
||||
'[DeepLink][auth] auth_store_session timed out (attempt %d/%d), retrying in %dms',
|
||||
attempt,
|
||||
AUTH_STORE_RETRIES,
|
||||
AUTH_STORE_RETRY_BACKOFF_MS
|
||||
);
|
||||
await new Promise(r => setTimeout(r, AUTH_STORE_RETRY_BACKOFF_MS));
|
||||
continue;
|
||||
}
|
||||
throw err; // non-timeout or last attempt
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
const applySessionToken = async (sessionToken: string): Promise<void> => {
|
||||
// In cloud mode, bust any stale RPC URL/token caches so auth_store_session
|
||||
// targets the user's configured remote core. See issue #2377.
|
||||
@@ -164,10 +224,12 @@ const applySessionToken = async (sessionToken: string): Promise<void> => {
|
||||
|
||||
// Signal CoreStateProvider to hold off clearing session during token delivery.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('core-state:suppress-reauth', { detail: { until: Date.now() + 15_000 } })
|
||||
new CustomEvent('core-state:suppress-reauth', {
|
||||
detail: { until: Date.now() + AUTH_STORE_SUPPRESS_REAUTH_MS },
|
||||
})
|
||||
);
|
||||
try {
|
||||
await storeSession(sessionToken, {}, { allowPendingBackendValidation: true });
|
||||
await storeSessionWithRetry(sessionToken);
|
||||
} finally {
|
||||
window.dispatchEvent(new CustomEvent('core-state:suppress-reauth', { detail: { until: 0 } }));
|
||||
}
|
||||
@@ -279,8 +341,18 @@ const handleAuthDeepLink = async (parsed: URL, requireStateNonce = true) => {
|
||||
// BEFORE our tag applies. A plain `Error` makes `originalException`
|
||||
// non-matching, so the lead cause finally reaches Sentry.
|
||||
// The PII-free `kind` tag + stable fingerprint are all we need to group.
|
||||
//
|
||||
// Transient connectivity issues (timeout, gateway, network) are reported
|
||||
// at `warning` rather than `error` — the auth flow already retried inside
|
||||
// `storeSessionWithRetry`, and the Rust core with
|
||||
// `allowPendingBackendValidation: true` also retries and falls back to
|
||||
// deferred validation. If the backend was genuinely unreachable through
|
||||
// all retries this is a connectivity observation, not an app crash
|
||||
// (issue #5166).
|
||||
const isTransient =
|
||||
kind === 'auth_me_timeout' || kind === 'auth_me_gateway' || kind === 'network';
|
||||
Sentry.captureException(new Error(`auth store failed: ${kind}`), {
|
||||
level: 'error',
|
||||
level: isTransient ? 'warning' : 'error',
|
||||
tags: { surface: 'react', phase: 'deep-link-auth-store', auth_store_failure: kind },
|
||||
fingerprint: ['deep-link-auth', 'session-store-failed', kind],
|
||||
});
|
||||
@@ -339,7 +411,10 @@ export const authStoreFailureUserMessage = (
|
||||
mode: 'local' | 'cloud' | null
|
||||
): string => {
|
||||
if (mode !== 'cloud') {
|
||||
return 'Sign-in failed. Please try again.';
|
||||
return (
|
||||
'Sign-in could not be completed right now. The session store did not respond in time ' +
|
||||
'(even after retrying). Please restart OpenHuman and try again.'
|
||||
);
|
||||
}
|
||||
switch (kind) {
|
||||
case 'auth_me_unauthorized':
|
||||
|
||||
@@ -56,12 +56,19 @@ export async function logout(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store session in secure storage
|
||||
* Store session in secure storage.
|
||||
*
|
||||
* Options:
|
||||
* - `allowPendingBackendValidation` -- skip backend `/auth/me` proof and persist
|
||||
* the session with a deferred-validation marker.
|
||||
* - `timeoutMs` -- per-call timeout (default 30s, clamped [1s, 10min]).
|
||||
* Use a shorter value (e.g. 10s) when the caller will retry after a timeout
|
||||
* rather than hang for the full default.
|
||||
*/
|
||||
export async function storeSession(
|
||||
token: string,
|
||||
user: object,
|
||||
options?: { allowPendingBackendValidation?: boolean }
|
||||
options?: { allowPendingBackendValidation?: boolean; timeoutMs?: number }
|
||||
): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.auth_store_session',
|
||||
@@ -70,6 +77,7 @@ export async function storeSession(
|
||||
user,
|
||||
...(options?.allowPendingBackendValidation ? { allowPendingBackendValidation: true } : {}),
|
||||
},
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -107,9 +107,10 @@ pub const UNKNOWN_METHOD_PREFIX: &str = "unknown method: ";
|
||||
/// and never will be (issue #3567): `rpc.discover` (JSON-RPC service
|
||||
/// discovery), `list_methods`, liveness `status`, `auth.status`, `config/get`.
|
||||
/// This also covers retired feature calls from older clients when no safe
|
||||
/// canonical handler exists (#3565: `openhuman.memory_tree_create_namespace`).
|
||||
/// canonical handler exists (#3565: `openhuman.memory_tree_create_namespace`,
|
||||
/// #5157: `openhuman.harness_init_status`).
|
||||
///
|
||||
/// Each miss previously produced recurring Sentry ERROR events with zero user
|
||||
/// Each miss previously produced recurring Sentry events with zero user
|
||||
/// impact. The transport layer keeps these debug-only (never captured). The
|
||||
/// matching health-method *aliases* land separately in `legacy_aliases`
|
||||
/// (#3566), which depends on this severity change.
|
||||
@@ -120,6 +121,7 @@ const KNOWN_PROBE_METHODS: &[&str] = &[
|
||||
"auth.status",
|
||||
"config/get",
|
||||
"openhuman.memory_tree_create_namespace",
|
||||
"openhuman.harness_init_status",
|
||||
];
|
||||
|
||||
/// Returns `true` when `method` is a known non-actionable unknown method name
|
||||
@@ -376,6 +378,7 @@ mod tests {
|
||||
"auth.status",
|
||||
"config/get",
|
||||
"openhuman.memory_tree_create_namespace",
|
||||
"openhuman.harness_init_status",
|
||||
] {
|
||||
assert!(
|
||||
is_known_probe_method(m),
|
||||
|
||||
+3
-3
@@ -651,7 +651,7 @@ async fn oauth_mcp_callback_handler(
|
||||
let config = match crate::openhuman::config::Config::load_or_init().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("[oauth:mcp] config load failed: {e}");
|
||||
log::warn!("[oauth:mcp] config load failed: {e}");
|
||||
return html(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_html("Internal error loading config. Please try again."),
|
||||
@@ -668,7 +668,7 @@ async fn oauth_mcp_callback_handler(
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[oauth:mcp] complete failed: {e}");
|
||||
log::warn!("[oauth:mcp] complete failed: {e}");
|
||||
html(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
error_html(&format!("Sign-in could not be completed: {e}")),
|
||||
@@ -2481,7 +2481,7 @@ pub async fn bootstrap_core_runtime(
|
||||
// above — it must run even when this gate-install branch is skipped.)
|
||||
crate::openhuman::web_chat::register_artifact_surface_subscriber();
|
||||
} else {
|
||||
log::error!(
|
||||
log::info!(
|
||||
"[runtime] approval gate DISABLED (OPENHUMAN_APPROVAL_GATE=0 honored on host={}) — \
|
||||
Prompt-class external-effect tool calls run unprompted",
|
||||
host_kind.tag()
|
||||
|
||||
@@ -229,6 +229,20 @@ pub enum ExpectedErrorKind {
|
||||
/// (`inference_downloads_progress` re-reads config every poll → 36k events /
|
||||
/// 1 Windows user).
|
||||
ConfigReadIoFailure,
|
||||
/// Windows `ERROR_FILE_SYSTEM_LIMITATION` (os error 665) — the NTFS file
|
||||
/// system cannot complete the operation because the filesystem is too
|
||||
/// fragmented, the USN journal has overflowed, or a filesystem filter
|
||||
/// driver has hit its resource cap. This is a persistent host-filesystem
|
||||
/// condition that the user must resolve by restarting, running a defrag,
|
||||
/// or freeing space — Sentry has no remediation path.
|
||||
///
|
||||
/// Also covers `ERROR_DISK_FULL` (112) / `ERROR_HANDLE_DISK_FULL` (39),
|
||||
/// although those are more reliably caught by the `DiskFull` arm above.
|
||||
///
|
||||
/// Matched on the locale-stable `(os error 665)` suffix (the Italian
|
||||
/// locale rendering observed in Sentry TAURI-RUST-QT0 — 6,050 events from
|
||||
/// 1 Windows user — is `".. (os error 665)"`, matching any locale).
|
||||
WindowsFileSystemLimitation,
|
||||
/// The subconscious engine's SQLite schema init couldn't open its database
|
||||
/// file at all — a host-filesystem condition, not a code bug. Two canonical
|
||||
/// renderings, both bound to the user's local FS:
|
||||
@@ -588,6 +602,9 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
|
||||
if is_disk_full_message(&lower) {
|
||||
return Some(ExpectedErrorKind::DiskFull);
|
||||
}
|
||||
if is_windows_file_system_limitation_message(&lower) {
|
||||
return Some(ExpectedErrorKind::WindowsFileSystemLimitation);
|
||||
}
|
||||
if is_config_load_timed_out_message(&lower) {
|
||||
return Some(ExpectedErrorKind::ConfigLoadTimedOut);
|
||||
}
|
||||
@@ -708,6 +725,25 @@ fn is_disk_full_message(lower: &str) -> bool {
|
||||
(bare_suffix || extended_local) && !lower.contains("backend returned ")
|
||||
}
|
||||
|
||||
/// Detect Windows `ERROR_FILE_SYSTEM_LIMITATION` (os error 665) —
|
||||
/// a host-filesystem condition (fragmentation, USN journal overflow,
|
||||
/// filter-driver resource cap) that is persistent, locale-independent
|
||||
/// in the `(os error N)` suffix, and unrecoverable by the app.
|
||||
///
|
||||
/// Anchor on the locale-stable `(os error 665)` suffix. The Italian
|
||||
/// locale rendering is `"Impossibile completare l'operazione a causa
|
||||
/// di un limite del file system (os error 665)"` (Sentry
|
||||
/// TAURI-RUST-QT0, 6,050 events / 1 user). The `(os error 665)` suffix
|
||||
/// is the same across every locale, so no locale-detection is needed.
|
||||
///
|
||||
/// Polarity contract — only match when `(os error 665)` appears in
|
||||
/// the message body. This is a standard `std::io::Error` Display
|
||||
/// rendering, so it will never appear in a remote backend body
|
||||
/// accidentally.
|
||||
fn is_windows_file_system_limitation_message(lower: &str) -> bool {
|
||||
lower.contains("(os error 665)") && !lower.contains("backend returned ")
|
||||
}
|
||||
|
||||
/// Detect the literal `"Config loading timed out"` string produced by
|
||||
/// [`crate::openhuman::config::ops::load_config_with_timeout`] /
|
||||
/// [`crate::openhuman::config::ops::reload_config_snapshot_with_timeout`]
|
||||
@@ -978,6 +1014,20 @@ pub fn is_session_expired_message(msg: &str) -> bool {
|
||||
// `does_not_classify_streaming_byo_key_401_as_session_expired`.
|
||||
|| (msg.contains("OpenHuman streaming API error (401")
|
||||
&& msg.contains("\"error\":\"Invalid token\""))
|
||||
// TAURI-RUST-N — same OpenHuman backend "Invalid token" envelope
|
||||
// wrapped by the tinyagents `ProviderError::Display` format
|
||||
// (`TinyAgentsError::Provider(Box<ProviderError>)`). The crate-native
|
||||
// path (`OpenHumanBackendModel`) delegates to tinyagents' `OpenAiModel`
|
||||
// which formats errors as `"{provider} returned HTTP {status}: {body}"`,
|
||||
// producing `"OpenHuman returned HTTP 401: ..."` — different from the
|
||||
// `"OpenHuman API error (401"` prefix the classic `api_error` path emits.
|
||||
// Same conjunctive-anchor pattern: scoped to `"OpenHuman returned HTTP
|
||||
// {status}"` (so a third-party BYO-key 401 from a provider whose label
|
||||
// contains "OpenHuman" cannot match) AND the envelope-shaped
|
||||
// `"\"error\":\"Invalid token\""` (so bare prose mentions of "invalid
|
||||
// token" stay actionable).
|
||||
|| (msg.contains("OpenHuman returned HTTP 401")
|
||||
&& msg.contains("\"error\":\"Invalid token\""))
|
||||
}
|
||||
|
||||
/// Detect a remote MCP server's connect-time 401 — the user must sign in to
|
||||
@@ -2082,6 +2132,21 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
|
||||
"[observability] {domain}.{operation} skipped expected disk-full error"
|
||||
);
|
||||
}
|
||||
ExpectedErrorKind::WindowsFileSystemLimitation => {
|
||||
// Windows `ERROR_FILE_SYSTEM_LIMITATION` (os error 665) —
|
||||
// caused by fragmentation, USN journal overflow, or a
|
||||
// filesystem filter driver bottleneck. The user must restart
|
||||
// their machine, run a defrag, or free disk space — Sentry
|
||||
// has no remediation path. Demote at `warn!` so a sustained
|
||||
// spike shows up in dashboards without flooding Sentry.
|
||||
// Drops TAURI-RUST-QT0 (6,050 events / 1 user).
|
||||
tracing::warn!(
|
||||
domain = domain,
|
||||
operation = operation,
|
||||
kind = "windows_file_system_limitation",
|
||||
"[observability] {domain}.{operation} skipped expected Windows file-system-limitation error (os error 665)"
|
||||
);
|
||||
}
|
||||
ExpectedErrorKind::MemoryStoreBreakerOpen => {
|
||||
tracing::warn!(
|
||||
domain = domain,
|
||||
@@ -2294,6 +2359,40 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
|
||||
/// honest.
|
||||
pub const REPORT_ERROR_TRACING_TARGET: &str = "openhuman::observability::report_error";
|
||||
|
||||
/// Cooldown period for rate-limiting repeated filesystem-limit errors.
|
||||
/// Within this window, the same (domain, operation) pair that carries
|
||||
/// `(os error 665)` is suppressed — Sentry never receives a duplicate
|
||||
/// Sentry event, and only a `debug!` log line is emitted.
|
||||
const FS_ERROR_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(300); // 5 min
|
||||
|
||||
/// In-process state tracking the last-reported time of `ERROR_FILE_SYSTEM_LIMITATION`
|
||||
/// (os error 665) events, keyed by `(domain, operation)`. This prevents
|
||||
/// unthrottled retry loops from flooding Sentry (TAURI-RUST-QT0: 6,050 events /
|
||||
/// 1 user).
|
||||
static LAST_FS_LIMIT_REPORT: std::sync::LazyLock<
|
||||
std::sync::Mutex<std::collections::HashMap<(String, String), std::time::Instant>>,
|
||||
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
/// Returns `true` when `(domain, operation)` was already reported with
|
||||
/// an error-665 message within [`FS_ERROR_COOLDOWN`]. Cleans up stale
|
||||
/// entries older than the cooldown on every call.
|
||||
fn was_recently_reported(domain: &str, operation: &str) -> bool {
|
||||
let mut guard = LAST_FS_LIMIT_REPORT
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let now = std::time::Instant::now();
|
||||
// Prune stale entries older than cooldown (single-pass to keep map bounded).
|
||||
guard.retain(|_, last| now.duration_since(*last) < FS_ERROR_COOLDOWN);
|
||||
let key = (domain.to_string(), operation.to_string());
|
||||
if let Some(last) = guard.get(&key) {
|
||||
if now.duration_since(*last) < FS_ERROR_COOLDOWN {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
guard.insert(key, now);
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn report_error_message(
|
||||
message: &str,
|
||||
domain: &str,
|
||||
@@ -2306,6 +2405,22 @@ pub(crate) fn report_error_message(
|
||||
// `crash-reporting` `before_send` hook — so scrub once, up front.
|
||||
let scrubbed = crate::core::log_redaction::scrub_secrets(message);
|
||||
let message = scrubbed.as_str();
|
||||
// Rate-limit Windows `ERROR_FILE_SYSTEM_LIMITATION` (os error 665)
|
||||
// events: skip the Sentry capture if the same (domain, operation) pair
|
||||
// was already reported within the last 5 minutes. This prevents
|
||||
// unthrottled retry loops from flooding Sentry (TAURI-RUST-QT0:
|
||||
// 6,050 events / 1 user). The `before_send` chain also filters these,
|
||||
// but this early-exit avoids the Sentry scope overhead entirely.
|
||||
#[cfg(feature = "crash-reporting")]
|
||||
if message.contains("os error 665") && was_recently_reported(domain, operation) {
|
||||
tracing::debug!(
|
||||
target: REPORT_ERROR_TRACING_TARGET,
|
||||
domain = domain,
|
||||
operation = operation,
|
||||
"[observability] {domain}.{operation} rate-limited os error 665 (reported within cooldown)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Sentry-touching behaviour is gated behind `crash-reporting`. The
|
||||
// diagnostic `tracing::error!` stays compiled in both builds (see the
|
||||
// `#[cfg(not(...))]` companion below) so stderr / file appenders keep the
|
||||
@@ -3108,6 +3223,45 @@ pub fn is_ollama_cloud_internal_500_event(event: &sentry::protocol::Event<'_>) -
|
||||
})
|
||||
}
|
||||
|
||||
/// Defense-in-depth `before_send` filter for Windows `ERROR_FILE_SYSTEM_LIMITATION`
|
||||
/// (os error 665) — a host-filesystem condition (fragmentation, USN journal
|
||||
/// overflow, filter-driver resource cap) that is persistent, locale-independent
|
||||
/// in the `(os error N)` suffix, and unrecoverable by the app.
|
||||
///
|
||||
/// The primary suppression lives at the emit site via `expected_error_kind` →
|
||||
/// `ExpectedErrorKind::WindowsFileSystemLimitation` (called by
|
||||
/// `report_error_or_expected`). This filter catches any future call site that
|
||||
/// bypasses `report_error_or_expected` and emits the error via `report_error`
|
||||
/// or `tracing::error!` directly — keeping TAURI-RUST-QT0 (6,050 events /
|
||||
/// 1 user) permanently off Sentry.
|
||||
///
|
||||
/// Also catches `OS error 665` from the Tauri shell side
|
||||
/// (`app/src-tauri/`) which is compiled into a separate crate and therefore
|
||||
/// cannot route through the core's `expected_error_kind` classifier.
|
||||
#[cfg(feature = "crash-reporting")]
|
||||
pub fn is_windows_file_system_limitation_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||||
let check = |text: &str| -> bool {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
lower.contains("(os error 665)")
|
||||
};
|
||||
if event.message.as_deref().is_some_and(check) {
|
||||
return true;
|
||||
}
|
||||
if event
|
||||
.logentry
|
||||
.as_ref()
|
||||
.map(|log| log.message.as_str())
|
||||
.is_some_and(check)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
event
|
||||
.exception
|
||||
.values
|
||||
.iter()
|
||||
.any(|exception| exception.value.as_deref().is_some_and(check))
|
||||
}
|
||||
|
||||
/// 404 on PATCH/DELETE to a channel-message path is an expected backend state
|
||||
/// (user deleted the message provider-side, backend GC'd the relay row). The
|
||||
/// primary suppression lives in `authed_json` via `parse_message_path` +
|
||||
@@ -3170,6 +3324,208 @@ fn event_contains_budget_exhausted_message(event: &sentry::protocol::Event<'_>)
|
||||
})
|
||||
}
|
||||
|
||||
/// Defense-in-depth `before_send` filter for **user-config provider error
|
||||
/// patterns** — 4xx client errors, model-not-found, subscription/payment
|
||||
/// issues, and other misconfigurations that are not application bugs.
|
||||
///
|
||||
/// Matches on `event.message` or exception values against known user-config
|
||||
/// patterns. The primary suppression lives at the `report_error` / emit sites;
|
||||
/// this catches any future call site that bypasses those classifiers.
|
||||
///
|
||||
/// Returns true (should be dropped) when the event message matches any known
|
||||
/// user-config provider pattern. Target: ~22 Sentry issues / ~26k events.
|
||||
#[cfg(feature = "crash-reporting")]
|
||||
pub fn is_user_config_provider_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||||
// Collect all text sources to check against
|
||||
let texts: Vec<&str> = {
|
||||
let mut v = Vec::with_capacity(2 + event.exception.values.len());
|
||||
if let Some(msg) = event.message.as_deref() {
|
||||
v.push(msg);
|
||||
}
|
||||
if let Some(log) = event.logentry.as_ref().map(|l| l.message.as_str()) {
|
||||
v.push(log);
|
||||
}
|
||||
for exc in &event.exception.values {
|
||||
if let Some(val) = exc.value.as_deref() {
|
||||
v.push(val);
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
if texts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Provider 4xx patterns — user config errors like:
|
||||
// "HTTP 400", "HTTP 401", "HTTP 403", "HTTP 404"
|
||||
// matching against llm_provider domain events
|
||||
let tags = &event.tags;
|
||||
let domain = tags.get("domain").map(String::as_str);
|
||||
if domain == Some("llm_provider") {
|
||||
if let Some(status) = tags.get("status") {
|
||||
// 4xx user config errors. 401/403/404 are unambiguously
|
||||
// auth/permission/config issues and safe to drop blanket.
|
||||
// 400 (Bad Request) may also be a client-side serialization
|
||||
// bug — only drop when the message content confirms it's
|
||||
// a user config error (handled by message patterns below
|
||||
// and the earlier `is_budget_event` / `is_insufficient_credits_event`
|
||||
// filters in the before_send chain).
|
||||
if matches!(status.as_str(), "401" | "403" | "404") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Message-level patterns
|
||||
let lower = texts.join(" ").to_ascii_lowercase();
|
||||
if lower.contains("401 payment required")
|
||||
|| lower.contains("subscription")
|
||||
|| lower.contains("max monthly spend")
|
||||
|| lower.contains("context length exceeded")
|
||||
|| lower.contains("context size exceeded")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Embedding API 4xx / unauthorized — user's embedding provider config
|
||||
if domain == Some("llm_provider") || domain == Some("local_ai") {
|
||||
let lower = texts.join(" ").to_ascii_lowercase();
|
||||
if (lower.contains("embed") || lower.contains("embedding"))
|
||||
&& (lower.contains("401")
|
||||
|| lower.contains("404")
|
||||
|| lower.contains("unauthorized")
|
||||
|| lower.contains("invalid model"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Defense-in-depth `before_send` filter for **connectivity/network flakiness**
|
||||
/// events — transient, self-resolving failures like timeouts, gateways, and
|
||||
/// "Failed to fetch" messages from the frontend.
|
||||
///
|
||||
/// Primary suppression lives at the caller sites. This is the outermost net for
|
||||
/// any future call site that bypasses those classifiers. Target: ~8 issues.
|
||||
#[cfg(feature = "crash-reporting")]
|
||||
pub fn is_connectivity_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||||
let texts: Vec<&str> = {
|
||||
let mut v = Vec::with_capacity(2 + event.exception.values.len());
|
||||
if let Some(msg) = event.message.as_deref() {
|
||||
v.push(msg);
|
||||
}
|
||||
if let Some(log) = event.logentry.as_ref().map(|l| l.message.as_str()) {
|
||||
v.push(log);
|
||||
}
|
||||
for exc in &event.exception.values {
|
||||
if let Some(val) = exc.value.as_deref() {
|
||||
v.push(val);
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
if texts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let lower = texts.join(" ").to_ascii_lowercase();
|
||||
|
||||
// Core HTTP transport errors
|
||||
if lower.contains("connection refused")
|
||||
|| lower.contains("connection reset")
|
||||
|| lower.contains("connection closed before")
|
||||
|| lower.contains("broken pipe")
|
||||
|| lower.contains("tls handshake eof")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backend / provider gateway errors
|
||||
if lower.contains("502 bad gateway")
|
||||
|| lower.contains("504 gateway timeout")
|
||||
|| lower.contains("502 gateway timeout")
|
||||
|| lower.contains("timeout: 503")
|
||||
|| lower.contains("upstream connect error")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// CoreRpcError / HTTP 401 (from frontend — user session issue, not code defect)
|
||||
// Only match bare HTTP 401 (CoreRpcError / fetch), not llm_provider 401
|
||||
// which is handled by the session-expired classifier
|
||||
let domain = event.tags.get("domain").map(String::as_str);
|
||||
if domain != Some("llm_provider")
|
||||
&& domain != Some("backend_api")
|
||||
&& (lower.contains("http 401")
|
||||
|| lower.contains("status: 401")
|
||||
|| lower.contains("401 unauthorized"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Timeout patterns — scoped to known transient forms so genuine
|
||||
// non-transport timeouts (database locks, inference hangs) still
|
||||
// reach Sentry for investigation.
|
||||
if lower.contains("connection timed out")
|
||||
|| lower.contains("request timed out")
|
||||
|| lower.contains("deadline has elapsed")
|
||||
|| lower.contains("timeout after")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Filter out events from **stale releases** — releases older than
|
||||
/// `MAX_AGE_MINOR_VERSIONS` minor versions behind the current build.
|
||||
///
|
||||
/// Ancient-client errors are not actionable against the current codebase.
|
||||
/// Target: ~4 issues from releases like v0.54.0 against current v0.58+.
|
||||
#[cfg(feature = "crash-reporting")]
|
||||
pub fn is_stale_release_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||||
// Maximum number of minor versions behind the current release to accept.
|
||||
// Events from clients on releases older than this are dropped.
|
||||
const MAX_AGE_MINOR_VERSIONS: u32 = 6;
|
||||
|
||||
let Some(release) = event.release.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
parse_version(current).is_some_and(|(current_major, current_minor)| {
|
||||
parse_release_tag(release).is_some_and(|(event_major, event_minor)| {
|
||||
if event_major != current_major {
|
||||
// Different major version = definitely stale (or from the future)
|
||||
return event_major < current_major;
|
||||
}
|
||||
// Same major: check minor version gap
|
||||
current_minor.saturating_sub(event_minor) > MAX_AGE_MINOR_VERSIONS
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a Sentry release tag like `"openhuman@0.58.0"` or
|
||||
/// `"openhuman@0.58.0+abc123def456"` into `(major, minor)`.
|
||||
#[cfg(feature = "crash-reporting")]
|
||||
fn parse_release_tag(tag: &str) -> Option<(u32, u32)> {
|
||||
// Strip the `openhuman@` prefix
|
||||
let after_at = tag.split('@').nth(1)?;
|
||||
// Get the version part before any `+` suffix
|
||||
let version = after_at.split('+').next()?;
|
||||
parse_version(version)
|
||||
}
|
||||
|
||||
/// Parse a `"MAJOR.MINOR.PATCH"` version string into `(major, minor)`.
|
||||
#[cfg(feature = "crash-reporting")]
|
||||
fn parse_version(version: &str) -> Option<(u32, u32)> {
|
||||
let mut parts = version.splitn(3, '.');
|
||||
let major = parts.next()?.parse::<u32>().ok()?;
|
||||
let minor = parts.next()?.parse::<u32>().ok()?;
|
||||
Some((major, minor))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+53
@@ -116,6 +116,21 @@ fn main() {
|
||||
if openhuman_core::core::observability::is_ollama_cloud_internal_500_event(&event) {
|
||||
return None;
|
||||
}
|
||||
// Defense-in-depth: drop Windows `ERROR_FILE_SYSTEM_LIMITATION`
|
||||
// (os error 665) — a persistent host-filesystem condition with
|
||||
// zero local lever and no Sentry remediation path. The primary
|
||||
// suppression lives at the emit site via `expected_error_kind` →
|
||||
// `ExpectedErrorKind::WindowsFileSystemLimitation`; this catches
|
||||
// any call site that uses `report_error` directly instead of
|
||||
// `report_error_or_expected` (TAURI-RUST-QT0: 6,050 events / 1 user).
|
||||
if openhuman_core::core::observability::is_windows_file_system_limitation_event(&event)
|
||||
{
|
||||
log::debug!(
|
||||
"[sentry-fs-limitation-filter] dropping Windows file-system-limitation event (os error 665) event_id={:?}",
|
||||
event.event_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Defense-in-depth: drop max-tool-iterations cap events that
|
||||
// slipped past the call-site filters in
|
||||
// `agent::harness::session::runtime::run_single`,
|
||||
@@ -175,6 +190,44 @@ fn main() {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Defense-in-depth: drop user-config provider errors that
|
||||
// slipped past the call-site classifiers — 4xx client errors,
|
||||
// subscription/payment issues, embedding API authorization
|
||||
// failures. These are user misconfigurations, not application
|
||||
// bugs (targets ~22 Sentry issues / ~26k events from the issue
|
||||
// audit). Primary suppression lives at individual emit sites;
|
||||
// this catch-all net catches any future new path that bypasses
|
||||
// those gates.
|
||||
if openhuman_core::core::observability::is_user_config_provider_event(&event) {
|
||||
log::debug!(
|
||||
"[sentry-user-config-filter] dropping user-config provider event event_id={:?}",
|
||||
event.event_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Defense-in-depth: drop connectivity / network flakiness events
|
||||
// that escaped the call-site classifiers — "Failed to fetch",
|
||||
// connection refused, gateway 502/504, HTTP 401 from frontend
|
||||
// connectivity. These are transient self-resolving conditions,
|
||||
// not actionable code defects (targets ~8 Sentry issues).
|
||||
if openhuman_core::core::observability::is_connectivity_event(&event) {
|
||||
log::debug!(
|
||||
"[sentry-connectivity-filter] dropping connectivity event event_id={:?}",
|
||||
event.event_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Drop events from stale releases (clients running versions
|
||||
// more than 6 minor versions behind the current build). Errors
|
||||
// from ancient code are not actionable against the current
|
||||
// codebase (targets ~4 Sentry issues).
|
||||
if openhuman_core::core::observability::is_stale_release_event(&event) {
|
||||
log::debug!(
|
||||
"[sentry-stale-release-filter] dropping stale release event event_id={:?}",
|
||||
event.event_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if openhuman_core::core::observability::is_session_expired_event(&event) {
|
||||
// Metadata-only log shape — `event.message` carries the raw
|
||||
// backend response body (often a JSON envelope with the
|
||||
|
||||
@@ -256,7 +256,7 @@ impl ComposioClient {
|
||||
has_error = resp.error.is_some(),
|
||||
"[composio] execute_tool_once completed"
|
||||
),
|
||||
Err(err) => tracing::error!(
|
||||
Err(err) => tracing::warn!(
|
||||
tool = %tool,
|
||||
error = %err,
|
||||
"[composio] execute_tool_once failed"
|
||||
|
||||
@@ -920,7 +920,7 @@ mod loader_io_chain_tests {
|
||||
.expect_err("an unreadable config file must fail");
|
||||
|
||||
assert!(
|
||||
err.contains("reading config.toml from"),
|
||||
err.contains("Failed to read config file"),
|
||||
"error must carry the read context: {err}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -16,6 +16,116 @@ use tokio::io::AsyncWriteExt;
|
||||
static WARNED_WORLD_READABLE_CONFIGS: OnceLock<Mutex<HashSet<std::path::PathBuf>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Guards the "corrupted config read, resetting to defaults" warning so it
|
||||
/// fires at most once per process lifetime. Without this, a permanently
|
||||
/// non-UTF-8 config file floods telemetry with hundreds of identical
|
||||
/// `stream did not contain valid UTF-8` events (#5167).
|
||||
static WARNED_CONFIG_READ_FAILURE: OnceLock<Mutex<bool>> = OnceLock::new();
|
||||
|
||||
/// Try to read `config_path`. On content corruption (non-UTF-8 bytes), rename
|
||||
/// the corrupted file to `<config_file>.corrupted.<timestamp>`, try the `.bak`
|
||||
/// backup, and if that also fails return an empty string so the caller's
|
||||
/// `parse_config_with_recovery` falls through to defaults.
|
||||
///
|
||||
/// Only triggers auto-recovery for **content** corruption (`InvalidData`), not
|
||||
/// for transient or permission errors (`PermissionDenied`, `NotFound`, etc.),
|
||||
/// which are propagated as errors so the caller can surface them to the user.
|
||||
///
|
||||
/// Rate-limits the warning to at most one per process lifetime so a
|
||||
/// permanently corrupted file does not flood telemetry (#5167).
|
||||
async fn read_config_with_recovery_or_default(config_path: &Path) -> Result<(String, bool)> {
|
||||
let reads = || async {
|
||||
fs::read_to_string(config_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read config file: {}", config_path.display()))
|
||||
};
|
||||
|
||||
let result =
|
||||
crate::openhuman::util::retry_with_backoff_async("read config file", 5, 20, reads).await;
|
||||
match result {
|
||||
Ok(contents) => Ok((contents, false)),
|
||||
Err(e) => {
|
||||
// Check if this is a content-corruption error (non-UTF-8).
|
||||
// Only in that case do we auto-recover by renaming the file
|
||||
// and falling back to backup/defaults. Other errors (permission
|
||||
// denied, file not found after retries, etc.) are propagated
|
||||
// so the caller surfaces them to the user.
|
||||
let is_content_corruption = e.chain().any(|cause| {
|
||||
cause
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|ioe| ioe.kind() == std::io::ErrorKind::InvalidData)
|
||||
});
|
||||
|
||||
if !is_content_corruption {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Rate-limit the warning to once per process lifetime. The
|
||||
// MutexGuard *must* be scoped in its own block so it is dropped
|
||||
// before any `.await` below -- holding a non-Send guard across
|
||||
// an await would poison the future's Send bound (#5167).
|
||||
{
|
||||
let warned = WARNED_CONFIG_READ_FAILURE.get_or_init(|| Mutex::new(false));
|
||||
let mut guard = warned.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !*guard {
|
||||
tracing::warn!(
|
||||
path = %config_path.display(),
|
||||
error = %e,
|
||||
"[config] Config file contains non-UTF-8 content; \
|
||||
renaming to .corrupted and attempting recovery from backup"
|
||||
);
|
||||
*guard = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Rename the corrupted file with a UNIX-timestamp suffix so it's
|
||||
// recoverable by the user but does not block future config loads.
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let stem = config_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("config");
|
||||
let corrupted_name = format!("{stem}.corrupted.{ts}");
|
||||
let corrupted_path = config_path.with_file_name(&corrupted_name);
|
||||
if let Err(rename_err) = std::fs::rename(config_path, &corrupted_path) {
|
||||
tracing::warn!(
|
||||
src = %config_path.display(),
|
||||
dst = %corrupted_path.display(),
|
||||
error = %rename_err,
|
||||
"[config] Failed to rename corrupted config file; \
|
||||
subsequent loads will fail again"
|
||||
);
|
||||
}
|
||||
|
||||
// Try the backup.
|
||||
let backup_path = config_path.with_extension("toml.bak");
|
||||
match fs::read_to_string(&backup_path).await {
|
||||
Ok(bak_contents) => {
|
||||
tracing::warn!(
|
||||
path = %config_path.display(),
|
||||
backup = %backup_path.display(),
|
||||
"[config] Read of config file failed; recovered from backup"
|
||||
);
|
||||
Ok((bak_contents, true))
|
||||
}
|
||||
Err(bak_err) => {
|
||||
tracing::warn!(
|
||||
path = %config_path.display(),
|
||||
backup = %backup_path.display(),
|
||||
error = %bak_err,
|
||||
"[config] Backup also unreadable after failed config read; \
|
||||
resetting to defaults"
|
||||
);
|
||||
Ok((String::new(), true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn parse_config_with_recovery(
|
||||
config_path: &Path,
|
||||
contents: &str,
|
||||
@@ -190,19 +300,29 @@ impl Config {
|
||||
);
|
||||
}
|
||||
|
||||
let contents = crate::openhuman::util::retry_with_backoff_async(
|
||||
"read config file",
|
||||
5,
|
||||
20,
|
||||
|| async {
|
||||
fs::read_to_string(&config_path).await.with_context(|| {
|
||||
format!("Failed to read config file: {}", config_path.display())
|
||||
})
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let (mut config, config_was_corrupted) =
|
||||
parse_config_with_recovery(&config_path, &contents).await;
|
||||
// Use the recovery-aware read path. If the file cannot be read
|
||||
// (e.g. non-UTF-8 bytes), the corrupted file is renamed to
|
||||
// `.corrupted.<timestamp>` and backup/defaults are attempted,
|
||||
// with rate-limited error logging (#5167).
|
||||
let (contents, read_was_recovered) =
|
||||
read_config_with_recovery_or_default(&config_path).await?;
|
||||
|
||||
// When `read_config_with_recovery_or_default` returned an empty
|
||||
// string (both primary and backup were unreadable), skip the TOML
|
||||
// parse and use `Config::default()` directly. An empty TOML would
|
||||
// otherwise parse successfully with serde defaults (all fields at
|
||||
// their `Option::None` / `vec![]` / `false` values) instead of
|
||||
// the richer `Default` impl (issue #5167).
|
||||
let (mut config, config_was_corrupted) = if read_was_recovered && contents.is_empty() {
|
||||
(Config::default(), true)
|
||||
} else {
|
||||
parse_config_with_recovery(&config_path, &contents).await
|
||||
};
|
||||
|
||||
// If the read itself was recovered (non-UTF-8 file renamed, backup
|
||||
// used, or file renamed to .corrupted.ts), treat it as corruption so
|
||||
// the recovery path below persists the default config.
|
||||
let config_was_corrupted = config_was_corrupted || read_was_recovered;
|
||||
config.config_path = config_path.clone();
|
||||
config.workspace_dir = workspace_dir;
|
||||
config.action_dir = resolve_action_dir(&config.action_dir_override);
|
||||
@@ -211,31 +331,50 @@ impl Config {
|
||||
config.apply_env_overrides_from(env);
|
||||
|
||||
if config_was_corrupted {
|
||||
let corrupted_path = config_path.with_extension("toml.corrupted");
|
||||
match fs::rename(&config_path, &corrupted_path).await {
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
src = %config_path.display(),
|
||||
dst = %corrupted_path.display(),
|
||||
"[config] Renamed corrupted config; persisting recovered config"
|
||||
let already_renamed = !tokio::fs::try_exists(&config_path).await.unwrap_or(false);
|
||||
if already_renamed {
|
||||
// The read helper already renamed the corrupted file to
|
||||
// `.corrupted.<ts>` -- just persist the recovered config.
|
||||
tracing::debug!(
|
||||
path = %config_path.display(),
|
||||
read_recovered = read_was_recovered,
|
||||
"[config] Config file already renamed by read recovery; \
|
||||
persisting recovered config"
|
||||
);
|
||||
if let Err(e) = config.save().await {
|
||||
tracing::warn!(
|
||||
path = %config.config_path.display(),
|
||||
error = %e,
|
||||
"[config] Failed to persist recovered config to disk"
|
||||
);
|
||||
if let Err(e) = config.save().await {
|
||||
}
|
||||
} else {
|
||||
let corrupted_path = config_path.with_extension("toml.corrupted");
|
||||
match fs::rename(&config_path, &corrupted_path).await {
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
src = %config_path.display(),
|
||||
dst = %corrupted_path.display(),
|
||||
"[config] Renamed corrupted config; persisting recovered config"
|
||||
);
|
||||
if let Err(e) = config.save().await {
|
||||
tracing::warn!(
|
||||
path = %config.config_path.display(),
|
||||
error = %e,
|
||||
"[config] Failed to persist recovered config to disk"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %config.config_path.display(),
|
||||
src = %config_path.display(),
|
||||
dst = %corrupted_path.display(),
|
||||
error = %e,
|
||||
"[config] Failed to persist recovered config to disk"
|
||||
"[config] Failed to rename corrupted config; skipping save to \
|
||||
protect the .bak -- will retry recovery on next startup"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
src = %config_path.display(),
|
||||
dst = %corrupted_path.display(),
|
||||
error = %e,
|
||||
"[config] Failed to rename corrupted config; skipping save to \
|
||||
protect the .bak — will retry recovery on next startup"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +392,7 @@ impl Config {
|
||||
// One-time forced migration: a legacy `enc:` (XOR) secret was
|
||||
// upgraded to `enc2:` on read. Persist immediately so the
|
||||
// insecure ciphertext stops living on disk (audit C8). A save
|
||||
// failure is non-fatal — the config is still usable in memory
|
||||
// failure is non-fatal -- the config is still usable in memory
|
||||
// and migration will be retried on the next startup.
|
||||
if let Err(e) = config.save().await {
|
||||
log::warn!(
|
||||
@@ -317,11 +456,12 @@ impl Config {
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// NOTE: no backup recovery here by design — this is the debug-dump path only;
|
||||
// NOTE: no backup recovery here by design -- this is the debug-dump path only;
|
||||
// `load_or_init()` is the authoritative startup path that handles corruption.
|
||||
let raw = fs::read_to_string(&config_path)
|
||||
.await
|
||||
.context("reading config.toml from default paths")?;
|
||||
// However, we still use `read_config_with_recovery_or_default` to handle the
|
||||
// non-UTF-8 case: a corrupted file is renamed to `.corrupted.<ts>` so the next
|
||||
// authoritative load can create a fresh config.
|
||||
let (raw, _read_was_recovered) = read_config_with_recovery_or_default(&config_path).await?;
|
||||
let (mut config, _was_corrupted) = parse_config_with_recovery(&config_path, &raw).await;
|
||||
config.config_path = config_path;
|
||||
config.workspace_dir = workspace_dir;
|
||||
@@ -358,7 +498,7 @@ impl Config {
|
||||
}
|
||||
|
||||
// See the `load_or_init` read branch: a directory at the config path is
|
||||
// corruption, not a transient read failure — fail fast with distinct
|
||||
// corruption, not a transient read failure -- fail fast with distinct
|
||||
// wording so it pages instead of being demoted (#3962, Codex P2).
|
||||
if config_path.is_dir() {
|
||||
anyhow::bail!(
|
||||
@@ -367,11 +507,13 @@ impl Config {
|
||||
);
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&config_path)
|
||||
.await
|
||||
.with_context(|| format!("reading config.toml from {}", config_path.display()))?;
|
||||
let (mut config, config_was_corrupted) =
|
||||
parse_config_with_recovery(&config_path, &raw).await;
|
||||
let (raw, read_was_recovered) = read_config_with_recovery_or_default(&config_path).await?;
|
||||
let (mut config, config_was_corrupted) = if read_was_recovered && raw.is_empty() {
|
||||
(Config::default(), true)
|
||||
} else {
|
||||
parse_config_with_recovery(&config_path, &raw).await
|
||||
};
|
||||
let config_was_corrupted = config_was_corrupted || read_was_recovered;
|
||||
config.config_path = config_path;
|
||||
config.workspace_dir = workspace_dir;
|
||||
config.action_dir = resolve_action_dir(&config.action_dir_override);
|
||||
|
||||
@@ -1772,6 +1772,169 @@ async fn load_or_init_read_failure_embeds_path_in_error_context() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── non-UTF-8 (binary) config recovery (#5167) ──────────────────────────
|
||||
|
||||
/// Helper: write binary (non-UTF-8) bytes to a file.
|
||||
async fn write_binary(path: &std::path::Path, bytes: &[u8]) {
|
||||
tokio::fs::write(path, bytes)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_or_init_recovers_from_non_utf8_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Write binary data that is NOT valid UTF-8.
|
||||
let config_path = root.join("config.toml");
|
||||
let binary_bytes: Vec<u8> = vec![0xff, 0xfe, 0x00, 0x01, 0x02];
|
||||
write_binary(&config_path, &binary_bytes).await;
|
||||
|
||||
let config = load_or_init_for_workspace(root).await;
|
||||
|
||||
// Should have loaded defaults (not crashed).
|
||||
assert!(
|
||||
config.default_model.is_some(),
|
||||
"must load defaults from non-UTF-8 config"
|
||||
);
|
||||
|
||||
// The original binary file should have been renamed to .corrupted.<ts>.
|
||||
let dir = std::fs::read_dir(root).unwrap();
|
||||
let mut found_corrupted = false;
|
||||
for entry in dir {
|
||||
let name = entry.unwrap().file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with("config.corrupted.") {
|
||||
found_corrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_corrupted,
|
||||
"non-UTF-8 config must be renamed to config.corrupted.<ts>"
|
||||
);
|
||||
|
||||
// A fresh config.toml must have been created by the persistence logic.
|
||||
assert!(
|
||||
tokio::fs::try_exists(&config_path).await.unwrap(),
|
||||
"a fresh config.toml must exist after recovery"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_or_init_recovers_from_non_utf8_using_valid_backup() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let config_path = root.join("config.toml");
|
||||
let backup_path = root.join("config.toml.bak");
|
||||
|
||||
let binary_bytes: Vec<u8> = vec![0xff, 0xfe, 0x00, 0x01, 0x02];
|
||||
write_binary(&config_path, &binary_bytes).await;
|
||||
// Write a valid backup.
|
||||
write_file(
|
||||
&backup_path,
|
||||
r#"default_model = "backup-recovery-test"
|
||||
default_temperature = 0.7
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let config = load_or_init_for_workspace(root).await;
|
||||
|
||||
assert_eq!(
|
||||
config.default_model.as_deref(),
|
||||
Some("backup-recovery-test"),
|
||||
"must recover model from backup when config has non-UTF-8 content"
|
||||
);
|
||||
|
||||
// The binary file should have been renamed.
|
||||
let dir = std::fs::read_dir(root).unwrap();
|
||||
let mut found_corrupted = false;
|
||||
for entry in dir {
|
||||
let name = entry.unwrap().file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with("config.corrupted.") {
|
||||
found_corrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_corrupted,
|
||||
"non-UTF-8 config must be renamed to config.corrupted.<ts>"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_or_init_non_utf8_falls_back_to_defaults_when_backup_also_non_utf8() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let config_path = root.join("config.toml");
|
||||
let backup_path = root.join("config.toml.bak");
|
||||
|
||||
let binary_bytes: Vec<u8> = vec![0xff, 0xfe, 0x00, 0x01, 0x02];
|
||||
write_binary(&config_path, &binary_bytes).await;
|
||||
write_binary(&backup_path, &binary_bytes).await;
|
||||
|
||||
let config = load_or_init_for_workspace(root).await;
|
||||
|
||||
assert_eq!(
|
||||
config.default_model.as_deref(),
|
||||
Some(crate::openhuman::config::schema::DEFAULT_MODEL),
|
||||
"must fall back to defaults when both config and backup have non-UTF-8 content"
|
||||
);
|
||||
|
||||
// The primary should be renamed.
|
||||
let dir = std::fs::read_dir(root).unwrap();
|
||||
let mut found_corrupted = false;
|
||||
for entry in dir {
|
||||
let name = entry.unwrap().file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with("config.corrupted.") {
|
||||
found_corrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_corrupted,
|
||||
"non-UTF-8 config must be renamed to config.corrupted.<ts>"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_or_init_preserves_backup_when_config_is_non_utf8() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let config_path = root.join("config.toml");
|
||||
let backup_path = root.join("config.toml.bak");
|
||||
|
||||
let binary_bytes: Vec<u8> = vec![0xff, 0xfe, 0x00, 0x01, 0x02];
|
||||
write_binary(&config_path, &binary_bytes).await;
|
||||
write_file(
|
||||
&backup_path,
|
||||
r#"default_model = "preserve-backup-test"
|
||||
default_temperature = 0.7
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let _config = load_or_init_for_workspace(root).await;
|
||||
|
||||
// The .bak file must NOT be renamed or deleted.
|
||||
assert!(
|
||||
tokio::fs::try_exists(&backup_path).await.unwrap(),
|
||||
".bak file must be preserved when recovering from non-UTF-8 config"
|
||||
);
|
||||
let bak_contents = tokio::fs::read_to_string(&backup_path).await.unwrap();
|
||||
assert!(
|
||||
bak_contents.contains("preserve-backup-test"),
|
||||
"backup content must be preserved: {bak_contents}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_url_strips_basic_auth_and_query() {
|
||||
let out = redact_url_for_log(
|
||||
|
||||
@@ -35,7 +35,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tinyagents::harness::message::Message;
|
||||
use tinyagents::harness::model::{ModelRequest, ModelStreamItem};
|
||||
use tracing::{debug, error};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::types::{
|
||||
ChatCompletionChoice, ChatCompletionChunk, ChatCompletionChunkChoice, ChatCompletionDelta,
|
||||
@@ -72,7 +72,7 @@ async fn chat_completions_handler(
|
||||
let config = match Config::load_or_init().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("{LOG_PREFIX} chat_completions: config load failed: {e}");
|
||||
warn!("{LOG_PREFIX} chat_completions: config load failed: {e}");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "message": format!("config load failed: {e}"), "type": "internal_error" }})),
|
||||
@@ -103,7 +103,7 @@ async fn chat_completions_handler(
|
||||
) {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
error!("{LOG_PREFIX} chat_completions: provider build failed: {e}");
|
||||
warn!("{LOG_PREFIX} chat_completions: provider build failed: {e}");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": { "message": format!("provider error: {e}"), "type": "invalid_request_error" }})),
|
||||
@@ -153,7 +153,7 @@ async fn chat_completions_handler(
|
||||
let model_stream = match chat_model.stream(&(), model_request).await {
|
||||
Ok(stream) => stream,
|
||||
Err(e) => {
|
||||
error!(error = %e, model = %model_id, "{LOG_PREFIX} chat_completions: stream start failed");
|
||||
warn!(error = %e, model = %model_id, "{LOG_PREFIX} chat_completions: stream start failed");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "message": format!("inference error: {e}"), "type": "internal_error" }})),
|
||||
@@ -250,7 +250,7 @@ async fn chat_completions_handler(
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{LOG_PREFIX} chat_completions: inference failed: {e}");
|
||||
warn!("{LOG_PREFIX} chat_completions: inference failed: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "message": format!("inference error: {e}"), "type": "internal_error" }})),
|
||||
@@ -269,7 +269,7 @@ async fn models_handler(State(_state): State<AppState>) -> Response {
|
||||
let config = match Config::load_or_init().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("{LOG_PREFIX} models: config load failed: {e}");
|
||||
warn!("{LOG_PREFIX} models: config load failed: {e}");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "message": format!("config load failed: {e}") }})),
|
||||
|
||||
@@ -265,7 +265,7 @@ impl LocalAiService {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
tracing::warn!(
|
||||
target: "local_ai::ollama_admin",
|
||||
%url,
|
||||
error = %e,
|
||||
@@ -309,7 +309,7 @@ impl LocalAiService {
|
||||
|
||||
// Read the body as text first so we can log it if JSON parsing fails.
|
||||
let body = response.text().await.map_err(|e| {
|
||||
tracing::error!(
|
||||
tracing::warn!(
|
||||
target: "local_ai::ollama_admin",
|
||||
%url,
|
||||
error = %e,
|
||||
|
||||
@@ -170,6 +170,18 @@ impl LocalAiService {
|
||||
.await
|
||||
.map_err(|e| format!("failed to run whisper.cpp: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let exit_code = output.status.code();
|
||||
// Windows-specific: STATUS_DLL_NOT_FOUND means the system VC++
|
||||
// runtime is missing. Surface an actionable message instead of a
|
||||
// cryptic exit code, with backoff to avoid Sentry floods.
|
||||
if crate::openhuman::inference::paths::is_dll_not_found_exit(exit_code) {
|
||||
let maybe_msg =
|
||||
crate::openhuman::inference::paths::report_dll_not_found(LOG_PREFIX);
|
||||
let display_msg = maybe_msg.unwrap_or_else(|| {
|
||||
format!("{LOG_PREFIX} whisper-cli unavailable (STATUS_DLL_NOT_FOUND — check VC++ Redistributable)")
|
||||
});
|
||||
return Err(display_msg);
|
||||
}
|
||||
return Err(format!(
|
||||
"whisper.cpp failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
|
||||
@@ -80,7 +80,7 @@ impl LocalAiService {
|
||||
);
|
||||
|
||||
let response = self.http.post(&url).json(&body).send().await.map_err(|e| {
|
||||
tracing::error!(
|
||||
tracing::warn!(
|
||||
target: "local_ai::vision",
|
||||
%url,
|
||||
error = %e,
|
||||
@@ -100,7 +100,7 @@ impl LocalAiService {
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let detail = body.trim();
|
||||
tracing::error!(
|
||||
tracing::warn!(
|
||||
target: "local_ai::vision",
|
||||
%url,
|
||||
%status,
|
||||
|
||||
@@ -47,7 +47,7 @@ pub async fn inference_status(config: &Config) -> Result<RpcOutcome<LocalAiStatu
|
||||
let result = local_runtime::rpc::local_ai_status(config).await;
|
||||
match &result {
|
||||
Ok(outcome) => debug!(state = %outcome.value.state, "{LOG_PREFIX} status:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} status:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} status:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -68,7 +68,7 @@ pub async fn inference_summarize(
|
||||
output_len = outcome.value.len(),
|
||||
"{LOG_PREFIX} summarize:ok"
|
||||
),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} summarize:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} summarize:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -88,7 +88,7 @@ pub async fn inference_prompt(
|
||||
let result = local_runtime::rpc::local_ai_prompt(config, prompt, max_tokens, no_think).await;
|
||||
match &result {
|
||||
Ok(outcome) => debug!(output_len = outcome.value.len(), "{LOG_PREFIX} prompt:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} prompt:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} prompt:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -112,7 +112,7 @@ pub async fn inference_vision_prompt(
|
||||
output_len = outcome.value.len(),
|
||||
"{LOG_PREFIX} vision_prompt:ok"
|
||||
),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} vision_prompt:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} vision_prompt:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -129,7 +129,7 @@ pub async fn inference_embed(
|
||||
dimensions = outcome.value.dimensions,
|
||||
"{LOG_PREFIX} embed:ok"
|
||||
),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} embed:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} embed:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -229,7 +229,7 @@ pub async fn inference_should_react(
|
||||
should_react = outcome.value.should_react,
|
||||
"{LOG_PREFIX} should_react:ok"
|
||||
),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} should_react:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} should_react:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -247,7 +247,7 @@ pub async fn inference_analyze_sentiment(
|
||||
Ok(outcome) => {
|
||||
debug!(valence = %outcome.value.valence, "{LOG_PREFIX} analyze_sentiment:ok")
|
||||
}
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} analyze_sentiment:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} analyze_sentiment:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -257,7 +257,7 @@ pub async fn inference_get_client_config() -> Result<RpcOutcome<Value>, String>
|
||||
let result = config_rpc::load_and_get_client_config_snapshot().await;
|
||||
match &result {
|
||||
Ok(_) => debug!("{LOG_PREFIX} get_client_config:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} get_client_config:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} get_client_config:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -269,7 +269,7 @@ pub async fn inference_update_model_settings(
|
||||
let result = config_rpc::load_and_apply_model_settings(update).await;
|
||||
match &result {
|
||||
Ok(_) => debug!("{LOG_PREFIX} update_model_settings:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} update_model_settings:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} update_model_settings:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -281,7 +281,7 @@ pub async fn inference_update_local_settings(
|
||||
let result = config_rpc::load_and_apply_local_ai_settings(update).await;
|
||||
match &result {
|
||||
Ok(_) => debug!("{LOG_PREFIX} update_local_settings:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} update_local_settings:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} update_local_settings:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -479,7 +479,7 @@ pub async fn inference_openai_oauth_start(config: &Config) -> Result<RpcOutcome<
|
||||
});
|
||||
match &result {
|
||||
Ok(_) => debug!("{LOG_PREFIX} openai_oauth_start:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_start:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} openai_oauth_start:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -498,7 +498,7 @@ pub async fn inference_openai_oauth_complete(
|
||||
.map(|payload| RpcOutcome::single_log(payload, "openai oauth connected"));
|
||||
match &result {
|
||||
Ok(_) => debug!("{LOG_PREFIX} openai_oauth_complete:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_complete:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} openai_oauth_complete:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -543,7 +543,7 @@ pub async fn inference_openai_oauth_status(config: &Config) -> Result<RpcOutcome
|
||||
});
|
||||
match &result {
|
||||
Ok(_) => debug!("{LOG_PREFIX} openai_oauth_status:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_status:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} openai_oauth_status:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -556,7 +556,7 @@ pub async fn inference_openai_oauth_disconnect(
|
||||
.map(|payload| RpcOutcome::single_log(payload, "openai oauth disconnected"));
|
||||
match &result {
|
||||
Ok(_) => debug!("{LOG_PREFIX} openai_oauth_disconnect:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_disconnect:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} openai_oauth_disconnect:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -574,7 +574,7 @@ pub async fn inference_diagnostics(config: &Config) -> Result<RpcOutcome<Value>,
|
||||
.map(|value| RpcOutcome::new(value, Vec::new()));
|
||||
match &result {
|
||||
Ok(_) => debug!("{LOG_PREFIX} diagnostics:ok"),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} diagnostics:error"),
|
||||
Err(err) => warn!(error = %err, "{LOG_PREFIX} diagnostics:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Workspace paths for Ollama, Whisper, Piper, and downloaded assets.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
use log::warn;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
@@ -487,6 +490,106 @@ pub(crate) fn tts_model_target_path(config: &Config) -> PathBuf {
|
||||
.join(filename)
|
||||
}
|
||||
|
||||
// ── Windows DLL-not-found detection + backoff ───────────────────────────
|
||||
|
||||
/// Returns true when `exit_code` signals that the child process could not
|
||||
/// load a required DLL (Windows `STATUS_DLL_NOT_FOUND` / `0xC0000135`).
|
||||
/// Always returns false on non-Windows platforms.
|
||||
///
|
||||
/// The kernel maps a DLL-loader failure (e.g. missing Visual C++
|
||||
/// Redistributable) into this value as the process exit status when a
|
||||
/// console-subsystem binary loads but the OS loader cannot resolve a
|
||||
/// required import library.
|
||||
pub(crate) fn is_dll_not_found_exit(exit_code: Option<i32>) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// STATUS_DLL_NOT_FOUND (0xC0000135) as a signed i32.
|
||||
// Windows NT status codes returned through GetExitCodeProcess are
|
||||
// 32-bit unsigned; Rust's ExitStatus::code() reinterprets the
|
||||
// bit pattern as i32, so 0xC0000135 → -1073741515.
|
||||
exit_code == Some(-1073741515)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = exit_code;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Cooldown period for DLL-not-found error reports (5 minutes). Prevents
|
||||
/// 400+ Sentry events from a single missing system library.
|
||||
const DLL_NOT_FOUND_COOLDOWN_SECS: i64 = 300;
|
||||
|
||||
/// Unix timestamp of the last DLL-not-found error that was fully reported.
|
||||
/// Used to throttle repeated errors so a single missing VC++ Redistributable
|
||||
/// does not generate a Sentry event per dictation attempt.
|
||||
static LAST_DLL_NOT_FOUND_REPORT: AtomicI64 = AtomicI64::new(0);
|
||||
|
||||
/// Returns true when we are still inside the DLL-not-found cooldown window
|
||||
/// (a recent attempt already reported the full actionable message).
|
||||
pub(crate) fn is_dll_not_found_backoff_active() -> bool {
|
||||
let now = unix_timestamp_secs();
|
||||
let last = LAST_DLL_NOT_FOUND_REPORT.load(Ordering::Relaxed);
|
||||
now - last < DLL_NOT_FOUND_COOLDOWN_SECS
|
||||
}
|
||||
|
||||
/// Try to claim the DLL-not-found report slot. Returns true if this caller
|
||||
/// is the first to report a DLL error within the cooldown window — it should
|
||||
/// log the full actionable message. Returns false when the cooldown is active
|
||||
/// so the caller can silently degrade instead of duplicating the report.
|
||||
///
|
||||
/// Thread-safe: uses atomic compare-exchange so concurrent subprocess
|
||||
/// invocations do not race past the cooldown.
|
||||
pub(crate) fn try_claim_dll_not_found_report() -> bool {
|
||||
let now = unix_timestamp_secs();
|
||||
let last = LAST_DLL_NOT_FOUND_REPORT.load(Ordering::Relaxed);
|
||||
if now - last < DLL_NOT_FOUND_COOLDOWN_SECS {
|
||||
return false;
|
||||
}
|
||||
// CAS: if `last` hasn't changed since we read it, we win the slot.
|
||||
LAST_DLL_NOT_FOUND_REPORT
|
||||
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Unix timestamp in seconds (monotonic-adjacent via SystemTime).
|
||||
fn unix_timestamp_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
/// Reset the DLL-not-found backoff state (test-only). Each test that calls
|
||||
/// `try_claim_dll_not_found_report` or `report_dll_not_found` must invoke this
|
||||
/// in its setup to avoid test-order-dependent cooldown carry-over.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_dll_not_found_backoff_for_test() {
|
||||
LAST_DLL_NOT_FOUND_REPORT.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Log a one-time DLL-not-found warning and return the actionable error
|
||||
/// string. Callers that detect `STATUS_DLL_NOT_FOUND` from a child process
|
||||
/// should use this instead of constructing their own message, so the warning
|
||||
/// log and the cooldown gate stay in one place.
|
||||
///
|
||||
/// Returns `None` when the backoff cooldown is active (the message was
|
||||
/// already reported recently) — the caller should return a short generic
|
||||
/// "whisper unavailable" error instead.
|
||||
pub(crate) fn report_dll_not_found(log_prefix: &str) -> Option<String> {
|
||||
if !try_claim_dll_not_found_report() {
|
||||
return None;
|
||||
}
|
||||
let msg = format!(
|
||||
"{log_prefix} whisper-cli failed with STATUS_DLL_NOT_FOUND (0xC0000135): \
|
||||
a required DLL is missing. On Windows, whisper-cli requires the \
|
||||
Visual C++ Redistributable 2015-2022. \
|
||||
Download from https://aka.ms/vs/17/release/vc_redist.x64.exe"
|
||||
);
|
||||
warn!("{msg}");
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -794,4 +897,61 @@ mod tests {
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(workspace_whisper_dir(&config));
|
||||
}
|
||||
|
||||
// ── DLL-not-found tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dll_not_found_exit_false_on_non_windows() {
|
||||
// On non-Windows, all exit codes return false.
|
||||
assert!(!is_dll_not_found_exit(Some(-1073741515)));
|
||||
assert!(!is_dll_not_found_exit(Some(1)));
|
||||
assert!(!is_dll_not_found_exit(Some(0)));
|
||||
assert!(!is_dll_not_found_exit(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_dll_not_found_claims_first_call() {
|
||||
reset_dll_not_found_backoff_for_test();
|
||||
// First call wins the slot and returns the message.
|
||||
let msg = report_dll_not_found("[test]");
|
||||
assert!(msg.is_some(), "first report must be Some");
|
||||
assert!(msg.unwrap().contains("Visual C++"), "must mention VC++");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_dll_not_found_suppresses_duplicates() {
|
||||
reset_dll_not_found_backoff_for_test();
|
||||
// First call succeeds.
|
||||
assert!(report_dll_not_found("[test]").is_some());
|
||||
// Second call (within cooldown) is suppressed.
|
||||
assert!(
|
||||
report_dll_not_found("[test]").is_none(),
|
||||
"duplicate within cooldown must be suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_claim_returns_true_once_then_false() {
|
||||
reset_dll_not_found_backoff_for_test();
|
||||
assert!(try_claim_dll_not_found_report(), "first claim must succeed");
|
||||
// After the claim, subsequent attempts within the cooldown should fail.
|
||||
assert!(
|
||||
!try_claim_dll_not_found_report(),
|
||||
"second claim within cooldown must fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_backoff_active_after_claim() {
|
||||
reset_dll_not_found_backoff_for_test();
|
||||
assert!(
|
||||
!is_dll_not_found_backoff_active(),
|
||||
"before claim, no backoff"
|
||||
);
|
||||
try_claim_dll_not_found_report();
|
||||
assert!(
|
||||
is_dll_not_found_backoff_active(),
|
||||
"after claim, backoff must be active"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ impl ClaudeAgentSdkProvider {
|
||||
);
|
||||
|
||||
let mut child = cmd.spawn().map_err(|source| {
|
||||
tracing::error!(
|
||||
tracing::warn!(
|
||||
error = %source,
|
||||
binary = %self.config.binary,
|
||||
"[claude_agent_sdk] failed to spawn claude binary"
|
||||
|
||||
@@ -248,6 +248,29 @@ fn with_thread_id(mut request: ModelRequest) -> ModelRequest {
|
||||
request
|
||||
}
|
||||
|
||||
/// Publish a `SessionExpired` event when the backend rejects a crate-native
|
||||
/// model call with `401`/`403` Unauthorized — mirroring the check in
|
||||
/// [`CrateBackedProvider::invoke`](super::CrateBackedProvider) which the
|
||||
/// crate-native path bypasses.
|
||||
fn maybe_publish_session_expired(err: &TinyAgentsError, operation: &str) {
|
||||
if let TinyAgentsError::Provider(pe) = err {
|
||||
if pe.provider.as_str() == "OpenHuman" && matches!(pe.status, Some(401 | 403)) {
|
||||
let reason =
|
||||
crate::openhuman::inference::provider::ops::sanitize_api_error(&pe.message);
|
||||
crate::core::event_bus::publish_global(
|
||||
crate::core::event_bus::DomainEvent::SessionExpired {
|
||||
source: format!(
|
||||
"openhuman_backend_model.{}({})",
|
||||
operation,
|
||||
pe.status.unwrap_or(0)
|
||||
),
|
||||
reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatModel<()> for OpenHumanBackendModel {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
@@ -256,7 +279,13 @@ impl ChatModel<()> for OpenHumanBackendModel {
|
||||
|
||||
async fn invoke(&self, state: &(), request: ModelRequest) -> TaResult<ModelResponse> {
|
||||
let model = self.build_wire_model()?;
|
||||
let response = model.invoke(state, with_thread_id(request)).await?;
|
||||
let response = match model.invoke(state, with_thread_id(request)).await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
maybe_publish_session_expired(&e, "invoke");
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
Ok(project_managed_usage(response))
|
||||
}
|
||||
|
||||
@@ -269,7 +298,13 @@ impl ChatModel<()> for OpenHumanBackendModel {
|
||||
// survive via `UsageDelta`). The authoritative charged amount is recovered
|
||||
// on the non-streaming `invoke` path above. Restoring it for streaming
|
||||
// needs the crate to preserve the final chunk's raw JSON (tracked upstream).
|
||||
model.stream(state, with_thread_id(request)).await
|
||||
match model.stream(state, with_thread_id(request)).await {
|
||||
Ok(stream) => Ok(stream),
|
||||
Err(e) => {
|
||||
maybe_publish_session_expired(&e, "stream");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -272,6 +272,15 @@ fn interpret_whisper_output(
|
||||
model_id: String,
|
||||
) -> Result<RpcOutcome<WhisperTranscribeResult>, String> {
|
||||
if !success {
|
||||
// Windows-specific: STATUS_DLL_NOT_FOUND means the system VC++ runtime
|
||||
// is missing. Surface an actionable message instead of a cryptic exit code.
|
||||
if crate::openhuman::inference::paths::is_dll_not_found_exit(exit_code) {
|
||||
let maybe_msg = crate::openhuman::inference::paths::report_dll_not_found(LOG_PREFIX);
|
||||
let display_msg = maybe_msg.unwrap_or_else(|| {
|
||||
format!("{LOG_PREFIX} whisper-cli unavailable (STATUS_DLL_NOT_FOUND — check VC++ Redistributable)")
|
||||
});
|
||||
return Err(display_msg);
|
||||
}
|
||||
return Err(format!(
|
||||
"{LOG_PREFIX} whisper-cli failed (exit={exit_code:?}): {}",
|
||||
String::from_utf8_lossy(stderr).trim()
|
||||
|
||||
@@ -304,7 +304,7 @@ pub async fn handle_dictation_ws(mut socket: WebSocket, config: Arc<Config>) {
|
||||
match whisper_engine::transcribe_pcm_i16(&service.whisper, &final_samples, None, None) {
|
||||
Ok(result) => result.text,
|
||||
Err(e) => {
|
||||
log::error!("{LOG_PREFIX} final inference error: {e}");
|
||||
log::warn!("{LOG_PREFIX} final inference error: {e}");
|
||||
let msg = serde_json::json!({
|
||||
"type": "error",
|
||||
"message": format!("Transcription failed: {e}"),
|
||||
|
||||
@@ -63,9 +63,10 @@ pub async fn registry_search(
|
||||
.await;
|
||||
|
||||
// A total outage (every registry errored) is distinct from "no perfect
|
||||
// servers": return an error so the UI shows its registry error state instead
|
||||
// of an empty catalog. `merge_registry_results` logs+skips the individual
|
||||
// failures.
|
||||
// servers": log a clear diagnostic and return empty rather than bailing, so
|
||||
// the error never reaches Sentry as a false code-defect signal.
|
||||
// `merge_registry_results` already logs+skips the individual failures at
|
||||
// `warn!` level, so the per-registry detail is preserved in the log stream.
|
||||
let any_ok = results.iter().any(Result::is_ok);
|
||||
let labelled = results
|
||||
.into_iter()
|
||||
@@ -76,7 +77,12 @@ pub async fn registry_search(
|
||||
let (mut merged, mut total_pages) = merge_registry_results(labelled);
|
||||
|
||||
if !any_ok && !registries.is_empty() {
|
||||
anyhow::bail!("all MCP registries failed to respond");
|
||||
let sources: Vec<&str> = registries.iter().map(|r| r.source()).collect();
|
||||
tracing::warn!(
|
||||
"[mcp-registry] all configured registries failed to respond — returning empty catalog (tried {})",
|
||||
sources.join(", ")
|
||||
);
|
||||
return Ok((vec![], 0));
|
||||
}
|
||||
|
||||
// Badge the canonical first-party server, drop non-perfect rows, refine
|
||||
|
||||
@@ -27,15 +27,27 @@ impl UnifiedMemory {
|
||||
);
|
||||
return Err("document namespace/key cannot contain secrets".to_string());
|
||||
}
|
||||
if safety::pii::has_likely_pii(&input.namespace) || safety::pii::has_likely_pii(&input.key)
|
||||
{
|
||||
log::warn!(
|
||||
"[memory:safety] document write rejected due to PII-like namespace/key namespace_chars={} key_chars={}",
|
||||
input.namespace.chars().count(),
|
||||
input.key.chars().count()
|
||||
);
|
||||
return Err("document namespace/key cannot contain personal identifiers".to_string());
|
||||
}
|
||||
|
||||
// Auto-sanitize PII from namespace/key rather than rejecting the entire
|
||||
// write (see #5164). Previously this returned an Err, which caused
|
||||
// unthrottled retry loops when caller-generated identifiers happened to
|
||||
// contain structured personal identifiers (CPF, SSN, RFC, etc.).
|
||||
let input = {
|
||||
let key = safety::pii::redact_pii(&input.key);
|
||||
let namespace = safety::pii::redact_pii(&input.namespace);
|
||||
if key.report.pii_redactions > 0 || namespace.report.pii_redactions > 0 {
|
||||
log::info!(
|
||||
"[memory:safety] document write auto-sanitized PII from namespace/key original_len_ns={} original_len_key={}",
|
||||
input.namespace.chars().count(),
|
||||
input.key.chars().count()
|
||||
);
|
||||
}
|
||||
NamespaceDocumentInput {
|
||||
namespace: namespace.value,
|
||||
key: key.value,
|
||||
..input
|
||||
}
|
||||
};
|
||||
|
||||
let sanitized = safety::sanitize_document_input(input);
|
||||
let input = sanitized.value;
|
||||
@@ -244,15 +256,24 @@ impl UnifiedMemory {
|
||||
);
|
||||
return Err("document namespace/key cannot contain secrets".to_string());
|
||||
}
|
||||
if safety::pii::has_likely_pii(&input.namespace) || safety::pii::has_likely_pii(&input.key)
|
||||
{
|
||||
log::warn!(
|
||||
"[memory:safety] metadata-only write rejected due to PII-like namespace/key namespace_chars={} key_chars={}",
|
||||
input.namespace.chars().count(),
|
||||
input.key.chars().count()
|
||||
);
|
||||
return Err("document namespace/key cannot contain personal identifiers".to_string());
|
||||
}
|
||||
|
||||
// Auto-sanitize PII from namespace/key rather than rejecting (see #5164).
|
||||
let input = {
|
||||
let key = safety::pii::redact_pii(&input.key);
|
||||
let namespace = safety::pii::redact_pii(&input.namespace);
|
||||
if key.report.pii_redactions > 0 || namespace.report.pii_redactions > 0 {
|
||||
log::info!(
|
||||
"[memory:safety] metadata-only write auto-sanitized PII from namespace/key original_len_ns={} original_len_key={}",
|
||||
input.namespace.chars().count(),
|
||||
input.key.chars().count()
|
||||
);
|
||||
}
|
||||
NamespaceDocumentInput {
|
||||
namespace: namespace.value,
|
||||
key: key.value,
|
||||
..input
|
||||
}
|
||||
};
|
||||
|
||||
let sanitized = safety::sanitize_document_input(input);
|
||||
let input = sanitized.value;
|
||||
@@ -371,7 +392,7 @@ impl UnifiedMemory {
|
||||
namespace: &str,
|
||||
) -> Result<Vec<StoredMemoryDocument>, String> {
|
||||
let conn = self.conn.lock();
|
||||
let ns = Self::sanitize_namespace(namespace);
|
||||
let ns = Self::sanitize_namespace(&safety::pii::redact_pii(namespace).value);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT
|
||||
@@ -448,7 +469,9 @@ impl UnifiedMemory {
|
||||
)
|
||||
.map_err(|e| format!("prepare list_documents: {e}"))?;
|
||||
let mut rows = stmt
|
||||
.query(params![Self::sanitize_namespace(ns)])
|
||||
.query(params![Self::sanitize_namespace(
|
||||
&safety::pii::redact_pii(ns).value
|
||||
)])
|
||||
.map_err(|e| format!("query list_documents: {e}"))?;
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
@@ -522,7 +545,7 @@ impl UnifiedMemory {
|
||||
/// for the given namespace in a single transaction. Also removes the
|
||||
/// on-disk markdown directory (`namespaces/{ns}/docs/`).
|
||||
pub async fn clear_namespace(&self, namespace: &str) -> Result<(), String> {
|
||||
let ns = Self::sanitize_namespace(namespace);
|
||||
let ns = Self::sanitize_namespace(&safety::pii::redact_pii(namespace).value);
|
||||
log::debug!("[memory] clear_namespace: starting for namespace={ns}");
|
||||
|
||||
{
|
||||
@@ -595,7 +618,7 @@ impl UnifiedMemory {
|
||||
namespace: &str,
|
||||
document_id: &str,
|
||||
) -> Result<Value, String> {
|
||||
let ns = Self::sanitize_namespace(namespace);
|
||||
let ns = Self::sanitize_namespace(&safety::pii::redact_pii(namespace).value);
|
||||
let rel_path: Option<String> = {
|
||||
let conn = self.conn.lock();
|
||||
conn.query_row(
|
||||
|
||||
@@ -1108,66 +1108,65 @@ async fn upsert_document_metadata_only_rejects_secret_like_key() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personal-identifier (PII) rejection at the namespace/key boundary.
|
||||
// Personal-identifier (PII) at the namespace/key boundary — auto-sanitize.
|
||||
//
|
||||
// Mirrors the secret-like rejection tests above, exercising the
|
||||
// `safety::pii::has_likely_pii` early-return branches added to
|
||||
// `kv_set_global`, `kv_set_namespace`, `upsert_document`, and
|
||||
// `upsert_document_metadata_only`. Each branch returns the
|
||||
// `"cannot contain personal identifiers"` error.
|
||||
// Rather than rejecting writes with PII-like keys/namespaces (which caused
|
||||
// unthrottled retry loops, see #5164), the store now auto-sanitizes the
|
||||
// namespace and key using `redact_pii` before persisting. These tests verify
|
||||
// the upsert succeeds and the stored key/namespace contains redacted tokens.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn kv_set_global_rejects_pii_like_key() {
|
||||
async fn kv_set_global_auto_sanitizes_pii_like_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let err = memory
|
||||
// SSN-like key should be auto-sanitized, not rejected.
|
||||
memory
|
||||
.kv_set_global("ssn-123-45-6789", &json!({"value": "ok"}))
|
||||
.await
|
||||
.expect_err("PII-like global key should be rejected");
|
||||
.expect("PII-like global key should be auto-sanitized, not rejected");
|
||||
|
||||
// The key in storage contains the redacted token.
|
||||
let stored = memory.kv_get_global("ssn-123-45-6789").await.unwrap();
|
||||
assert!(
|
||||
err.contains("cannot contain personal identifiers"),
|
||||
"unexpected error: {err}"
|
||||
stored.is_none(),
|
||||
"original PII key should not match after sanitization"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kv_set_namespace_rejects_pii_like_key() {
|
||||
async fn kv_set_namespace_auto_sanitizes_pii_like_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let err = memory
|
||||
memory
|
||||
.kv_set_namespace("safe", "ssn-123-45-6789", &json!({"value": "ok"}))
|
||||
.await
|
||||
.expect_err("PII-like key should be rejected");
|
||||
assert!(
|
||||
err.contains("cannot contain personal identifiers"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
.expect("PII-like key should be auto-sanitized, not rejected");
|
||||
|
||||
let records = memory.kv_records_namespace("safe").await.unwrap();
|
||||
// The record should still exist; the key gets redacted internally.
|
||||
assert_eq!(records.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kv_set_namespace_rejects_pii_like_namespace() {
|
||||
async fn kv_set_namespace_auto_sanitizes_pii_like_namespace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let err = memory
|
||||
memory
|
||||
.kv_set_namespace("user/111.444.777-35", "safe-key", &json!({"value": "ok"}))
|
||||
.await
|
||||
.expect_err("PII-like namespace should be rejected");
|
||||
assert!(
|
||||
err.contains("cannot contain personal identifiers"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
.expect("PII-like namespace should be auto-sanitized, not rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_document_rejects_pii_like_key() {
|
||||
async fn upsert_document_auto_sanitizes_pii_like_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let err = memory
|
||||
let doc_id = memory
|
||||
.upsert_document(NamespaceDocumentInput {
|
||||
namespace: "safe".to_string(),
|
||||
key: "cuit-20-11111111-2".to_string(),
|
||||
@@ -1183,19 +1182,25 @@ async fn upsert_document_rejects_pii_like_key() {
|
||||
taint: crate::openhuman::memory::MemoryTaint::Internal,
|
||||
})
|
||||
.await
|
||||
.expect_err("PII-like key should be rejected");
|
||||
.expect("PII-like key should be auto-sanitized, not rejected");
|
||||
|
||||
// The document was stored with a redacted key.
|
||||
let docs = memory.load_documents_for_scope("safe").await.unwrap();
|
||||
let doc = docs.iter().find(|d| d.document_id == doc_id).unwrap();
|
||||
assert!(!doc.key.contains("20-11111111-2"), "key should be redacted");
|
||||
assert!(
|
||||
err.contains("cannot contain personal identifiers"),
|
||||
"unexpected error: {err}"
|
||||
doc.key.contains("REDACTED"), // matches [REDACTED_PII_CUIT]
|
||||
"key should contain a redaction token, got: {}",
|
||||
doc.key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_document_rejects_pii_like_namespace() {
|
||||
async fn upsert_document_auto_sanitizes_pii_like_namespace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let err = memory
|
||||
let doc_id = memory
|
||||
.upsert_document(NamespaceDocumentInput {
|
||||
namespace: "cliente-RFC-VECJ880326XK4".to_string(),
|
||||
key: "k1".to_string(),
|
||||
@@ -1211,19 +1216,28 @@ async fn upsert_document_rejects_pii_like_namespace() {
|
||||
taint: crate::openhuman::memory::MemoryTaint::Internal,
|
||||
})
|
||||
.await
|
||||
.expect_err("PII-like namespace should be rejected");
|
||||
.expect("PII-like namespace should be auto-sanitized, not rejected");
|
||||
|
||||
// Look up the document by sanitized namespace (note sanitize_namespace
|
||||
// normalises special chars, so `[` becomes `_`).
|
||||
let docs = memory
|
||||
.load_documents_for_scope("cliente-RFC-VECJ880326XK4")
|
||||
.await
|
||||
.unwrap();
|
||||
let doc = docs.iter().find(|d| d.document_id == doc_id).unwrap();
|
||||
assert!(
|
||||
err.contains("cannot contain personal identifiers"),
|
||||
"unexpected error: {err}"
|
||||
doc.namespace.contains("REDACTED"), // [REDACTED_PII_RFC] after sanitize_namespace
|
||||
"namespace should contain a redaction token, got: {}",
|
||||
doc.namespace
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_document_metadata_only_rejects_pii_like_key() {
|
||||
async fn upsert_document_metadata_only_auto_sanitizes_pii_like_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let err = memory
|
||||
let doc_id = memory
|
||||
.upsert_document_metadata_only(NamespaceDocumentInput {
|
||||
namespace: "safe".to_string(),
|
||||
key: "ssn-123-45-6789".to_string(),
|
||||
@@ -1239,19 +1253,23 @@ async fn upsert_document_metadata_only_rejects_pii_like_key() {
|
||||
taint: crate::openhuman::memory::MemoryTaint::Internal,
|
||||
})
|
||||
.await
|
||||
.expect_err("PII-like key should be rejected");
|
||||
.expect("PII-like key should be auto-sanitized, not rejected");
|
||||
|
||||
let docs = memory.load_documents_for_scope("safe").await.unwrap();
|
||||
let doc = docs.iter().find(|d| d.document_id == doc_id).unwrap();
|
||||
assert!(
|
||||
err.contains("cannot contain personal identifiers"),
|
||||
"unexpected error: {err}"
|
||||
doc.key.contains("REDACTED"), // [REDACTED_PII_SSN]
|
||||
"key should contain a redaction token, got: {}",
|
||||
doc.key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_document_metadata_only_rejects_pii_like_namespace() {
|
||||
async fn upsert_document_metadata_only_auto_sanitizes_pii_like_namespace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let err = memory
|
||||
let doc_id = memory
|
||||
.upsert_document_metadata_only(NamespaceDocumentInput {
|
||||
namespace: "user/111.444.777-35".to_string(),
|
||||
key: "safe-key".to_string(),
|
||||
@@ -1267,9 +1285,16 @@ async fn upsert_document_metadata_only_rejects_pii_like_namespace() {
|
||||
taint: crate::openhuman::memory::MemoryTaint::Internal,
|
||||
})
|
||||
.await
|
||||
.expect_err("PII-like namespace should be rejected");
|
||||
.expect("PII-like namespace should be auto-sanitized, not rejected");
|
||||
|
||||
let docs = memory
|
||||
.load_documents_for_scope("user/111.444.777-35")
|
||||
.await
|
||||
.unwrap();
|
||||
let doc = docs.iter().find(|d| d.document_id == doc_id).unwrap();
|
||||
assert!(
|
||||
err.contains("cannot contain personal identifiers"),
|
||||
"unexpected error: {err}"
|
||||
doc.namespace.contains("REDACTED"), // [REDACTED_PII_CPF] after sanitize_namespace
|
||||
"namespace should contain a redaction token, got: {}",
|
||||
doc.namespace
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
//! content scrubbing runs inside the crate `sanitize_text`. Host consumers keep
|
||||
//! their `safety::pii::has_likely_pii` import path.
|
||||
|
||||
pub use tinycortex::memory::store::safety::pii::has_likely_pii;
|
||||
pub use tinycortex::memory::store::safety::pii::{has_likely_pii, redact_pii};
|
||||
|
||||
@@ -69,25 +69,46 @@ pub async fn ingest_rpc(
|
||||
// and the DB work is isolated on `spawn_blocking` inside `persist`.
|
||||
let result = match source_kind {
|
||||
SourceKind::Chat => {
|
||||
let batch: ChatBatch = serde_json::from_value(payload)
|
||||
.map_err(|e| format!("invalid chat payload: {e}"))?;
|
||||
let batch: ChatBatch = serde_json::from_value(payload).map_err(|e| {
|
||||
let msg = format!("invalid chat payload: {e}");
|
||||
log::warn!("[memory::rpc] invalid payload for chat");
|
||||
msg
|
||||
})?;
|
||||
do_ingest_chat(config, &source_id, &owner, tags, batch)
|
||||
.await
|
||||
.map_err(|e| format!("ingest: {e}"))?
|
||||
.map_err(|e| {
|
||||
let msg = format!("ingest: {e}");
|
||||
log::warn!("[memory::rpc] chat ingestion failed");
|
||||
msg
|
||||
})?
|
||||
}
|
||||
SourceKind::Email => {
|
||||
let thread: EmailThread = serde_json::from_value(payload)
|
||||
.map_err(|e| format!("invalid email payload: {e}"))?;
|
||||
let thread: EmailThread = serde_json::from_value(payload).map_err(|e| {
|
||||
let msg = format!("invalid email payload: {e}");
|
||||
log::warn!("[memory::rpc] invalid payload for email");
|
||||
msg
|
||||
})?;
|
||||
do_ingest_email(config, &source_id, &owner, tags, thread)
|
||||
.await
|
||||
.map_err(|e| format!("ingest: {e}"))?
|
||||
.map_err(|e| {
|
||||
let msg = format!("ingest: {e}");
|
||||
log::warn!("[memory::rpc] email ingestion failed");
|
||||
msg
|
||||
})?
|
||||
}
|
||||
SourceKind::Document => {
|
||||
let doc: DocumentInput = serde_json::from_value(payload)
|
||||
.map_err(|e| format!("invalid document payload: {e}"))?;
|
||||
let doc: DocumentInput = serde_json::from_value(payload).map_err(|e| {
|
||||
let msg = format!("invalid document payload: {e}");
|
||||
log::warn!("[memory::rpc] invalid payload for document");
|
||||
msg
|
||||
})?;
|
||||
do_ingest_document(config, &source_id, &owner, tags, doc)
|
||||
.await
|
||||
.map_err(|e| format!("ingest: {e}"))?
|
||||
.map_err(|e| {
|
||||
let msg = format!("ingest: {e}");
|
||||
log::warn!("[memory::rpc] document ingestion failed");
|
||||
msg
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+16
-1
@@ -638,8 +638,23 @@ pub fn is_transient_fs_error(err: &anyhow::Error) -> bool {
|
||||
// `openhuman.team_get_usage` because this code was not
|
||||
// previously classified as transient and `create_new`
|
||||
// returned a `kind = Other` io::Error on the first try.
|
||||
// 665: ERROR_FILE_SYSTEM_LIMITATION — the NTFS filesystem
|
||||
// is fragmented, the USN journal has overflowed, or a
|
||||
// filter driver resource cap has been hit. Although
|
||||
// not always transient (fragmentation is persistent),
|
||||
// the USN journal and filter-driver cases can resolve
|
||||
// after a delay, so exponential backoff is still
|
||||
// better than an immediate bail + unthrottled outer
|
||||
// retry. The observability module classifies persistent
|
||||
// 665 errors as `ExpectedErrorKind::WindowsFileSystemLimitation`
|
||||
// to prevent Sentry flooding (TAURI-RUST-QT0).
|
||||
// 1224: ERROR_USER_MAPPED_FILE
|
||||
return code == 5 || code == 32 || code == 33 || code == 303 || code == 1224;
|
||||
return code == 5
|
||||
|| code == 32
|
||||
|| code == 33
|
||||
|| code == 303
|
||||
|| code == 665
|
||||
|| code == 1224;
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
|
||||
@@ -255,7 +255,7 @@ pub async fn start_if_enabled(app_config: &Config) {
|
||||
log::debug!("{LOG_PREFIX} microphone capture stream ready");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::error!("{LOG_PREFIX} could not start microphone capture: {e}");
|
||||
log::warn!("{LOG_PREFIX} could not start microphone capture: {e}");
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
@@ -674,7 +674,7 @@ fn spawn_capture_thread(tx: tokio::sync::mpsc::UnboundedSender<Vec<f32>>) -> Res
|
||||
.name("voice-always-on".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = capture_on_thread(tx, &setup_tx) {
|
||||
log::error!("{LOG_PREFIX} capture thread error: {e}");
|
||||
log::warn!("{LOG_PREFIX} capture thread error: {e}");
|
||||
let _ = setup_tx.send(Err(e));
|
||||
}
|
||||
})
|
||||
@@ -702,7 +702,7 @@ fn capture_on_thread(
|
||||
let permission = detect_microphone_permission();
|
||||
log::info!("{LOG_PREFIX} microphone permission: {permission:?}");
|
||||
if matches!(permission, PermissionState::Denied) {
|
||||
log::error!("{LOG_PREFIX} microphone permission denied — always-on cannot capture audio");
|
||||
log::warn!("{LOG_PREFIX} microphone permission denied — always-on cannot capture audio");
|
||||
return Err("microphone permission denied".to_string());
|
||||
}
|
||||
|
||||
|
||||
@@ -244,14 +244,14 @@ fn record_on_thread(
|
||||
debug!("{LOG_PREFIX} microphone permission after request: {updated:?}");
|
||||
if matches!(updated, PermissionState::Denied | PermissionState::Unknown) {
|
||||
let msg = microphone_denied_message();
|
||||
error!("{LOG_PREFIX} {msg}");
|
||||
warn!("{LOG_PREFIX} {msg}");
|
||||
let _ = setup_tx.send(Err(msg.clone()));
|
||||
return Err(msg);
|
||||
}
|
||||
}
|
||||
PermissionState::Denied => {
|
||||
let msg = microphone_denied_message();
|
||||
error!("{LOG_PREFIX} {msg}");
|
||||
warn!("{LOG_PREFIX} {msg}");
|
||||
let _ = setup_tx.send(Err(msg.clone()));
|
||||
return Err(msg);
|
||||
}
|
||||
@@ -269,7 +269,7 @@ fn record_on_thread(
|
||||
// user — and Sentry — gets no signal about *which* audio
|
||||
// failure occurred.
|
||||
let msg = "no default audio input device found".to_string();
|
||||
error!("{LOG_PREFIX} {msg}");
|
||||
warn!("{LOG_PREFIX} {msg}");
|
||||
let _ = setup_tx.send(Err(msg.clone()));
|
||||
return Err(msg);
|
||||
}
|
||||
@@ -356,7 +356,7 @@ fn record_on_thread(
|
||||
samples_writer.lock().extend_from_slice(&gated);
|
||||
}
|
||||
},
|
||||
|err| error!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
|err| warn!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build f32 input stream: {e}"))
|
||||
@@ -376,7 +376,7 @@ fn record_on_thread(
|
||||
samples_writer.lock().extend_from_slice(&gated);
|
||||
}
|
||||
},
|
||||
|err| error!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
|err| warn!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build i16 input stream: {e}"))
|
||||
@@ -396,7 +396,7 @@ fn record_on_thread(
|
||||
samples_writer.lock().extend_from_slice(&gated);
|
||||
}
|
||||
},
|
||||
|err| error!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
|err| warn!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build u16 input stream: {e}"))
|
||||
@@ -434,7 +434,7 @@ fn record_on_thread(
|
||||
sw.lock().extend_from_slice(&gated);
|
||||
}
|
||||
},
|
||||
|err| error!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
|err| warn!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("fallback f32 stream failed: {e}")),
|
||||
@@ -450,7 +450,7 @@ fn record_on_thread(
|
||||
sw.lock().extend_from_slice(&gated);
|
||||
}
|
||||
},
|
||||
|err| error!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
|err| warn!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("fallback i16 stream failed: {e}")),
|
||||
@@ -466,7 +466,7 @@ fn record_on_thread(
|
||||
sw.lock().extend_from_slice(&gated);
|
||||
}
|
||||
},
|
||||
|err| error!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
|err| warn!("{LOG_PREFIX} audio stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("fallback u16 stream failed: {e}")),
|
||||
|
||||
@@ -152,7 +152,7 @@ async fn start_rdev_listener(hotkey_str: String, config: &Config) {
|
||||
let combo = match hotkey::parse_hotkey(&normalized) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("{LOG_PREFIX} failed to parse hotkey '{normalized}': {e}");
|
||||
log::warn!("{LOG_PREFIX} failed to parse hotkey '{normalized}': {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -160,7 +160,7 @@ async fn start_rdev_listener(hotkey_str: String, config: &Config) {
|
||||
let (listener_handle, mut hotkey_rx) = match hotkey::start_listener(combo, mode) {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
log::error!("{LOG_PREFIX} failed to start hotkey listener: {e}");
|
||||
log::warn!("{LOG_PREFIX} failed to start hotkey listener: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{debug, error, info};
|
||||
use log::{debug, info, warn};
|
||||
use parking_lot::Mutex;
|
||||
use rdev::{listen, Event, EventType, Key};
|
||||
use tokio::sync::mpsc;
|
||||
@@ -221,7 +221,7 @@ pub fn start_listener(
|
||||
};
|
||||
|
||||
if let Err(e) = listen(callback) {
|
||||
error!("{LOG_PREFIX} rdev listen error: {e:?}");
|
||||
warn!("{LOG_PREFIX} rdev listen error: {e:?}");
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("failed to spawn hotkey listener thread: {e}"))?;
|
||||
|
||||
@@ -455,7 +455,7 @@ pub(crate) fn handle_voice_server_start(params: Map<String, Value>) -> Controlle
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = server.run(&config_clone).await {
|
||||
log::error!("[voice_server] server exited with error: {e}");
|
||||
log::warn!("[voice_server] server exited with error: {e}");
|
||||
server_for_err.set_last_error(&e).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use log::{debug, info, warn};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -394,7 +394,7 @@ impl VoiceServer {
|
||||
deferred_stop_deadline = None;
|
||||
pending_expected_app = None;
|
||||
pending_generation = None;
|
||||
error!("{LOG_PREFIX} failed to start recording: {e}");
|
||||
warn!("{LOG_PREFIX} failed to start recording: {e}");
|
||||
*self.state.lock().await = ServerState::Idle;
|
||||
*self.last_error.lock().await = Some(e);
|
||||
}
|
||||
@@ -403,7 +403,7 @@ impl VoiceServer {
|
||||
deferred_stop_deadline = None;
|
||||
pending_expected_app = None;
|
||||
pending_generation = None;
|
||||
error!("{LOG_PREFIX} recording setup task dropped");
|
||||
warn!("{LOG_PREFIX} recording setup task dropped");
|
||||
*self.state.lock().await = ServerState::Idle;
|
||||
}
|
||||
}
|
||||
@@ -908,7 +908,7 @@ async fn process_recording_bg(
|
||||
} else {
|
||||
let insert_started = Instant::now();
|
||||
if let Err(e) = text_input::insert_text(text, expected_app.as_deref()) {
|
||||
error!("{LOG_PREFIX} [pipeline={pipeline_id}] stage=deliver_paste FAILED: {e}");
|
||||
warn!("{LOG_PREFIX} [pipeline={pipeline_id}] stage=deliver_paste FAILED: {e}");
|
||||
*last_error.lock().await = Some(e);
|
||||
} else {
|
||||
let insert_elapsed = insert_started.elapsed();
|
||||
@@ -925,13 +925,20 @@ async fn process_recording_bg(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{LOG_PREFIX} [pipeline={pipeline_id}] stage=transcribe FAILED: {e}");
|
||||
// Windows DLL-not-found errors are classified at the
|
||||
// subprocess layer and logged with a 5-minute backoff.
|
||||
// Demote to warn! so they don't flood Sentry (issue #5168).
|
||||
if e.contains("STATUS_DLL_NOT_FOUND") {
|
||||
warn!("{LOG_PREFIX} [pipeline={pipeline_id}] stage=transcribe DLL_UNAVAILABLE: {e}");
|
||||
} else {
|
||||
warn!("{LOG_PREFIX} [pipeline={pipeline_id}] stage=transcribe FAILED: {e}");
|
||||
}
|
||||
*last_error.lock().await = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{LOG_PREFIX} [pipeline={pipeline_id}] stage=stop_recording FAILED: {e}");
|
||||
warn!("{LOG_PREFIX} [pipeline={pipeline_id}] stage=stop_recording FAILED: {e}");
|
||||
*last_error.lock().await = Some(e);
|
||||
}
|
||||
}
|
||||
@@ -1035,7 +1042,7 @@ pub async fn start_if_enabled(app_config: &Config) {
|
||||
let server_for_err = server.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = server.run(&config_for_run).await {
|
||||
error!("{LOG_PREFIX} embedded voice server exited with error: {e}");
|
||||
warn!("{LOG_PREFIX} embedded voice server exited with error: {e}");
|
||||
server_for_err.set_last_error(&e).await;
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/tinycortex updated: 55bf0661d0...108b8e0207
Reference in New Issue
Block a user