diff --git a/.env.example b/.env.example index db9f7b55f..5f18833ae 100644 --- a/.env.example +++ b/.env.example @@ -323,6 +323,14 @@ OPENHUMAN_ANALYTICS_ENABLED=true # OPENHUMAN_COMPOSIO_CLICKUP_SYNC_INTERVAL_SECS=3600 # OPENHUMAN_COMPOSIO_GITHUB_SYNC_INTERVAL_SECS=3600 # OPENHUMAN_COMPOSIO_LINEAR_SYNC_INTERVAL_SECS=3600 +# +# Global memory-sync cadence (seconds) applied to ALL opted-in sources, +# overriding the per-provider defaults above (floored at each provider default +# so we never sync more often than the provider intends). This is the ops-level +# equivalent of the Memory Sources schedule control (#3302). Normally set from +# the UI; this var is for headless / fleet deployments. `0` means "Manual only" +# (periodic auto-sync disabled). Unset → 24h default. +# OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS=86400 # --------------------------------------------------------------------------- # Logging diff --git a/app/src/components/intelligence/MemorySourcesRegistry.tsx b/app/src/components/intelligence/MemorySourcesRegistry.tsx index 2331f576f..cb15d71ba 100644 --- a/app/src/components/intelligence/MemorySourcesRegistry.tsx +++ b/app/src/components/intelligence/MemorySourcesRegistry.tsx @@ -8,6 +8,7 @@ * row dispatches `openhuman.memory_sources_sync` which runs in the * background and emits MemorySyncStageChanged events. */ +import debug from 'debug'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; @@ -28,11 +29,18 @@ import type { ConfirmationModal as ConfirmationModalType, ToastNotification, } from '../../types/intelligence'; +import { + type MemorySyncSettings, + openhumanGetMemorySyncSettings, + openhumanUpdateMemorySyncSettings, +} from '../../utils/tauriCommands/config'; import { memoryTreeFlushSource } from '../../utils/tauriCommands/memoryTree'; import { AddMemorySourceDialog } from './AddMemorySourceDialog'; import { ConfirmationModal } from './ConfirmationModal'; import { SourceSettingsPanel } from './SourceSettingsPanel'; +const log = debug('intelligence:memory-sync'); + interface MemorySourcesRegistryProps { onToast?: (toast: Omit) => void; pollIntervalMs?: number; @@ -292,6 +300,19 @@ export function MemorySourcesRegistry({ return m; }, [statuses]); + // Newest chunk timestamp across every source — the "Last synced …" anchor + // for the global schedule header. Derived from persisted chunk data, so it + // survives restarts. + const overallLastSyncMs = useMemo(() => { + let newest: number | null = null; + for (const s of statuses) { + if (s.last_chunk_at_ms != null && (newest === null || s.last_chunk_at_ms > newest)) { + newest = s.last_chunk_at_ms; + } + } + return newest; + }, [statuses]); + const handleToggle = useCallback( async (source: MemorySourceEntry) => { try { @@ -491,6 +512,8 @@ export function MemorySourcesRegistry({ + + {loading ? (

