feat(memory-sync): configurable global sync interval (4h/12h/24h/manual) (#3408)

This commit is contained in:
Cyrus Gray
2026-06-05 10:12:07 -04:00
committed by GitHub
parent d6dfcfbe27
commit 9411188ea8
32 changed files with 1348 additions and 16 deletions
+8
View File
@@ -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
@@ -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<ToastNotification, 'id'>) => 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({
</div>
</header>
<MemorySyncSchedule lastSyncMs={overallLastSyncMs} onToast={onToast} />
{loading ? (
<p className="text-xs text-stone-500 dark:text-neutral-400">{t('common.loading')}</p>
) : 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<ToastNotification, 'id'>) => 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<MemorySyncSettings | null>(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 (
<div
className="mb-3 rounded-md border border-stone-200 bg-stone-50 p-3 dark:border-neutral-800 dark:bg-neutral-800/40"
data-testid="memory-sync-schedule">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-semibold text-stone-600 dark:text-neutral-300">
{t('memorySyncInterval.title')}
</p>
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
<span>
{t('memorySyncInterval.lastSynced')} {lastSync ?? t('memorySyncInterval.never')}
</span>
<span aria-hidden="true"> · </span>
<span data-testid="memory-sync-current">{currentLabel}</span>
</p>
</div>
<div
role="radiogroup"
aria-label={t('memorySyncInterval.title')}
className="flex flex-wrap items-center gap-1.5">
{options.map(secs => {
const isSelected = secs === selectedSecs;
return (
<button
key={secs}
type="button"
role="radio"
aria-checked={isSelected}
disabled={saving}
onClick={() => void handleSelect(secs)}
data-testid={`memory-sync-preset-${secs}`}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors
focus:outline-none focus:ring-2 focus:ring-primary-200
disabled:cursor-not-allowed disabled:opacity-50 ${
isSelected
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-500/50 dark:bg-primary-500/10 dark:text-primary-300'
: 'border-stone-200 bg-white text-stone-600 hover:bg-stone-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800'
}`}>
{intervalChipLabel(secs, t)}
</button>
);
})}
</div>
</div>
</div>
);
}
interface SourceRowProps {
source: MemorySourceEntry;
status: SourceStatus | null;
@@ -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<string, unknown> = {}) {
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> = {}): 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(<MemorySourcesRegistry pollIntervalMs={0} />);
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' }));
});
});
});
+7
View File
@@ -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': 'المصادر العرفية',
+7
View File
@@ -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': 'স্বনির্ধারিত উৎস',
+7
View File
@@ -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',
+9
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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':
'अभी तक कोई स्मृति स्रोत नहीं है। एक को खिला मेमोरी शुरू करने के लिए जोड़ें।',
+7
View File
@@ -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',
+8
View File
@@ -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.',
+7
View File
@@ -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': '사용자 지정 소스',
+7
View File
@@ -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',
+7
View File
@@ -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.',
+7
View File
@@ -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': 'Пользовательские источники',
+7
View File
@@ -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': '自定义来源',
+2
View File
@@ -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',
+47
View File
@@ -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<MemorySyncSettings>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<MemorySyncSettings>>({
method: CORE_RPC_METHODS.configGetMemorySyncSettings,
});
}
export async function openhumanUpdateMemorySyncSettings(
update: MemorySyncSettingsUpdate
): Promise<CommandResponse<MemorySyncSettings>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<MemorySyncSettings>>({
method: CORE_RPC_METHODS.configUpdateMemorySyncSettings,
params: update,
});
}
// ── Agent execution settings (action/tool timeout) ──────────────────────────
/** Agent execution settings as returned by config_get_agent_settings. */
@@ -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<void> {
// 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');
});
});
+19
View File
@@ -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",
+5 -4
View File
@@ -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,
+75
View File
@@ -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<u64>,
}
/// Build the JSON view of the memory-sync settings shared by get + apply.
fn memory_sync_settings_value(stored: Option<u64>) -> 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<RpcOutcome<serde_json::Value>, 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<RpcOutcome<serde_json::Value>, 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<RpcOutcome<serde_json::Value>, 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
+58
View File
@@ -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();
+23
View File
@@ -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::<u64>() {
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() {
+23
View File
@@ -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();
+28
View File
@@ -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<u64>,
#[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(),
+48
View File
@@ -284,6 +284,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
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<RegisteredController> {
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 04).")],
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<String, Value>) -> Controll
})
}
#[derive(Debug, Deserialize)]
struct MemorySyncSettingsUpdate {
sync_interval_secs: Option<u64>,
}
fn handle_get_memory_sync_settings(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(config_rpc::get_memory_sync_settings().await?) })
}
fn handle_update_memory_sync_settings(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let update = deserialize_params::<MemorySyncSettingsUpdate>(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<String>,
+17
View File
@@ -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();
+337 -12
View File
@@ -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<u64>) -> Option<u64> {
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<Duration>) -> 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<String, DateTime<Utc>> {
let mut idx: HashMap<String, DateTime<Utc>> = 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<String, DateTime<Utc>>,
connection_id: &str,
now: DateTime<Utc>,
) -> Option<Duration> {
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<u64>, since: Option<Duration>| -> Option<bool> {
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<Utc>,
) -> 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
@@ -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",
+184
View File
@@ -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();
}