feat(memory): add Sync and Learn sections to Memory workspace (#1031)

This commit is contained in:
Steven Enamakel
2026-04-29 18:43:11 -07:00
committed by GitHub
parent ac97a96d3a
commit 2304d9d58f
10 changed files with 1047 additions and 14 deletions
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntelligenceStats } from '../../hooks/useIntelligenceStats';
import { channelConnectionsApi } from '../../services/api/channelConnectionsApi';
import type { ChannelStatusEntry } from '../../types/channels';
import type { ToastNotification } from '../../types/intelligence';
import {
aiListMemoryFiles,
@@ -10,11 +12,15 @@ import {
isTauri,
memoryDeleteDocument,
memoryGraphQuery,
memoryLearnAll,
type MemoryLearnAllResult,
memoryListDocuments,
memoryListNamespaces,
memoryQueryNamespace,
type MemoryQueryResult,
memoryRecallNamespace,
memorySyncAll,
memorySyncChannel,
} from '../../utils/tauriCommands';
import { MemoryGraphMap } from './MemoryGraphMap';
import { MemoryHeatmap } from './MemoryHeatmap';
@@ -132,6 +138,18 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
const [graphRelations, setGraphRelations] = useState<GraphRelation[]>([]);
const [graphRelationsLoading, setGraphRelationsLoading] = useState(false);
// Sync section
const [syncOpen, setSyncOpen] = useState(false);
const [connectedChannels, setConnectedChannels] = useState<ChannelStatusEntry[]>([]);
const [syncingAll, setSyncingAll] = useState(false);
const [syncingChannelId, setSyncingChannelId] = useState<string | null>(null);
// Learn section
const [learnOpen, setLearnOpen] = useState(false);
const [learning, setLearning] = useState(false);
const [learnResult, setLearnResult] = useState<MemoryLearnAllResult | null>(null);
const [learnErrorOpen, setLearnErrorOpen] = useState(false);
// Manage memory section (collapsible)
const [manageOpen, setManageOpen] = useState(false);
const [selectedNamespace, setSelectedNamespace] = useState('');
@@ -180,6 +198,15 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
const docs = normalizeMemoryDocuments(documentsPayload);
const combinedFiles = ['memory.md', ...memoryDirFiles.map(file => `memory/${file}`)];
// Load connected channels for the Sync section.
try {
const statuses = await channelConnectionsApi.listStatus();
setConnectedChannels(statuses.filter(s => s.connected));
} catch (err) {
console.debug('[MemoryWorkspace] listStatus failed (non-fatal):', err);
setConnectedChannels([]);
}
setMemoryDocs(docs);
setMemoryNamespaces(namespacesPayload);
setMemoryFilesList(combinedFiles);
@@ -309,6 +336,84 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
}
}, [loadSelectedFile, loadWorkspace, memoryNote, onToast, refetchStats]);
// ---------------------------------------------------------------------------
// Sync handlers
// ---------------------------------------------------------------------------
const handleSyncAll = useCallback(async () => {
setSyncingAll(true);
try {
await memorySyncAll();
onToast({
type: 'success',
title: 'Sync Requested',
message: 'Sync requested for all channels.',
});
} catch (err) {
onToast({
type: 'error',
title: 'Sync Failed',
message: err instanceof Error ? err.message : 'Sync all failed.',
});
} finally {
setSyncingAll(false);
}
}, [onToast]);
const handleSyncChannel = useCallback(
async (channelId: string) => {
setSyncingChannelId(channelId);
try {
await memorySyncChannel(channelId);
onToast({
type: 'success',
title: 'Sync Requested',
message: `Sync requested for channel ${channelId}.`,
});
} catch (err) {
onToast({
type: 'error',
title: 'Sync Failed',
message: err instanceof Error ? err.message : 'Sync failed.',
});
} finally {
setSyncingChannelId(null);
}
},
[onToast]
);
// ---------------------------------------------------------------------------
// Learn handler
// ---------------------------------------------------------------------------
const handleLearnAll = useCallback(async () => {
setLearning(true);
setLearnResult(null);
setLearnErrorOpen(false);
try {
const result = await memoryLearnAll();
setLearnResult(result);
onToast({
type: 'success',
title: 'Learn Complete',
message: `${result.namespaces_processed} namespace(s) processed.`,
});
// Refresh workspace data after a successful learn; errors here are
// non-fatal (learn already completed) so we don't surface them as
// "Learn Failed".
void Promise.allSettled([loadWorkspace(), refetchStats()]);
} catch (err) {
onToast({
type: 'error',
title: 'Learn Failed',
message: err instanceof Error ? err.message : 'Learn failed.',
});
} finally {
setLearning(false);
}
}, [loadWorkspace, onToast, refetchStats]);
// ---------------------------------------------------------------------------
// Derived data
// ---------------------------------------------------------------------------
@@ -565,6 +670,163 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
)}
</div>
{/* Collapsible: Sync */}
<div className="rounded-xl border border-stone-200 bg-stone-50">
<button
onClick={() => setSyncOpen(!syncOpen)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-stone-100 transition-colors rounded-xl">
<div className="flex items-center gap-2">
<svg
className={`w-4 h-4 text-stone-500 transition-transform ${syncOpen ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h3 className="text-sm font-semibold text-stone-900">Sync</h3>
<span className="text-xs text-stone-500">
{connectedChannels.length} connected channel(s)
</span>
</div>
</button>
{syncOpen && (
<div className="px-4 pb-4 space-y-3 animate-fade-up">
<div className="flex items-center justify-between">
<p className="text-xs text-stone-500">
Request ingestion from all connected channels.
</p>
<button
onClick={() => void handleSyncAll()}
disabled={syncingAll}
className="px-3 py-1.5 text-xs rounded-lg border border-primary-500/40 bg-primary-500/20 hover:bg-primary-500/30 text-primary-700 disabled:opacity-40">
{syncingAll ? 'Requesting...' : 'Sync all'}
</button>
</div>
{connectedChannels.length === 0 ? (
<p className="text-xs text-stone-400">No connected channels found.</p>
) : (
<div className="space-y-1.5">
{connectedChannels.map(ch => (
<div
key={ch.channel_id}
className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 bg-white px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-stone-800 truncate">
{ch.channel_id}
</div>
<div className="text-[11px] text-stone-400">{ch.auth_mode}</div>
</div>
<button
onClick={() => void handleSyncChannel(ch.channel_id)}
disabled={syncingChannelId === ch.channel_id}
className="text-[11px] px-2 py-1 rounded border border-primary-500/30 text-primary-600 hover:bg-primary-500/10 disabled:opacity-40 shrink-0">
{syncingChannelId === ch.channel_id ? 'Requesting...' : 'Sync'}
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Collapsible: Learn */}
<div className="rounded-xl border border-stone-200 bg-stone-50">
<button
onClick={() => setLearnOpen(!learnOpen)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-stone-100 transition-colors rounded-xl">
<div className="flex items-center gap-2">
<svg
className={`w-4 h-4 text-stone-500 transition-transform ${learnOpen ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h3 className="text-sm font-semibold text-stone-900">Learn</h3>
{learnResult && (
<span className="text-xs text-stone-500">
Last run: {learnResult.namespaces_processed} namespace(s)
</span>
)}
</div>
</button>
{learnOpen && (
<div className="px-4 pb-4 space-y-3 animate-fade-up">
<p className="text-xs text-stone-500">
Runs the summarizer tree over raw memory namespaces, condensing buffered content into
hour-level summaries and propagating them upward. Requires local AI to be enabled.
</p>
<button
onClick={() => void handleLearnAll()}
disabled={learning}
className="px-3 py-1.5 text-xs rounded-lg border border-primary-500/40 bg-primary-500/20 hover:bg-primary-500/30 text-primary-700 disabled:opacity-40">
{learning ? (
<span className="flex items-center gap-1.5">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Learning...
</span>
) : (
'Learn all'
)}
</button>
{learnResult && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-stone-700">
{learnResult.namespaces_processed} processed
</span>
<span className="text-stone-400">·</span>
<span className="text-sage-600">
{learnResult.results.filter(r => r.status === 'ok').length} ok
</span>
{learnResult.results.some(r => r.status === 'error') && (
<>
<span className="text-stone-400">·</span>
<button
onClick={() => setLearnErrorOpen(!learnErrorOpen)}
className="text-coral-500 underline decoration-dotted">
{learnResult.results.filter(r => r.status === 'error').length} error(s)
</button>
</>
)}
</div>
{learnErrorOpen && (
<div className="space-y-1">
{learnResult.results
.filter(r => r.status === 'error')
.map(r => (
<div
key={r.namespace}
className="text-[11px] rounded border border-coral-500/30 bg-coral-500/10 px-2 py-1 text-coral-700">
<span className="font-medium">{r.namespace}</span>:{' '}
{r.error ?? 'unknown error'}
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Warnings */}
{(memoryWorkspaceError || (!isTauri() && !memoryWorkspaceLoading)) && (
<div className="text-xs text-amber-300 border border-amber-500/30 bg-amber-500/10 rounded-lg p-3">
@@ -1,4 +1,4 @@
import { screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
@@ -15,6 +15,21 @@ vi.mock('../../../hooks/useIntelligenceStats', () => ({
}),
}));
// Mock channelConnectionsApi so listStatus doesn't hit the network
vi.mock('../../../services/api/channelConnectionsApi', () => ({
channelConnectionsApi: {
listStatus: vi.fn().mockResolvedValue([
{
channel_id: 'telegram-main',
auth_mode: 'managed_dm',
connected: true,
has_credentials: true,
},
{ channel_id: 'discord-bot', auth_mode: 'bot_token', connected: true, has_credentials: true },
]),
},
}));
// Override the global tauriCommands mock from setup.ts with memory-specific stubs
vi.mock('../../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
@@ -31,6 +46,15 @@ vi.mock('../../../utils/tauriCommands', () => ({
memoryDeleteDocument: vi.fn().mockResolvedValue(undefined),
memoryQueryNamespace: vi.fn().mockResolvedValue({ text: 'query result', entities: [] }),
memoryRecallNamespace: vi.fn().mockResolvedValue({ text: 'recall result', entities: [] }),
memorySyncAll: vi.fn().mockResolvedValue({ requested: true }),
memorySyncChannel: vi.fn().mockResolvedValue({ requested: true, channel_id: 'telegram-main' }),
memoryLearnAll: vi.fn().mockResolvedValue({
namespaces_processed: 2,
results: [
{ namespace: 'research', status: 'ok' },
{ namespace: 'conversations', status: 'ok' },
],
}),
memoryGraphQuery: vi.fn().mockResolvedValue([
{
namespace: 'research',
@@ -146,3 +170,88 @@ describe('MemoryWorkspace non-Tauri environment', () => {
vi.mocked(tauriMod.isTauri).mockReturnValue(true);
});
});
describe('MemoryWorkspace Sync section', () => {
const onToast = vi.fn();
it('renders the Sync collapsible button', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('Sync')).toBeInTheDocument();
});
});
it('expands and shows Sync all button when toggled', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('Sync')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Sync').closest('button')!);
await waitFor(() => {
expect(screen.getByText('Sync all')).toBeInTheDocument();
});
});
it('calls memorySyncAll when Sync all button clicked', async () => {
const tauriMod = await import('../../../utils/tauriCommands');
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => screen.getByText('Sync'));
fireEvent.click(screen.getByText('Sync').closest('button')!);
await waitFor(() => screen.getByText('Sync all'));
fireEvent.click(screen.getByText('Sync all'));
await waitFor(() => {
expect(vi.mocked(tauriMod.memorySyncAll)).toHaveBeenCalled();
});
});
it('calls memorySyncChannel when per-channel Sync button clicked', async () => {
const tauriMod = await import('../../../utils/tauriCommands');
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => screen.getByText('Sync'));
fireEvent.click(screen.getByText('Sync').closest('button')!);
await waitFor(() => screen.getAllByText('Sync').length > 1);
// The per-channel sync buttons appear after channels load
await waitFor(() => screen.getByText('telegram-main'));
const syncBtns = screen.getAllByText('Sync');
// Last Sync buttons are per-channel (first is the header)
fireEvent.click(syncBtns[syncBtns.length - 1]);
await waitFor(() => {
expect(vi.mocked(tauriMod.memorySyncChannel)).toHaveBeenCalledWith('discord-bot');
});
});
});
describe('MemoryWorkspace Learn section', () => {
const onToast = vi.fn();
it('renders the Learn collapsible button', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('Learn')).toBeInTheDocument();
});
});
it('expands and shows Learn all button when toggled', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => screen.getByText('Learn'));
fireEvent.click(screen.getByText('Learn').closest('button')!);
await waitFor(() => {
expect(screen.getByText('Learn all')).toBeInTheDocument();
});
});
it('calls memoryLearnAll and shows result summary', async () => {
const tauriMod = await import('../../../utils/tauriCommands');
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => screen.getByText('Learn'));
fireEvent.click(screen.getByText('Learn').closest('button')!);
await waitFor(() => screen.getByText('Learn all'));
fireEvent.click(screen.getByText('Learn all'));
await waitFor(() => {
expect(vi.mocked(tauriMod.memoryLearnAll)).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText(/2 processed/)).toBeInTheDocument();
});
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Unit tests for memory RPC wrappers: memorySyncChannel, memorySyncAll, memoryLearnAll.
*/
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
import { isTauri } from './common';
import { memoryLearnAll, memorySyncAll, memorySyncChannel } from './memory';
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('./common', () => ({ isTauri: vi.fn(() => true) }));
const mockCallCoreRpc = callCoreRpc as Mock;
const mockIsTauri = isTauri as Mock;
beforeEach(() => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
});
describe('memorySyncChannel', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(memorySyncChannel('ch-1')).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('calls memory_sync_channel with correct channel_id', async () => {
const mockResp = { requested: true, channel_id: 'ch-1' };
mockCallCoreRpc.mockResolvedValueOnce(mockResp);
const result = await memorySyncChannel('ch-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_sync_channel',
params: { channel_id: 'ch-1' },
});
expect(result).toEqual(mockResp);
});
});
describe('memorySyncAll', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(memorySyncAll()).rejects.toThrow('Not running in Tauri');
});
test('calls memory_sync_all and returns result', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ requested: true });
const result = await memorySyncAll();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.memory_sync_all' });
expect(result).toEqual({ requested: true });
});
});
describe('memoryLearnAll', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(memoryLearnAll()).rejects.toThrow('Not running in Tauri');
});
test('calls memory_learn_all without namespaces param when none provided', async () => {
const mockResp = { namespaces_processed: 0, results: [] };
mockCallCoreRpc.mockResolvedValueOnce(mockResp);
const result = await memoryLearnAll();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_learn_all',
params: {},
});
expect(result.namespaces_processed).toBe(0);
});
test('includes namespaces param when provided', async () => {
const mockResp = {
namespaces_processed: 1,
results: [{ namespace: 'research', status: 'ok' }],
};
mockCallCoreRpc.mockResolvedValueOnce(mockResp);
const result = await memoryLearnAll(['research']);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_learn_all',
params: { namespaces: ['research'] },
});
expect(result.namespaces_processed).toBe(1);
expect(result.results[0].status).toBe('ok');
});
test('returns aggregated error results without throwing', async () => {
const mockResp = {
namespaces_processed: 2,
results: [
{ namespace: 'ns-a', status: 'ok' },
{ namespace: 'ns-b', status: 'error', error: 'local AI not enabled' },
],
};
mockCallCoreRpc.mockResolvedValueOnce(mockResp);
const result = await memoryLearnAll();
expect(result.namespaces_processed).toBe(2);
const errEntry = result.results.find(r => r.status === 'error');
expect(errEntry?.namespace).toBe('ns-b');
expect(errEntry?.error).toContain('local AI');
});
});
+73
View File
@@ -267,3 +267,76 @@ export async function aiWriteMemoryFile(relativePath: string, content: string):
params: { relative_path: relativePath, content },
});
}
export interface MemorySyncChannelResult {
requested: boolean;
channel_id: string;
}
export interface MemorySyncAllResult {
requested: boolean;
}
export interface NamespaceLearnResult {
namespace: string;
status: 'ok' | 'skipped' | 'error';
error?: string;
}
export interface MemoryLearnAllResult {
namespaces_processed: number;
results: NamespaceLearnResult[];
}
/**
* Request a memory sync for a specific channel.
* Publishes MemorySyncRequested on the core event bus and returns confirmation.
* No ingestion runs synchronously — future subscribers will react.
*/
export async function memorySyncChannel(channelId: string): Promise<MemorySyncChannelResult> {
console.debug('[memory.sync] memorySyncChannel: entry channel_id=%s', channelId);
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const resp = await callCoreRpc<MemorySyncChannelResult>({
method: 'openhuman.memory_sync_channel',
params: { channel_id: channelId },
});
console.debug('[memory.sync] memorySyncChannel: exit result=%o', resp);
return resp;
}
/**
* Request a memory sync for all channels.
* Publishes MemorySyncRequested { channel_id: None } on the core event bus.
*/
export async function memorySyncAll(): Promise<MemorySyncAllResult> {
console.debug('[memory.sync] memorySyncAll: entry');
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const resp = await callCoreRpc<MemorySyncAllResult>({ method: 'openhuman.memory_sync_all' });
console.debug('[memory.sync] memorySyncAll: exit result=%o', resp);
return resp;
}
/**
* Run the tree summarizer over all memory namespaces (or a subset).
* Processes sequentially; a failing namespace is recorded, not fatal.
*/
export async function memoryLearnAll(namespaces?: string[]): Promise<MemoryLearnAllResult> {
console.debug('[memory.learn] memoryLearnAll: entry namespaces=%o', namespaces);
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const params: Record<string, unknown> = {};
if (namespaces && namespaces.length > 0) {
params.namespaces = namespaces;
}
const resp = await callCoreRpc<MemoryLearnAllResult>({
method: 'openhuman.memory_learn_all',
params,
});
console.debug('[memory.learn] memoryLearnAll: exit processed=%d', resp?.namespaces_processed);
return resp;
}
+10 -1
View File
@@ -65,6 +65,13 @@ pub enum DomainEvent {
},
/// A memory recall query completed.
MemoryRecalled { query: String, hit_count: usize },
/// A memory sync was requested for a specific channel or all channels.
///
/// Published by `openhuman.memory_sync_channel` (channel_id = Some(...)) and
/// `openhuman.memory_sync_all` (channel_id = None). No consumers exist yet —
/// this variant is a hook for future ingestion subscribers to react to pull
/// requests. See `src/openhuman/memory/ops.rs` for the RPC handlers.
MemorySyncRequested { channel_id: Option<String> },
// ── Channels ────────────────────────────────────────────────────────
/// An inbound channel message from the transport layer, ready for processing.
@@ -357,7 +364,9 @@ impl DomainEvent {
| Self::SubagentCompleted { .. }
| Self::SubagentFailed { .. } => "agent",
Self::MemoryStored { .. } | Self::MemoryRecalled { .. } => "memory",
Self::MemoryStored { .. }
| Self::MemoryRecalled { .. }
| Self::MemorySyncRequested { .. } => "memory",
Self::ChannelInboundMessage { .. }
| Self::ChannelMessageReceived { .. }
+34 -10
View File
@@ -832,21 +832,41 @@ async fn resolve_runtime_config_dirs_with_empty_env_falls_back_to_default() {
#[test]
fn apply_env_overrides_commits_side_effects_to_runtime_proxy() {
use crate::openhuman::config::schema::proxy::runtime_proxy_config;
use crate::openhuman::config::schema::proxy::{runtime_proxy_config, set_runtime_proxy_config};
// Build a config with a proxy URL via the injectable seam.
// Hold the env lock so no other test races on proxy-related env vars.
let _g = ENV_LOCK.lock().unwrap();
clear_env(&[
"OPENHUMAN_PROXY_ENABLED",
"OPENHUMAN_HTTP_PROXY",
"HTTP_PROXY",
"OPENHUMAN_HTTPS_PROXY",
"HTTPS_PROXY",
"OPENHUMAN_ALL_PROXY",
"ALL_PROXY",
]);
// Snapshot the global runtime proxy config so we can restore it afterwards
// and avoid leaking state into other tests.
let previous_runtime = runtime_proxy_config();
// Build a config with proxy fields set directly on the struct.
// We cannot pre-configure via apply_env_overlay_with + a HashMapEnv and
// then call apply_env_overrides(), because apply_env_overrides() internally
// re-runs apply_env_overlay_with(&ProcessEnv) which reads the real process
// environment — overwriting anything set via a HashMapEnv beforehand.
// Setting fields directly ensures they survive the ProcessEnv overlay
// (which only writes fields when the corresponding env var is present).
let mut cfg = Config::default();
cfg.apply_env_overlay_with(
&HashMapEnv::new()
.with("OPENHUMAN_HTTP_PROXY", "http://proxy.test:8080")
.with("OPENHUMAN_PROXY_ENABLED", "true"),
);
cfg.proxy.http_proxy = Some("http://proxy.test:8080".to_string());
cfg.proxy.enabled = true;
// Now call the public wrapper which commits side effects.
// apply_env_overrides commits side effects: it calls set_runtime_proxy_config
// with the current proxy config after the ProcessEnv overlay.
cfg.apply_env_overrides();
// `set_runtime_proxy_config` must have been called: the global should
// reflect the proxy URL we injected.
// reflect the proxy URL we set on cfg.proxy.
let runtime = runtime_proxy_config();
assert!(
runtime.enabled,
@@ -855,6 +875,10 @@ fn apply_env_overrides_commits_side_effects_to_runtime_proxy() {
assert_eq!(
runtime.http_proxy.as_deref(),
Some("http://proxy.test:8080"),
"runtime proxy URL must match the env-injected value"
"runtime proxy URL must match the value set on cfg.proxy"
);
// Restore the global runtime proxy state so this test doesn't bleed into
// other tests that inspect runtime_proxy_config().
set_runtime_proxy_config(previous_runtime);
}
+210
View File
@@ -883,6 +883,216 @@ pub async fn graph_query(
Ok(RpcOutcome::single_log(rows, "memory graph queried"))
}
/// Parameters for `memory_sync_channel`.
#[derive(Debug, serde::Deserialize)]
pub struct SyncChannelParams {
pub channel_id: String,
}
/// Result returned by `memory_sync_channel`.
#[derive(Debug, serde::Serialize)]
pub struct SyncChannelResult {
pub requested: bool,
pub channel_id: String,
}
/// Result returned by `memory_sync_all`.
#[derive(Debug, serde::Serialize)]
pub struct SyncAllResult {
pub requested: bool,
}
/// Per-namespace outcome for `memory_learn_all`.
#[derive(Debug, serde::Serialize)]
pub struct NamespaceLearnResult {
pub namespace: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Result returned by `memory_learn_all`.
#[derive(Debug, serde::Serialize)]
pub struct LearnAllResult {
pub namespaces_processed: usize,
pub results: Vec<NamespaceLearnResult>,
}
/// Parameters for `memory_learn_all`.
#[derive(Debug, serde::Deserialize)]
pub struct LearnAllParams {
/// Optional list of namespaces to constrain. Defaults to all namespaces.
#[serde(default)]
pub namespaces: Option<Vec<String>>,
}
/// Request a memory sync for a specific channel.
///
/// Ingestion in OpenHuman is listener/webhook-driven — there is no per-provider
/// pull mechanism yet. This RPC publishes `DomainEvent::MemorySyncRequested` so
/// that future ingestion subscribers can react to an explicit pull request.
/// The event is fire-and-forget; the caller receives confirmation that the
/// request was published, not that ingestion ran.
pub async fn memory_sync_channel(
params: SyncChannelParams,
) -> Result<RpcOutcome<SyncChannelResult>, String> {
tracing::info!(
"[memory.sync] memory_sync_channel: entry channel_id={}",
params.channel_id
);
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::MemorySyncRequested {
channel_id: Some(params.channel_id.clone()),
},
);
tracing::debug!(
"[memory.sync] memory_sync_channel: MemorySyncRequested published channel_id={}",
params.channel_id
);
Ok(RpcOutcome::new(
SyncChannelResult {
requested: true,
channel_id: params.channel_id,
},
vec![],
))
}
/// Request a memory sync for all channels.
///
/// Publishes `DomainEvent::MemorySyncRequested { channel_id: None }` on the
/// global event bus. No consumers exist yet — this is a hook for future
/// ingestion subscribers.
pub async fn memory_sync_all() -> Result<RpcOutcome<SyncAllResult>, String> {
tracing::info!("[memory.sync] memory_sync_all: entry");
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::MemorySyncRequested { channel_id: None },
);
tracing::debug!("[memory.sync] memory_sync_all: MemorySyncRequested(all) published");
Ok(RpcOutcome::new(SyncAllResult { requested: true }, vec![]))
}
/// Run the tree summarizer over all (or a constrained set of) namespaces.
///
/// Enumerates namespaces via `namespace_list`, then for each runs
/// `tree_summarizer_run`. Results are collected per-namespace; a failing
/// namespace does not abort the rest. Runs sequentially to avoid saturating
/// the local AI provider.
pub async fn memory_learn_all(
params: LearnAllParams,
) -> Result<RpcOutcome<LearnAllResult>, String> {
tracing::info!(
"[memory.learn] memory_learn_all: entry namespaces={:?}",
params.namespaces
);
// Resolve the target namespace list.
let client = active_memory_client().await?;
let all_ns = client.list_namespaces().await?;
tracing::debug!("[memory.learn] available namespaces: {:?}", all_ns);
let target_ns: Vec<String> = match &params.namespaces {
Some(requested) if !requested.is_empty() => {
let mut seen = BTreeSet::new();
let filtered: Vec<_> = requested
.iter()
.filter(|ns| all_ns.contains(ns))
.filter(|ns| seen.insert((*ns).clone()))
.cloned()
.collect();
tracing::debug!("[memory.learn] constrained to namespaces: {:?}", filtered);
filtered
}
Some(requested) => {
// Explicit empty list → no-op (don't fall back to all namespaces).
let mut seen = BTreeSet::new();
let filtered: Vec<_> = requested
.iter()
.filter(|ns| all_ns.contains(ns))
.filter(|ns| seen.insert((*ns).clone()))
.cloned()
.collect();
tracing::debug!(
"[memory.learn] Some([]) empty request → no-op or filtered to {:?}",
filtered
);
filtered
}
None => {
tracing::debug!("[memory.learn] using all {} namespaces", all_ns.len());
all_ns
}
};
// Short-circuit when there are no namespaces to process — avoids loading
// config (and the local_ai.enabled guard) for an empty batch.
if target_ns.is_empty() {
tracing::info!(
"[memory.learn] memory_learn_all: no namespaces to process, returning early"
);
return Ok(RpcOutcome::new(
LearnAllResult {
namespaces_processed: 0,
results: vec![],
},
vec![],
));
}
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("load config: {e}"))?;
if !config.local_ai.enabled {
return Err("memory_learn_all requires local_ai.enabled=true".to_string());
}
let mut results = Vec::with_capacity(target_ns.len());
for namespace in &target_ns {
tracing::info!(
"[memory.learn] running summarization for namespace='{}'",
namespace
);
let outcome =
crate::openhuman::tree_summarizer::ops::tree_summarizer_run(&config, namespace).await;
match outcome {
Ok(_) => {
tracing::info!("[memory.learn] namespace='{}' ok", namespace);
results.push(NamespaceLearnResult {
namespace: namespace.clone(),
status: "ok".to_string(),
error: None,
});
}
Err(e) => {
tracing::warn!("[memory.learn] namespace='{}' error: {}", namespace, e);
results.push(NamespaceLearnResult {
namespace: namespace.clone(),
status: "error".to_string(),
error: Some(e),
});
}
}
}
let namespaces_processed = results.len();
tracing::info!(
"[memory.learn] memory_learn_all: done processed={} results={:?}",
namespaces_processed,
results
.iter()
.map(|r| (&r.namespace, &r.status))
.collect::<Vec<_>>()
);
Ok(RpcOutcome::new(
LearnAllResult {
namespaces_processed,
results,
},
vec![],
))
}
/// Initialise the local-only (SQLite) memory subsystem for the current workspace.
///
/// `request.jwt_token` is accepted for backward compatibility but ignored — all
+99 -2
View File
@@ -11,8 +11,8 @@ use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::memory::rpc::{
self, ClearNamespaceParams, DeleteDocParams, GraphQueryParams, GraphUpsertParams,
IngestDocParams, KvGetDeleteParams, KvSetParams, NamespaceOnlyParams, PutDocParams,
QueryNamespaceParams, RecallNamespaceParams,
IngestDocParams, KvGetDeleteParams, KvSetParams, LearnAllParams, NamespaceOnlyParams,
PutDocParams, QueryNamespaceParams, RecallNamespaceParams, SyncChannelParams,
};
use crate::openhuman::memory::{
DeleteDocumentRequest, EmptyRequest, ListDocumentsRequest, ListMemoryFilesRequest,
@@ -52,6 +52,9 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("graph_upsert"),
schemas("graph_query"),
schemas("clear_namespace"),
schemas("sync_channel"),
schemas("sync_all"),
schemas("learn_all"),
]
}
@@ -154,6 +157,18 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("clear_namespace"),
handler: handle_clear_namespace,
},
RegisteredController {
schema: schemas("sync_channel"),
handler: handle_sync_channel,
},
RegisteredController {
schema: schemas("sync_all"),
handler: handle_sync_all,
},
RegisteredController {
schema: schemas("learn_all"),
handler: handle_learn_all,
},
]
}
@@ -914,6 +929,70 @@ pub fn schemas(function: &str) -> ControllerSchema {
],
},
// ----- sync / learn -----
"sync_channel" => ControllerSchema {
namespace: "memory",
function: "sync_channel",
description: "Request a memory sync for a specific channel. Publishes MemorySyncRequested on the event bus. No ingestion consumers exist yet; this is a hook for future pull-based subscribers.",
inputs: vec![FieldSchema {
name: "channel_id",
ty: TypeSchema::String,
comment: "ID of the channel to sync.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "requested",
ty: TypeSchema::Bool,
comment: "Always true when the event was published.",
required: true,
},
FieldSchema {
name: "channel_id",
ty: TypeSchema::String,
comment: "Echo of the channel_id that was requested.",
required: true,
},
],
},
"sync_all" => ControllerSchema {
namespace: "memory",
function: "sync_all",
description: "Request a memory sync for all channels. Publishes MemorySyncRequested { channel_id: None } on the event bus.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "requested",
ty: TypeSchema::Bool,
comment: "Always true when the event was published.",
required: true,
}],
},
"learn_all" => ControllerSchema {
namespace: "memory",
function: "learn_all",
description: "Run the tree summarizer over all memory namespaces (or a constrained subset). Processes namespaces sequentially; a failing namespace is recorded but does not abort the rest.",
inputs: vec![FieldSchema {
name: "namespaces",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
comment: "Optional list of namespaces to constrain. Defaults to all namespaces.",
required: false,
}],
outputs: vec![
FieldSchema {
name: "namespaces_processed",
ty: TypeSchema::U64,
comment: "Total number of namespaces processed.",
required: true,
},
FieldSchema {
name: "results",
ty: TypeSchema::Json,
comment: "Per-namespace outcomes: [{ namespace, status: 'ok'|'skipped'|'error', error? }].",
required: true,
},
],
},
// ----- fallback -----
_other => ControllerSchema {
namespace: "memory",
@@ -1112,6 +1191,24 @@ fn handle_clear_namespace(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_sync_channel(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<SyncChannelParams>(params)?;
to_json(rpc::memory_sync_channel(payload).await?)
})
}
fn handle_sync_all(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(rpc::memory_sync_all().await?) })
}
fn handle_learn_all(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<LearnAllParams>(params)?;
to_json(rpc::memory_learn_all(payload).await?)
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+39
View File
@@ -26,6 +26,9 @@ const ALL_FUNCTIONS: &[&str] = &[
"graph_upsert",
"graph_query",
"clear_namespace",
"sync_channel",
"sync_all",
"learn_all",
];
#[test]
@@ -108,3 +111,39 @@ fn parse_params_surfaces_deserialization_errors_with_context() {
let err = parse_params::<Strict>(m).unwrap_err();
assert!(err.contains("invalid params"));
}
// ── sync / learn schema shape tests ─────────────────────────────────────
#[test]
fn sync_channel_schema_requires_channel_id() {
let s = schemas("sync_channel");
assert_eq!(s.namespace, "memory");
assert_eq!(s.function, "sync_channel");
let required: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert!(
required.contains(&"channel_id"),
"channel_id must be required"
);
}
#[test]
fn sync_all_schema_has_no_inputs() {
let s = schemas("sync_all");
assert_eq!(s.function, "sync_all");
assert!(s.inputs.is_empty(), "sync_all takes no inputs");
}
#[test]
fn learn_all_schema_namespaces_is_optional() {
let s = schemas("learn_all");
assert_eq!(s.function, "learn_all");
assert_eq!(s.inputs.len(), 1);
let ns_field = &s.inputs[0];
assert_eq!(ns_field.name, "namespaces");
assert!(!ns_field.required, "namespaces must be optional");
}
+99
View File
@@ -901,6 +901,105 @@ async fn json_rpc_thread_labels_create_and_update() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_memory_sync_and_learn() {
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");
let _embed_strict_guard = EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false");
let _embed_endpoint_guard = EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", "");
let _embed_model_guard = EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", "");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
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;
// ── memory_sync_all: returns requested:true ──────────────────────────────
let sync_all = post_json_rpc(&rpc_base, 7001, "openhuman.memory_sync_all", json!({})).await;
let sync_all_result = assert_no_jsonrpc_error(&sync_all, "memory_sync_all");
assert_eq!(
sync_all_result.get("requested"),
Some(&json!(true)),
"memory_sync_all must return requested:true"
);
// ── memory_sync_channel: echoes channel_id and returns requested:true ─────
let sync_ch = post_json_rpc(
&rpc_base,
7002,
"openhuman.memory_sync_channel",
json!({ "channel_id": "test-channel-abc" }),
)
.await;
let sync_ch_result = assert_no_jsonrpc_error(&sync_ch, "memory_sync_channel");
assert_eq!(
sync_ch_result.get("requested"),
Some(&json!(true)),
"memory_sync_channel must return requested:true"
);
assert_eq!(
sync_ch_result.get("channel_id").and_then(Value::as_str),
Some("test-channel-abc"),
"memory_sync_channel must echo channel_id"
);
// ── memory_sync_channel: missing channel_id returns a JSON-RPC error ────
let sync_bad = post_json_rpc(&rpc_base, 7003, "openhuman.memory_sync_channel", json!({})).await;
assert!(
sync_bad.get("error").is_some(),
"missing channel_id must return an error, got: {sync_bad}"
);
// ── memory_learn_all: no namespaces → zero processed (empty store) ──────
let learn_all = post_json_rpc(&rpc_base, 7004, "openhuman.memory_learn_all", json!({})).await;
let learn_result = assert_no_jsonrpc_error(&learn_all, "memory_learn_all");
let processed = learn_result
.get("namespaces_processed")
.and_then(Value::as_u64)
.expect("namespaces_processed must be present");
assert_eq!(processed, 0, "no namespaces in a fresh store");
let results_arr = learn_result
.get("results")
.and_then(Value::as_array)
.expect("results array must be present");
assert!(
results_arr.is_empty(),
"results must be empty when no namespaces"
);
// ── memory_learn_all: constrained to non-existent namespace → also zero ──
let learn_constrained = post_json_rpc(
&rpc_base,
7005,
"openhuman.memory_learn_all",
json!({ "namespaces": ["does-not-exist"] }),
)
.await;
let learn_c_result =
assert_no_jsonrpc_error(&learn_constrained, "memory_learn_all constrained");
assert_eq!(
learn_c_result
.get("namespaces_processed")
.and_then(Value::as_u64),
Some(0),
"non-existent namespace must be filtered out"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_memory_tree_end_to_end() {
let _env_lock = json_rpc_e2e_env_lock();