{t('common.loading')}

) : sources.length === 0 ? ( @@ -532,6 +555,131 @@ export function MemorySourcesRegistry({ ); } +/** Manual-only sentinel — stored as `sync_interval_secs = 0`. */ +const MANUAL_INTERVAL_SECS = 0; +/** Preset cadences offered in the UI (seconds): 4h / 12h / 24h. */ +const SYNC_INTERVAL_PRESETS_SECS = [14_400, 43_200, 86_400]; + +/** Human label for a cadence ("Every 4h" / "Every 30m" / "Manual only"). */ +function intervalChipLabel(secs: number, t: (k: string) => string): string { + if (secs === MANUAL_INTERVAL_SECS) return t('memorySyncInterval.manual'); + if (secs % 3600 === 0) { + return t('memorySyncInterval.everyHours').replace('{h}', String(secs / 3600)); + } + return t('memorySyncInterval.everyMinutes').replace('{m}', String(Math.round(secs / 60))); +} + +interface MemorySyncScheduleProps { + lastSyncMs: number | null; + onToast?: (toast: Omit) => void; +} + +/** + * Global memory-sync schedule control (#3302). Presented like a backup + * schedule: "Last synced … · Sync every …", with preset cadences (4h / 12h / + * 24h) plus "Manual only". Reads/writes `config_*_memory_sync_settings`. + */ +function MemorySyncSchedule({ lastSyncMs, onToast }: MemorySyncScheduleProps) { + const { t } = useT(); + const [settings, setSettings] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let active = true; + const loadSettings = async () => { + try { + const resp = await openhumanGetMemorySyncSettings(); + if (active) setSettings(resp.result); + } catch (err) { + log('get settings failed: %O', err); + } + }; + void loadSettings(); + return () => { + active = false; + }; + }, []); + + const handleSelect = useCallback( + async (secs: number) => { + setSaving(true); + try { + const resp = await openhumanUpdateMemorySyncSettings({ sync_interval_secs: secs }); + setSettings(resp.result); + } catch (err) { + onToast?.({ + type: 'error', + title: t('memorySyncInterval.saveFailed'), + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setSaving(false); + } + }, + [onToast, t] + ); + + if (!settings) return null; + + const lastSync = relativeTimestamp(lastSyncMs, t); + const currentLabel = settings.is_manual + ? t('memorySyncInterval.manual') + : intervalChipLabel(settings.selected_secs, t); + // Option list: backend presets (fall back to the local defaults) + Manual. + const presetSecs = + settings.presets && settings.presets.length > 0 ? settings.presets : SYNC_INTERVAL_PRESETS_SECS; + const options = [...presetSecs, MANUAL_INTERVAL_SECS]; + const selectedSecs = settings.is_manual ? MANUAL_INTERVAL_SECS : settings.selected_secs; + + return ( +
+
+
+

+ {t('memorySyncInterval.title')} +

+

+ + {t('memorySyncInterval.lastSynced')} {lastSync ?? t('memorySyncInterval.never')} + + + {currentLabel} +

+
+
+ {options.map(secs => { + const isSelected = secs === selectedSecs; + return ( + + ); + })} +
+
+
+ ); +} + interface SourceRowProps { source: MemorySourceEntry; status: SourceStatus | null; diff --git a/app/src/components/intelligence/__tests__/MemorySourcesRegistry.test.tsx b/app/src/components/intelligence/__tests__/MemorySourcesRegistry.test.tsx index bc8c32d60..1e86e1660 100644 --- a/app/src/components/intelligence/__tests__/MemorySourcesRegistry.test.tsx +++ b/app/src/components/intelligence/__tests__/MemorySourcesRegistry.test.tsx @@ -8,6 +8,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as service from '../../../services/memorySourcesService'; import type { MemorySourceEntry } from '../../../services/memorySourcesService'; import { renderWithProviders } from '../../../test/test-utils'; +import { + openhumanGetMemorySyncSettings, + openhumanUpdateMemorySyncSettings, +} from '../../../utils/tauriCommands/config'; import { MemorySourcesRegistry } from '../MemorySourcesRegistry'; // Mock the entire service so we don't hit RPC @@ -31,10 +35,33 @@ vi.mock('../../../utils/tauriCommands/memoryTree', () => ({ memoryTreeFlushSource: vi.fn().mockResolvedValue({ seals_fired: 0 }), })); +// Mock the memory-sync schedule config RPCs (#3302). +vi.mock('../../../utils/tauriCommands/config', () => ({ + openhumanGetMemorySyncSettings: vi.fn(), + openhumanUpdateMemorySyncSettings: vi.fn(), +})); + const mockedList = vi.mocked(service.listMemorySources); const mockedStatus = vi.mocked(service.memorySourcesStatusList); const mockedUpdate = vi.mocked(service.updateMemorySource); const mockedApplyAllIn = vi.mocked(service.applyAllIn); +const mockedGetSync = vi.mocked(openhumanGetMemorySyncSettings); +const mockedUpdateSync = vi.mocked(openhumanUpdateMemorySyncSettings); + +function syncSettings(overrides: Record = {}) { + return { + result: { + sync_interval_secs: null, + selected_secs: 86_400, + is_manual: false, + is_default: true, + default_secs: 86_400, + presets: [14_400, 43_200, 86_400], + ...overrides, + }, + logs: [], + }; +} function makeSource(overrides: Partial = {}): MemorySourceEntry { return { @@ -61,6 +88,9 @@ function setup(sources: MemorySourceEntry[] = [makeSource()]) { describe('MemorySourcesRegistry', () => { beforeEach(() => { vi.clearAllMocks(); + // Default: schedule RPCs succeed with the unset/default 24h view. + mockedGetSync.mockResolvedValue(syncSettings()); + mockedUpdateSync.mockResolvedValue(syncSettings()); }); // ------------------------------------------------------------------------- @@ -319,4 +349,85 @@ describe('MemorySourcesRegistry', () => { expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); }); }); + + // ------------------------------------------------------------------------- + // Memory sync schedule (#3302) + // ------------------------------------------------------------------------- + it('renders the sync schedule with the default 24h cadence highlighted', async () => { + setup(); + const schedule = await screen.findByTestId('memory-sync-schedule'); + // Default view highlights the 24h preset. + const preset24h = within(schedule).getByTestId('memory-sync-preset-86400'); + expect(preset24h).toHaveAttribute('aria-checked', 'true'); + // Manual is offered and not selected. + const manual = within(schedule).getByTestId('memory-sync-preset-0'); + expect(manual).toHaveAttribute('aria-checked', 'false'); + // The summary shows the current cadence. + expect(within(schedule).getByTestId('memory-sync-current')).toHaveTextContent('Every 24h'); + }); + + it('shows "Last synced …" from the newest source chunk timestamp', async () => { + mockedList.mockResolvedValue([makeSource()]); + mockedStatus.mockResolvedValue([ + { + source_id: 'src_1', + chunks_synced: 5, + chunks_pending: 0, + last_chunk_at_ms: Date.now() - 2 * 60 * 60 * 1000, + freshness: 'recent', + }, + ]); + renderWithProviders(); + const schedule = await screen.findByTestId('memory-sync-schedule'); + await waitFor(() => { + expect(schedule).toHaveTextContent(/Last synced/i); + expect(schedule).toHaveTextContent(/2h ago/i); + }); + }); + + it('selecting a preset calls update with the cadence in seconds', async () => { + setup(); + const schedule = await screen.findByTestId('memory-sync-schedule'); + + mockedUpdateSync.mockResolvedValue( + syncSettings({ sync_interval_secs: 14_400, selected_secs: 14_400, is_default: false }) + ); + fireEvent.click(within(schedule).getByTestId('memory-sync-preset-14400')); + + await waitFor(() => { + expect(mockedUpdateSync).toHaveBeenCalledWith({ sync_interval_secs: 14_400 }); + }); + await waitFor(() => { + expect(within(schedule).getByTestId('memory-sync-current')).toHaveTextContent('Every 4h'); + }); + }); + + it('selecting Manual sends sync_interval_secs = 0 and shows Manual only', async () => { + setup(); + const schedule = await screen.findByTestId('memory-sync-schedule'); + + mockedUpdateSync.mockResolvedValue( + syncSettings({ sync_interval_secs: 0, selected_secs: 0, is_manual: true, is_default: false }) + ); + fireEvent.click(within(schedule).getByTestId('memory-sync-preset-0')); + + await waitFor(() => { + expect(mockedUpdateSync).toHaveBeenCalledWith({ sync_interval_secs: 0 }); + }); + await waitFor(() => { + expect(within(schedule).getByTestId('memory-sync-current')).toHaveTextContent('Manual only'); + }); + }); + + it('schedule update failure shows an error toast', async () => { + const { onToast } = setup(); + const schedule = await screen.findByTestId('memory-sync-schedule'); + + mockedUpdateSync.mockRejectedValue(new Error('RPC down')); + fireEvent.click(within(schedule).getByTestId('memory-sync-preset-14400')); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); + }); + }); }); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 9fb469610..bca179998 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1926,6 +1926,13 @@ const messages: TranslationMap = { 'sync.sync': 'مزامنة', 'sync.failedToLoad': 'فشل تحميل حالة المزامنة', 'sync.noContent': 'لم تتم مزامنة أي محتوى في الذاكرة بعد. اربط تكاملاً للبدء.', + 'memorySyncInterval.title': 'جدول المزامنة', + 'memorySyncInterval.lastSynced': 'آخر مزامنة', + 'memorySyncInterval.never': 'أبدًا', + 'memorySyncInterval.everyHours': 'كل {h} ساعة', + 'memorySyncInterval.everyMinutes': 'كل {m} دقيقة', + 'memorySyncInterval.manual': 'يدوي فقط', + 'memorySyncInterval.saveFailed': 'فشل تحديث جدول المزامنة', 'memorySources.title': 'المصادر الذاكرة', 'memorySources.empty': 'لا توجد مصادر للذاكرة بعد أضف واحدة لبدء تغذية الذاكرة.', 'memorySources.customSources': 'المصادر العرفية', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 53370dc94..97f9e1968 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1965,6 +1965,13 @@ const messages: TranslationMap = { 'sync.failedToLoad': 'সিঙ্ক স্ট্যাটাস লোড করতে ব্যর্থ', 'sync.noContent': 'এখনো কোনো কন্টেন্ট মেমোরিতে সিঙ্ক হয়নি। শুরু করতে একটি ইন্টিগ্রেশন সংযুক্ত করুন।', + 'memorySyncInterval.title': 'সিঙ্ক সময়সূচি', + 'memorySyncInterval.lastSynced': 'সর্বশেষ সিঙ্ক', + 'memorySyncInterval.never': 'কখনও না', + 'memorySyncInterval.everyHours': 'প্রতি {h} ঘণ্টা', + 'memorySyncInterval.everyMinutes': 'প্রতি {m} মিনিট', + 'memorySyncInterval.manual': 'শুধু ম্যানুয়াল', + 'memorySyncInterval.saveFailed': 'সিঙ্ক সময়সূচি আপডেট করা যায়নি', 'memorySources.title': 'মেমরি উৎস', 'memorySources.empty': 'কোনো মেমরি পাওয়া যায়নি। স্মৃতি খাওয়া শুরু করতে একটা যোগ করো।', 'memorySources.customSources': 'স্বনির্ধারিত উৎস', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b468a30b4..b8041aaee 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2014,6 +2014,13 @@ const messages: TranslationMap = { 'sync.failedToLoad': 'Der Synchronisierungsstatus konnte nicht geladen werden', 'sync.noContent': 'Es wurden noch keine Inhalte in den Speicher synchronisiert. Verbinde eine Integration, um zu beginnen.', + 'memorySyncInterval.title': 'Synchronisierungsplan', + 'memorySyncInterval.lastSynced': 'Zuletzt synchronisiert', + 'memorySyncInterval.never': 'nie', + 'memorySyncInterval.everyHours': 'Alle {h} Std.', + 'memorySyncInterval.everyMinutes': 'Alle {m} Min.', + 'memorySyncInterval.manual': 'Nur manuell', + 'memorySyncInterval.saveFailed': 'Synchronisierungsplan konnte nicht aktualisiert werden', 'memorySources.title': 'Speicherquellen', 'memorySources.empty': 'Noch keine Speicherquellen. Fügen Sie einen hinzu, um den Fütterungsspeicher zu starten', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index b70292f73..cc4d13c8b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2419,6 +2419,15 @@ const en: TranslationMap = { 'sync.failedToLoad': 'Failed to load sync status', 'sync.noContent': 'No content has been synced into memory yet. Connect an integration to start.', + // Memory Sync Schedule (global cadence) + 'memorySyncInterval.title': 'Sync schedule', + 'memorySyncInterval.lastSynced': 'Last synced', + 'memorySyncInterval.never': 'never', + 'memorySyncInterval.everyHours': 'Every {h}h', + 'memorySyncInterval.everyMinutes': 'Every {m}m', + 'memorySyncInterval.manual': 'Manual only', + 'memorySyncInterval.saveFailed': 'Failed to update sync schedule', + // Memory Sources Registry 'memorySources.title': 'Memory Sources', 'memorySources.empty': 'No memory sources yet. Add one to start feeding memory.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 4c68e1e4c..d58f1b306 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2006,6 +2006,13 @@ const messages: TranslationMap = { 'sync.failedToLoad': 'No se pudo cargar el estado de sincronización', 'sync.noContent': 'Aún no se ha sincronizado contenido en la memoria. Conecta una integración para empezar.', + 'memorySyncInterval.title': 'Programa de sincronización', + 'memorySyncInterval.lastSynced': 'Última sincronización', + 'memorySyncInterval.never': 'nunca', + 'memorySyncInterval.everyHours': 'Cada {h} h', + 'memorySyncInterval.everyMinutes': 'Cada {m} min', + 'memorySyncInterval.manual': 'Solo manual', + 'memorySyncInterval.saveFailed': 'No se pudo actualizar el programa de sincronización', 'memorySources.title': 'Fuentes de memoria', 'memorySources.empty': 'Aún no hay fuentes de memoria. Agrega una para comenzar a alimentar la memoria.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3b02c7bde..900166826 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2012,6 +2012,13 @@ const messages: TranslationMap = { 'sync.failedToLoad': "Échec du chargement de l'état de synchronisation", 'sync.noContent': "Aucun contenu n'a encore été synchronisé dans la mémoire. Connecte une intégration pour commencer.", + 'memorySyncInterval.title': 'Calendrier de synchronisation', + 'memorySyncInterval.lastSynced': 'Dernière synchronisation', + 'memorySyncInterval.never': 'jamais', + 'memorySyncInterval.everyHours': 'Toutes les {h} h', + 'memorySyncInterval.everyMinutes': 'Toutes les {m} min', + 'memorySyncInterval.manual': 'Manuel uniquement', + 'memorySyncInterval.saveFailed': 'Échec de la mise à jour du calendrier de synchronisation', 'memorySources.title': 'Sources de mémoire', 'memorySources.empty': 'Aucune source de mémoire pour le moment. Ajoutez-en une pour commencer à alimenter la mémoire.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 8db280bda..d30dd5f8e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1965,6 +1965,13 @@ const messages: TranslationMap = { 'sync.failedToLoad': 'सिंक स्टेटस लोड नहीं हो पाई', 'sync.noContent': 'अभी कोई कॉन्टेंट मेमोरी में सिंक नहीं हुआ। शुरू करने के लिए कोई इंटीग्रेशन कनेक्ट करें।', + 'memorySyncInterval.title': 'सिंक शेड्यूल', + 'memorySyncInterval.lastSynced': 'अंतिम सिंक', + 'memorySyncInterval.never': 'कभी नहीं', + 'memorySyncInterval.everyHours': 'हर {h} घंटे', + 'memorySyncInterval.everyMinutes': 'हर {m} मिनट', + 'memorySyncInterval.manual': 'केवल मैन्युअल', + 'memorySyncInterval.saveFailed': 'सिंक शेड्यूल अपडेट करने में विफल', 'memorySources.title': 'मेमोरी स्रोत', 'memorySources.empty': 'अभी तक कोई स्मृति स्रोत नहीं है। एक को खिला मेमोरी शुरू करने के लिए जोड़ें।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index f22c1f17f..284100616 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1968,6 +1968,13 @@ const messages: TranslationMap = { 'sync.failedToLoad': 'Gagal memuat status sinkronisasi', 'sync.noContent': 'Belum ada konten yang disinkronkan ke memori. Hubungkan integrasi untuk memulai.', + 'memorySyncInterval.title': 'Jadwal sinkronisasi', + 'memorySyncInterval.lastSynced': 'Terakhir disinkronkan', + 'memorySyncInterval.never': 'tidak pernah', + 'memorySyncInterval.everyHours': 'Setiap {h} jam', + 'memorySyncInterval.everyMinutes': 'Setiap {m} menit', + 'memorySyncInterval.manual': 'Hanya manual', + 'memorySyncInterval.saveFailed': 'Gagal memperbarui jadwal sinkronisasi', 'memorySources.title': 'Sumber Memori', 'memorySources.empty': 'Belum ada sumber ingatan. Tambahkan satu untuk mulai makan memori.', 'memorySources.customSources': 'Sumber Kustom', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index a3a534820..0956264e4 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1996,6 +1996,14 @@ const messages: TranslationMap = { 'sync.failedToLoad': 'Impossibile caricare lo stato di sincronizzazione', 'sync.noContent': "Nessun contenuto è stato sincronizzato nella memoria. Connetti un'integrazione per iniziare.", + 'memorySyncInterval.title': 'Pianificazione sincronizzazione', + 'memorySyncInterval.lastSynced': 'Ultima sincronizzazione', + 'memorySyncInterval.never': 'mai', + 'memorySyncInterval.everyHours': 'Ogni {h} h', + 'memorySyncInterval.everyMinutes': 'Ogni {m} min', + 'memorySyncInterval.manual': 'Solo manuale', + 'memorySyncInterval.saveFailed': + 'Impossibile aggiornare la pianificazione della sincronizzazione', 'memorySources.title': 'Fonti di memoria', 'memorySources.empty': 'Ancora nessuna fonte di memoria. Aggiungine una per iniziare a alimentare la memoria.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index dc8955891..c8bfd5905 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1943,6 +1943,13 @@ const messages: TranslationMap = { 'sync.sync': '동기화', 'sync.failedToLoad': '동기화 상태를 불러오지 못했습니다', 'sync.noContent': '아직 메모리에 동기화된 콘텐츠가 없습니다. 시작하려면 통합을 연결하세요.', + 'memorySyncInterval.title': '동기화 일정', + 'memorySyncInterval.lastSynced': '마지막 동기화', + 'memorySyncInterval.never': '안 함', + 'memorySyncInterval.everyHours': '{h}시간마다', + 'memorySyncInterval.everyMinutes': '{m}분마다', + 'memorySyncInterval.manual': '수동만', + 'memorySyncInterval.saveFailed': '동기화 일정을 업데이트하지 못했습니다', 'memorySources.title': '메모리 소스', 'memorySources.empty': '아직 메모리 소스가 없습니다. 메모리를 채우려면 하나를 추가하세요.', 'memorySources.customSources': '사용자 지정 소스', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 994972c19..c2d2a302f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1986,6 +1986,13 @@ const messages: TranslationMap = { 'sync.failedToLoad': 'Nie udało się wczytać stanu synchronizacji', 'sync.noContent': 'Żadna treść nie została jeszcze zsynchronizowana do pamięci. Podłącz integrację, aby zacząć.', + 'memorySyncInterval.title': 'Harmonogram synchronizacji', + 'memorySyncInterval.lastSynced': 'Ostatnia synchronizacja', + 'memorySyncInterval.never': 'nigdy', + 'memorySyncInterval.everyHours': 'Co {h} godz.', + 'memorySyncInterval.everyMinutes': 'Co {m} min', + 'memorySyncInterval.manual': 'Tylko ręcznie', + 'memorySyncInterval.saveFailed': 'Nie udało się zaktualizować harmonogramu synchronizacji', 'memorySources.title': 'Źródła pamięci', 'memorySources.empty': 'Brak źródeł pamięci. Dodaj jedno, aby zacząć zasilać pamięć.', 'memorySources.customSources': 'Własne źródła', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 59d755cf8..04c6c1cc1 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2004,6 +2004,13 @@ const messages: TranslationMap = { 'sync.failedToLoad': 'Falha ao carregar status de sincronização', 'sync.noContent': 'Nenhum conteúdo foi sincronizado na memória ainda. Conecte uma integração para começar.', + 'memorySyncInterval.title': 'Agenda de sincronização', + 'memorySyncInterval.lastSynced': 'Última sincronização', + 'memorySyncInterval.never': 'nunca', + 'memorySyncInterval.everyHours': 'A cada {h} h', + 'memorySyncInterval.everyMinutes': 'A cada {m} min', + 'memorySyncInterval.manual': 'Apenas manual', + 'memorySyncInterval.saveFailed': 'Falha ao atualizar a agenda de sincronização', 'memorySources.title': 'Fontes de Memória', 'memorySources.empty': 'Ainda não há fontes de memória. Adicione uma para começar a alimentar a memória.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 3d54b6b6c..0ca703f59 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1978,6 +1978,13 @@ const messages: TranslationMap = { 'sync.sync': 'Синхронизировать', 'sync.failedToLoad': 'Не удалось загрузить статус синхронизации', 'sync.noContent': 'Контент в память ещё не синхронизирован. Подключи интеграцию для начала.', + 'memorySyncInterval.title': 'Расписание синхронизации', + 'memorySyncInterval.lastSynced': 'Последняя синхронизация', + 'memorySyncInterval.never': 'никогда', + 'memorySyncInterval.everyHours': 'Каждые {h} ч', + 'memorySyncInterval.everyMinutes': 'Каждые {m} мин', + 'memorySyncInterval.manual': 'Только вручную', + 'memorySyncInterval.saveFailed': 'Не удалось обновить расписание синхронизации', 'memorySources.title': 'Источники памяти', 'memorySources.empty': 'Источников памяти пока нет. Добавьте один, чтобы начать кормить память.', 'memorySources.customSources': 'Пользовательские источники', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 2a72eda39..3ae81a588 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1865,6 +1865,13 @@ const messages: TranslationMap = { 'sync.sync': '同步', 'sync.failedToLoad': '加载失败', 'sync.noContent': '无可用内容', + 'memorySyncInterval.title': '同步计划', + 'memorySyncInterval.lastSynced': '上次同步', + 'memorySyncInterval.never': '从不', + 'memorySyncInterval.everyHours': '每 {h} 小时', + 'memorySyncInterval.everyMinutes': '每 {m} 分钟', + 'memorySyncInterval.manual': '仅手动', + 'memorySyncInterval.saveFailed': '更新同步计划失败', 'memorySources.title': '记忆来源', 'memorySources.empty': '暂无记忆来源。添加一个来源即可开始注入记忆。', 'memorySources.customSources': '自定义来源', diff --git a/app/src/services/rpcMethods.ts b/app/src/services/rpcMethods.ts index 6b6bebcdd..e7417a9fe 100644 --- a/app/src/services/rpcMethods.ts +++ b/app/src/services/rpcMethods.ts @@ -7,6 +7,7 @@ export const CORE_RPC_METHODS = { configGetComposioTriggerSettings: 'openhuman.config_get_composio_trigger_settings', configGetDashboardSettings: 'openhuman.config_get_dashboard_settings', configGetRuntimeFlags: 'openhuman.config_get_runtime_flags', + configGetMemorySyncSettings: 'openhuman.config_get_memory_sync_settings', configGetSandboxSettings: 'openhuman.config_get_sandbox_settings', configGetSearchSettings: 'openhuman.config_get_search_settings', configUpdateSearchSettings: 'openhuman.config_update_search_settings', @@ -19,6 +20,7 @@ export const CORE_RPC_METHODS = { configUpdateComposioTriggerSettings: 'openhuman.config_update_composio_trigger_settings', configUpdateLocalAiSettings: 'openhuman.config_update_local_ai_settings', configUpdateMemorySettings: 'openhuman.config_update_memory_settings', + configUpdateMemorySyncSettings: 'openhuman.config_update_memory_sync_settings', configUpdateModelSettings: 'openhuman.config_update_model_settings', configUpdateRuntimeSettings: 'openhuman.config_update_runtime_settings', configUpdateSandboxSettings: 'openhuman.config_update_sandbox_settings', diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 0890778a9..db1cc3e77 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -526,6 +526,53 @@ export async function openhumanUpdateSandboxSettings( }); } +// ── Memory sync schedule (#3302) ───────────────────────────────────────────── + +/** Global memory-sync schedule returned by config_get_memory_sync_settings. */ +export interface MemorySyncSettings { + /** Stored value: null = use the default cadence, 0 = Manual only, n>0 = seconds. */ + sync_interval_secs: number | null; + /** Resolved cadence to highlight in the UI (the default when unset; 0 for manual). */ + selected_secs: number; + /** True when the user picked "Manual only" (stored value is 0). */ + is_manual: boolean; + /** True when no explicit choice is stored (falls back to `default_secs`). */ + is_default: boolean; + /** The effective default cadence (seconds) applied when unset (24h). */ + default_secs: number; + /** Preset cadences (seconds) offered in the UI: 4h / 12h / 24h. */ + presets: number[]; +} + +/** Partial update — set `sync_interval_secs` to `null` to reset to default. */ +export interface MemorySyncSettingsUpdate { + /** null = default, 0 = Manual only, n>0 = sync every n seconds. */ + sync_interval_secs?: number | null; +} + +export async function openhumanGetMemorySyncSettings(): Promise< + CommandResponse +> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: CORE_RPC_METHODS.configGetMemorySyncSettings, + }); +} + +export async function openhumanUpdateMemorySyncSettings( + update: MemorySyncSettingsUpdate +): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: CORE_RPC_METHODS.configUpdateMemorySyncSettings, + params: update, + }); +} + // ── Agent execution settings (action/tool timeout) ────────────────────────── /** Agent execution settings as returned by config_get_agent_settings. */ diff --git a/app/test/e2e/specs/memory-sync-schedule.spec.ts b/app/test/e2e/specs/memory-sync-schedule.spec.ts new file mode 100644 index 000000000..bb7eac6b0 --- /dev/null +++ b/app/test/e2e/specs/memory-sync-schedule.spec.ts @@ -0,0 +1,112 @@ +import { browser, expect } from '@wdio/globals'; + +import { waitForApp } from '../helpers/app-helpers'; +import { waitForTestId } from '../helpers/element-helpers'; +import { isTauriDriver } from '../helpers/platform'; +import { resetApp } from '../helpers/reset-app'; +import { startMockServer, stopMockServer } from '../mock-server'; + +/** + * E2E for the global memory-sync schedule control (#3302). + * + * Drives the real React UI + in-process core: navigate to Intelligence → + * Memory, confirm the schedule defaults to "Every 24h", pick the 4h preset, + * confirm the summary updates, then re-navigate to prove the choice persisted + * through `config_update_memory_sync_settings` / `config_get_memory_sync_settings`. + * + * DOM-testid assertions only run on the tauri-driver (Linux CI) lane; the macOS + * Appium Mac2 lane self-skips, matching the other DOM-driven intelligence specs. + */ +function stepLog(message: string, context?: unknown): void { + const stamp = new Date().toISOString(); + if (context === undefined) { + console.log(`[MemorySyncScheduleE2E][${stamp}] ${message}`); + return; + } + console.log(`[MemorySyncScheduleE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2)); +} + +async function gotoMemoryWorkspace(): Promise { + // HashRouter + useSearchParams: the Memory tab lives at + // `#/intelligence?tab=memory`, whose default sub-tab renders the + // MemoryWorkspace (and the MemorySourcesRegistry schedule control). + await browser.execute(() => { + window.location.hash = '/intelligence?tab=memory'; + }); + await browser.pause(2_000); +} + +describe('Memory sync schedule', () => { + before(async function () { + stepLog('Starting Memory Sync Schedule E2E'); + await startMockServer(); + await waitForApp(); + await resetApp('e2e-memory-sync-user'); + }); + + after(async () => { + await stopMockServer(); + }); + + it('defaults to Every 24h and persists a picked preset', async function () { + if (!isTauriDriver()) { + this.skip(); + return; + } + + await gotoMemoryWorkspace(); + + // The schedule control renders independent of any connected sources. + const schedule = await waitForTestId('memory-sync-schedule'); + expect(await schedule.isExisting()).toBe(true); + + // Default (unset) resolves to the 24h preset being selected. + const preset24h = await waitForTestId('memory-sync-preset-86400'); + expect(await preset24h.getAttribute('aria-checked')).toBe('true'); + + const current = await waitForTestId('memory-sync-current'); + expect(await current.getText()).toContain('Every 24h'); + + // Pick the 4h preset — writes config_update_memory_sync_settings. + const preset4h = await waitForTestId('memory-sync-preset-14400'); + await preset4h.click(); + + // The summary reflects the new cadence and the 4h preset becomes selected. + await browser.waitUntil( + async () => { + const el = await waitForTestId('memory-sync-current'); + return (await el.getText()).includes('Every 4h'); + }, + { timeout: 10_000, timeoutMsg: 'sync summary did not update to "Every 4h"' } + ); + expect( + await (await waitForTestId('memory-sync-preset-14400')).getAttribute('aria-checked') + ).toBe('true'); + + // Navigate away and back to prove the value was persisted server-side. + await browser.execute(() => { + window.location.hash = '/intelligence?tab=tasks'; + }); + await browser.pause(1_000); + await gotoMemoryWorkspace(); + + const reloaded = await waitForTestId('memory-sync-current'); + expect(await reloaded.getText()).toContain('Every 4h'); + expect( + await (await waitForTestId('memory-sync-preset-14400')).getAttribute('aria-checked') + ).toBe('true'); + + // Switch to Manual only and confirm the summary flips. + const manual = await waitForTestId('memory-sync-preset-0'); + await manual.click(); + await browser.waitUntil( + async () => { + const el = await waitForTestId('memory-sync-current'); + return (await el.getText()).includes('Manual only'); + }, + { timeout: 10_000, timeoutMsg: 'sync summary did not update to "Manual only"' } + ); + + stepLog('Memory sync schedule preset + persistence verified'); + }); +}); diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 7c8f6e666..2ac0dd99d 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -384,6 +384,25 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: LOCAL_RAW, }, + Capability { + id: "intelligence.memory_sync_schedule", + name: "Memory Sync Schedule", + domain: "config", + category: CapabilityCategory::Intelligence, + description: "Pick a single global cadence for how often all opted-in memory sources \ + auto-sync, presented like a backup schedule (\"Last synced … · Sync every …\"). \ + Presets are every 4h / 12h / 24h, plus \"Manual only\" which disables background \ + auto-sync entirely (you can still sync on demand). The chosen interval overrides each \ + provider's built-in cadence but is floored at it, so syncs never run more often than \ + the provider intends — handy for keeping credit spend predictable. Unset defaults to \ + every 24h.", + how_to: "Intelligence > Memory Sources — choose a Sync every… preset or Manual only. \ + Programmatic: openhuman.config_get_memory_sync_settings / \ + openhuman.config_update_memory_sync_settings (RPC); ops override via the \ + OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS env var (0 = manual).", + status: CapabilityStatus::Beta, + privacy: LOCAL_RAW, + }, Capability { id: "intelligence.embedding_provider_config", name: "Configure Embedding Provider", diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index b91d142ad..3ec7ac135 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -46,10 +46,11 @@ pub use schema::{ SearchEngineCredentials, SearxngConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TeamModelConfig, TelegramConfig, UpdateConfig, UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig, - WebSearchConfig, WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1, - MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, - MODEL_SUMMARIZATION_V1, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_DISABLED, SEARCH_ENGINE_MANAGED, - SEARCH_ENGINE_PARALLEL, SEARCH_ENGINE_QUERIT, + WebSearchConfig, WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MEMORY_SYNC_INTERVAL_SECS, + DEFAULT_MODEL, MEMORY_SYNC_INTERVAL_PRESETS_SECS, MODEL_AGENTIC_V1, MODEL_CHAT_V1, + MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1, + SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_DISABLED, SEARCH_ENGINE_MANAGED, SEARCH_ENGINE_PARALLEL, + SEARCH_ENGINE_QUERIT, }; pub use schemas::{ all_controller_schemas as all_config_controller_schemas, diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 4681eaa23..71be7b97d 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -1180,6 +1180,81 @@ pub async fn load_and_apply_activity_level_settings( apply_activity_level_settings(&mut config, update).await } +/// Patch for the global memory-sync cadence (#3302). +/// +/// `sync_interval_secs` carries the new value to store in +/// [`Config::memory_sync_interval_secs`]: +/// - omitted / `null` → reset to "use the default cadence" (`None`) +/// - `0` → "Manual only" (periodic auto-sync disabled) +/// - `n > 0` → sync every `n` seconds (applied per source as a floor over the +/// provider default by the scheduler) +#[derive(Debug, Default)] +pub struct MemorySyncSettingsPatch { + pub sync_interval_secs: Option, +} + +/// Build the JSON view of the memory-sync settings shared by get + apply. +fn memory_sync_settings_value(stored: Option) -> serde_json::Value { + let is_manual = stored == Some(0); + let is_default = stored.is_none(); + // The cadence the UI should highlight: the stored value when set, else the + // resolved 24h default. `0` (manual) is surfaced verbatim so the UI can + // select the "Manual only" option. + let selected_secs = + stored.unwrap_or(crate::openhuman::config::DEFAULT_MEMORY_SYNC_INTERVAL_SECS); + json!({ + "sync_interval_secs": stored, + "selected_secs": selected_secs, + "is_manual": is_manual, + "is_default": is_default, + "default_secs": crate::openhuman::config::DEFAULT_MEMORY_SYNC_INTERVAL_SECS, + "presets": crate::openhuman::config::MEMORY_SYNC_INTERVAL_PRESETS_SECS, + }) +} + +/// Returns the current global memory-sync cadence and its derived view. +pub async fn get_memory_sync_settings() -> Result, String> { + let config = load_config_with_timeout().await?; + let value = memory_sync_settings_value(config.memory_sync_interval_secs); + Ok(RpcOutcome::single_log(value, "memory sync settings read")) +} + +/// Updates the global memory-sync cadence and persists it. The running +/// scheduler reads `config.memory_sync_interval_secs` fresh on each tick, so +/// the new cadence takes effect from the next tick without a restart. +pub async fn apply_memory_sync_settings( + config: &mut Config, + update: MemorySyncSettingsPatch, +) -> Result, String> { + config.memory_sync_interval_secs = update.sync_interval_secs; + config.save().await.map_err(|e| e.to_string())?; + + tracing::info!( + sync_interval_secs = ?config.memory_sync_interval_secs, + "[config:memory_sync] memory sync interval updated" + ); + + let stored = config.memory_sync_interval_secs; + let value = memory_sync_settings_value(stored); + let msg = match stored { + Some(0) => "memory sync set to Manual only".to_string(), + Some(n) => format!("memory sync interval set to {n}s"), + None => "memory sync interval reset to default".to_string(), + }; + Ok(RpcOutcome::new( + value, + vec![format!("{msg} — saved to {}", config.config_path.display())], + )) +} + +/// Loads the configuration, applies memory-sync settings, and saves it. +pub async fn load_and_apply_memory_sync_settings( + update: MemorySyncSettingsPatch, +) -> Result, String> { + let mut config = load_config_with_timeout().await?; + apply_memory_sync_settings(&mut config, update).await +} + /// Serializes the load-modify-save in [`add_auto_approve_tool`] so two /// concurrent "Always allow" appends (different tools) can't read the same /// `auto_approve`, each push their own, and clobber the other on save diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 421be43cd..03f1496e5 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -331,6 +331,64 @@ fn tmp_config(tmp: &tempfile::TempDir) -> Config { cfg } +#[tokio::test] +async fn apply_memory_sync_settings_stores_interval_and_view() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + + // Pick the 4h preset. + let patch = MemorySyncSettingsPatch { + sync_interval_secs: Some(14_400), + }; + let outcome = apply_memory_sync_settings(&mut cfg, patch) + .await + .expect("apply"); + assert_eq!(cfg.memory_sync_interval_secs, Some(14_400)); + assert_eq!(outcome.value["sync_interval_secs"], 14_400); + assert_eq!(outcome.value["selected_secs"], 14_400); + assert_eq!(outcome.value["is_manual"], false); + assert_eq!(outcome.value["is_default"], false); +} + +#[tokio::test] +async fn apply_memory_sync_settings_manual_only() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + + let patch = MemorySyncSettingsPatch { + sync_interval_secs: Some(0), + }; + let outcome = apply_memory_sync_settings(&mut cfg, patch) + .await + .expect("apply"); + assert_eq!(cfg.memory_sync_interval_secs, Some(0)); + assert_eq!(outcome.value["is_manual"], true); + assert_eq!(outcome.value["sync_interval_secs"], 0); +} + +#[tokio::test] +async fn apply_memory_sync_settings_reset_to_default() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.memory_sync_interval_secs = Some(43_200); + + // Omitted field → None → reset to default. + let patch = MemorySyncSettingsPatch { + sync_interval_secs: None, + }; + let outcome = apply_memory_sync_settings(&mut cfg, patch) + .await + .expect("apply"); + assert_eq!(cfg.memory_sync_interval_secs, None); + assert_eq!(outcome.value["is_default"], true); + assert!(outcome.value["sync_interval_secs"].is_null()); + // The UI still gets a concrete cadence to highlight (the 24h default). + assert_eq!( + outcome.value["selected_secs"], + crate::openhuman::config::DEFAULT_MEMORY_SYNC_INTERVAL_SECS + ); +} + #[tokio::test] async fn apply_model_settings_updates_fields_and_persists_snapshot() { let tmp = tempdir().unwrap(); diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 791823da5..464da83e3 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -136,6 +136,11 @@ pub const PROJECTS_DIR_ENV_VAR: &str = "OPENHUMAN_PROJECTS_DIR"; /// Environment override for the agent action sandbox directory. pub const ACTION_DIR_ENV_VAR: &str = "OPENHUMAN_ACTION_DIR"; +/// Environment override for the global memory-sync cadence (seconds). +/// `0` means "Manual only". See issue #3302 and +/// [`Config::memory_sync_interval_secs`]. +pub const MEMORY_SYNC_INTERVAL_SECS_ENV_VAR: &str = "OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS"; + /// The agent's default **projects home** — a visible, read-write directory /// (`~/OpenHuman/projects`) where the coding agent creates and saves projects, /// kept distinct from the hidden internal state dir (`~/.openhuman/workspace`, @@ -1444,6 +1449,24 @@ impl Config { } } + // Global memory-sync cadence override (#3302). `0` is honoured here + // (unlike the per-provider `OPENHUMAN_COMPOSIO_*_SYNC_INTERVAL_SECS`) + // because it carries the "Manual only" meaning. A non-numeric value + // is warned-and-ignored, leaving the persisted/None value intact. + if let Some(raw) = env.get(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR) { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + match trimmed.parse::() { + Ok(secs) => self.memory_sync_interval_secs = Some(secs), + Err(_) => tracing::warn!( + env = %MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, + value = %raw, + "invalid memory-sync interval ignored; expected an unsigned integer (0 = manual)" + ), + } + } + } + if let Some(language) = env.get("OPENHUMAN_OUTPUT_LANGUAGE") { let language = language.trim(); if !language.is_empty() { diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index eaf0df6ae..83461b049 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -567,6 +567,29 @@ fn env_overlay_autonomy_max_actions_per_hour_accepts_valid_u32() { ); } +#[test] +fn env_overlay_memory_sync_interval_parses_and_honours_zero() { + let mut cfg = Config::default(); + assert!(cfg.memory_sync_interval_secs.is_none()); + + // A positive value is stored verbatim. + cfg.apply_env_overlay_with(&HashMapEnv::new().with(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, "14400")); + assert_eq!(cfg.memory_sync_interval_secs, Some(14_400)); + + // `0` is honoured as the "Manual only" sentinel (unlike the per-provider + // override which rejects it). + cfg.apply_env_overlay_with(&HashMapEnv::new().with(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, "0")); + assert_eq!(cfg.memory_sync_interval_secs, Some(0)); + + // A non-numeric value is ignored, leaving the previous value intact. + cfg.apply_env_overlay_with(&HashMapEnv::new().with(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, "nope")); + assert_eq!(cfg.memory_sync_interval_secs, Some(0)); + + // A blank value is ignored too. + cfg.apply_env_overlay_with(&HashMapEnv::new().with(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, " ")); + assert_eq!(cfg.memory_sync_interval_secs, Some(0)); +} + #[test] fn env_overlay_output_language_accepts_non_empty_value() { let mut cfg = Config::default(); diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index db655ab90..87e2a4c11 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -23,6 +23,16 @@ pub const MODEL_SUMMARIZATION_V1: &str = "summarization-v1"; /// reasoning is needed. pub const DEFAULT_MODEL: &str = MODEL_CHAT_V1; +/// Effective default global memory-sync cadence (seconds) used when +/// [`Config::memory_sync_interval_secs`] is `None` — i.e. the user has not +/// explicitly picked a schedule. 24h, matching the "Sync every 24h" preset +/// surfaced in the Memory Sources UI. See issue #3302. +pub const DEFAULT_MEMORY_SYNC_INTERVAL_SECS: u64 = 86_400; + +/// Preset memory-sync cadences (seconds) offered in the UI: 4h / 12h / 24h. +/// "Manual only" is represented separately by `Some(0)`. See issue #3302. +pub const MEMORY_SYNC_INTERVAL_PRESETS_SECS: [u64; 3] = [14_400, 43_200, 86_400]; + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ModelRegistryEntry { pub id: String, @@ -132,6 +142,23 @@ pub struct Config { #[serde(default)] pub agent_activity_level: AgentActivityLevel, + /// Global memory-sync cadence applied to **all** opted-in memory + /// sources, presented to the user like a backup schedule ("Sync + /// every 4h / 12h / 24h", plus "Manual only"). See issue #3302. + /// + /// Semantics consumed by `memory_sync::composio::periodic`: + /// - `None` — no explicit user choice; the effective cadence falls + /// back to [`DEFAULT_MEMORY_SYNC_INTERVAL_SECS`] (24h). + /// - `Some(0)` — **Manual only**: the periodic scheduler skips + /// auto-sync entirely; manual `memory_sources_sync` still works. + /// - `Some(n)` — sync every `n` seconds, applied per connection as + /// `max(n, provider_default)` so it overrides the provider's own + /// cadence while never syncing more often than the provider intends. + /// + /// Overridable via `OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS` (`0` = manual). + #[serde(default)] + pub memory_sync_interval_secs: Option, + #[serde(default)] pub agent: AgentConfig, @@ -677,6 +704,7 @@ impl Default for Config { scheduler: SchedulerConfig::default(), scheduler_gate: SchedulerGateConfig::default(), agent_activity_level: AgentActivityLevel::default(), + memory_sync_interval_secs: None, agent: AgentConfig::default(), orchestrator: OrchestratorModelConfig::default(), teams: HashMap::new(), diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index 9f699dddd..908cbee87 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -284,6 +284,8 @@ pub fn all_controller_schemas() -> Vec { schemas("get_search_settings"), schemas("get_activity_level_settings"), schemas("update_activity_level_settings"), + schemas("get_memory_sync_settings"), + schemas("update_memory_sync_settings"), schemas("get_sandbox_settings"), schemas("update_sandbox_settings"), ] @@ -447,6 +449,14 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("update_activity_level_settings"), handler: handle_update_activity_level_settings, }, + RegisteredController { + schema: schemas("get_memory_sync_settings"), + handler: handle_get_memory_sync_settings, + }, + RegisteredController { + schema: schemas("update_memory_sync_settings"), + handler: handle_update_memory_sync_settings, + }, RegisteredController { schema: schemas("get_sandbox_settings"), handler: handle_get_sandbox_settings, @@ -971,6 +981,25 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![optional_string("level", "Activity level: off | minimal | moderate | active | always_on (or 0–4).")], outputs: vec![json_output("settings", "Updated activity level settings with cost estimates.")], }, + "get_memory_sync_settings" => ControllerSchema { + namespace: "config", + function: "get_memory_sync_settings", + description: "Get the global memory-sync cadence applied to all opted-in sources: stored value, resolved selected cadence, manual/default flags, the 24h default, and the preset options (4h/12h/24h).", + inputs: vec![], + outputs: vec![json_output("settings", "Memory sync schedule settings.")], + }, + "update_memory_sync_settings" => ControllerSchema { + namespace: "config", + function: "update_memory_sync_settings", + description: "Set the global memory-sync cadence. Omit/null resets to the default; 0 means Manual only (auto-sync disabled); a positive value is seconds between syncs. Takes effect on the next scheduler tick.", + inputs: vec![FieldSchema { + name: "sync_interval_secs", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Seconds between auto-syncs. null = default (24h); 0 = Manual only; n>0 = sync every n seconds.", + required: false, + }], + outputs: vec![json_output("settings", "Updated memory sync schedule settings.")], + }, "get_sandbox_settings" => ControllerSchema { namespace: "config", function: "get_sandbox_settings", @@ -1862,6 +1891,25 @@ fn handle_update_activity_level_settings(params: Map) -> Controll }) } +#[derive(Debug, Deserialize)] +struct MemorySyncSettingsUpdate { + sync_interval_secs: Option, +} + +fn handle_get_memory_sync_settings(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(config_rpc::get_memory_sync_settings().await?) }) +} + +fn handle_update_memory_sync_settings(params: Map) -> ControllerFuture { + Box::pin(async move { + let update = deserialize_params::(params)?; + let patch = config_rpc::MemorySyncSettingsPatch { + sync_interval_secs: update.sync_interval_secs, + }; + to_json(config_rpc::load_and_apply_memory_sync_settings(patch).await?) + }) +} + #[derive(Debug, Deserialize)] struct SandboxSettingsUpdate { backend: Option, diff --git a/src/openhuman/config/schemas_tests.rs b/src/openhuman/config/schemas_tests.rs index 6a4df22e4..97e9ffb7c 100644 --- a/src/openhuman/config/schemas_tests.rs +++ b/src/openhuman/config/schemas_tests.rs @@ -182,6 +182,23 @@ fn autonomy_settings_rpc_is_registered() { assert!(funcs.contains(&"update_autonomy_settings")); } +#[test] +fn memory_sync_settings_rpc_is_registered() { + let funcs: Vec<&str> = all_controller_schemas() + .iter() + .map(|s| s.function) + .collect(); + assert!(funcs.contains(&"get_memory_sync_settings")); + assert!(funcs.contains(&"update_memory_sync_settings")); + // The handler registry must stay in lockstep with the schema list. + let handlers: Vec<&str> = all_registered_controllers() + .iter() + .map(|h| h.schema.function) + .collect(); + assert!(handlers.contains(&"get_memory_sync_settings")); + assert!(handlers.contains(&"update_memory_sync_settings")); +} + #[test] fn deserialize_params_parses_memory_settings_update() { let mut m = Map::new(); diff --git a/src/openhuman/memory_sync/composio/periodic.rs b/src/openhuman/memory_sync/composio/periodic.rs index 557d031ac..b7da0f811 100644 --- a/src/openhuman/memory_sync/composio/periodic.rs +++ b/src/openhuman/memory_sync/composio/periodic.rs @@ -35,8 +35,10 @@ //! (bus subscribers, `on_connection_created`) via //! [`record_sync_success`] so a recent non-periodic sync prevents //! the scheduler from redundantly re-firing. The map is rebuilt on -//! restart, which is fine — a missed periodic sync is harmless -//! because the next tick after restart picks it back up immediately. +//! restart; to keep a user-configured cadence (e.g. "Sync every 24h", +//! #3302) from re-firing on every cold start, the due-check falls back +//! to the **persisted** sync-audit timestamp ([`read_audit_log`]) when +//! the in-memory record is absent — see [`persisted_since_last_sync`]. //! * Errors are logged and swallowed; the scheduler must never panic //! out of its loop or periodic sync stops silently for the rest of //! the process lifetime. @@ -49,6 +51,7 @@ use std::time::{Duration, Instant}; use tokio::time::interval; use crate::openhuman::config::rpc as config_rpc; +use crate::openhuman::config::DEFAULT_MEMORY_SYNC_INTERVAL_SECS; use crate::openhuman::scheduler_gate::gate::current_policy; use crate::openhuman::scheduler_gate::policy::PauseReason; @@ -57,7 +60,10 @@ use crate::openhuman::composio::client::{ create_composio_client, direct_list_connections, ComposioClientKind, }; use crate::openhuman::composio::ops; -use crate::openhuman::memory_sync::sources::audit::{append_audit_entry, SyncAuditEntry}; +use crate::openhuman::memory_sync::sources::audit::{ + append_audit_entry, read_audit_log, SyncAuditEntry, +}; +use chrono::{DateTime, Utc}; /// How often the scheduler wakes up to look for due syncs. Independent /// from per-provider `sync_interval_secs` — this just bounds how long @@ -106,6 +112,86 @@ pub fn record_sync_success(toolkit: &str, connection_id: &str) { } } +/// Resolve the effective periodic sync interval (seconds) for one connection, +/// combining the provider's own default with the user's global +/// memory-sync cadence ([`Config::memory_sync_interval_secs`], #3302). +/// +/// - `global == Some(0)` → `None`: "Manual only" — the scheduler skips this +/// source entirely (manual sync still works). +/// - `global == Some(n)` → `Some(max(n, provider_default))`: the user's +/// cadence overrides the provider default but is floored at it, so we never +/// sync *more* often than the provider intended. +/// - `global == None` → `Some(max(DEFAULT, provider_default))`: no explicit +/// user choice, so fall back to the 24h default cadence (also floored at the +/// provider default). +fn effective_interval_secs(provider_default: u64, global: Option) -> Option { + match global { + Some(0) => None, + Some(n) => Some(n.max(provider_default)), + None => Some(DEFAULT_MEMORY_SYNC_INTERVAL_SECS.max(provider_default)), + } +} + +/// Decide whether a connection is due for a periodic sync right now, given the +/// effective interval and how long ago it last synced this run. +/// +/// `since_last_sync == None` means we have no record of a sync this process +/// lifetime, so we fire immediately (the restart-recovery path). Kept pure so +/// the due-check can be simulated without driving the real `Instant` clock. +fn connection_is_due(interval_secs: u64, since_last_sync: Option) -> bool { + match since_last_sync { + Some(elapsed) => elapsed >= Duration::from_secs(interval_secs), + None => true, + } +} + +/// Build an index of `connection_id → most recent successful Composio sync +/// timestamp` from the persisted sync audit log (#3302). +/// +/// The periodic loop writes audit entries with `source_id = connection_id` and +/// `scope = "{toolkit}:{connection_id}"`; we accept either shape and key by the +/// connection id. Only successful syncs count — matching the in-memory +/// [`record_sync_success`] semantics, which never records a failed tick so the +/// next tick retries. This is the wall-clock record that lets the cadence +/// survive restarts (the in-memory monotonic map cannot). +fn index_last_success_by_connection(entries: &[SyncAuditEntry]) -> HashMap> { + let mut idx: HashMap> = HashMap::new(); + for e in entries { + if e.source_kind != "composio" || !e.success { + continue; + } + let connection_id = e + .scope + .rsplit_once(':') + .map(|(_, c)| c.to_string()) + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| e.source_id.clone()); + idx.entry(connection_id) + .and_modify(|t| { + if e.timestamp > *t { + *t = e.timestamp; + } + }) + .or_insert(e.timestamp); + } + idx +} + +/// Wall-clock elapsed since a connection's last persisted successful sync, if +/// any. Saturates at zero for a future timestamp (clock skew), so a skewed +/// record never reads as "wildly overdue". Returns `None` when the connection +/// has no persisted sync — letting the caller treat it as never-synced. +fn persisted_since_last_sync( + idx: &HashMap>, + connection_id: &str, + now: DateTime, +) -> Option { + idx.get(connection_id).map(|ts| { + let secs = (now - *ts).num_seconds().max(0) as u64; + Duration::from_secs(secs) + }) +} + /// Spawn the periodic sync background task. Idempotent: only the /// first call actually spawns the loop, every subsequent call is a /// cheap no-op (logged at `debug` so it's visible during startup @@ -278,6 +364,20 @@ pub(crate) async fn run_one_tick() -> Result<(), String> { let sync_map = last_sync_map(); + // Global, user-configurable memory-sync cadence (#3302). Applied to every + // opted-in source as a floor/override over the provider's own default; a + // value of `Some(0)` disables periodic auto-sync ("Manual only"). + let global_interval = config.memory_sync_interval_secs; + + // Persisted last-sync fallback (#3302). The in-memory `LAST_SYNC_AT` map is + // rebuilt empty on every launch, so without this a cold start would re-fire + // every connection on the first tick — silently breaking the configured + // "Sync every 24h" gap across app restarts. We index the persisted sync + // audit log (wall-clock timestamps that survive restarts) and use it as the + // due-check fallback whenever the in-memory monotonic record is absent. + let audit_index = index_last_success_by_connection(&read_audit_log(&config)); + let now = Utc::now(); + let mut considered = 0usize; let mut fired = 0usize; for conn in resp.connections { @@ -296,20 +396,32 @@ pub(crate) async fn run_one_tick() -> Result<(), String> { continue; }; - let Some(interval_secs) = provider.sync_interval_secs() else { + let Some(provider_default) = provider.sync_interval_secs() else { // Provider opted out of periodic sync entirely. continue; }; - let key = (toolkit.clone(), conn.id.clone()); - let due = { - let map = sync_map.lock().unwrap_or_else(|e| e.into_inner()); - match map.get(&key) { - Some(when) => when.elapsed() >= Duration::from_secs(interval_secs), - None => true, // never synced this run — fire immediately - } + let Some(interval_secs) = effective_interval_secs(provider_default, global_interval) else { + // User selected "Manual only" — skip auto-sync for this source. + // Manual `memory_sources_sync` still works. + tracing::debug!( + toolkit = %toolkit, + connection_id = %conn.id, + "[composio:periodic] manual-only mode — skipping periodic sync" + ); + continue; }; - if !due { + + let key = (toolkit.clone(), conn.id.clone()); + // Prefer the in-memory monotonic record (most accurate within this run); + // fall back to the persisted audit timestamp so the configured cadence + // is honoured across restarts instead of re-firing on every cold start. + let since_last_sync = { + let map = sync_map.lock().unwrap_or_else(|e| e.into_inner()); + map.get(&key).map(|when| when.elapsed()) + } + .or_else(|| persisted_since_last_sync(&audit_index, &conn.id, now)); + if !connection_is_due(interval_secs, since_last_sync) { continue; } @@ -463,6 +575,219 @@ mod tests { assert!(TICK_SECONDS <= 3600); } + #[test] + fn effective_interval_none_falls_back_to_default() { + // No user choice → 24h default, floored at the provider default. + assert_eq!( + effective_interval_secs(15 * 60, None), + Some(DEFAULT_MEMORY_SYNC_INTERVAL_SECS) + ); + } + + #[test] + fn effective_interval_manual_disables_sync() { + // Some(0) is the "Manual only" sentinel — periodic sync is skipped. + assert_eq!(effective_interval_secs(15 * 60, Some(0)), None); + } + + #[test] + fn effective_interval_override_is_floored_at_provider_default() { + // A user cadence longer than the provider default is honoured as-is. + assert_eq!( + effective_interval_secs(15 * 60, Some(4 * 3600)), + Some(4 * 3600) + ); + // A user cadence shorter than the provider default is clamped up to it + // so we never sync more often than the provider intends. + assert_eq!(effective_interval_secs(30 * 60, Some(60)), Some(30 * 60)); + // Exactly equal stays equal. + assert_eq!(effective_interval_secs(1800, Some(1800)), Some(1800)); + } + + #[test] + fn effective_interval_default_is_floored_at_a_longer_provider_default() { + // If a provider ever defaults to longer than 24h, that wins under None. + let long = DEFAULT_MEMORY_SYNC_INTERVAL_SECS + 3600; + assert_eq!(effective_interval_secs(long, None), Some(long)); + } + + #[test] + fn connection_is_due_compares_elapsed_against_interval() { + let interval = 4 * 3600; + // Never synced this run → always due. + assert!(connection_is_due(interval, None)); + // Synced more recently than the interval → not due. + assert!(!connection_is_due( + interval, + Some(Duration::from_secs(3600)) + )); + // Synced exactly at the interval boundary → due. + assert!(connection_is_due( + interval, + Some(Duration::from_secs(interval)) + )); + // Synced longer ago than the interval → due. + assert!(connection_is_due( + interval, + Some(Duration::from_secs(interval + 1)) + )); + } + + /// End-to-end simulation of the scheduler's per-connection decision: prove + /// that **changing the global setting changes when the next sync fires** + /// (issue #3302 acceptance criterion). We drive the same two pure helpers + /// the live tick uses (`effective_interval_secs` → `connection_is_due`) + /// across realistic last-sync ages, so no clock or network is needed. + #[test] + fn scheduler_decision_honors_the_global_setting() { + // A chatty provider that natively wants to sync every 15 minutes. + let provider_default = 15 * 60; + + // Helper mirroring the live loop: returns whether the connection would + // fire right now, or `None` for "Manual only" (skipped entirely). + let decide = |global: Option, since: Option| -> Option { + effective_interval_secs(provider_default, global) + .map(|interval| connection_is_due(interval, since)) + }; + + let one_hour_ago = Some(Duration::from_secs(3600)); + let five_hours_ago = Some(Duration::from_secs(5 * 3600)); + + // Baseline (no global override): with only the 15m provider default, a + // connection synced an hour ago is already overdue and WOULD fire. + // (This is the behavior the feature is reining in.) + assert!(connection_is_due(provider_default, one_hour_ago)); + + // User picks "every 4h": now that same hour-old connection must NOT + // fire — the global cadence (not the 15m default) governs the gap… + assert_eq!(decide(Some(4 * 3600), one_hour_ago), Some(false)); + // …but once 5h have passed it fires again. + assert_eq!(decide(Some(4 * 3600), five_hours_ago), Some(true)); + + // User picks "Manual only" (0): never auto-fires, no matter how stale. + assert_eq!(decide(Some(0), five_hours_ago), None); + assert_eq!(decide(Some(0), None), None); + + // Unset (None) → 24h default: the hour-old connection is not yet due, + // confirming the default is far more conservative than the 15m native + // cadence. + assert_eq!(decide(None, one_hour_ago), Some(false)); + assert_eq!( + decide(None, Some(Duration::from_secs(25 * 3600))), + Some(true) + ); + + // A never-synced connection fires on any non-manual setting (the + // restart-recovery path). + assert_eq!(decide(Some(4 * 3600), None), Some(true)); + } + + fn audit_entry( + connection_id: &str, + scope: &str, + success: bool, + ts: DateTime, + ) -> SyncAuditEntry { + SyncAuditEntry { + timestamp: ts, + source_id: connection_id.to_string(), + source_kind: "composio".to_string(), + scope: scope.to_string(), + items_fetched: 1, + batches: 0, + input_tokens: 0, + output_tokens: 0, + estimated_cost_usd: 0.0, + composio_actions_called: 1, + composio_cost_usd: 0.0, + actual_charged_usd: None, + duration_ms: 10, + success, + error: None, + } + } + + #[test] + fn index_last_success_keeps_latest_success_and_ignores_failures() { + let now = Utc::now(); + let older = now - chrono::Duration::hours(6); + let newer = now - chrono::Duration::hours(1); + let entries = vec![ + audit_entry("cmp-1", "gmail:cmp-1", true, older), + audit_entry("cmp-1", "gmail:cmp-1", true, newer), // newer success wins + audit_entry("cmp-1", "gmail:cmp-1", false, now), // failure ignored + audit_entry("cmp-2", "slack:cmp-2", false, now), // only-failure → absent + ]; + let idx = index_last_success_by_connection(&entries); + assert_eq!(idx.get("cmp-1"), Some(&newer)); + assert!( + !idx.contains_key("cmp-2"), + "a connection with only failed syncs is not indexed" + ); + } + + #[test] + fn index_last_success_falls_back_to_source_id_without_scope_suffix() { + let now = Utc::now(); + // A non-composio kind is skipped entirely. + let entries = vec![ + SyncAuditEntry { + source_kind: "github_repo".to_string(), + ..audit_entry("ignored", "github:org/repo", true, now) + }, + // Composio entry whose scope has no ':' → key by source_id. + audit_entry("cmp-3", "noscope", true, now), + ]; + let idx = index_last_success_by_connection(&entries); + assert!(idx.contains_key("cmp-3")); + assert!(!idx.contains_key("ignored")); + } + + #[test] + fn persisted_since_last_sync_computes_and_saturates() { + let now = Utc::now(); + let mut idx = HashMap::new(); + idx.insert("cmp-1".to_string(), now - chrono::Duration::hours(3)); + idx.insert("future".to_string(), now + chrono::Duration::hours(2)); + + let elapsed = persisted_since_last_sync(&idx, "cmp-1", now).unwrap(); + // ~3h, allow a small window for test execution time. + assert!(elapsed >= Duration::from_secs(3 * 3600 - 5)); + assert!(elapsed <= Duration::from_secs(3 * 3600 + 5)); + // Clock skew (future timestamp) saturates to zero, not a huge value. + assert_eq!( + persisted_since_last_sync(&idx, "future", now), + Some(Duration::ZERO) + ); + // Unknown connection → None (treated as never synced). + assert_eq!(persisted_since_last_sync(&idx, "unknown", now), None); + } + + /// The cadence must survive a restart: with the in-memory map cold, the + /// persisted audit timestamp drives the due-check so a connection synced + /// 1h ago does NOT re-fire under a 4h setting, but one synced 5h ago does. + #[test] + fn cadence_survives_restart_via_persisted_audit() { + let now = Utc::now(); + let mut idx = HashMap::new(); + idx.insert("cmp-1".to_string(), now - chrono::Duration::hours(1)); + idx.insert("cmp-2".to_string(), now - chrono::Duration::hours(5)); + + let interval = effective_interval_secs(15 * 60, Some(4 * 3600)).unwrap(); + + // cmp-1 (synced 1h ago) — in-memory cold, persisted fallback says NOT due. + let cmp1 = None.or_else(|| persisted_since_last_sync(&idx, "cmp-1", now)); + assert!(!connection_is_due(interval, cmp1)); + + // cmp-2 (synced 5h ago) — persisted fallback says due. + let cmp2 = None.or_else(|| persisted_since_last_sync(&idx, "cmp-2", now)); + assert!(connection_is_due(interval, cmp2)); + + // A connection with no persisted record still fires (truly fresh). + let fresh = None.or_else(|| persisted_since_last_sync(&idx, "cmp-new", now)); + assert!(connection_is_due(interval, fresh)); + } + /// A successful periodic tick produces a Composio-kind audit entry that /// carries the billable-action tally + cost and zeroes the LLM-cost /// columns (summarisation happens later in the job worker). Pins the diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index a46282e44..ee0def022 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -2794,6 +2794,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "openhuman.config_get_data_paths", "openhuman.config_get_dictation_settings", "openhuman.config_get_meet_settings", + "openhuman.config_get_memory_sync_settings", "openhuman.config_get_onboarding_completed", "openhuman.config_get_runtime_flags", "openhuman.config_get_sandbox_settings", @@ -2814,6 +2815,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "openhuman.config_update_local_ai_settings", "openhuman.config_update_meet_settings", "openhuman.config_update_memory_settings", + "openhuman.config_update_memory_sync_settings", "openhuman.config_update_model_settings", "openhuman.config_update_runtime_settings", "openhuman.config_update_sandbox_settings", diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index df9da6086..f5fde0b3c 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -10570,3 +10570,187 @@ async fn json_rpc_voice_server_settings_roundtrip_always_on_and_wake_word() { rpc_join.abort(); } + +/// Memory-sync schedule (#3302): get defaults → set a 4h cadence → confirm it +/// persists → switch to Manual only → confirm the flag flips. +#[tokio::test] +async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + // The global override would mask the persisted value; keep it unset here. + let _interval_guard = EnvVarGuard::unset("OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS"); + + write_min_config(&openhuman_home, "http://127.0.0.1:9"); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + // GET defaults — unset stored value, but a resolved 24h selected cadence. + let initial = post_json_rpc( + &rpc_base, + 7501, + "openhuman.config_get_memory_sync_settings", + json!({}), + ) + .await; + let initial_outer = assert_no_jsonrpc_error(&initial, "get_memory_sync_settings initial"); + let initial_result = initial_outer.get("result").unwrap_or(&initial_outer); + assert!( + initial_result.get("sync_interval_secs").map(Value::is_null) == Some(true), + "default stored value should be null, envelope: {initial_outer}" + ); + assert_eq!( + initial_result.get("is_default").and_then(Value::as_bool), + Some(true), + "fresh config should report is_default=true, envelope: {initial_outer}" + ); + assert_eq!( + initial_result.get("selected_secs").and_then(Value::as_u64), + Some(86_400), + "selected cadence should resolve to the 24h default, envelope: {initial_outer}" + ); + + // UPDATE — pick the 4h preset. + let update = post_json_rpc( + &rpc_base, + 7502, + "openhuman.config_update_memory_sync_settings", + json!({ "sync_interval_secs": 14400 }), + ) + .await; + let update_outer = assert_no_jsonrpc_error(&update, "update_memory_sync_settings 4h"); + let update_result = update_outer.get("result").unwrap_or(&update_outer); + assert_eq!( + update_result + .get("sync_interval_secs") + .and_then(Value::as_u64), + Some(14_400), + "4h cadence should be stored, envelope: {update_outer}" + ); + assert_eq!( + update_result.get("is_manual").and_then(Value::as_bool), + Some(false) + ); + + // GET again — the 4h cadence persists across the round-trip. + let after = post_json_rpc( + &rpc_base, + 7503, + "openhuman.config_get_memory_sync_settings", + json!({}), + ) + .await; + let after_outer = assert_no_jsonrpc_error(&after, "get_memory_sync_settings after 4h"); + let after_result = after_outer.get("result").unwrap_or(&after_outer); + assert_eq!( + after_result + .get("sync_interval_secs") + .and_then(Value::as_u64), + Some(14_400), + "4h cadence should persist, envelope: {after_outer}" + ); + + // UPDATE — switch to Manual only (0). + let manual = post_json_rpc( + &rpc_base, + 7504, + "openhuman.config_update_memory_sync_settings", + json!({ "sync_interval_secs": 0 }), + ) + .await; + let manual_outer = assert_no_jsonrpc_error(&manual, "update_memory_sync_settings manual"); + let manual_result = manual_outer.get("result").unwrap_or(&manual_outer); + assert_eq!( + manual_result.get("is_manual").and_then(Value::as_bool), + Some(true), + "0 should flip is_manual, envelope: {manual_outer}" + ); + + // GET once more — manual mode persisted (stored value 0, is_manual true). + let manual_get = post_json_rpc( + &rpc_base, + 7505, + "openhuman.config_get_memory_sync_settings", + json!({}), + ) + .await; + let manual_get_outer = assert_no_jsonrpc_error(&manual_get, "get_memory_sync_settings manual"); + let manual_get_result = manual_get_outer.get("result").unwrap_or(&manual_get_outer); + assert_eq!( + manual_get_result.get("is_manual").and_then(Value::as_bool), + Some(true), + "manual mode should persist across a GET, envelope: {manual_get_outer}" + ); + assert_eq!( + manual_get_result + .get("sync_interval_secs") + .and_then(Value::as_u64), + Some(0), + "manual stored value should be 0, envelope: {manual_get_outer}" + ); + + rpc_join.abort(); +} + +/// Ops / headless path (#3302): a fleet operator sets +/// `OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS` in the environment, and the running +/// core surfaces that cadence through `config_get_memory_sync_settings` with no +/// UI interaction. Verifies the env override flows all the way through the RPC. +/// (The `0` = "Manual only" sentinel on the env path is covered at the parse +/// layer by `env_overlay_memory_sync_interval_parses_and_honours_zero`.) +#[tokio::test] +async fn json_rpc_memory_sync_settings_env_override_is_reflected() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + // Operator sets an 8h cadence via the environment (not a UI write). + let _interval_guard = EnvVarGuard::set("OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS", "28800"); + + write_min_config(&openhuman_home, "http://127.0.0.1:9"); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + // GET reflects the env-provided cadence even though config.toml never set it. + let resp = post_json_rpc( + &rpc_base, + 7601, + "openhuman.config_get_memory_sync_settings", + json!({}), + ) + .await; + let outer = assert_no_jsonrpc_error(&resp, "get_memory_sync_settings env-override"); + let result = outer.get("result").unwrap_or(&outer); + assert_eq!( + result.get("sync_interval_secs").and_then(Value::as_u64), + Some(28_800), + "env override should surface via RPC, envelope: {outer}" + ); + assert_eq!( + result.get("selected_secs").and_then(Value::as_u64), + Some(28_800), + "selected cadence should match the env override, envelope: {outer}" + ); + assert_eq!( + result.get("is_default").and_then(Value::as_bool), + Some(false), + "an env-provided value is not the default, envelope: {outer}" + ); + + rpc_join.abort(); +}