mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory): clear source memory on disconnect (#2528)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
bf2400f0f9
commit
484e31dc1d
@@ -46,6 +46,9 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
|
||||
|
||||
const [busyKeys, setBusyKeys] = useState<Record<string, boolean>>({});
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
|
||||
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Pending link tokens, keyed by compositeKey (discord:managed_dm). Only present while polling. */
|
||||
const [linkToken, setLinkToken] = useState<string | null>(null);
|
||||
@@ -259,15 +262,19 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
|
||||
|
||||
const handleDisconnect = useCallback(
|
||||
(authMode: ChannelAuthMode) => {
|
||||
const key = `discord:${authMode}`;
|
||||
void runBusy(`discord:${authMode}`, async () => {
|
||||
log('disconnecting discord via %s', authMode);
|
||||
pollAbort.current?.abort();
|
||||
setLinkToken(null);
|
||||
await channelConnectionsApi.disconnectChannel('discord', authMode);
|
||||
await channelConnectionsApi.disconnectChannel('discord', authMode, {
|
||||
clearMemory: Boolean(clearMemoryOnDisconnect[key]),
|
||||
});
|
||||
setClearMemoryOnDisconnect(prev => ({ ...prev, [key]: false }));
|
||||
dispatch(disconnectChannelConnection({ channel: 'discord', authMode }));
|
||||
});
|
||||
},
|
||||
[dispatch, runBusy]
|
||||
[clearMemoryOnDisconnect, dispatch, runBusy]
|
||||
);
|
||||
|
||||
const copyToken = useCallback(() => {
|
||||
@@ -366,38 +373,86 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
|
||||
|
||||
{/* Connected state for managed_dm — show only Disconnect */}
|
||||
{spec.mode === 'managed_dm' && status === 'connected' ? (
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-xs text-sage-700 dark:text-sage-300 font-medium">
|
||||
{t('channels.discord.accountLinked')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => handleDisconnect(spec.mode)}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
|
||||
{t('accounts.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
) : /* Connect / Disconnect buttons for all other modes and states */
|
||||
spec.mode !== 'managed_dm' || status !== 'connecting' ? (
|
||||
<div className="mt-3 flex gap-2">
|
||||
{status !== 'connected' && (
|
||||
<>
|
||||
<label className="mt-3 flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(clearMemoryOnDisconnect[compositeKey])}
|
||||
onChange={event =>
|
||||
setClearMemoryOnDisconnect(prev => ({
|
||||
...prev,
|
||||
[compositeKey]: event.currentTarget.checked,
|
||||
}))
|
||||
}
|
||||
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium text-stone-800 dark:text-neutral-100">
|
||||
{t('accounts.disconnectClearMemory')}
|
||||
</span>
|
||||
<span className="block text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('accounts.disconnectClearMemoryHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-xs text-sage-700 dark:text-sage-300 font-medium">
|
||||
{t('channels.discord.accountLinked')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => handleConnect(spec)}
|
||||
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
|
||||
{t('channels.discord.connect')}
|
||||
onClick={() => handleDisconnect(spec.mode)}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
|
||||
{t('accounts.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : /* Connect / Disconnect buttons for all other modes and states */
|
||||
spec.mode !== 'managed_dm' || status !== 'connecting' ? (
|
||||
<>
|
||||
{status === 'connected' && (
|
||||
<label className="mt-3 flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(clearMemoryOnDisconnect[compositeKey])}
|
||||
onChange={event =>
|
||||
setClearMemoryOnDisconnect(prev => ({
|
||||
...prev,
|
||||
[compositeKey]: event.currentTarget.checked,
|
||||
}))
|
||||
}
|
||||
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium text-stone-800 dark:text-neutral-100">
|
||||
{t('accounts.disconnectClearMemory')}
|
||||
</span>
|
||||
<span className="block text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('accounts.disconnectClearMemoryHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || status === 'disconnected'}
|
||||
onClick={() => handleDisconnect(spec.mode)}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
|
||||
{t('accounts.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{status !== 'connected' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => handleConnect(spec)}
|
||||
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
|
||||
{t('channels.discord.connect')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || status === 'disconnected'}
|
||||
onClick={() => handleDisconnect(spec.mode)}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
|
||||
{t('accounts.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Server + Channel picker — shown after successful bot_token connection */}
|
||||
|
||||
@@ -46,6 +46,9 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
|
||||
|
||||
const [busyKeys, setBusyKeys] = useState<Record<string, boolean>>({});
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
|
||||
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const managedDmPollControllers = useRef<Record<string, AbortController>>({});
|
||||
|
||||
@@ -323,11 +326,14 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
|
||||
void runBusy(key, async () => {
|
||||
log('disconnecting telegram via %s', authMode);
|
||||
stopManagedDmPolling(`telegram:${authMode}`);
|
||||
await channelConnectionsApi.disconnectChannel('telegram', authMode);
|
||||
await channelConnectionsApi.disconnectChannel('telegram', authMode, {
|
||||
clearMemory: Boolean(clearMemoryOnDisconnect[key]),
|
||||
});
|
||||
setClearMemoryOnDisconnect(prev => ({ ...prev, [key]: false }));
|
||||
dispatch(disconnectChannelConnection({ channel: 'telegram', authMode }));
|
||||
});
|
||||
},
|
||||
[dispatch, runBusy, stopManagedDmPolling]
|
||||
[clearMemoryOnDisconnect, dispatch, runBusy, stopManagedDmPolling]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -397,6 +403,30 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'connected' && (
|
||||
<label className="mt-3 flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(clearMemoryOnDisconnect[compositeKey])}
|
||||
onChange={event =>
|
||||
setClearMemoryOnDisconnect(prev => ({
|
||||
...prev,
|
||||
[compositeKey]: event.currentTarget.checked,
|
||||
}))
|
||||
}
|
||||
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium text-stone-800 dark:text-neutral-100">
|
||||
{t('accounts.disconnectClearMemory')}
|
||||
</span>
|
||||
<span className="block text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('accounts.disconnectClearMemoryHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi';
|
||||
import { upsertChannelConnection } from '../../../store/channelConnectionsSlice';
|
||||
import { createTestStore, renderWithProviders } from '../../../test/test-utils';
|
||||
import DiscordConfig from '../DiscordConfig';
|
||||
|
||||
const coreStateMock = vi.hoisted(() => vi.fn(() => ({ snapshot: { sessionToken: 'jwt-abc' } })));
|
||||
@@ -11,6 +13,27 @@ vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => cor
|
||||
|
||||
const discordDef = FALLBACK_DEFINITIONS.find(d => d.id === 'discord')!;
|
||||
|
||||
vi.mock('../../../hooks/useOAuthConnectionListener', () => ({
|
||||
useOAuthConnectionListener: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/api/channelConnectionsApi', () => ({
|
||||
channelConnectionsApi: {
|
||||
connectChannel: vi.fn(),
|
||||
disconnectChannel: vi.fn(),
|
||||
discordLinkStart: vi.fn(),
|
||||
discordLinkCheck: vi.fn(),
|
||||
listDefinitions: vi.fn(),
|
||||
listStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/core', () => ({ restartCoreProcess: vi.fn() }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'jwt-abc' } });
|
||||
@@ -40,6 +63,62 @@ describe('DiscordConfig', () => {
|
||||
expect(connectButtons.length).toBe(3);
|
||||
});
|
||||
|
||||
it('passes clearMemory when disconnecting a connected bot token account', async () => {
|
||||
const store = createTestStore();
|
||||
store.dispatch(
|
||||
upsertChannelConnection({
|
||||
channel: 'discord',
|
||||
authMode: 'bot_token',
|
||||
patch: { status: 'connected', capabilities: ['read', 'write'] },
|
||||
})
|
||||
);
|
||||
vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<DiscordConfig definition={discordDef} />, { store });
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/also delete memory/i));
|
||||
const disconnectButton = screen
|
||||
.getAllByRole('button', { name: 'Disconnect' })
|
||||
.find(button => !button.hasAttribute('disabled'));
|
||||
expect(disconnectButton).toBeDefined();
|
||||
fireEvent.click(disconnectButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith('discord', 'bot_token', {
|
||||
clearMemory: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('passes clearMemory when disconnecting a connected managed DM account', async () => {
|
||||
const store = createTestStore();
|
||||
store.dispatch(
|
||||
upsertChannelConnection({
|
||||
channel: 'discord',
|
||||
authMode: 'managed_dm',
|
||||
patch: { status: 'connected', capabilities: ['dm'] },
|
||||
})
|
||||
);
|
||||
vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<DiscordConfig definition={discordDef} />, { store });
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/also delete memory/i));
|
||||
const disconnectButton = screen
|
||||
.getAllByRole('button', { name: 'Disconnect' })
|
||||
.find(button => !button.hasAttribute('disabled'));
|
||||
expect(disconnectButton).toBeDefined();
|
||||
fireEvent.click(disconnectButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith(
|
||||
'discord',
|
||||
'managed_dm',
|
||||
{ clearMemory: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides managed channel auth modes for local users', () => {
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'header.payload.local' } });
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions';
|
||||
import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { upsertChannelConnection } from '../../../store/channelConnectionsSlice';
|
||||
import { createTestStore, renderWithProviders } from '../../../test/test-utils';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import TelegramConfig from '../TelegramConfig';
|
||||
|
||||
@@ -69,6 +70,35 @@ describe('TelegramConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes clearMemory when disconnecting with the memory checkbox selected', async () => {
|
||||
const store = createTestStore();
|
||||
store.dispatch(
|
||||
upsertChannelConnection({
|
||||
channel: 'telegram',
|
||||
authMode: 'bot_token',
|
||||
patch: { status: 'connected', capabilities: ['read', 'write'] },
|
||||
})
|
||||
);
|
||||
vi.mocked(channelConnectionsApi.disconnectChannel).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<TelegramConfig definition={telegramDef} />, { store });
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/also delete memory/i));
|
||||
const disconnectButton = screen
|
||||
.getAllByRole('button', { name: 'Disconnect' })
|
||||
.find(button => !button.hasAttribute('disabled'));
|
||||
expect(disconnectButton).toBeDefined();
|
||||
fireEvent.click(disconnectButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(channelConnectionsApi.disconnectChannel).toHaveBeenCalledWith(
|
||||
'telegram',
|
||||
'bot_token',
|
||||
{ clearMemory: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('starts managed dm flow via core RPC, opens the deep link, and marks connected after polling', async () => {
|
||||
vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({
|
||||
status: 'pending_auth',
|
||||
|
||||
@@ -223,6 +223,47 @@ describe('<ComposioConnectModal>', () => {
|
||||
expect(screen.queryByText('(oxox)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes clearMemory only when the disconnect memory checkbox is selected', async () => {
|
||||
const connection: ComposioConnection = { id: 'ca_xyz', toolkit: 'gmail', status: 'ACTIVE' };
|
||||
vi.mocked(composioApi.deleteConnection).mockResolvedValue({
|
||||
deleted: true,
|
||||
memory_chunks_deleted: 1,
|
||||
});
|
||||
|
||||
render(
|
||||
<ComposioConnectModal toolkit={mockToolkit} connection={connection} onClose={() => {}} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/also delete memory/i));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Disconnect$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(composioApi.deleteConnection).toHaveBeenCalledWith('ca_xyz', { clearMemory: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the clear-memory checkbox after a failed disconnect is dismissed', async () => {
|
||||
const connection: ComposioConnection = { id: 'ca_xyz', toolkit: 'gmail', status: 'ACTIVE' };
|
||||
vi.mocked(composioApi.deleteConnection).mockRejectedValueOnce(new Error('backend down'));
|
||||
|
||||
render(
|
||||
<ComposioConnectModal toolkit={mockToolkit} connection={connection} onClose={() => {}} />
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText(/also delete memory/i);
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Disconnect$/i }));
|
||||
|
||||
expect(await screen.findByText(/backend down/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/also delete memory/i)).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an expired-auth recovery state with a reconnect CTA', () => {
|
||||
const connection: ComposioConnection = {
|
||||
id: 'ca_expired',
|
||||
|
||||
@@ -194,6 +194,7 @@ export default function ComposioConnectModal({
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectUrl, setConnectUrl] = useState<string | null>(null);
|
||||
const [clearMemoryOnDisconnect, setClearMemoryOnDisconnect] = useState(false);
|
||||
|
||||
// Provider-specific required fields are sourced from the declarative
|
||||
// registry rather than per-toolkit booleans (#2127). New providers
|
||||
@@ -508,16 +509,18 @@ export default function ComposioConnectModal({
|
||||
setPhase('disconnecting');
|
||||
setError(null);
|
||||
try {
|
||||
await deleteConnection(activeConnection.id);
|
||||
await deleteConnection(activeConnection.id, { clearMemory: clearMemoryOnDisconnect });
|
||||
setActiveConnection(undefined);
|
||||
setClearMemoryOnDisconnect(false);
|
||||
setPhase('idle');
|
||||
onChanged?.();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setPhase('error');
|
||||
setError(`${t('composio.connect.disconnectFailed')}: ${msg}`);
|
||||
setClearMemoryOnDisconnect(false);
|
||||
}
|
||||
}, [activeConnection, onChanged, t]);
|
||||
}, [activeConnection, clearMemoryOnDisconnect, onChanged, t]);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
@@ -734,6 +737,22 @@ export default function ComposioConnectModal({
|
||||
connectionId={activeConnection.id}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearMemoryOnDisconnect}
|
||||
onChange={event => setClearMemoryOnDisconnect(event.currentTarget.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-stone-800 dark:text-neutral-100">
|
||||
{t('accounts.disconnectClearMemory')}
|
||||
</span>
|
||||
<span className="block text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('accounts.disconnectClearMemoryHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -765,6 +784,7 @@ export default function ComposioConnectModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setClearMemoryOnDisconnect(false);
|
||||
setPhase(
|
||||
initiallyConnected ? 'connected' : initiallyExpired ? 'expired' : 'idle'
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
deleteConnection,
|
||||
disableTrigger,
|
||||
enableTrigger,
|
||||
listAgentReadyToolkits,
|
||||
@@ -100,6 +101,21 @@ describe('composioApi trigger wrappers', () => {
|
||||
});
|
||||
expect(out.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteConnection forwards clear_memory only when requested', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
result: { deleted: true, memory_chunks_deleted: 3 },
|
||||
logs: [],
|
||||
});
|
||||
|
||||
const out = await deleteConnection('conn-1', { clearMemory: true });
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.composio_delete_connection',
|
||||
params: { connection_id: 'conn-1', clear_memory: true },
|
||||
});
|
||||
expect(out.memory_chunks_deleted).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncConnection', () => {
|
||||
|
||||
@@ -111,10 +111,17 @@ export async function authorize(
|
||||
* Delete an existing Composio connection. Backend verifies ownership
|
||||
* before forwarding to Composio.
|
||||
*/
|
||||
export async function deleteConnection(connectionId: string): Promise<ComposioDeleteResponse> {
|
||||
export async function deleteConnection(
|
||||
connectionId: string,
|
||||
options?: { clearMemory?: boolean }
|
||||
): Promise<ComposioDeleteResponse> {
|
||||
const params: { connection_id: string; clear_memory?: boolean } = { connection_id: connectionId };
|
||||
if (options?.clearMemory) {
|
||||
params.clear_memory = true;
|
||||
}
|
||||
const raw = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.composio_delete_connection',
|
||||
params: { connection_id: connectionId },
|
||||
params,
|
||||
});
|
||||
return unwrapCliEnvelope<ComposioDeleteResponse>(raw);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface ComposioAuthorizeResponse {
|
||||
|
||||
export interface ComposioDeleteResponse {
|
||||
deleted: boolean;
|
||||
memory_chunks_deleted?: number;
|
||||
}
|
||||
|
||||
export interface ComposioToolFunction {
|
||||
|
||||
@@ -299,6 +299,9 @@ const ar1: TranslationMap = {
|
||||
'accounts.respondQueue': 'قائمة الردود',
|
||||
'accounts.disconnect': 'قطع الاتصال',
|
||||
'accounts.disconnectConfirm': 'هل أنت متأكد من أنك تريد قطع الاتصال بهذا الحساب؟',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'البحث في الحسابات...',
|
||||
'channels.title': 'القنوات',
|
||||
'channels.configure': 'ضبط القناة',
|
||||
|
||||
@@ -306,6 +306,9 @@ const bn1: TranslationMap = {
|
||||
'accounts.respondQueue': 'রেসপন্ড কিউ',
|
||||
'accounts.disconnect': 'সংযোগ বিচ্ছিন্ন',
|
||||
'accounts.disconnectConfirm': 'আপনি কি এই অ্যাকাউন্ট সংযোগ বিচ্ছিন্ন করতে চান?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'অ্যাকাউন্ট খুঁজুন...',
|
||||
'channels.title': 'চ্যানেল',
|
||||
'channels.configure': 'চ্যানেল কনফিগার করুন',
|
||||
|
||||
@@ -351,6 +351,9 @@ const de1: TranslationMap = {
|
||||
'accounts.respondQueue': 'Antwortwarteschlange',
|
||||
'accounts.disconnect': 'Trennen',
|
||||
'accounts.disconnectConfirm': 'Möchtest du dieses Konto wirklich trennen?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Konten durchsuchen...',
|
||||
'channels.title': 'Kanäle',
|
||||
'channels.configure': 'Kanal konfigurieren',
|
||||
|
||||
@@ -608,6 +608,9 @@ const en1: TranslationMap = {
|
||||
'accounts.respondQueue': 'Respond Queue',
|
||||
'accounts.disconnect': 'Disconnect',
|
||||
'accounts.disconnectConfirm': 'Are you sure you want to disconnect this account?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Search accounts...',
|
||||
'channels.title': 'Channels',
|
||||
'channels.configure': 'Configure Channel',
|
||||
|
||||
@@ -313,6 +313,9 @@ const es1: TranslationMap = {
|
||||
'accounts.respondQueue': 'Cola de respuestas',
|
||||
'accounts.disconnect': 'Desconectar',
|
||||
'accounts.disconnectConfirm': '¿Seguro que quieres desconectar esta cuenta?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Buscar cuentas...',
|
||||
'channels.title': 'Canales',
|
||||
'channels.configure': 'Configurar canal',
|
||||
|
||||
@@ -314,6 +314,9 @@ const fr1: TranslationMap = {
|
||||
'accounts.respondQueue': 'File de réponses',
|
||||
'accounts.disconnect': 'Déconnecter',
|
||||
'accounts.disconnectConfirm': 'Es-tu sûr de vouloir déconnecter ce compte ?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Rechercher des comptes…',
|
||||
'channels.title': 'Canaux',
|
||||
'channels.configure': 'Configurer le canal',
|
||||
|
||||
@@ -303,6 +303,9 @@ const hi1: TranslationMap = {
|
||||
'accounts.respondQueue': 'रिस्पॉन्ड क्यू',
|
||||
'accounts.disconnect': 'डिसकनेक्ट करें',
|
||||
'accounts.disconnectConfirm': 'क्या आप वाकई इस अकाउंट को डिसकनेक्ट करना चाहते हैं?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'अकाउंट सर्च करें...',
|
||||
'channels.title': 'चैनल',
|
||||
'channels.configure': 'चैनल कॉन्फिगर करें',
|
||||
|
||||
@@ -307,6 +307,9 @@ const id1: TranslationMap = {
|
||||
'accounts.respondQueue': 'Antrean Respons',
|
||||
'accounts.disconnect': 'Putuskan',
|
||||
'accounts.disconnectConfirm': 'Yakin ingin memutuskan akun ini?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Cari akun...',
|
||||
'channels.title': 'Kanal',
|
||||
'channels.configure': 'Konfigurasi Kanal',
|
||||
|
||||
@@ -310,6 +310,9 @@ const it1: TranslationMap = {
|
||||
'accounts.respondQueue': 'Coda di risposta',
|
||||
'accounts.disconnect': 'Disconnetti',
|
||||
'accounts.disconnectConfirm': 'Sei sicuro di voler disconnettere questo account?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Cerca account...',
|
||||
'channels.title': 'Canali',
|
||||
'channels.configure': 'Configura canale',
|
||||
|
||||
@@ -306,6 +306,9 @@ const ko1: TranslationMap = {
|
||||
'accounts.respondQueue': '응답 대기열',
|
||||
'accounts.disconnect': '연결 해제',
|
||||
'accounts.disconnectConfirm': '이 계정의 연결을 해제하시겠습니까?',
|
||||
'accounts.disconnectClearMemory': '이 소스의 메모리도 삭제',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'이 연결과 연관된 로컬 메모리 조각을 영구적으로 삭제합니다.',
|
||||
'accounts.searchAccounts': '계정 검색...',
|
||||
'channels.title': '채널',
|
||||
'channels.configure': '채널 구성',
|
||||
|
||||
@@ -313,6 +313,9 @@ const pt1: TranslationMap = {
|
||||
'accounts.respondQueue': 'Fila de Respostas',
|
||||
'accounts.disconnect': 'Desconectar',
|
||||
'accounts.disconnectConfirm': 'Tem certeza de que deseja desconectar esta conta?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Pesquisar contas...',
|
||||
'channels.title': 'Canais',
|
||||
'channels.configure': 'Configurar Canal',
|
||||
|
||||
@@ -306,6 +306,9 @@ const ru1: TranslationMap = {
|
||||
'accounts.respondQueue': 'Очередь ответов',
|
||||
'accounts.disconnect': 'Отключить',
|
||||
'accounts.disconnectConfirm': 'Ты уверен, что хочешь отключить этот аккаунт?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Поиск аккаунтов...',
|
||||
'channels.title': 'Каналы',
|
||||
'channels.configure': 'Настроить канал',
|
||||
|
||||
@@ -291,6 +291,9 @@ const zhCN1: TranslationMap = {
|
||||
'accounts.respondQueue': '回复队列',
|
||||
'accounts.disconnect': '断开连接',
|
||||
'accounts.disconnectConfirm': '确定要断开此账户的连接吗?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': '搜索账户...',
|
||||
'channels.title': '渠道',
|
||||
'channels.configure': '配置渠道',
|
||||
|
||||
@@ -432,6 +432,9 @@ const en: TranslationMap = {
|
||||
'accounts.respondQueue': 'Respond Queue',
|
||||
'accounts.disconnect': 'Disconnect',
|
||||
'accounts.disconnectConfirm': 'Are you sure you want to disconnect this account?',
|
||||
'accounts.disconnectClearMemory': 'Also delete memory from this source',
|
||||
'accounts.disconnectClearMemoryHint':
|
||||
'Permanently removes local memory chunks linked to this connection.',
|
||||
'accounts.searchAccounts': 'Search accounts...',
|
||||
|
||||
// Channels
|
||||
|
||||
@@ -43,6 +43,20 @@ describe('channelConnectionsApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards clear_memory when disconnecting a channel with memory cleanup enabled', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
result: { disconnected: true, memory_chunks_deleted: 2 },
|
||||
logs: ['removed credentials'],
|
||||
});
|
||||
|
||||
await channelConnectionsApi.disconnectChannel('discord', 'bot_token', { clearMemory: true });
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.channels_disconnect',
|
||||
params: { channel: 'discord', authMode: 'bot_token', clearMemory: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid Discord guild list shape', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
result: { guilds: [] },
|
||||
|
||||
@@ -15,6 +15,10 @@ interface ConnectChannelPayload {
|
||||
credentials?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DisconnectChannelOptions {
|
||||
clearMemory?: boolean;
|
||||
}
|
||||
|
||||
export interface TelegramLoginStartResult {
|
||||
linkToken: string;
|
||||
telegramUrl: string;
|
||||
@@ -146,8 +150,19 @@ export const channelConnectionsApi = {
|
||||
},
|
||||
|
||||
/** Disconnect a channel for a given auth mode. */
|
||||
disconnectChannel: async (channel: ChannelType, authMode: ChannelAuthMode): Promise<void> => {
|
||||
await callCoreRpc({ method: 'openhuman.channels_disconnect', params: { channel, authMode } });
|
||||
disconnectChannel: async (
|
||||
channel: ChannelType,
|
||||
authMode: ChannelAuthMode,
|
||||
options?: DisconnectChannelOptions
|
||||
): Promise<void> => {
|
||||
const params: { channel: ChannelType; authMode: ChannelAuthMode; clearMemory?: boolean } = {
|
||||
channel,
|
||||
authMode,
|
||||
};
|
||||
if (options?.clearMemory) {
|
||||
params.clearMemory = true;
|
||||
}
|
||||
await callCoreRpc({ method: 'openhuman.channels_disconnect', params });
|
||||
},
|
||||
|
||||
/** Test channel credentials without persisting. */
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::api::jwt::get_session_token;
|
||||
use crate::api::rest::BackendOAuthClient;
|
||||
use crate::openhuman::config::{Config, DiscordConfig, IMessageConfig, TelegramConfig};
|
||||
use crate::openhuman::credentials;
|
||||
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
|
||||
use crate::openhuman::memory_store::chunks::types::SourceKind;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::definitions::{
|
||||
@@ -353,6 +355,7 @@ pub async fn disconnect_channel(
|
||||
config: &Config,
|
||||
channel_id: &str,
|
||||
auth_mode: ChannelAuthMode,
|
||||
clear_memory: bool,
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
// Verify channel exists.
|
||||
find_channel_definition(channel_id).ok_or_else(|| format!("unknown channel: {channel_id}"))?;
|
||||
@@ -404,17 +407,36 @@ pub async fn disconnect_channel(
|
||||
}
|
||||
}
|
||||
|
||||
let memory_chunks_deleted = if clear_memory {
|
||||
clear_channel_memory(config, channel_id).map_err(|e| {
|
||||
format!("channel disconnected, but failed to clear memory chunks: {e:#}")
|
||||
})?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(RpcOutcome::single_log(
|
||||
json!({
|
||||
"channel": channel_id,
|
||||
"auth_mode": auth_mode,
|
||||
"disconnected": true,
|
||||
"restart_required": true,
|
||||
"memory_chunks_deleted": memory_chunks_deleted,
|
||||
}),
|
||||
format!("removed credentials for {}", provider_key),
|
||||
))
|
||||
}
|
||||
|
||||
fn clear_channel_memory(config: &Config, channel_id: &str) -> anyhow::Result<usize> {
|
||||
let exact = memory_tree_store::delete_chunks_by_source(config, SourceKind::Chat, channel_id)?;
|
||||
let prefixed = memory_tree_store::delete_chunks_by_source_prefix(
|
||||
config,
|
||||
SourceKind::Chat,
|
||||
&format!("{channel_id}:"),
|
||||
)?;
|
||||
Ok(exact + prefixed)
|
||||
}
|
||||
|
||||
/// Get connection status for one or all channels.
|
||||
pub async fn channel_status(
|
||||
config: &Config,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use super::*;
|
||||
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
|
||||
use crate::openhuman::memory_store::chunks::types::{
|
||||
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
|
||||
};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn isolated_test_config() -> (tempfile::TempDir, Config) {
|
||||
@@ -10,6 +15,29 @@ fn isolated_test_config() -> (tempfile::TempDir, Config) {
|
||||
(tmp, config)
|
||||
}
|
||||
|
||||
fn sample_chat_chunk(source_id: &str, seq: u32) -> Chunk {
|
||||
let ts = Utc
|
||||
.timestamp_millis_opt(1_700_000_000_000 + i64::from(seq))
|
||||
.unwrap();
|
||||
Chunk {
|
||||
id: chunk_id(SourceKind::Chat, source_id, seq, "channel memory"),
|
||||
content: format!("channel memory {source_id} {seq}"),
|
||||
metadata: Metadata {
|
||||
source_kind: SourceKind::Chat,
|
||||
source_id: source_id.to_string(),
|
||||
owner: "alice@example.com".to_string(),
|
||||
timestamp: ts,
|
||||
time_range: (ts, ts),
|
||||
tags: vec!["channel".to_string()],
|
||||
source_ref: Some(SourceRef::new(format!("discord://{source_id}/{seq}"))),
|
||||
},
|
||||
token_count: 12,
|
||||
seq_in_source: seq,
|
||||
created_at: ts,
|
||||
partial_message: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_channels_returns_definitions() {
|
||||
let result = list_channels().await.unwrap();
|
||||
@@ -138,7 +166,7 @@ async fn disconnect_discord_bot_token_clears_runtime_config() {
|
||||
.await
|
||||
.expect("preloaded config should be persisted");
|
||||
|
||||
disconnect_channel(&config, "discord", ChannelAuthMode::BotToken)
|
||||
disconnect_channel(&config, "discord", ChannelAuthMode::BotToken, false)
|
||||
.await
|
||||
.expect("discord disconnect should succeed");
|
||||
|
||||
@@ -154,6 +182,49 @@ async fn disconnect_discord_bot_token_clears_runtime_config() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_channel_clear_memory_deletes_matching_chat_sources() {
|
||||
let (_tmp, mut config) = isolated_test_config();
|
||||
config.channels_config.discord = Some(DiscordConfig {
|
||||
bot_token: "discord-token-abc".to_string(),
|
||||
guild_id: Some("guild-1".to_string()),
|
||||
channel_id: Some("channel-2".to_string()),
|
||||
allowed_users: vec![],
|
||||
listen_to_bots: false,
|
||||
mention_only: false,
|
||||
});
|
||||
config
|
||||
.save()
|
||||
.await
|
||||
.expect("preloaded config should be persisted");
|
||||
|
||||
let target_a = sample_chat_chunk("discord:guild-1", 0);
|
||||
let target_b = sample_chat_chunk("discord:guild-1:channel-2", 1);
|
||||
let unrelated = sample_chat_chunk("telegram:chat-1", 0);
|
||||
memory_tree_store::upsert_chunks(&config, &[target_a, target_b, unrelated])
|
||||
.expect("chunks should seed");
|
||||
|
||||
let result = disconnect_channel(&config, "discord", ChannelAuthMode::BotToken, true)
|
||||
.await
|
||||
.expect("discord disconnect should succeed");
|
||||
|
||||
assert_eq!(
|
||||
result.value["memory_chunks_deleted"].as_u64(),
|
||||
Some(2),
|
||||
"disconnect should report deleted memory chunks"
|
||||
);
|
||||
let remaining = memory_tree_store::list_chunks(
|
||||
&config,
|
||||
&memory_tree_store::ListChunksQuery {
|
||||
source_kind: Some(SourceKind::Chat),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("chunks should list");
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].metadata.source_id, "telegram:chat-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_channel_validates_fields() {
|
||||
let config = Config::default();
|
||||
@@ -349,7 +420,7 @@ async fn disconnect_imessage_clears_runtime_config() {
|
||||
.await
|
||||
.expect("preloaded config should be persisted");
|
||||
|
||||
disconnect_channel(&config, "imessage", ChannelAuthMode::ManagedDm)
|
||||
disconnect_channel(&config, "imessage", ChannelAuthMode::ManagedDm, false)
|
||||
.await
|
||||
.expect("imessage disconnect should succeed");
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ struct ConnectParams {
|
||||
struct DisconnectParams {
|
||||
channel: String,
|
||||
auth_mode: String,
|
||||
#[serde(default)]
|
||||
clear_memory: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -265,10 +267,16 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"disconnect" => ControllerSchema {
|
||||
namespace: "channels",
|
||||
function: "disconnect",
|
||||
description: "Disconnect a channel and remove stored credentials.",
|
||||
description: "Disconnect a channel and optionally remove source-scoped memory.",
|
||||
inputs: vec![
|
||||
required_string("channel", "Channel identifier."),
|
||||
required_string("authMode", "Auth mode to disconnect."),
|
||||
FieldSchema {
|
||||
name: "clearMemory",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "When true, delete memory chunks ingested from this channel.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![json_output("result", "Disconnect result.")],
|
||||
},
|
||||
@@ -480,7 +488,7 @@ fn handle_disconnect(params: Map<String, Value>) -> ControllerFuture {
|
||||
.auth_mode
|
||||
.parse()
|
||||
.map_err(|e: String| format!("invalid authMode: {e}"))?;
|
||||
to_json(ops::disconnect_channel(&config, p.channel.trim(), mode).await?)
|
||||
to_json(ops::disconnect_channel(&config, p.channel.trim(), mode, p.clear_memory).await?)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,18 @@ fn deserialize_disconnect_params() {
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(params.channel, "discord");
|
||||
assert!(!params.clear_memory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_disconnect_params_accepts_clear_memory() {
|
||||
let params: DisconnectParams = serde_json::from_value(json!({
|
||||
"channel": "discord",
|
||||
"authMode": "bot_token",
|
||||
"clearMemory": true
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(params.clear_memory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
//! agent harness) when they need composio data at runtime.
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::memory::MemoryClient;
|
||||
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
|
||||
use crate::openhuman::memory_store::chunks::types::SourceKind;
|
||||
use crate::openhuman::memory_store::content::raw::slug_account_email;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
/// Result alias used by every `composio_*` op in this module.
|
||||
@@ -22,12 +26,12 @@ type OpResult<T> = std::result::Result<T, String>;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::client::{
|
||||
build_composio_client, create_composio_client, direct_authorize, direct_list_connections,
|
||||
direct_list_tools, ComposioClient, ComposioClientKind,
|
||||
build_composio_client, create_composio_client, direct_list_connections, direct_list_tools,
|
||||
ComposioClient, ComposioClientKind,
|
||||
};
|
||||
use super::providers::{
|
||||
agent_ready_toolkits, capability_matrix, get_provider, ProviderContext, ProviderUserProfile,
|
||||
SyncOutcome, SyncReason,
|
||||
agent_ready_toolkits, capability_matrix, get_provider, sync_state::SyncState, ProviderContext,
|
||||
ProviderUserProfile, SyncOutcome, SyncReason,
|
||||
};
|
||||
use super::types::{
|
||||
ComposioActiveTriggersResponse, ComposioAuthorizeResponse, ComposioAvailableTriggersResponse,
|
||||
@@ -407,16 +411,48 @@ pub async fn composio_authorize(
|
||||
pub async fn composio_delete_connection(
|
||||
config: &Config,
|
||||
connection_id: &str,
|
||||
clear_memory: bool,
|
||||
) -> OpResult<RpcOutcome<ComposioDeleteResponse>> {
|
||||
tracing::debug!(connection_id = %connection_id, "[composio] rpc delete_connection");
|
||||
let client = resolve_client(config)?;
|
||||
let toolkit = resolve_toolkit_for_connection(&client, connection_id)
|
||||
.await
|
||||
.ok();
|
||||
let resp = client.delete_connection(connection_id).await.map_err(|e| {
|
||||
let toolkit = match resolve_toolkit_for_connection(&client, connection_id).await {
|
||||
Ok(toolkit) => Some(toolkit),
|
||||
Err(error) if clear_memory => {
|
||||
return Err(format!(
|
||||
"[composio] delete_connection cannot clear memory without resolving toolkit: {error}"
|
||||
));
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let memory_targets = if clear_memory {
|
||||
composio_memory_targets_for_connection(config, toolkit.as_deref(), connection_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!("[composio] delete_connection cannot enumerate memory targets: {error:#}")
|
||||
})?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut resp = client.delete_connection(connection_id).await.map_err(|e| {
|
||||
report_composio_op_error("delete_connection", &e);
|
||||
format!("[composio] delete_connection failed: {e:#}")
|
||||
})?;
|
||||
let mut memory_chunks_deleted = 0;
|
||||
let mut memory_clear_errors = Vec::new();
|
||||
for target in &memory_targets {
|
||||
match target.delete(config) {
|
||||
Ok(deleted) => {
|
||||
memory_chunks_deleted += deleted;
|
||||
}
|
||||
Err(error) => {
|
||||
memory_clear_errors.push(format!(
|
||||
"[composio] connection deleted, but failed to clear memory chunks for {}: {error:#}",
|
||||
target.label()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.memory_chunks_deleted = memory_chunks_deleted;
|
||||
if let Some(toolkit) = toolkit.as_deref() {
|
||||
let deleted =
|
||||
super::providers::profile::delete_connected_identity_facets(toolkit, connection_id);
|
||||
@@ -477,12 +513,168 @@ pub async fn composio_delete_connection(
|
||||
);
|
||||
}
|
||||
}
|
||||
if !memory_clear_errors.is_empty() {
|
||||
return Err(memory_clear_errors.join("; "));
|
||||
}
|
||||
Ok(RpcOutcome::new(
|
||||
resp,
|
||||
vec![format!("composio: connection {connection_id} deleted")],
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum MemoryCleanupTarget {
|
||||
Exact(SourceKind, String),
|
||||
Prefix(SourceKind, String),
|
||||
}
|
||||
|
||||
impl MemoryCleanupTarget {
|
||||
fn delete(&self, config: &Config) -> anyhow::Result<usize> {
|
||||
match self {
|
||||
Self::Exact(source_kind, source_id) => {
|
||||
memory_tree_store::delete_chunks_by_source(config, *source_kind, source_id)
|
||||
}
|
||||
Self::Prefix(source_kind, source_id_prefix) => {
|
||||
memory_tree_store::delete_chunks_by_source_prefix(
|
||||
config,
|
||||
*source_kind,
|
||||
source_id_prefix,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Exact(source_kind, source_id) => {
|
||||
format!("{}:{source_id}", source_kind.as_str())
|
||||
}
|
||||
Self::Prefix(source_kind, source_id_prefix) => {
|
||||
format!("{}:{source_id_prefix}*", source_kind.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn composio_memory_targets_for_connection(
|
||||
config: &Config,
|
||||
toolkit: Option<&str>,
|
||||
connection_id: &str,
|
||||
) -> anyhow::Result<Vec<MemoryCleanupTarget>> {
|
||||
let Some(toolkit) = toolkit.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let targets = match toolkit.to_ascii_lowercase().as_str() {
|
||||
"slack" => vec![MemoryCleanupTarget::Exact(
|
||||
SourceKind::Chat,
|
||||
format!("slack:{connection_id}"),
|
||||
)],
|
||||
"gmail" => gmail_memory_sources_for_connection(connection_id),
|
||||
"notion" => notion_memory_targets_for_connection(config, connection_id).await?,
|
||||
"drive" | "googledrive" | "google_drive" => {
|
||||
drive_memory_targets_for_connection(connection_id)
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
fn gmail_memory_sources_for_connection(connection_id: &str) -> Vec<MemoryCleanupTarget> {
|
||||
let normalized_connection_id =
|
||||
super::providers::profile::normalize_connection_identifier(connection_id);
|
||||
let mut sources = Vec::new();
|
||||
for identity in super::providers::profile::load_connected_identities() {
|
||||
if identity.source != "gmail" || identity.identifier != normalized_connection_id {
|
||||
continue;
|
||||
}
|
||||
let Some(email) = identity
|
||||
.email
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let source = MemoryCleanupTarget::Exact(
|
||||
SourceKind::Email,
|
||||
format!("gmail:{}", slug_account_email(email)),
|
||||
);
|
||||
if !sources.iter().any(|existing| existing == &source) {
|
||||
sources.push(source);
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
async fn notion_memory_targets_for_connection(
|
||||
config: &Config,
|
||||
connection_id: &str,
|
||||
) -> anyhow::Result<Vec<MemoryCleanupTarget>> {
|
||||
let mut targets = connection_scoped_document_targets("notion", connection_id);
|
||||
|
||||
let memory = Arc::new(
|
||||
MemoryClient::from_workspace_dir(config.workspace_dir.clone()).map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"failed to open memory client for notion cleanup target discovery: {error}"
|
||||
)
|
||||
})?,
|
||||
);
|
||||
let state = SyncState::load(&memory, "notion", connection_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
anyhow::anyhow!("failed to load notion sync state for memory cleanup: {error}")
|
||||
})?;
|
||||
for raw_id in state.synced_ids {
|
||||
let Some(page_id) = notion_synced_page_id(&raw_id) else {
|
||||
continue;
|
||||
};
|
||||
targets.push(MemoryCleanupTarget::Exact(
|
||||
SourceKind::Document,
|
||||
format!("notion:{page_id}"),
|
||||
));
|
||||
targets.push(MemoryCleanupTarget::Exact(
|
||||
SourceKind::Document,
|
||||
format!("composio-notion-page-{page_id}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(dedupe_memory_targets(targets))
|
||||
}
|
||||
|
||||
fn drive_memory_targets_for_connection(connection_id: &str) -> Vec<MemoryCleanupTarget> {
|
||||
["drive", "googledrive", "google_drive"]
|
||||
.into_iter()
|
||||
.flat_map(|prefix| connection_scoped_document_targets(prefix, connection_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn connection_scoped_document_targets(
|
||||
prefix: &str,
|
||||
connection_id: &str,
|
||||
) -> Vec<MemoryCleanupTarget> {
|
||||
vec![
|
||||
MemoryCleanupTarget::Exact(SourceKind::Document, format!("{prefix}:{connection_id}")),
|
||||
MemoryCleanupTarget::Prefix(SourceKind::Document, format!("{prefix}:{connection_id}:")),
|
||||
MemoryCleanupTarget::Prefix(SourceKind::Document, format!("{prefix}:{connection_id}/")),
|
||||
]
|
||||
}
|
||||
|
||||
fn notion_synced_page_id(raw_id: &str) -> Option<String> {
|
||||
let page_id = raw_id.split_once('@').map_or(raw_id, |(id, _)| id).trim();
|
||||
(!page_id.is_empty()).then(|| page_id.to_string())
|
||||
}
|
||||
|
||||
fn dedupe_memory_targets(targets: Vec<MemoryCleanupTarget>) -> Vec<MemoryCleanupTarget> {
|
||||
let mut unique = Vec::new();
|
||||
for target in targets {
|
||||
if !unique.contains(&target) {
|
||||
unique.push(target);
|
||||
}
|
||||
}
|
||||
unique
|
||||
}
|
||||
|
||||
// ── Tools ───────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn composio_list_tools(
|
||||
|
||||
@@ -113,7 +113,7 @@ async fn composio_authorize_errors_without_session() {
|
||||
async fn composio_delete_connection_errors_without_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let err = composio_delete_connection(&config, "c-1")
|
||||
let err = composio_delete_connection(&config, "c-1", false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("composio unavailable"));
|
||||
@@ -231,11 +231,16 @@ fn invalidate_connected_integrations_cache_is_safe_without_prior_insert() {
|
||||
|
||||
// ── Mock-backend integration tests for ops ─────────────────────
|
||||
|
||||
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
|
||||
use crate::openhuman::memory_store::chunks::types::{
|
||||
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -310,6 +315,29 @@ fn config_with_backend(tmp: &tempfile::TempDir, base: String) -> Config {
|
||||
c
|
||||
}
|
||||
|
||||
fn sample_memory_chunk(source_kind: SourceKind, source_id: &str, seq: u32) -> Chunk {
|
||||
let ts = Utc
|
||||
.timestamp_millis_opt(1_700_000_000_000 + i64::from(seq))
|
||||
.unwrap();
|
||||
Chunk {
|
||||
id: chunk_id(source_kind, source_id, seq, "composio memory"),
|
||||
content: format!("composio memory {source_id} {seq}"),
|
||||
metadata: Metadata {
|
||||
source_kind,
|
||||
source_id: source_id.to_string(),
|
||||
owner: "alice@example.com".to_string(),
|
||||
timestamp: ts,
|
||||
time_range: (ts, ts),
|
||||
tags: vec!["composio".to_string()],
|
||||
source_ref: Some(SourceRef::new(format!("composio://{source_id}/{seq}"))),
|
||||
},
|
||||
token_count: 12,
|
||||
seq_in_source: seq,
|
||||
created_at: ts,
|
||||
partial_message: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn composio_list_toolkits_via_mock() {
|
||||
let app = Router::new().route(
|
||||
@@ -433,10 +461,135 @@ async fn composio_delete_connection_via_mock() {
|
||||
let base = start_mock_backend(app).await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config_with_backend(&tmp, base);
|
||||
let outcome = composio_delete_connection(&config, "c1").await.unwrap();
|
||||
let outcome = composio_delete_connection(&config, "c1", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(outcome.value.deleted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn composio_delete_connection_clear_memory_deletes_slack_source() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/agent-integrations/composio/connections",
|
||||
get(|| async {
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"data": {"connections": [
|
||||
{"id":"c1","toolkit":"slack","status":"ACTIVE"}
|
||||
]}
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/agent-integrations/composio/connections/{id}",
|
||||
axum::routing::delete(|Path(_id): Path<String>| async move {
|
||||
Json(json!({"success": true, "data": {"deleted": true}}))
|
||||
}),
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config_with_backend(&tmp, base);
|
||||
let target = sample_memory_chunk(SourceKind::Chat, "slack:c1", 0);
|
||||
let unrelated = sample_memory_chunk(SourceKind::Chat, "slack:c2", 0);
|
||||
memory_tree_store::upsert_chunks(&config, &[target, unrelated]).expect("chunks should seed");
|
||||
|
||||
let outcome = composio_delete_connection(&config, "c1", true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.value.deleted);
|
||||
assert_eq!(outcome.value.memory_chunks_deleted, 1);
|
||||
let remaining = memory_tree_store::list_chunks(
|
||||
&config,
|
||||
&memory_tree_store::ListChunksQuery {
|
||||
source_kind: Some(SourceKind::Chat),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("chunks should list");
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].metadata.source_id, "slack:c2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notion_cleanup_targets_include_synced_page_sources() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let memory = std::sync::Arc::new(
|
||||
MemoryClient::from_workspace_dir(config.workspace_dir.clone())
|
||||
.expect("memory client should initialise"),
|
||||
);
|
||||
let mut state = SyncState::new("notion", "conn-1");
|
||||
state.mark_synced("page-a@2026-01-01T00:00:00Z");
|
||||
state.mark_synced("page-b");
|
||||
state.save(&memory).await.expect("sync state should save");
|
||||
|
||||
let targets = composio_memory_targets_for_connection(&config, Some("notion"), "conn-1")
|
||||
.await
|
||||
.expect("notion cleanup targets should resolve");
|
||||
|
||||
assert!(targets.contains(&MemoryCleanupTarget::Exact(
|
||||
SourceKind::Document,
|
||||
"notion:page-a".to_string()
|
||||
)));
|
||||
assert!(targets.contains(&MemoryCleanupTarget::Exact(
|
||||
SourceKind::Document,
|
||||
"notion:page-b".to_string()
|
||||
)));
|
||||
assert!(targets.contains(&MemoryCleanupTarget::Exact(
|
||||
SourceKind::Document,
|
||||
"composio-notion-page-page-a".to_string()
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notion_cleanup_targets_surface_corrupt_sync_state() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let memory = std::sync::Arc::new(
|
||||
MemoryClient::from_workspace_dir(config.workspace_dir.clone())
|
||||
.expect("memory client should initialise"),
|
||||
);
|
||||
memory
|
||||
.kv_set(
|
||||
Some(super::super::providers::sync_state::KV_NAMESPACE),
|
||||
"notion:conn-1",
|
||||
&serde_json::json!({ "toolkit": 42 }),
|
||||
)
|
||||
.await
|
||||
.expect("corrupt sync state should be written");
|
||||
|
||||
let err = composio_memory_targets_for_connection(&config, Some("notion"), "conn-1")
|
||||
.await
|
||||
.expect_err("corrupt sync state should surface");
|
||||
|
||||
assert!(err.to_string().contains("failed to load notion sync state"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_cleanup_targets_are_connection_scoped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
|
||||
let targets = composio_memory_targets_for_connection(&config, Some("google_drive"), "conn-1")
|
||||
.await
|
||||
.expect("drive cleanup targets should resolve");
|
||||
|
||||
assert!(targets.contains(&MemoryCleanupTarget::Exact(
|
||||
SourceKind::Document,
|
||||
"drive:conn-1".to_string()
|
||||
)));
|
||||
assert!(targets.contains(&MemoryCleanupTarget::Prefix(
|
||||
SourceKind::Document,
|
||||
"googledrive:conn-1:".to_string()
|
||||
)));
|
||||
assert!(targets.contains(&MemoryCleanupTarget::Prefix(
|
||||
SourceKind::Document,
|
||||
"google_drive:conn-1/".to_string()
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn composio_get_user_profile_via_mock_returns_provider_profile() {
|
||||
use crate::openhuman::config::TEST_ENV_LOCK;
|
||||
|
||||
@@ -278,19 +278,35 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"delete_connection" => ControllerSchema {
|
||||
namespace: "composio",
|
||||
function: "delete_connection",
|
||||
description: "Delete a Composio connection owned by the caller.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "connection_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Identifier of the connection to delete.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "deleted",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the backend confirmed the deletion.",
|
||||
required: true,
|
||||
}],
|
||||
description: "Delete a Composio connection and optionally remove source-scoped memory.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "connection_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Identifier of the connection to delete.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "clear_memory",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "When true, delete memory chunks ingested from this connection.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "deleted",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the backend confirmed the deletion.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "memory_chunks_deleted",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Number of memory chunks deleted for this connection.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"list_tools" => ControllerSchema {
|
||||
namespace: "composio",
|
||||
@@ -751,7 +767,10 @@ fn handle_delete_connection(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let connection_id = read_required_non_empty(¶ms, "connection_id")?;
|
||||
to_json(super::ops::composio_delete_connection(&config, &connection_id).await?)
|
||||
let clear_memory = read_optional::<bool>(¶ms, "clear_memory")?.unwrap_or(false);
|
||||
to_json(
|
||||
super::ops::composio_delete_connection(&config, &connection_id, clear_memory).await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,8 @@ pub struct ComposioAuthorizeResponse {
|
||||
pub struct ComposioDeleteResponse {
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
#[serde(default)]
|
||||
pub memory_chunks_deleted: usize,
|
||||
}
|
||||
|
||||
// ── Tools ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +35,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::memory::util::redact;
|
||||
use crate::openhuman::memory_store::chunks::types::{Chunk, Metadata, SourceKind, SourceRef};
|
||||
use crate::openhuman::memory_store::content::StagedChunk;
|
||||
|
||||
@@ -46,7 +47,7 @@ const MAX_LIST_LIMIT: usize = 10_000;
|
||||
// contention (4 job workers + scheduler + ingest producers all writing the
|
||||
// same `memory_tree/chunks.db`) is absorbed inside rusqlite instead of
|
||||
// surfacing as `SQLITE_BUSY` to callers. Workers still treat busy as a
|
||||
// soft signal (see `memory::tree::jobs::worker`) so even if this is
|
||||
// soft signal (see `memory_tree::jobs::worker`) so even if this is
|
||||
// exceeded, the only effect is a one-poll backoff — but 15s is
|
||||
// comfortably above realistic peer-write durations and shrinks the rate
|
||||
// at which we have to fall back to that path. The previous 5s was tight
|
||||
@@ -702,6 +703,188 @@ pub(crate) fn claim_source_ingest_tx(
|
||||
Ok(inserted > 0)
|
||||
}
|
||||
|
||||
/// Delete all chunk rows for one exact `(source_kind, source_id)` and clear
|
||||
/// dependent source-local indexes. Returns the number of chunk rows removed.
|
||||
pub fn delete_chunks_by_source(
|
||||
config: &Config,
|
||||
source_kind: SourceKind,
|
||||
source_id: &str,
|
||||
) -> Result<usize> {
|
||||
delete_chunks_by_source_filter(config, source_kind, |candidate| candidate == source_id)
|
||||
}
|
||||
|
||||
/// Delete all chunk rows whose source id starts with `source_id_prefix`.
|
||||
///
|
||||
/// This is intentionally a Rust-side prefix filter rather than a SQL `LIKE`
|
||||
/// expression so provider ids containing `_` / `%` are treated literally.
|
||||
pub fn delete_chunks_by_source_prefix(
|
||||
config: &Config,
|
||||
source_kind: SourceKind,
|
||||
source_id_prefix: &str,
|
||||
) -> Result<usize> {
|
||||
delete_chunks_by_source_filter(config, source_kind, |candidate| {
|
||||
candidate.starts_with(source_id_prefix)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_chunks_by_source_filter(
|
||||
config: &Config,
|
||||
source_kind: SourceKind,
|
||||
matches_source: impl Fn(&str) -> bool,
|
||||
) -> Result<usize> {
|
||||
let mut content_paths = Vec::new();
|
||||
let deleted = with_connection(config, |conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
let chunks = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT id, source_id, content_path
|
||||
FROM mem_tree_chunks
|
||||
WHERE source_kind = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![source_kind.as_str()], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.filter_map(|row| match row {
|
||||
Ok((id, source_id, content_path)) if matches_source(&source_id) => {
|
||||
Some(Ok((id, content_path)))
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(error) => Some(Err(error)),
|
||||
})
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.context("Failed to collect memory_tree chunks by source")?
|
||||
};
|
||||
|
||||
for (chunk_id, content_path) in &chunks {
|
||||
tx.execute(
|
||||
"DELETE FROM mem_tree_score WHERE chunk_id = ?1",
|
||||
params![chunk_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM mem_tree_entity_index WHERE node_id = ?1",
|
||||
params![chunk_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM mem_tree_chunk_embeddings WHERE chunk_id = ?1",
|
||||
params![chunk_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM mem_tree_chunk_reembed_skipped WHERE chunk_id = ?1",
|
||||
params![chunk_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM mem_tree_chunks WHERE id = ?1",
|
||||
params![chunk_id],
|
||||
)?;
|
||||
if let Some(path) = content_path.as_ref().filter(|path| !path.is_empty()) {
|
||||
content_paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let ingested_sources = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT source_id
|
||||
FROM mem_tree_ingested_sources
|
||||
WHERE source_kind = ?1",
|
||||
)?;
|
||||
let rows =
|
||||
stmt.query_map(params![source_kind.as_str()], |row| row.get::<_, String>(0))?;
|
||||
rows.filter_map(|row| match row {
|
||||
Ok(source_id) if matches_source(&source_id) => Some(Ok(source_id)),
|
||||
Ok(_) => None,
|
||||
Err(error) => Some(Err(error)),
|
||||
})
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.context("Failed to collect memory_tree ingested sources")?
|
||||
};
|
||||
|
||||
for source_id in &ingested_sources {
|
||||
tx.execute(
|
||||
"DELETE FROM mem_tree_ingested_sources
|
||||
WHERE source_kind = ?1 AND source_id = ?2",
|
||||
params![source_kind.as_str(), source_id],
|
||||
)?;
|
||||
}
|
||||
|
||||
let deleted = chunks.len();
|
||||
tx.commit()?;
|
||||
Ok(deleted)
|
||||
})?;
|
||||
|
||||
remove_chunk_content_files(config, &content_paths);
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
fn remove_chunk_content_files(config: &Config, content_paths: &[String]) {
|
||||
use std::path::{Component, Path};
|
||||
|
||||
let root = config.memory_tree_content_root();
|
||||
let canonical_root = match std::fs::canonicalize(&root) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
if error.kind() != std::io::ErrorKind::NotFound {
|
||||
log::warn!(
|
||||
"[memory_tree::store] failed to resolve content root {}: {error}",
|
||||
root.display(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for rel in content_paths {
|
||||
let rel_path = Path::new(rel);
|
||||
let has_escape_component = rel_path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
});
|
||||
if has_escape_component {
|
||||
log::warn!(
|
||||
"[memory_tree::store] refusing to remove chunk file with unsafe content_path path_hash={}",
|
||||
redact::redact(rel),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = root.join(rel_path);
|
||||
let resolved_path = match std::fs::canonicalize(&path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
if error.kind() != std::io::ErrorKind::NotFound {
|
||||
log::warn!(
|
||||
"[memory_tree::store] failed to resolve chunk file path_hash={}: {error}",
|
||||
redact::redact(rel),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !resolved_path.starts_with(&canonical_root) {
|
||||
log::warn!(
|
||||
"[memory_tree::store] refusing to remove chunk file outside content root path_hash={}",
|
||||
redact::redact(rel),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error) = std::fs::remove_file(&path) {
|
||||
if error.kind() != std::io::ErrorKind::NotFound {
|
||||
log::warn!(
|
||||
"[memory_tree::store] failed to remove chunk file path_hash={}: {error}",
|
||||
redact::redact(rel),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<Chunk> {
|
||||
let id: String = row.get(0)?;
|
||||
let source_kind_s: String = row.get(1)?;
|
||||
|
||||
@@ -218,6 +218,151 @@ fn list_limit_is_clamped_to_sane_range() {
|
||||
assert_eq!(huge_limit.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_chunks_by_source_removes_chunks_side_rows_and_ingest_gate() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let target_a = sample_chunk("slack:c-1", 0, 1_700_000_000_000);
|
||||
let target_b = sample_chunk("slack:c-1", 1, 1_700_000_001_000);
|
||||
let other = sample_chunk("slack:c-2", 0, 1_700_000_002_000);
|
||||
upsert_chunks(&cfg, &[target_a.clone(), target_b.clone(), other.clone()]).unwrap();
|
||||
|
||||
with_connection(&cfg, |conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for chunk in [&target_a, &target_b, &other] {
|
||||
tx.execute(
|
||||
"INSERT INTO mem_tree_score (
|
||||
chunk_id, total, token_count_signal, unique_words_signal,
|
||||
metadata_weight, source_weight, interaction_weight,
|
||||
entity_density, dropped, reason, computed_at_ms
|
||||
) VALUES (?1, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0, NULL, 1700000000000)",
|
||||
params![chunk.id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO mem_tree_entity_index (
|
||||
entity_id, node_id, node_kind, entity_kind, surface, score, timestamp_ms
|
||||
) VALUES (?1, ?2, 'chunk', 'person', 'chat', 0.9, 1700000000000)",
|
||||
params![format!("entity:{}", chunk.id), chunk.id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO mem_tree_chunk_embeddings (
|
||||
chunk_id, model_signature, vector, dim, created_at
|
||||
) VALUES (?1, 'test/model@3', ?2, 3, 1700000000.0)",
|
||||
params![chunk.id, vec![1_u8, 2, 3]],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO mem_tree_chunk_reembed_skipped (
|
||||
chunk_id, model_signature, reason, skipped_at_ms
|
||||
) VALUES (?1, 'test/model@3', 'terminal', 1700000000000)",
|
||||
params![chunk.id],
|
||||
)?;
|
||||
}
|
||||
assert!(claim_source_ingest_tx(
|
||||
&tx,
|
||||
SourceKind::Chat,
|
||||
"slack:c-1",
|
||||
1_700_000_000_000
|
||||
)?);
|
||||
assert!(claim_source_ingest_tx(
|
||||
&tx,
|
||||
SourceKind::Chat,
|
||||
"slack:c-2",
|
||||
1_700_000_000_000
|
||||
)?);
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let deleted = delete_chunks_by_source(&cfg, SourceKind::Chat, "slack:c-1").unwrap();
|
||||
|
||||
assert_eq!(deleted, 2);
|
||||
assert_eq!(count_chunks(&cfg).unwrap(), 1);
|
||||
assert!(get_chunk(&cfg, &target_a.id).unwrap().is_none());
|
||||
assert!(get_chunk(&cfg, &target_b.id).unwrap().is_none());
|
||||
assert!(get_chunk(&cfg, &other.id).unwrap().is_some());
|
||||
assert!(!is_source_ingested(&cfg, SourceKind::Chat, "slack:c-1").unwrap());
|
||||
assert!(is_source_ingested(&cfg, SourceKind::Chat, "slack:c-2").unwrap());
|
||||
|
||||
with_connection(&cfg, |conn| {
|
||||
let count_by_table = |table: &str| -> rusqlite::Result<i64> {
|
||||
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
|
||||
};
|
||||
assert_eq!(count_by_table("mem_tree_score")?, 1);
|
||||
assert_eq!(count_by_table("mem_tree_entity_index")?, 1);
|
||||
assert_eq!(count_by_table("mem_tree_chunk_embeddings")?, 1);
|
||||
assert_eq!(count_by_table("mem_tree_chunk_reembed_skipped")?, 1);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_chunks_by_source_removes_safe_content_files_but_rejects_escape_paths() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let safe = sample_chunk("slack:c-1", 0, 1_700_000_000_000);
|
||||
let unsafe_chunk = sample_chunk("slack:c-1", 1, 1_700_000_001_000);
|
||||
upsert_chunks(&cfg, &[safe.clone(), unsafe_chunk.clone()]).unwrap();
|
||||
|
||||
let content_root = cfg.memory_tree_content_root();
|
||||
let safe_rel = "chunks/safe.md";
|
||||
let safe_path = content_root.join(safe_rel);
|
||||
std::fs::create_dir_all(safe_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&safe_path, "safe").unwrap();
|
||||
|
||||
let outside_path = content_root.parent().unwrap().join("outside.md");
|
||||
std::fs::write(&outside_path, "outside").unwrap();
|
||||
|
||||
with_connection(&cfg, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE mem_tree_chunks SET content_path = ?1 WHERE id = ?2",
|
||||
params![safe_rel, safe.id],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE mem_tree_chunks SET content_path = ?1 WHERE id = ?2",
|
||||
params!["../outside.md", unsafe_chunk.id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let deleted = delete_chunks_by_source(&cfg, SourceKind::Chat, "slack:c-1").unwrap();
|
||||
|
||||
assert_eq!(deleted, 2);
|
||||
assert!(!safe_path.exists());
|
||||
assert!(outside_path.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn delete_chunks_by_source_removes_symlink_entry_not_target_file() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let linked_chunk = sample_chunk("slack:c-1", 0, 1_700_000_000_000);
|
||||
upsert_chunks(&cfg, &[linked_chunk.clone()]).unwrap();
|
||||
|
||||
let content_root = cfg.memory_tree_content_root();
|
||||
let target_path = content_root.join("chunks/target.md");
|
||||
let link_rel = "chunks/link.md";
|
||||
let link_path = content_root.join(link_rel);
|
||||
std::fs::create_dir_all(target_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&target_path, "target").unwrap();
|
||||
std::os::unix::fs::symlink("target.md", &link_path).unwrap();
|
||||
|
||||
with_connection(&cfg, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE mem_tree_chunks SET content_path = ?1 WHERE id = ?2",
|
||||
params![link_rel, linked_chunk.id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let deleted = delete_chunks_by_source(&cfg, SourceKind::Chat, "slack:c-1").unwrap();
|
||||
|
||||
assert_eq!(deleted, 1);
|
||||
assert!(target_path.exists());
|
||||
assert!(!link_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_chunk_returns_none() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
|
||||
@@ -506,6 +506,10 @@ fn normalize_token(raw: &str) -> String {
|
||||
out.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_connection_identifier(raw: &str) -> String {
|
||||
normalize_token(raw)
|
||||
}
|
||||
|
||||
fn title_case(raw: &str) -> String {
|
||||
let mut chars = raw.chars();
|
||||
match chars.next() {
|
||||
|
||||
Reference in New Issue
Block a user