mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory-sync): configurable global sync interval (4h/12h/24h/manual) (#3408)
This commit is contained in:
@@ -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' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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': 'المصادر العرفية',
|
||||
|
||||
@@ -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': 'স্বনির্ধারিত উৎস',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
'अभी तक कोई स्मृति स्रोत नहीं है। एक को खिला मेमोरी शुरू करने के लिए जोड़ें।',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '사용자 지정 소스',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Пользовательские источники',
|
||||
|
||||
@@ -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': '自定义来源',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 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<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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user