feat(task-sources): proactive task ingestion from GitHub/Notion/Linear/ClickUp (#2894)

Co-authored-by: cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
This commit is contained in:
Steven Enamakel
2026-05-29 19:30:34 +05:30
committed by GitHub
co-authored by cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
parent 3e59548f12
commit 29bf148f38
52 changed files with 6072 additions and 11 deletions
@@ -0,0 +1,211 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import TaskSourcesPanel from './TaskSourcesPanel';
const navigateBack = vi.fn();
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack, breadcrumbs: [] }),
}));
vi.mock('../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
}));
const listMock = vi.fn();
const statusMock = vi.fn();
const addMock = vi.fn();
const updateMock = vi.fn();
const removeMock = vi.fn();
const fetchMock = vi.fn();
const previewMock = vi.fn();
vi.mock('../../../utils/tauriCommands', () => ({
openhumanTaskSourcesList: () => listMock(),
openhumanTaskSourcesStatus: () => statusMock(),
openhumanTaskSourcesAdd: (p: unknown) => addMock(p),
openhumanTaskSourcesUpdate: (id: string, patch: unknown) => updateMock(id, patch),
openhumanTaskSourcesRemove: (id: string) => removeMock(id),
openhumanTaskSourcesFetch: (id: string) => fetchMock(id),
openhumanTaskSourcesPreviewFilter: (...args: unknown[]) => previewMock(...args),
}));
function sampleSource(overrides: Record<string, unknown> = {}) {
return {
id: 's-1',
provider: 'github',
name: 'My open issues',
enabled: true,
filter: { provider: 'github', repo: 'o/r', labels: [], assignee_is_me: true },
intervalSecs: 1800,
target: 'agent_todo_proactive',
maxTasksPerFetch: 25,
createdAt: '2025-01-01T00:00:00Z',
...overrides,
};
}
function renderPanel() {
return render(
<MemoryRouter>
<TaskSourcesPanel />
</MemoryRouter>
);
}
describe('<TaskSourcesPanel />', () => {
beforeEach(() => {
vi.clearAllMocks();
listMock.mockResolvedValue([sampleSource()]);
statusMock.mockResolvedValue({
enabled: true,
defaultIntervalSecs: 1800,
sourceCount: 1,
enabledSourceCount: 1,
});
addMock.mockResolvedValue(sampleSource({ id: 's-2' }));
updateMock.mockImplementation((id, patch) =>
Promise.resolve(sampleSource({ id, ...(patch as object) }))
);
removeMock.mockResolvedValue({ id: 's-1', removed: true });
fetchMock.mockResolvedValue({
sourceId: 's-1',
provider: 'github',
fetched: 3,
routed: 2,
skippedDupe: 1,
});
previewMock.mockResolvedValue([{ externalId: '1' }, { externalId: '2' }]);
});
afterEach(() => vi.restoreAllMocks());
it('loads and renders configured sources', async () => {
renderPanel();
await waitFor(() => expect(listMock).toHaveBeenCalled());
expect(await screen.findByTestId('task-source-s-1')).toBeInTheDocument();
expect(screen.getByText('My open issues')).toBeInTheDocument();
expect(statusMock).toHaveBeenCalled();
});
it('shows the empty state when there are no sources', async () => {
listMock.mockResolvedValue([]);
renderPanel();
expect(await screen.findByText('No task sources configured yet.')).toBeInTheDocument();
});
it('shows the disabled banner when the domain is off', async () => {
statusMock.mockResolvedValue({
enabled: false,
defaultIntervalSecs: 1800,
sourceCount: 0,
enabledSourceCount: 0,
});
listMock.mockResolvedValue([]);
renderPanel();
expect(
await screen.findByText(
'Task sources are disabled in settings. Enable them to poll automatically.'
)
).toBeInTheDocument();
});
it('surfaces a load error', async () => {
listMock.mockRejectedValue(new Error('boom'));
renderPanel();
await waitFor(() => expect(screen.getByText(/boom/)).toBeInTheDocument());
});
it('adds a source with the form-built filter', async () => {
renderPanel();
await screen.findByTestId('task-source-s-1');
fireEvent.change(screen.getByPlaceholderText('e.g. My open issues'), {
target: { value: 'My PRs' },
});
fireEvent.change(screen.getByLabelText('Repository (owner/name, optional)'), {
target: { value: 'acme/app' },
});
fireEvent.change(screen.getByLabelText('Labels (comma-separated)'), {
target: { value: 'bug, p1' },
});
fireEvent.click(screen.getByRole('button', { name: 'Add source' }));
await waitFor(() => expect(addMock).toHaveBeenCalled());
expect(addMock).toHaveBeenCalledWith({
provider: 'github',
name: 'My PRs',
filter: { provider: 'github', repo: 'acme/app', labels: ['bug', 'p1'], assignee_is_me: true },
});
// List is reloaded after a successful add.
expect(listMock).toHaveBeenCalledTimes(2);
});
it('previews the current filter without routing', async () => {
renderPanel();
await screen.findByTestId('task-source-s-1');
fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
await waitFor(() => expect(previewMock).toHaveBeenCalled());
expect(await screen.findByText(/2 task\(s\) match this filter/)).toBeInTheDocument();
});
it('toggles a source enabled state', async () => {
renderPanel();
const card = await screen.findByTestId('task-source-s-1');
fireEvent.click(within(card).getByRole('button', { name: 'Disable' }));
await waitFor(() => expect(updateMock).toHaveBeenCalledWith('s-1', { enabled: false }));
});
it('fetches now and surfaces the routed/fetched counts', async () => {
renderPanel();
const card = await screen.findByTestId('task-source-s-1');
fireEvent.click(within(card).getByRole('button', { name: 'Fetch now' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('s-1'));
expect(await screen.findByText(/Routed 2 of 3 task\(s\)/)).toBeInTheDocument();
});
it('surfaces a fetch outcome error', async () => {
fetchMock.mockResolvedValue({
sourceId: 's-1',
provider: 'github',
fetched: 0,
routed: 0,
skippedDupe: 0,
error: 'no connection',
});
renderPanel();
const card = await screen.findByTestId('task-source-s-1');
fireEvent.click(within(card).getByRole('button', { name: 'Fetch now' }));
await waitFor(() => expect(screen.getByText('no connection')).toBeInTheDocument());
});
it('removes a source after confirm', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
renderPanel();
const card = await screen.findByTestId('task-source-s-1');
fireEvent.click(within(card).getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(removeMock).toHaveBeenCalledWith('s-1'));
await waitFor(() => expect(screen.queryByTestId('task-source-s-1')).not.toBeInTheDocument());
});
it('does not remove a source when confirm is cancelled', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
renderPanel();
const card = await screen.findByTestId('task-source-s-1');
fireEvent.click(within(card).getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(window.confirm).toHaveBeenCalled());
expect(removeMock).not.toHaveBeenCalled();
expect(screen.getByTestId('task-source-s-1')).toBeInTheDocument();
});
it('switches the primary field label when the provider changes', async () => {
renderPanel();
await screen.findByTestId('task-source-s-1');
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'notion' } });
expect(screen.getByLabelText('Database (board) ID')).toBeInTheDocument();
// GitHub-only labels field is gone for Notion.
expect(screen.queryByLabelText('Labels (comma-separated)')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,429 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
openhumanTaskSourcesAdd,
openhumanTaskSourcesFetch,
openhumanTaskSourcesList,
openhumanTaskSourcesPreviewFilter,
openhumanTaskSourcesRemove,
openhumanTaskSourcesStatus,
openhumanTaskSourcesUpdate,
type TaskSource,
type TaskSourceFilter,
type TaskSourceProvider,
type TaskSourcesStatus,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const PROVIDERS: TaskSourceProvider[] = ['github', 'notion', 'linear', 'clickup'];
function providerLabel(provider: TaskSourceProvider, t: (key: string) => string): string {
switch (provider) {
case 'github':
return t('settings.taskSources.providers.github');
case 'notion':
return t('settings.taskSources.providers.notion');
case 'linear':
return t('settings.taskSources.providers.linear');
case 'clickup':
return t('settings.taskSources.providers.clickup');
default:
return provider;
}
}
/** Build a `TaskSourceFilter` from the create-form fields. */
function buildFilter(
provider: TaskSourceProvider,
fields: { primary: string; labels: string; assignedToMe: boolean }
): TaskSourceFilter {
const primary = fields.primary.trim();
switch (provider) {
case 'github':
return {
provider: 'github',
repo: primary || undefined,
labels: fields.labels
.split(',')
.map(l => l.trim())
.filter(Boolean),
assignee_is_me: fields.assignedToMe,
};
case 'notion':
return {
provider: 'notion',
database_id: primary || undefined,
assigned_to_me: fields.assignedToMe,
};
case 'linear':
return {
provider: 'linear',
team_id: primary || undefined,
assignee_is_me: fields.assignedToMe,
};
case 'clickup':
return {
provider: 'clickup',
team_id: primary || undefined,
assignee_is_me: fields.assignedToMe,
};
default:
return { provider: 'github', assignee_is_me: fields.assignedToMe };
}
}
const TaskSourcesPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sources, setSources] = useState<TaskSource[]>([]);
const [status, setStatus] = useState<TaskSourcesStatus | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// ── create-form state ────────────────────────────────────────────
const [provider, setProvider] = useState<TaskSourceProvider>('github');
const [name, setName] = useState('');
const [primary, setPrimary] = useState('');
const [labels, setLabels] = useState('');
const [assignedToMe, setAssignedToMe] = useState(true);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [list, stat] = await Promise.all([
openhumanTaskSourcesList(),
openhumanTaskSourcesStatus(),
]);
setSources(list);
setStatus(stat);
} catch (err) {
setError(
`${t('settings.taskSources.loadError')}: ${err instanceof Error ? err.message : String(err)}`
);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
const primaryLabel = useMemo(() => {
switch (provider) {
case 'github':
return t('settings.taskSources.github.repo');
case 'notion':
return t('settings.taskSources.notion.database');
case 'linear':
return t('settings.taskSources.linear.team');
case 'clickup':
return t('settings.taskSources.clickup.team');
default:
return '';
}
}, [provider, t]);
const addSource = async () => {
setBusyKey('add');
setError(null);
setNotice(null);
try {
await openhumanTaskSourcesAdd({
provider,
name: name.trim() || undefined,
filter: buildFilter(provider, { primary, labels, assignedToMe }),
});
setName('');
setPrimary('');
setLabels('');
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
};
const previewFilter = async () => {
setBusyKey('preview');
setError(null);
setNotice(null);
try {
const tasks = await openhumanTaskSourcesPreviewFilter(
provider,
buildFilter(provider, { primary, labels, assignedToMe })
);
setNotice(t('settings.taskSources.previewResult').replace('{count}', String(tasks.length)));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
};
const toggleSource = async (source: TaskSource) => {
setBusyKey(`toggle:${source.id}`);
setError(null);
try {
const updated = await openhumanTaskSourcesUpdate(source.id, { enabled: !source.enabled });
setSources(prev => prev.map(s => (s.id === updated.id ? updated : s)));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
};
const fetchNow = async (source: TaskSource) => {
setBusyKey(`fetch:${source.id}`);
setError(null);
setNotice(null);
try {
const outcome = await openhumanTaskSourcesFetch(source.id);
// Refresh the source list first (updates lastFetchAt/lastStatus);
// `load()` resets the error/notice, so set the outcome message
// *after* it so the message isn't immediately cleared.
await load();
if (outcome.error) {
setError(outcome.error);
} else {
setNotice(
t('settings.taskSources.fetchResult')
.replace('{routed}', String(outcome.routed))
.replace('{fetched}', String(outcome.fetched))
);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
};
const removeSource = async (source: TaskSource) => {
if (!window.confirm(t('settings.taskSources.removeConfirm'))) return;
setBusyKey(`remove:${source.id}`);
setError(null);
try {
await openhumanTaskSourcesRemove(source.id);
setSources(prev => prev.filter(s => s.id !== source.id));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
};
return (
<div data-testid="task-sources-panel">
<SettingsHeader
title={t('settings.taskSources.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-5">
<section className="space-y-1">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.taskSources.description')}
</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('settings.taskSources.connectHint')}
</p>
</section>
{status && !status.enabled && (
<div className="rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.taskSources.disabledBanner')}
</div>
)}
{error && (
<div className="rounded-lg border border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{notice && (
<div className="rounded-lg border border-sky-300 dark:border-sky-500/40 bg-sky-50 dark:bg-sky-500/10 px-4 py-3 text-sm text-sky-700 dark:text-sky-300">
{notice}
</div>
)}
{/* ── Add a source ─────────────────────────────────────────── */}
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.taskSources.addTitle')}
</h3>
<label className="block text-xs text-stone-500 dark:text-neutral-400">
{t('settings.taskSources.provider')}
<select
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
value={provider}
onChange={e => setProvider(e.target.value as TaskSourceProvider)}>
{PROVIDERS.map(p => (
<option key={p} value={p}>
{providerLabel(p, t)}
</option>
))}
</select>
</label>
<label className="block text-xs text-stone-500 dark:text-neutral-400">
{t('settings.taskSources.name')}
<input
type="text"
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
placeholder={t('settings.taskSources.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
/>
</label>
<label className="block text-xs text-stone-500 dark:text-neutral-400">
{primaryLabel}
<input
type="text"
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
value={primary}
onChange={e => setPrimary(e.target.value)}
/>
</label>
{provider === 'github' && (
<label className="block text-xs text-stone-500 dark:text-neutral-400">
{t('settings.taskSources.github.labels')}
<input
type="text"
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
value={labels}
onChange={e => setLabels(e.target.value)}
/>
</label>
)}
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
<input
type="checkbox"
checked={assignedToMe}
onChange={e => setAssignedToMe(e.target.checked)}
/>
{t('settings.taskSources.assignedToMe')}
</label>
<div className="flex gap-2 pt-1">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busyKey === 'add'}
onClick={() => void addSource()}>
{busyKey === 'add' ? t('settings.taskSources.adding') : t('settings.taskSources.add')}
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={busyKey === 'preview'}
onClick={() => void previewFilter()}>
{t('settings.taskSources.preview')}
</button>
</div>
</section>
{/* ── Configured sources ───────────────────────────────────── */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.taskSources.configured')}
</h3>
{loading ? (
<p className="text-sm text-stone-400 dark:text-neutral-500">{t('common.loading')}</p>
) : sources.length === 0 ? (
<p className="text-sm text-stone-400 dark:text-neutral-500">
{t('settings.taskSources.empty')}
</p>
) : (
<ul className="space-y-2">
{sources.map(source => (
<li
key={source.id}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3 space-y-2"
data-testid={`task-source-${source.id}`}>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{source.name || providerLabel(source.provider, t)}
</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{providerLabel(source.provider, t)}
{source.target === 'agent_todo_proactive'
? ` · ${t('settings.taskSources.proactive')}`
: ''}
</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('settings.taskSources.lastFetch')}:{' '}
{source.lastFetchAt
? new Date(source.lastFetchAt).toLocaleString()
: t('settings.taskSources.never')}
</p>
</div>
<span
className={`text-xs rounded-full px-2 py-0.5 ${
source.enabled
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
: 'bg-stone-100 text-stone-500 dark:bg-neutral-800 dark:text-neutral-400'
}`}>
{source.enabled
? t('settings.taskSources.statusEnabled')
: t('settings.taskSources.statusDisabled')}
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn btn-outline btn-xs"
disabled={busyKey === `toggle:${source.id}`}
onClick={() => void toggleSource(source)}>
{source.enabled
? t('settings.taskSources.disable')
: t('settings.taskSources.enable')}
</button>
<button
type="button"
className="btn btn-outline btn-xs"
disabled={busyKey === `fetch:${source.id}`}
onClick={() => void fetchNow(source)}>
{busyKey === `fetch:${source.id}`
? t('settings.taskSources.fetching')
: t('settings.taskSources.fetchNow')}
</button>
<button
type="button"
className="btn btn-ghost btn-xs text-red-600 dark:text-red-400"
disabled={busyKey === `remove:${source.id}`}
onClick={() => void removeSource(source)}>
{t('settings.taskSources.remove')}
</button>
</div>
</li>
))}
</ul>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void load()}>
{t('settings.taskSources.refresh')}
</button>
</section>
</div>
</div>
);
};
export default TaskSourcesPanel;
+46
View File
@@ -928,6 +928,52 @@ const ar5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -941,6 +941,52 @@ const bn5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -969,6 +969,52 @@ const de5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+45
View File
@@ -939,6 +939,51 @@ const en5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -955,6 +955,52 @@ const es5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -959,6 +959,52 @@ const fr5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -942,6 +942,52 @@ const hi5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -942,6 +942,52 @@ const id5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -953,6 +953,52 @@ const it5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -931,6 +931,52 @@ const ko5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -893,6 +893,52 @@ const pl5: TranslationMap = {
'skills.meetingBots.platforms.zoom': 'Zoom',
'skills.meetingBots.soonSuffix': 'wkrótce',
'skills.setup.screenIntel.permissionPathLabel': 'macOS stosuje politykę prywatności do:',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
'skills.tabs.runners': 'Runners',
'settings.developerMenu.skillsRunner.title': 'Skills Runner',
'settings.developerMenu.skillsRunner.desc':
+46
View File
@@ -952,6 +952,52 @@ const pt5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -948,6 +948,52 @@ const ru5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+46
View File
@@ -902,6 +902,52 @@ const zhCN5: TranslationMap = {
'settings.modelHealth.modal.apply': 'Apply Replacement',
'settings.modelHealth.tag.cheaper': 'CHEAPER',
'settings.modelHealth.tag.better': 'BETTER',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure (Phase 2/3) — dashboard + new-skill page.
'skills.dashboard.title': 'Skills',
+45
View File
@@ -4108,6 +4108,51 @@ const en: TranslationMap = {
'settings.appearanceDesc': 'Pick light, dark, or match your system theme',
'settings.mascot': 'Mascot',
'settings.mascotDesc': 'Pick the mascot color used across the app',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
'settings.taskSources.description':
'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.',
'settings.taskSources.connectHint':
'Task sources use your connected accounts. Connect them under Integrations first.',
'settings.taskSources.disabledBanner':
'Task sources are disabled in settings. Enable them to poll automatically.',
'settings.taskSources.loadError': 'Failed to load task sources',
'settings.taskSources.addTitle': 'Add a task source',
'settings.taskSources.provider': 'Provider',
'settings.taskSources.name': 'Name (optional)',
'settings.taskSources.namePlaceholder': 'e.g. My open issues',
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
'settings.taskSources.github.labels': 'Labels (comma-separated)',
'settings.taskSources.notion.database': 'Database (board) ID',
'settings.taskSources.linear.team': 'Team ID (optional)',
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
'settings.taskSources.assignedToMe': 'Only items assigned to me',
'settings.taskSources.add': 'Add source',
'settings.taskSources.adding': 'Adding…',
'settings.taskSources.preview': 'Preview',
'settings.taskSources.previewResult': '{count} task(s) match this filter',
'settings.taskSources.fetchNow': 'Fetch now',
'settings.taskSources.fetching': 'Fetching…',
'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
'settings.taskSources.lastFetch': 'Last fetch',
'settings.taskSources.never': 'Never',
'settings.taskSources.statusEnabled': 'Enabled',
'settings.taskSources.statusDisabled': 'Disabled',
'settings.taskSources.enable': 'Enable',
'settings.taskSources.disable': 'Disable',
'settings.taskSources.remove': 'Remove',
'settings.taskSources.removeConfirm':
'Remove this task source? All ingested task history will be deleted and cannot be undone.',
'settings.taskSources.refresh': 'Refresh',
// Task sources provider labels (#task-sources)
'settings.taskSources.providers.github': 'GitHub',
'settings.taskSources.providers.notion': 'Notion',
'settings.taskSources.providers.linear': 'Linear',
'settings.taskSources.providers.clickup': 'ClickUp',
// /skills IA restructure: landing-dashboard + /skills/new authoring page.
// The runner UX (existing Skills page → SkillsRunnerBody) moves to
+9
View File
@@ -38,6 +38,7 @@ import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAware
import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel';
import SearchPanel from '../components/settings/panels/SearchPanel';
import SkillsRunnerPanel from '../components/settings/panels/SkillsRunnerPanel';
import TaskSourcesPanel from '../components/settings/panels/TaskSourcesPanel';
import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel';
import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel';
@@ -338,6 +339,13 @@ const Settings = () => {
];
const composioSettingsItems = [
{
id: 'task-sources',
title: t('settings.taskSources.title'),
description: t('settings.taskSources.subtitle'),
route: 'task-sources',
icon: ToolsIcon,
},
{
id: 'composio-routing',
title: t('settings.developerMenu.composioRouting.title'),
@@ -460,6 +468,7 @@ const Settings = () => {
<Route path="search" element={wrapSettingsPage(<SearchPanel />)} />
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
<Route path="task-sources" element={wrapSettingsPage(<TaskSourcesPanel />)} />
<Route path="dev-workflow" element={wrapSettingsPage(<DevWorkflowPanel />)} />
<Route path="skills-runner" element={wrapSettingsPage(<SkillsRunnerPanel />)} />
<Route
+1
View File
@@ -14,6 +14,7 @@ export * from './subconscious';
export * from './localAi';
export * from './config';
export * from './cron';
export * from './taskSources';
export * from './heartbeat';
export * from './service';
export * from './accessibility';
@@ -0,0 +1,149 @@
/**
* Vitest for the task-sources tauriCommands surface.
*
* Covers each `openhuman.task_sources_*` RPC wrapper plus the
* `isTauri()` guard. Mirrors the mocking pattern in
* `subconscious.test.ts` — validates the wrappers against the
* `callCoreRpc` contract without a real Tauri runtime.
*/
import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
import { isTauri } from './common';
import {
openhumanTaskSourcesAdd,
openhumanTaskSourcesFetch,
openhumanTaskSourcesGet,
openhumanTaskSourcesList,
openhumanTaskSourcesListTasks,
openhumanTaskSourcesPreviewFilter,
openhumanTaskSourcesRemove,
openhumanTaskSourcesStatus,
openhumanTaskSourcesUpdate,
} from './taskSources';
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('./common', () => ({ isTauri: vi.fn() }));
describe('tauriCommands/taskSources', () => {
const mockIsTauri = isTauri as Mock;
const mockCallCoreRpc = callCoreRpc as Mock;
beforeEach(() => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
mockCallCoreRpc.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
test('list forwards the list method with no params', async () => {
mockCallCoreRpc.mockResolvedValue([]);
await openhumanTaskSourcesList();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.task_sources_list' });
});
test('get forwards id', async () => {
await openhumanTaskSourcesGet('s-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.task_sources_get',
params: { id: 's-1' },
});
});
test('add forwards the add params verbatim', async () => {
const params = {
provider: 'github' as const,
filter: { provider: 'github' as const, repo: 'o/r', assignee_is_me: true },
name: 'My issues',
};
await openhumanTaskSourcesAdd(params);
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.task_sources_add', params });
});
test('update forwards id + patch', async () => {
await openhumanTaskSourcesUpdate('s-1', { enabled: false });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.task_sources_update',
params: { id: 's-1', patch: { enabled: false } },
});
});
test('remove forwards id', async () => {
await openhumanTaskSourcesRemove('s-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.task_sources_remove',
params: { id: 's-1' },
});
});
test('fetch forwards id', async () => {
await openhumanTaskSourcesFetch('s-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.task_sources_fetch',
params: { id: 's-1' },
});
});
test('listTasks forwards id + default limit', async () => {
mockCallCoreRpc.mockResolvedValue([]);
await openhumanTaskSourcesListTasks('s-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.task_sources_list_tasks',
params: { id: 's-1', limit: 50 },
});
});
test('listTasks forwards an explicit limit', async () => {
mockCallCoreRpc.mockResolvedValue([]);
await openhumanTaskSourcesListTasks('s-1', 10);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.task_sources_list_tasks',
params: { id: 's-1', limit: 10 },
});
});
test('previewFilter forwards provider/filter/connection/max', async () => {
mockCallCoreRpc.mockResolvedValue([]);
await openhumanTaskSourcesPreviewFilter(
'notion',
{ provider: 'notion', database_id: 'db-1', assigned_to_me: true },
'conn-1',
5
);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.task_sources_preview_filter',
params: {
provider: 'notion',
filter: { provider: 'notion', database_id: 'db-1', assigned_to_me: true },
connection_id: 'conn-1',
max: 5,
},
});
});
test('status forwards the status method', async () => {
await openhumanTaskSourcesStatus();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.task_sources_status' });
});
test('every wrapper throws and skips RPC when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanTaskSourcesList()).rejects.toThrow('Not running in Tauri');
await expect(openhumanTaskSourcesGet('x')).rejects.toThrow('Not running in Tauri');
await expect(
openhumanTaskSourcesAdd({ provider: 'github', filter: { provider: 'github' } })
).rejects.toThrow('Not running in Tauri');
await expect(openhumanTaskSourcesUpdate('x', {})).rejects.toThrow('Not running in Tauri');
await expect(openhumanTaskSourcesRemove('x')).rejects.toThrow('Not running in Tauri');
await expect(openhumanTaskSourcesFetch('x')).rejects.toThrow('Not running in Tauri');
await expect(openhumanTaskSourcesListTasks('x')).rejects.toThrow('Not running in Tauri');
await expect(
openhumanTaskSourcesPreviewFilter('github', { provider: 'github' })
).rejects.toThrow('Not running in Tauri');
await expect(openhumanTaskSourcesStatus()).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* Task-sources commands.
*
* Thin wrappers around the core `openhuman.task_sources_*` JSON-RPC
* surface. These operations return bare values (the core ops attach no
* log envelope), so `callCoreRpc<T>` resolves directly to the typed
* payload.
*/
import { callCoreRpc } from '../../services/coreRpcClient';
import { isTauri } from './common';
export type TaskSourceProvider = 'github' | 'notion' | 'linear' | 'clickup';
export type TaskSourceTarget = 'agent_todo_proactive' | 'todo_only';
/** Per-provider filter, discriminated by `provider`. Mirrors the Rust
* `FilterSpec` (serde snake_case, tagged by `provider`). */
export type TaskSourceFilter =
| {
provider: 'github';
repo?: string;
labels?: string[];
assignee_is_me?: boolean;
state?: string;
extra?: Record<string, unknown>;
}
| {
provider: 'notion';
database_id?: string;
assigned_to_me?: boolean;
status?: string;
extra?: Record<string, unknown>;
}
| {
provider: 'linear';
team_id?: string;
assignee_is_me?: boolean;
state?: string;
extra?: Record<string, unknown>;
}
| {
provider: 'clickup';
team_id?: string;
list_id?: string;
assignee_is_me?: boolean;
extra?: Record<string, unknown>;
};
export interface TaskSource {
id: string;
provider: TaskSourceProvider;
connectionId?: string;
name?: string;
enabled: boolean;
filter: TaskSourceFilter;
intervalSecs: number;
target: TaskSourceTarget;
maxTasksPerFetch: number;
createdAt: string;
lastFetchAt?: string;
lastStatus?: string;
}
export interface NormalizedTask {
externalId: string;
sourceId: string;
provider: string;
title: string;
body?: string;
url?: string;
status?: string;
assignee?: string;
due?: string;
labels: string[];
priority?: string;
updatedAt?: string;
}
export interface FetchOutcome {
sourceId: string;
provider: string;
fetched: number;
routed: number;
skippedDupe: number;
error?: string;
}
export interface TaskSourcesStatus {
enabled: boolean;
defaultIntervalSecs: number;
sourceCount: number;
enabledSourceCount: number;
}
export interface TaskSourcePatch {
name?: string;
enabled?: boolean;
filter?: TaskSourceFilter;
intervalSecs?: number;
target?: TaskSourceTarget;
maxTasksPerFetch?: number;
connectionId?: string;
}
export interface TaskSourceAddParams {
provider: TaskSourceProvider;
filter: TaskSourceFilter;
name?: string;
connection_id?: string;
interval_secs?: number;
target?: TaskSourceTarget;
max_tasks_per_fetch?: number;
}
function ensureTauri(): void {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
}
export async function openhumanTaskSourcesList(): Promise<TaskSource[]> {
ensureTauri();
return await callCoreRpc<TaskSource[]>({ method: 'openhuman.task_sources_list' });
}
export async function openhumanTaskSourcesGet(id: string): Promise<TaskSource> {
ensureTauri();
return await callCoreRpc<TaskSource>({ method: 'openhuman.task_sources_get', params: { id } });
}
export async function openhumanTaskSourcesAdd(params: TaskSourceAddParams): Promise<TaskSource> {
ensureTauri();
return await callCoreRpc<TaskSource>({
method: 'openhuman.task_sources_add',
params: params as unknown as Record<string, unknown>,
});
}
export async function openhumanTaskSourcesUpdate(
id: string,
patch: TaskSourcePatch
): Promise<TaskSource> {
ensureTauri();
return await callCoreRpc<TaskSource>({
method: 'openhuman.task_sources_update',
params: { id, patch },
});
}
export async function openhumanTaskSourcesRemove(
id: string
): Promise<{ id: string; removed: boolean }> {
ensureTauri();
return await callCoreRpc<{ id: string; removed: boolean }>({
method: 'openhuman.task_sources_remove',
params: { id },
});
}
export async function openhumanTaskSourcesFetch(id: string): Promise<FetchOutcome> {
ensureTauri();
return await callCoreRpc<FetchOutcome>({
method: 'openhuman.task_sources_fetch',
params: { id },
});
}
export async function openhumanTaskSourcesListTasks(
id: string,
limit = 50
): Promise<NormalizedTask[]> {
ensureTauri();
return await callCoreRpc<NormalizedTask[]>({
method: 'openhuman.task_sources_list_tasks',
params: { id, limit },
});
}
export async function openhumanTaskSourcesPreviewFilter(
provider: TaskSourceProvider,
filter: TaskSourceFilter,
connectionId?: string,
max?: number
): Promise<NormalizedTask[]> {
ensureTauri();
return await callCoreRpc<NormalizedTask[]>({
method: 'openhuman.task_sources_preview_filter',
params: { provider, filter, connection_id: connectionId, max },
});
}
export async function openhumanTaskSourcesStatus(): Promise<TaskSourcesStatus> {
ensureTauri();
return await callCoreRpc<TaskSourcesStatus>({ method: 'openhuman.task_sources_status' });
}
+3
View File
@@ -114,6 +114,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::composio::all_composio_registered_controllers());
// Scheduled job management
controllers.extend(crate::openhuman::cron::all_cron_registered_controllers());
// Proactive task ingestion from external tools (github/notion/linear/clickup)
controllers.extend(crate::openhuman::task_sources::all_task_sources_registered_controllers());
controllers.extend(crate::openhuman::dashboard::all_dashboard_registered_controllers());
// MCP client subsystem: Smithery registry browser, local server install/connect, tool dispatch
controllers.extend(crate::openhuman::mcp_registry::all_mcp_registry_registered_controllers());
@@ -294,6 +296,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::audio_toolkit::all_audio_toolkit_controller_schemas());
schemas.extend(crate::openhuman::composio::all_composio_controller_schemas());
schemas.extend(crate::openhuman::cron::all_cron_controller_schemas());
schemas.extend(crate::openhuman::task_sources::all_task_sources_controller_schemas());
schemas.extend(crate::openhuman::dashboard::all_dashboard_controller_schemas());
schemas.extend(crate::openhuman::mcp_registry::all_mcp_registry_controller_schemas());
schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas());
+31
View File
@@ -632,6 +632,30 @@ pub enum DomainEvent {
/// detection (already redacted by the call site) — surfaced to logs,
/// never to Sentry or the UI verbatim.
SessionExpired { source: String, reason: String },
// ── Task sources ─────────────────────────────────────────────────────
/// A task source completed a fetch pass.
TaskSourceFetched {
source_id: String,
provider: String,
fetched: usize,
routed: usize,
skipped: usize,
},
/// A single external task was ingested and routed onto the board.
TaskSourceTaskIngested {
source_id: String,
provider: String,
external_id: String,
title: String,
urgency: f32,
},
/// A task source fetch pass failed.
TaskSourceFetchFailed {
source_id: String,
provider: String,
error: String,
},
}
impl DomainEvent {
@@ -719,6 +743,10 @@ impl DomainEvent {
Self::SessionExpired { .. } => "auth",
Self::TaskSourceFetched { .. }
| Self::TaskSourceTaskIngested { .. }
| Self::TaskSourceFetchFailed { .. } => "task_sources",
Self::ApprovalRequested { .. } | Self::ApprovalDecided { .. } => "approval",
Self::McpServerInstalled { .. }
@@ -806,6 +834,9 @@ impl DomainEvent {
Self::McpClientToolExecuted { .. } => "McpClientToolExecuted",
Self::McpSetupSecretRequested { .. } => "McpSetupSecretRequested",
Self::EmbeddingModelUnhealthy { .. } => "EmbeddingModelUnhealthy",
Self::TaskSourceFetched { .. } => "TaskSourceFetched",
Self::TaskSourceTaskIngested { .. } => "TaskSourceTaskIngested",
Self::TaskSourceFetchFailed { .. } => "TaskSourceFetchFailed",
}
}
+3
View File
@@ -1843,6 +1843,9 @@ fn register_domain_subscribers(
}
crate::openhuman::composio::register_composio_trigger_subscriber();
crate::openhuman::composio::start_periodic_sync();
// Task-sources proactive ingestion: connection-created hook + poll.
crate::openhuman::task_sources::bus::register_task_sources_subscriber();
crate::openhuman::task_sources::start_periodic_poll();
// Seed memory_sources with active Composio connections so the
// user sees their connected integrations as memory sources by
// default. Best-effort: failure is logged but does not block startup.
+12
View File
@@ -1186,6 +1186,18 @@ const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::ComingSoon,
privacy: None,
},
Capability {
id: "automation.task_sources",
name: "Task Sources",
domain: "automation",
category: CapabilityCategory::Automation,
description: "Pull work items from GitHub, Notion, Linear, and ClickUp using per-source \
filters, then enrich them onto the agent's todo board and (for proactive \
sources) start an agent working on them.",
how_to: "Settings > Task Sources",
status: CapabilityStatus::Beta,
privacy: DERIVED_TO_BACKEND,
},
Capability {
id: "automation.view_cron_jobs",
name: "View Cron Jobs",
@@ -64,6 +64,11 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
// is intentionally cheap and the loop body no-ops when there are
// no connections.
crate::openhuman::composio::start_periodic_sync();
// Task-sources: subscribe to Composio connection-created events for
// one-shot fetches, and spawn the periodic poll that pulls work from
// configured external sources onto the agent's todo board.
crate::openhuman::task_sources::bus::register_task_sources_subscriber();
crate::openhuman::task_sources::start_periodic_poll();
// Native request handlers. Re-registering is safe (latest wins) so
// this is idempotent even if `bootstrap_core_runtime` also runs.
// Must happen before `run_message_dispatch_loop` begins, because
+2
View File
@@ -37,6 +37,7 @@ mod runtime;
mod runtime_python;
mod scheduler_gate;
mod storage_memory;
mod task_sources;
mod tools;
mod update;
@@ -77,6 +78,7 @@ pub use storage_memory::{
LlmBackend, MemoryConfig, MemoryTreeConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, DEFAULT_CLOUD_LLM_MODEL,
};
pub use task_sources::TaskSourcesConfig;
pub use tools::{
BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig,
GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, McpAuthConfig,
@@ -0,0 +1,85 @@
//! Task-sources configuration — app-level defaults for the
//! [`crate::openhuman::task_sources`] domain.
//!
//! Per-source records (provider + filter + schedule) live in the
//! domain's SQLite store, not here. This block only carries the master
//! switch and the defaults applied when a new source is created without
//! explicit values.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct TaskSourcesConfig {
/// Master switch. When `false`, the periodic poll skips every source
/// (manual `task_sources_fetch` RPCs still work).
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Default poll interval (seconds) for a new source that doesn't
/// specify one. Default: 1800 (30 minutes).
#[serde(default = "default_interval_secs")]
pub default_interval_secs: u64,
/// Default per-fetch task cap for a new source. Default: 25.
#[serde(default = "default_max_tasks")]
pub max_tasks_per_fetch: u32,
/// When `true` (default), a new source defaults to the proactive
/// target (todo card + triage turn); when `false`, todo-only.
#[serde(default = "default_auto_proactive")]
pub auto_proactive: bool,
}
fn default_enabled() -> bool {
true
}
fn default_interval_secs() -> u64 {
1800
}
fn default_max_tasks() -> u32 {
25
}
fn default_auto_proactive() -> bool {
true
}
impl Default for TaskSourcesConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
default_interval_secs: default_interval_secs(),
max_tasks_per_fetch: default_max_tasks(),
auto_proactive: default_auto_proactive(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_sane() {
let c = TaskSourcesConfig::default();
assert!(c.enabled);
assert_eq!(c.default_interval_secs, 1800);
assert_eq!(c.max_tasks_per_fetch, 25);
assert!(c.auto_proactive);
}
#[test]
fn deserializes_from_empty_table() {
let c: TaskSourcesConfig = serde_json::from_str("{}").unwrap();
assert!(c.enabled);
assert_eq!(c.default_interval_secs, 1800);
}
#[test]
fn partial_override_keeps_other_defaults() {
let c: TaskSourcesConfig = serde_json::from_str(r#"{"enabled": false}"#).unwrap();
assert!(!c.enabled);
assert_eq!(c.max_tasks_per_fetch, 25);
}
}
+7
View File
@@ -152,6 +152,12 @@ pub struct Config {
#[serde(default)]
pub cron: CronConfig,
/// Task-sources domain defaults — master switch + new-source
/// defaults. Per-source records live in the domain's SQLite store.
/// See [`crate::openhuman::task_sources`].
#[serde(default)]
pub task_sources: TaskSourcesConfig,
#[serde(default)]
pub channels_config: ChannelsConfig,
@@ -640,6 +646,7 @@ impl Default for Config {
embedding_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(),
cron: CronConfig::default(),
task_sources: TaskSourcesConfig::default(),
channels_config: ChannelsConfig::default(),
memory: MemoryConfig::default(),
memory_tree: MemoryTreeConfig::default(),
@@ -32,8 +32,8 @@ use crate::openhuman::memory_sync::composio::providers::sync_state::{
persist_single_item, SyncState,
};
use crate::openhuman::memory_sync::composio::providers::{
pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
SyncReason,
first_array_str, merge_extra, pick_str, ComposioProvider, CuratedTool, NormalizedTask,
ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
};
pub(crate) const ACTION_GET_AUTHORIZED_USER: &str = "CLICKUP_GET_AUTHORIZED_USER";
@@ -465,6 +465,144 @@ impl ComposioProvider for ClickUpProvider {
}),
})
}
async fn fetch_tasks(
&self,
ctx: &ProviderContext,
filter: &TaskFetchFilter,
) -> Result<Vec<NormalizedTask>, String> {
let max = filter.effective_max();
tracing::debug!(
connection_id = ?ctx.connection_id,
max,
team_id = ?filter.team_id,
assignee_is_me = filter.assignee_is_me,
"[composio:clickup] fetch_tasks"
);
// Resolve which workspaces (teams) to query. An explicit
// `team_id` from the filter wins; otherwise enumerate every
// workspace the connection can see.
let workspaces = match &filter.team_id {
Some(team) if !team.trim().is_empty() => vec![team.trim().to_string()],
_ => {
let resp = ctx
.execute(ACTION_GET_AUTHORIZED_TEAMS_WORKSPACES, Some(json!({})))
.await
.map_err(|e| {
format!(
"[composio:clickup] {ACTION_GET_AUTHORIZED_TEAMS_WORKSPACES}: {e:#}"
)
})?;
if !resp.successful {
return Err(format!(
"[composio:clickup] {ACTION_GET_AUTHORIZED_TEAMS_WORKSPACES}: {}",
resp.error.unwrap_or_else(|| "provider failure".into())
));
}
sync::extract_workspace_ids(&resp.data)
}
};
// Resolve the current user id only when the filter scopes to
// "assigned to me".
let assignees: Vec<String> = if filter.assignee_is_me {
let resp = ctx
.execute(ACTION_GET_AUTHORIZED_USER, Some(json!({})))
.await
.map_err(|e| format!("[composio:clickup] {ACTION_GET_AUTHORIZED_USER}: {e:#}"))?;
// Fail closed: if we can't resolve the user, error rather than
// silently dropping the assignee filter and fetching the whole
// workspace's tasks.
if !resp.successful {
return Err(format!(
"[composio:clickup] {ACTION_GET_AUTHORIZED_USER}: {}",
resp.error.unwrap_or_else(|| "provider failure".into())
));
}
let id = sync::extract_user_id(&resp.data).ok_or_else(|| {
"[composio:clickup] CLICKUP_GET_AUTHORIZED_USER returned no user.id".to_string()
})?;
vec![id]
} else {
Vec::new()
};
let mut out: Vec<NormalizedTask> = Vec::new();
'workspaces: for workspace_id in &workspaces {
let mut args = json!({
"team_id": workspace_id,
"order_by": "updated",
"reverse": true,
"page": 0,
"page_size": max.min(100) as u32,
"subtasks": true,
});
if !assignees.is_empty() {
args["assignees"] = json!(assignees);
}
if let Some(list_id) = filter.list_id.as_deref().filter(|s| !s.trim().is_empty()) {
args["list_ids"] = json!([list_id]);
}
merge_extra(&mut args, &filter.extra);
let resp = ctx
.execute(ACTION_GET_FILTERED_TEAM_TASKS, Some(args))
.await
.map_err(|e| {
format!("[composio:clickup] {ACTION_GET_FILTERED_TEAM_TASKS} ws={workspace_id}: {e:#}")
})?;
if !resp.successful {
return Err(format!(
"[composio:clickup] {ACTION_GET_FILTERED_TEAM_TASKS} ws={workspace_id}: {}",
resp.error.unwrap_or_else(|| "provider failure".into())
));
}
for task in sync::extract_tasks(&resp.data) {
if out.len() >= max {
break 'workspaces;
}
if let Some(nt) = normalize_clickup_task(&task) {
out.push(nt);
}
}
}
tracing::debug!(count = out.len(), "[composio:clickup] fetch_tasks complete");
Ok(out)
}
}
/// Map a raw ClickUp task payload into a [`NormalizedTask`]. Returns
/// `None` only when the task has no extractable id (unroutable).
fn normalize_clickup_task(task: &serde_json::Value) -> Option<NormalizedTask> {
let external_id =
crate::openhuman::memory_sync::composio::providers::sync_state::extract_item_id(
task,
TASK_ID_PATHS,
)?;
let title =
sync::extract_task_name(task).unwrap_or_else(|| format!("ClickUp task {external_id}"));
Some(NormalizedTask {
external_id,
source_id: String::new(),
provider: "clickup".to_string(),
title,
body: pick_str(task, &["description", "data.description", "text_content"]),
url: pick_str(task, &["url", "data.url"]),
status: pick_str(task, &["status.status", "data.status.status", "status"]),
assignee: first_array_str(
task,
&["assignees", "data.assignees"],
&["username", "email"],
),
due: pick_str(task, &["due_date", "data.due_date"]),
labels: Vec::new(),
priority: pick_str(task, &["priority.priority", "data.priority.priority"]),
updated_at: sync::extract_task_updated(task),
raw: task.clone(),
})
}
impl ClickUpProvider {
@@ -27,8 +27,8 @@ use crate::openhuman::memory_sync::composio::providers::sync_state::{
persist_single_item, SyncState,
};
use crate::openhuman::memory_sync::composio::providers::{
pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
SyncReason,
merge_extra, pick_str, ComposioProvider, CuratedTool, NormalizedTask, ProviderContext,
ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
};
pub(crate) const ACTION_GET_AUTHENTICATED_USER: &str = "GITHUB_GET_THE_AUTHENTICATED_USER";
@@ -381,6 +381,133 @@ impl ComposioProvider for GitHubProvider {
}),
})
}
async fn fetch_tasks(
&self,
ctx: &ProviderContext,
filter: &TaskFetchFilter,
) -> Result<Vec<NormalizedTask>, String> {
let max = filter.effective_max();
let query = build_fetch_query(filter);
tracing::debug!(
connection_id = ?ctx.connection_id,
max,
query = %query,
"[composio:github] fetch_tasks"
);
let mut args = json!({
"q": query,
"sort": "updated",
"order": "desc",
"per_page": max.min(100) as u32,
"page": 1,
});
merge_extra(&mut args, &filter.extra);
let resp = ctx
.execute(ACTION_SEARCH_ISSUES, Some(args))
.await
.map_err(|e| format!("[composio:github] {ACTION_SEARCH_ISSUES}: {e:#}"))?;
if !resp.successful {
return Err(format!(
"[composio:github] {ACTION_SEARCH_ISSUES}: {}",
resp.error.unwrap_or_else(|| "provider failure".into())
));
}
let mut out: Vec<NormalizedTask> = Vec::new();
for issue in sync::extract_issues(&resp.data) {
if out.len() >= max {
break;
}
if let Some(nt) = normalize_github_issue(&issue) {
out.push(nt);
}
}
tracing::debug!(count = out.len(), "[composio:github] fetch_tasks complete");
Ok(out)
}
}
/// Build a GitHub Search-Issues query from a [`TaskFetchFilter`].
///
/// Combines repo / label / state / assignee qualifiers. When the filter
/// carries no constraints at all we fall back to `involves:@me` so a
/// task source never accidentally pulls the entire public issue
/// universe.
pub(super) fn build_fetch_query(filter: &TaskFetchFilter) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(repo) = filter
.repo
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
parts.push(format!("repo:{repo}"));
}
for label in filter
.labels
.iter()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
{
parts.push(format!("label:\"{label}\""));
}
if let Some(state) = filter
.state
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
parts.push(format!("state:{state}"));
}
if filter.assignee_is_me {
parts.push("assignee:@me".to_string());
}
if parts.is_empty() {
return "involves:@me".to_string();
}
parts.join(" ")
}
/// Map a raw GitHub issue/PR payload into a [`NormalizedTask`].
fn normalize_github_issue(issue: &serde_json::Value) -> Option<NormalizedTask> {
let external_id = sync::extract_issue_id(issue)?;
let title =
sync::extract_issue_title(issue).unwrap_or_else(|| format!("GitHub issue {external_id}"));
Some(NormalizedTask {
external_id,
source_id: String::new(),
provider: "github".to_string(),
title,
body: pick_str(issue, &["body", "data.body"]),
url: pick_str(issue, &["html_url", "data.html_url"]),
status: pick_str(issue, &["state", "data.state"]),
assignee: pick_str(issue, &["assignee.login", "data.assignee.login"]),
due: None,
labels: extract_github_labels(issue),
priority: None,
updated_at: sync::extract_issue_updated_at(issue),
raw: issue.clone(),
})
}
/// Extract label names from a GitHub issue payload (`labels` is an array
/// of `{ name }` objects). Tolerant of the Composio `data` wrapper.
fn extract_github_labels(issue: &serde_json::Value) -> Vec<String> {
let arr = issue
.get("labels")
.or_else(|| issue.get("data").and_then(|d| d.get("labels")))
.and_then(|v| v.as_array());
match arr {
Some(items) => items
.iter()
.filter_map(|l| l.get("name").and_then(|n| n.as_str()))
.map(|s| s.to_string())
.collect(),
None => Vec::new(),
}
}
impl GitHubProvider {
@@ -31,3 +31,51 @@ pub(crate) fn pick_str(value: &serde_json::Value, paths: &[&str]) -> Option<Stri
}
None
}
/// Shallow-merge an `extra` JSON object into a (mutable) action-args
/// object. Only object-typed extras are merged; non-object `extra`
/// values are ignored. Backs the `task_sources` advanced free-form
/// filter escape hatch — provider `fetch_tasks` impls call this to fold
/// user-supplied provider-native query fragments into their request
/// arguments.
pub(crate) fn merge_extra(args: &mut serde_json::Value, extra: &serde_json::Value) {
if let (Some(args_obj), Some(extra_obj)) = (args.as_object_mut(), extra.as_object()) {
for (k, v) in extra_obj {
args_obj.insert(k.clone(), v.clone());
}
}
}
/// Resolve the first array found among `array_paths` (dotted object
/// paths), then return the first non-empty string at one of `fields`
/// on that array's first element. Complements [`pick_str`], which
/// cannot index into arrays. Used to pull e.g. the first assignee's
/// username out of an `assignees` array.
pub(crate) fn first_array_str(
value: &serde_json::Value,
array_paths: &[&str],
fields: &[&str],
) -> Option<String> {
for path in array_paths {
let mut cur = value;
let mut ok = true;
for segment in path.split('.') {
match cur.get(segment) {
Some(next) => cur = next,
None => {
ok = false;
break;
}
}
}
if !ok {
continue;
}
if let Some(first) = cur.as_array().and_then(|a| a.first()) {
if let Some(found) = pick_str(first, fields) {
return Some(found);
}
}
}
None
}
@@ -26,8 +26,8 @@ use crate::openhuman::memory_sync::composio::providers::sync_state::{
persist_single_item, SyncState,
};
use crate::openhuman::memory_sync::composio::providers::{
pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
SyncReason,
merge_extra, pick_str, ComposioProvider, CuratedTool, NormalizedTask, ProviderContext,
ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
};
const ACTION_LIST_USERS: &str = "LINEAR_LIST_LINEAR_USERS";
@@ -390,6 +390,136 @@ impl ComposioProvider for LinearProvider {
}),
})
}
async fn fetch_tasks(
&self,
ctx: &ProviderContext,
filter: &TaskFetchFilter,
) -> Result<Vec<NormalizedTask>, String> {
let max = filter.effective_max();
tracing::debug!(
connection_id = ?ctx.connection_id,
max,
team_id = ?filter.team_id,
assignee_is_me = filter.assignee_is_me,
"[composio:linear] fetch_tasks"
);
let mut args = json!({
"first": max.min(100) as u64,
"orderBy": "updatedAt",
});
if filter.assignee_is_me {
let resp = ctx
.execute(ACTION_LIST_USERS, Some(json!({ "isMe": true })))
.await
.map_err(|e| format!("[composio:linear] {ACTION_LIST_USERS}: {e:#}"))?;
// Fail closed: a failed viewer lookup must not silently widen
// the query beyond "assigned to me".
if !resp.successful {
return Err(format!(
"[composio:linear] {ACTION_LIST_USERS}: {}",
resp.error.unwrap_or_else(|| "provider failure".into())
));
}
let viewer_id = sync::extract_viewer_id(&resp.data).ok_or_else(|| {
"[composio:linear] LINEAR_LIST_LINEAR_USERS returned no viewer id".to_string()
})?;
args["assigneeId"] = json!(viewer_id);
}
if let Some(team) = filter
.team_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
args["teamId"] = json!(team);
}
merge_extra(&mut args, &filter.extra);
let resp = ctx
.execute(ACTION_LIST_ISSUES, Some(args))
.await
.map_err(|e| format!("[composio:linear] {ACTION_LIST_ISSUES}: {e:#}"))?;
if !resp.successful {
return Err(format!(
"[composio:linear] {ACTION_LIST_ISSUES}: {}",
resp.error.unwrap_or_else(|| "provider failure".into())
));
}
let want_state = filter
.state
.as_deref()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let mut out: Vec<NormalizedTask> = Vec::new();
for issue in sync::extract_issues(&resp.data) {
if out.len() >= max {
break;
}
let Some(nt) = normalize_linear_issue(&issue) else {
continue;
};
if let Some(ref want) = want_state {
let matches = nt
.status
.as_deref()
.map(|s| s.to_ascii_lowercase() == *want)
.unwrap_or(false);
if !matches {
continue;
}
}
out.push(nt);
}
tracing::debug!(count = out.len(), "[composio:linear] fetch_tasks complete");
Ok(out)
}
}
/// Map a raw Linear issue payload into a [`NormalizedTask`].
fn normalize_linear_issue(issue: &serde_json::Value) -> Option<NormalizedTask> {
let external_id =
crate::openhuman::memory_sync::composio::providers::sync_state::extract_item_id(
issue,
ISSUE_ID_PATHS,
)?;
let title =
sync::extract_issue_title(issue).unwrap_or_else(|| format!("Linear issue {external_id}"));
Some(NormalizedTask {
external_id,
source_id: String::new(),
provider: "linear".to_string(),
title,
body: pick_str(issue, &["description", "data.description"]),
url: pick_str(issue, &["url", "data.url"]),
status: pick_str(issue, &["state.name", "data.state.name", "state.type"]),
assignee: pick_str(issue, &["assignee.name", "data.assignee.name"]),
due: pick_str(issue, &["dueDate", "data.dueDate"]),
labels: extract_linear_labels(issue),
priority: pick_str(issue, &["priorityLabel", "data.priorityLabel"]),
updated_at: sync::extract_issue_updated(issue),
raw: issue.clone(),
})
}
/// Extract label names from a Linear issue (`labels.nodes[].name`).
fn extract_linear_labels(issue: &serde_json::Value) -> Vec<String> {
let arr = issue
.get("labels")
.or_else(|| issue.get("data").and_then(|d| d.get("labels")))
.and_then(|l| l.get("nodes"))
.and_then(|v| v.as_array());
match arr {
Some(items) => items
.iter()
.filter_map(|l| l.get("name").and_then(|n| n.as_str()))
.map(|s| s.to_string())
.collect(),
None => Vec::new(),
}
}
impl LinearProvider {
@@ -276,14 +276,16 @@ pub fn agent_ready_toolkits() -> Vec<&'static str> {
}
pub use descriptions::toolkit_description;
pub(crate) use helpers::pick_str;
pub(crate) use helpers::{first_array_str, merge_extra, pick_str};
pub use registry::{
all_providers, get_provider, init_default_providers, register_provider, ProviderArc,
};
pub use scope_lookup::{curated_scope_for, toolkit_has_scope};
pub use tool_scope::{classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope};
pub use traits::ComposioProvider;
pub use types::{ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason};
pub use types::{
NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
};
pub use user_scopes::{load_or_default as load_user_scope_or_default, UserScopePref};
#[cfg(test)]
@@ -23,12 +23,13 @@ use crate::openhuman::memory_sync::composio::providers::sync_state::{
extract_item_id, persist_single_item, SyncState,
};
use crate::openhuman::memory_sync::composio::providers::{
pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
SyncReason,
first_array_str, merge_extra, pick_str, ComposioProvider, CuratedTool, NormalizedTask,
ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
};
pub(crate) const ACTION_GET_ABOUT_ME: &str = "NOTION_GET_ABOUT_ME";
pub(crate) const ACTION_FETCH_DATA: &str = "NOTION_FETCH_DATA";
pub(crate) const ACTION_QUERY_DATABASE: &str = "NOTION_QUERY_DATABASE";
/// Page size per API call.
const PAGE_SIZE: u32 = 25;
@@ -361,6 +362,92 @@ impl ComposioProvider for NotionProvider {
})
}
async fn fetch_tasks(
&self,
ctx: &ProviderContext,
filter: &TaskFetchFilter,
) -> Result<Vec<NormalizedTask>, String> {
let max = filter.effective_max();
let database_id = filter
.database_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
tracing::debug!(
connection_id = ?ctx.connection_id,
max,
has_database = database_id.is_some(),
"[composio:notion] fetch_tasks"
);
// A configured board (database) uses NOTION_QUERY_DATABASE;
// otherwise fall back to NOTION_FETCH_DATA (recent pages), the
// same action the periodic sync uses.
let (action, mut args) = match database_id {
Some(db) => (
ACTION_QUERY_DATABASE,
json!({
"database_id": db,
"page_size": max.min(100) as u32,
"sorts": [ { "timestamp": "last_edited_time", "direction": "descending" } ],
}),
),
None => (
ACTION_FETCH_DATA,
json!({
"page_size": max.min(100) as u32,
"filter": { "value": "page", "property": "object" },
"sort": { "direction": "descending", "timestamp": "last_edited_time" },
}),
),
};
merge_extra(&mut args, &filter.extra);
let resp = ctx
.execute(action, Some(args))
.await
.map_err(|e| format!("[composio:notion] {action}: {e:#}"))?;
if !resp.successful {
return Err(format!(
"[composio:notion] {action}: {}",
resp.error.unwrap_or_else(|| "provider failure".into())
));
}
// Optional client-side status filter — Notion status properties
// are user-defined, so we match on the normalized status rather
// than building a server-side property filter.
let want_status = filter
.status
.as_deref()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let mut out: Vec<NormalizedTask> = Vec::new();
for page in sync::extract_results(&resp.data) {
if out.len() >= max {
break;
}
let Some(nt) = normalize_notion_page(&page) else {
continue;
};
if let Some(ref want) = want_status {
let matches = nt
.status
.as_deref()
.map(|s| s.to_ascii_lowercase() == *want)
.unwrap_or(false);
if !matches {
continue;
}
}
out.push(nt);
}
tracing::debug!(count = out.len(), "[composio:notion] fetch_tasks complete");
Ok(out)
}
async fn on_trigger(
&self,
ctx: &ProviderContext,
@@ -381,3 +468,56 @@ impl ComposioProvider for NotionProvider {
Ok(())
}
}
/// Map a raw Notion page payload into a [`NormalizedTask`].
///
/// Notion databases are user-defined, so property extraction is
/// best-effort against common property names (`Status`, `Assignee`,
/// `Due`). Anything unmatched is simply left `None` — the raw payload is
/// preserved for enrichment.
fn normalize_notion_page(page: &serde_json::Value) -> Option<NormalizedTask> {
let external_id = extract_item_id(page, PAGE_ID_PATHS)?;
let title =
sync::extract_page_title(page).unwrap_or_else(|| format!("Notion page {external_id}"));
Some(NormalizedTask {
external_id,
source_id: String::new(),
provider: "notion".to_string(),
title,
body: None,
url: pick_str(page, &["url", "data.url"]),
status: pick_str(
page,
&[
"properties.Status.status.name",
"properties.Status.select.name",
"data.properties.Status.status.name",
],
),
assignee: first_array_str(
page,
&[
"properties.Assignee.people",
"data.properties.Assignee.people",
],
&["name"],
),
due: pick_str(
page,
&[
"properties.Due.date.start",
"data.properties.Due.date.start",
],
),
labels: Vec::new(),
priority: pick_str(
page,
&[
"properties.Priority.select.name",
"data.properties.Priority.select.name",
],
),
updated_at: extract_item_id(page, PAGE_EDITED_PATHS),
raw: page.clone(),
})
}
@@ -3,7 +3,9 @@
use async_trait::async_trait;
use super::tool_scope::CuratedTool;
use super::types::{ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason};
use super::types::{
NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
};
/// Native provider implementation for a specific Composio toolkit.
///
@@ -52,6 +54,36 @@ pub trait ComposioProvider: Send + Sync {
/// the memory layer via [`ProviderContext::memory_client`]).
async fn sync(&self, ctx: &ProviderContext, reason: SyncReason) -> Result<SyncOutcome, String>;
/// Fetch a filtered set of work items as structured
/// [`NormalizedTask`]s — the read path that powers the
/// `task_sources` domain.
///
/// Unlike [`Self::sync`], this does **not** persist anything into
/// the memory store; it *returns* normalized tasks so the caller can
/// enrich them and route them onto the agent's todo board. `filter`
/// is provider-agnostic — implementations read only the fields that
/// apply to their toolkit and translate them into their own action
/// slug + arguments, then map the upstream payload back into
/// `NormalizedTask`. Implementations must honour
/// [`TaskFetchFilter::effective_max`] as an upper bound on the
/// number of tasks returned.
///
/// Default impl: `Err` — providers without a task surface (e.g.
/// gmail, slack) opt out, exactly as
/// [`Self::sync_interval_secs`] returning `None` opts out of the
/// periodic scheduler.
async fn fetch_tasks(
&self,
ctx: &ProviderContext,
filter: &TaskFetchFilter,
) -> Result<Vec<NormalizedTask>, String> {
let _ = (ctx, filter);
Err(format!(
"[composio:{}] provider has no task-fetch surface",
self.toolkit_slug()
))
}
/// Standardized identity callback for provider implementations.
///
/// Providers can override this to customize how identity fragments
@@ -77,6 +77,112 @@ impl SyncOutcome {
}
}
/// A provider-agnostic, structured work item produced by
/// [`super::ComposioProvider::fetch_tasks`].
///
/// Unlike the `sync()` path — which persists upstream items into the
/// memory store as passive context — `fetch_tasks` *returns* normalized
/// tasks so the `task_sources` domain can enrich them and route them
/// onto the agent's todo board. Every native task provider (github,
/// notion, linear, clickup) maps its upstream payload shape into this
/// common envelope.
///
/// `source_id` is left empty by providers and stamped by the
/// `task_sources` pipeline with the originating `TaskSource.id` — a
/// provider has no knowledge of which configured source asked for the
/// fetch.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NormalizedTask {
/// Upstream provider's stable id for the item (issue/task/page id).
pub external_id: String,
/// The `TaskSource.id` that produced this task. Empty until the
/// pipeline stamps it.
#[serde(default)]
pub source_id: String,
/// Toolkit slug, e.g. `"github"`.
pub provider: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
/// Due date as an ISO-8601 string, when the provider exposes one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
/// Last-updated ISO-8601 timestamp — used for cursor advancement and
/// edit-aware dedup (`{external_id}@{updated_at}`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
/// The raw upstream payload, retained for enrichment / debugging.
#[serde(default)]
pub raw: serde_json::Value,
}
/// Provider-agnostic filter passed into
/// [`super::ComposioProvider::fetch_tasks`].
///
/// The `task_sources` domain builds this from a user-configured,
/// per-provider `FilterSpec`. Each provider reads only the fields that
/// apply to it (github reads `repo`/`labels`; notion reads
/// `database_id`; linear/clickup read `team_id`; …) and ignores the
/// rest. `extra` is a free-form escape hatch surfaced in the UI for
/// advanced provider-native query fragments.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TaskFetchFilter {
/// Scope to items assigned to (or involving) the authenticated user.
#[serde(default)]
pub assignee_is_me: bool,
/// GitHub `owner/name` repository scope.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
/// GitHub label filter.
#[serde(default)]
pub labels: Vec<String>,
/// Issue/task state filter (e.g. `"open"`, `"todo"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
/// Notion database (board) id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_id: Option<String>,
/// Notion status property filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
/// Linear / ClickUp team (workspace) id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
/// ClickUp list id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub list_id: Option<String>,
/// Free-form provider-native filter fragment (advanced).
#[serde(default)]
pub extra: serde_json::Value,
/// Hard cap on how many tasks a single fetch returns.
#[serde(default)]
pub max: u32,
}
impl TaskFetchFilter {
/// Effective per-fetch item cap, defaulting to a safe bound when the
/// caller leaves `max` unset (0).
pub fn effective_max(&self) -> usize {
if self.max == 0 {
25
} else {
self.max as usize
}
}
}
/// Per-call context handed to provider methods.
///
/// `connection_id` is `None` when a method runs in a "no specific
+1
View File
@@ -88,6 +88,7 @@ pub mod skills;
pub mod socket;
pub mod startup;
pub mod subconscious;
pub mod task_sources;
pub mod team;
#[cfg(feature = "e2e-test-support")]
pub mod test_support;
+114
View File
@@ -0,0 +1,114 @@
//! Event-bus integration for the `task_sources` domain.
//!
//! Subscribes to `ComposioConnectionCreated`: when a user connects a
//! toolkit that has matching enabled task sources, fire a one-shot
//! `ConnectionCreated` fetch so freshly-connected work shows up without
//! waiting for the next periodic tick.
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle};
use crate::openhuman::config::rpc as config_rpc;
use super::types::{FetchReason, ProviderSlug};
use super::{pipeline, store};
static CONNECTION_HANDLE: OnceLock<SubscriptionHandle> = OnceLock::new();
/// Fires a one-shot fetch for matching sources when a Composio
/// connection is created.
pub struct TaskSourcesConnectionSubscriber;
#[async_trait]
impl EventHandler for TaskSourcesConnectionSubscriber {
fn name(&self) -> &str {
"task_sources::connection"
}
fn domains(&self) -> Option<&[&str]> {
Some(&["composio"])
}
async fn handle(&self, event: &DomainEvent) {
let DomainEvent::ComposioConnectionCreated {
toolkit,
connection_id,
..
} = event
else {
return;
};
// Only act for toolkits we model as task sources.
let Ok(provider) = ProviderSlug::parse(toolkit) else {
return;
};
let config = match config_rpc::load_config_with_timeout().await {
Ok(config) => config,
Err(e) => {
tracing::debug!(error = %e, "[task_sources:bus] load_config failed, skipping");
return;
}
};
if !config.task_sources.enabled {
return;
}
let sources = match store::list_sources(&config) {
Ok(sources) => sources,
Err(e) => {
tracing::debug!(error = %e, "[task_sources:bus] list_sources failed, skipping");
return;
}
};
for source in sources
.into_iter()
.filter(|s| s.enabled && s.provider == provider)
{
// If the source pins a specific connection, only fire for it.
if let Some(pinned) = source.connection_id.as_deref() {
if pinned != connection_id {
continue;
}
}
tracing::info!(
source_id = %source.id,
toolkit = %toolkit,
"[task_sources:bus] connection created → one-shot fetch"
);
// Spawn each fetch independently so the event handler does not
// block dispatch on N sequential network round-trips (same
// pattern as the periodic poll). Each fetch captures its own
// owned config + source.
let config = config.clone();
tokio::spawn(async move {
let _ = pipeline::run_source_once(&config, &source, FetchReason::ConnectionCreated)
.await;
});
}
}
}
/// Register the connection subscriber. Idempotent — the handle is held
/// in a process-global `OnceLock` so it is never dropped (which would
/// cancel the subscription).
pub fn register_task_sources_subscriber() {
if CONNECTION_HANDLE.get().is_some() {
return;
}
match subscribe_global(Arc::new(TaskSourcesConnectionSubscriber)) {
Some(handle) => {
let _ = CONNECTION_HANDLE.set(handle);
tracing::debug!("[task_sources:bus] connection subscriber registered");
}
None => {
tracing::warn!(
"[task_sources:bus] event bus not initialized; subscriber not registered"
);
}
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Enrichment: turn a raw [`NormalizedTask`] into an agent-ready
//! [`EnrichedTask`].
//!
//! Enrichment is deterministic and dependency-free: it derives an
//! urgency score from the task's priority / labels / due date, links the
//! assignee as a person, builds a concise summary, and templates an
//! actionable agent prompt. The downstream triage turn does the heavy
//! LLM reasoning, so enrichment intentionally avoids a second model call
//! here (cheaper, deterministic, unit-testable). An optional LLM
//! summarizer can be layered on later behind the same signature.
use chrono::Utc;
use super::types::EnrichedTask;
use super::NormalizedTask;
/// Maximum length of the derived summary, in characters.
const SUMMARY_MAX_CHARS: usize = 200;
/// Enrich a normalized task. Never fails — worst case it returns a
/// title-only summary with a neutral urgency.
pub fn enrich_task(task: NormalizedTask) -> EnrichedTask {
let summary = derive_summary(&task);
let urgency = derive_urgency(&task);
let linked_people = task
.assignee
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| vec![s.to_string()])
.unwrap_or_default();
let agent_prompt = build_agent_prompt(&task, &summary, urgency);
EnrichedTask {
task,
summary,
urgency,
linked_people,
linked_memory_ids: Vec::new(),
agent_prompt,
enriched_at: Utc::now(),
}
}
/// One- to two-line summary: the first non-empty line of the body, else
/// the title. Truncated on a char boundary.
fn derive_summary(task: &NormalizedTask) -> String {
let raw = task
.body
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|b| b.lines().find(|l| !l.trim().is_empty()))
.map(|l| l.trim())
.filter(|s| !s.is_empty())
.unwrap_or(task.title.trim());
truncate_chars(raw, SUMMARY_MAX_CHARS)
}
/// Heuristic urgency in `0.0..=1.0` from priority, labels, and due date.
fn derive_urgency(task: &NormalizedTask) -> f32 {
let mut score: f32 = 0.4; // neutral baseline
if let Some(p) = task.priority.as_deref().map(|s| s.to_ascii_lowercase()) {
if p.contains("urgent") || p.contains("critical") || p.contains("p0") {
score = score.max(0.95);
} else if p.contains("high") || p.contains("p1") {
score = score.max(0.8);
} else if p.contains("medium") || p.contains("p2") {
score = score.max(0.55);
} else if p.contains("low") || p.contains("p3") {
// Low priority should pull urgency *down* from the 0.4 baseline.
score = score.min(0.3);
}
}
for label in &task.labels {
let l = label.to_ascii_lowercase();
if l.contains("urgent") || l.contains("p0") || l == "critical" || l == "blocker" {
score = score.max(0.9);
} else if l == "bug" || l.contains("p1") || l.contains("security") {
score = score.max(0.7);
}
}
// A present due date nudges urgency up; an overdue one more so.
if let Some(due) = task.due.as_deref().and_then(parse_iso) {
let now = Utc::now();
if due <= now {
score = score.max(0.85);
} else {
let days = (due - now).num_days();
if days <= 1 {
score = score.max(0.8);
} else if days <= 3 {
score = score.max(0.65);
} else if days <= 7 {
score = score.max(0.5);
}
}
}
score.clamp(0.0, 1.0)
}
fn build_agent_prompt(task: &NormalizedTask, summary: &str, urgency: f32) -> String {
let mut lines = vec![format!(
"A task was ingested from {} and needs your attention.",
task.provider
)];
lines.push(format!("Title: {}", task.title));
if !summary.is_empty() && summary != task.title.trim() {
lines.push(format!("Summary: {summary}"));
}
if let Some(status) = task.status.as_deref().filter(|s| !s.trim().is_empty()) {
lines.push(format!("Status: {status}"));
}
if let Some(assignee) = task.assignee.as_deref().filter(|s| !s.trim().is_empty()) {
lines.push(format!("Assignee: {assignee}"));
}
if let Some(due) = task.due.as_deref().filter(|s| !s.trim().is_empty()) {
lines.push(format!("Due: {due}"));
}
if let Some(url) = task.url.as_deref().filter(|s| !s.trim().is_empty()) {
lines.push(format!("Link: {url}"));
}
lines.push(format!("Estimated urgency: {:.0}%.", urgency * 100.0));
lines.push(
"Decide whether this is actionable now; if so, make progress and update the todo card."
.to_string(),
);
lines.join("\n")
}
fn parse_iso(raw: &str) -> Option<chrono::DateTime<Utc>> {
chrono::DateTime::parse_from_rfc3339(raw.trim())
.ok()
.map(|d| d.with_timezone(&Utc))
}
fn truncate_chars(s: &str, max: usize) -> String {
let s = s.trim();
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn task() -> NormalizedTask {
NormalizedTask {
external_id: "1".into(),
provider: "github".into(),
title: "Fix login bug".into(),
..Default::default()
}
}
#[test]
fn summary_prefers_first_body_line_then_title() {
let mut t = task();
t.body = Some("\n First line of detail\nsecond line".into());
assert_eq!(enrich_task(t).summary, "First line of detail");
let bare = task();
assert_eq!(enrich_task(bare).summary, "Fix login bug");
}
#[test]
fn summary_truncates_long_text() {
let mut t = task();
t.title = "x".repeat(500);
let e = enrich_task(t);
assert!(e.summary.chars().count() <= SUMMARY_MAX_CHARS);
assert!(e.summary.ends_with('…'));
}
#[test]
fn urgency_baseline_is_neutral() {
let e = enrich_task(task());
assert!((e.urgency - 0.4).abs() < f32::EPSILON);
}
#[test]
fn urgency_escalates_with_priority_and_labels() {
let mut t = task();
t.priority = Some("Urgent".into());
assert!(enrich_task(t).urgency >= 0.95);
let mut t2 = task();
t2.labels = vec!["bug".into()];
assert!(enrich_task(t2).urgency >= 0.7);
}
#[test]
fn urgency_escalates_when_overdue() {
let mut t = task();
t.due = Some("2000-01-01T00:00:00Z".into());
assert!(enrich_task(t).urgency >= 0.85);
}
#[test]
fn assignee_becomes_linked_person() {
let mut t = task();
t.assignee = Some("alice".into());
let e = enrich_task(t);
assert_eq!(e.linked_people, vec!["alice".to_string()]);
}
#[test]
fn agent_prompt_includes_title_provider_and_link() {
let mut t = task();
t.url = Some("https://example.com/1".into());
let e = enrich_task(t);
assert!(e.agent_prompt.contains("github"));
assert!(e.agent_prompt.contains("Fix login bug"));
assert!(e.agent_prompt.contains("https://example.com/1"));
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Translate a user-configured [`FilterSpec`] into the provider-agnostic
//! [`TaskFetchFilter`] consumed by `ComposioProvider::fetch_tasks`.
//!
//! Each provider's `fetch_tasks` impl reads only the fields relevant to
//! its toolkit, so this mapping just flattens the per-provider
//! `FilterSpec` variant into the shared filter envelope and stamps the
//! per-fetch cap.
use crate::openhuman::memory_sync::composio::providers::TaskFetchFilter;
use super::types::FilterSpec;
/// Build the runtime [`TaskFetchFilter`] for a source's filter and a
/// per-fetch item cap.
pub fn to_fetch_filter(spec: &FilterSpec, max: u32) -> TaskFetchFilter {
match spec {
FilterSpec::Github {
repo,
labels,
assignee_is_me,
state,
extra,
} => TaskFetchFilter {
assignee_is_me: *assignee_is_me,
repo: repo.clone(),
labels: labels.clone(),
state: state.clone(),
extra: extra.clone(),
max,
..Default::default()
},
FilterSpec::Notion {
database_id,
assigned_to_me,
status,
extra,
} => TaskFetchFilter {
assignee_is_me: *assigned_to_me,
database_id: database_id.clone(),
status: status.clone(),
extra: extra.clone(),
max,
..Default::default()
},
FilterSpec::Linear {
team_id,
assignee_is_me,
state,
extra,
} => TaskFetchFilter {
assignee_is_me: *assignee_is_me,
team_id: team_id.clone(),
state: state.clone(),
extra: extra.clone(),
max,
..Default::default()
},
FilterSpec::Clickup {
team_id,
list_id,
assignee_is_me,
extra,
} => TaskFetchFilter {
assignee_is_me: *assignee_is_me,
team_id: team_id.clone(),
list_id: list_id.clone(),
extra: extra.clone(),
max,
..Default::default()
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn github_filter_maps_repo_labels_and_assignee() {
let spec = FilterSpec::Github {
repo: Some("tinyhumansai/openhuman".into()),
labels: vec!["bug".into(), "p1".into()],
assignee_is_me: true,
state: Some("open".into()),
extra: json!({"per_page": 10}),
};
let f = to_fetch_filter(&spec, 25);
assert_eq!(f.repo.as_deref(), Some("tinyhumansai/openhuman"));
assert_eq!(f.labels, vec!["bug".to_string(), "p1".to_string()]);
assert!(f.assignee_is_me);
assert_eq!(f.state.as_deref(), Some("open"));
assert_eq!(f.extra["per_page"], 10);
assert_eq!(f.max, 25);
// Fields that don't apply to github stay unset.
assert!(f.database_id.is_none());
assert!(f.team_id.is_none());
}
#[test]
fn notion_filter_maps_board_and_status() {
let spec = FilterSpec::Notion {
database_id: Some("board-xyz".into()),
assigned_to_me: true,
status: Some("Todo".into()),
extra: json!({}),
};
let f = to_fetch_filter(&spec, 5);
assert_eq!(f.database_id.as_deref(), Some("board-xyz"));
assert!(f.assignee_is_me, "assigned_to_me maps to assignee_is_me");
assert_eq!(f.status.as_deref(), Some("Todo"));
assert_eq!(f.max, 5);
}
#[test]
fn linear_filter_maps_team_and_state() {
let spec = FilterSpec::Linear {
team_id: Some("team-1".into()),
assignee_is_me: true,
state: Some("started".into()),
extra: json!({}),
};
let f = to_fetch_filter(&spec, 50);
assert_eq!(f.team_id.as_deref(), Some("team-1"));
assert!(f.assignee_is_me);
assert_eq!(f.state.as_deref(), Some("started"));
}
#[test]
fn clickup_filter_maps_team_and_list() {
let spec = FilterSpec::Clickup {
team_id: Some("ws-1".into()),
list_id: Some("list-9".into()),
assignee_is_me: true,
extra: json!({}),
};
let f = to_fetch_filter(&spec, 25);
assert_eq!(f.team_id.as_deref(), Some("ws-1"));
assert_eq!(f.list_id.as_deref(), Some("list-9"));
assert!(f.assignee_is_me);
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Task sources — proactive ingestion of work items from external tools.
//!
//! A [`TaskSource`] is a user-configured pull from an external tool
//! (GitHub, Notion, Linear, ClickUp) with a per-provider [`FilterSpec`].
//! The periodic poll ([`periodic`]) fetches matching items through the
//! existing Composio providers' [`fetch_tasks`] surface, the pipeline
//! ([`pipeline`]) dedups + enriches them, and [`route`] drops a todo
//! card onto the dedicated `task-sources` thread board and (for
//! proactive sources) dispatches a triage turn so an agent can start
//! working.
//!
//! Layering mirrors the `cron` domain: `mod.rs` is export-only, business
//! logic lives in the sibling modules, persistence is SQLite in
//! `store.rs`, and the RPC surface is wired through `schemas.rs`.
//!
//! [`fetch_tasks`]: crate::openhuman::memory_sync::composio::providers::ComposioProvider::fetch_tasks
pub mod bus;
pub mod enrich;
pub mod filter;
pub mod ops;
pub mod periodic;
pub mod pipeline;
pub mod route;
mod schemas;
pub mod store;
pub mod types;
pub use crate::openhuman::memory_sync::composio::providers::{NormalizedTask, TaskFetchFilter};
pub use periodic::start_periodic_poll;
pub use pipeline::run_source_once;
pub use route::TASK_SOURCES_THREAD_ID;
pub use schemas::{
all_controller_schemas as all_task_sources_controller_schemas,
all_registered_controllers as all_task_sources_registered_controllers,
schemas as task_sources_schemas,
};
pub use types::{
EnrichedTask, FetchOutcome, FetchReason, FilterSpec, ProviderSlug, SourceTarget, TaskSource,
TaskSourcePatch,
};
+162
View File
@@ -0,0 +1,162 @@
//! RPC-facing operations for the `task_sources` domain.
//!
//! Each function returns an [`RpcOutcome`] so the controller layer can
//! surface logs alongside the value. Errors are `String` to match the
//! `ControllerFuture` boundary. Business logic stays here; `schemas.rs`
//! only parses params and delegates.
use std::sync::Arc;
use serde_json::{json, Value};
use crate::openhuman::config::Config;
use crate::openhuman::memory_sync::composio::providers::{
get_provider, NormalizedTask, ProviderContext,
};
use crate::rpc::RpcOutcome;
use super::types::{
FetchReason, FilterSpec, ProviderSlug, SourceTarget, TaskSource, TaskSourcePatch,
};
use super::{filter, pipeline, store};
/// List all configured task sources.
pub async fn list(config: &Config) -> Result<RpcOutcome<Vec<TaskSource>>, String> {
let sources = store::list_sources(config).map_err(|e| e.to_string())?;
tracing::debug!(count = sources.len(), "[task_sources:ops] list");
Ok(RpcOutcome::new(sources, vec![]))
}
/// Fetch a single source by id.
pub async fn get(config: &Config, id: &str) -> Result<RpcOutcome<TaskSource>, String> {
let source = store::get_source(config, id).map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(source, vec![]))
}
/// Create a new source. Missing schedule / target / cap fields fall back
/// to the `[task_sources]` config defaults.
pub async fn add(
config: &Config,
provider: ProviderSlug,
connection_id: Option<String>,
name: Option<String>,
filter: FilterSpec,
interval_secs: Option<u64>,
target: Option<SourceTarget>,
max_tasks_per_fetch: Option<u32>,
) -> Result<RpcOutcome<TaskSource>, String> {
let defaults = &config.task_sources;
let interval_secs = interval_secs.unwrap_or(defaults.default_interval_secs);
let max = max_tasks_per_fetch.unwrap_or(defaults.max_tasks_per_fetch);
let target = target.unwrap_or(if defaults.auto_proactive {
SourceTarget::AgentTodoProactive
} else {
SourceTarget::TodoOnly
});
let source = store::add_source(
config,
provider,
connection_id.filter(|s| !s.trim().is_empty()),
name.filter(|s| !s.trim().is_empty()),
filter,
interval_secs,
target,
max,
)
.map_err(|e| e.to_string())?;
tracing::info!(
source_id = %source.id,
provider = %source.provider.as_str(),
"[task_sources:ops] add created source"
);
Ok(RpcOutcome::new(source, vec![]))
}
/// Apply a partial update to a source.
pub async fn update(
config: &Config,
id: &str,
patch: TaskSourcePatch,
) -> Result<RpcOutcome<TaskSource>, String> {
let source = store::update_source(config, id, patch).map_err(|e| e.to_string())?;
tracing::debug!(source_id = %id, "[task_sources:ops] update applied");
Ok(RpcOutcome::new(source, vec![]))
}
/// Remove a source by id.
pub async fn remove(config: &Config, id: &str) -> Result<RpcOutcome<Value>, String> {
store::remove_source(config, id).map_err(|e| e.to_string())?;
tracing::debug!(source_id = %id, "[task_sources:ops] removed");
Ok(RpcOutcome::new(
json!({ "id": id, "removed": true }),
vec![],
))
}
/// Manually fetch one source now (`FetchReason::Manual`).
pub async fn fetch(config: &Config, id: &str) -> Result<RpcOutcome<super::FetchOutcome>, String> {
let source = store::get_source(config, id).map_err(|e| e.to_string())?;
let outcome = pipeline::run_source_once(config, &source, FetchReason::Manual).await;
Ok(RpcOutcome::new(outcome, vec![]))
}
/// Recently ingested tasks for a source (newest first).
pub async fn list_tasks(
config: &Config,
id: &str,
limit: Option<usize>,
) -> Result<RpcOutcome<Vec<NormalizedTask>>, String> {
let limit = limit.unwrap_or(50);
let tasks = store::list_ingested(config, id, limit).map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(tasks, vec![]))
}
/// Dry-run a filter: fetch matching tasks WITHOUT routing or recording
/// anything. Lets the UI validate a filter before saving a source.
pub async fn preview_filter(
config: &Config,
provider: ProviderSlug,
filter_spec: FilterSpec,
connection_id: Option<String>,
max: Option<u32>,
) -> Result<RpcOutcome<Vec<NormalizedTask>>, String> {
if filter_spec.provider() != provider {
return Err(format!(
"filter provider '{}' does not match requested provider '{}'",
filter_spec.provider().as_str(),
provider.as_str()
));
}
let provider_impl = get_provider(provider.as_str())
.ok_or_else(|| format!("no native provider registered for '{}'", provider.as_str()))?;
let ctx = ProviderContext {
config: Arc::new(config.clone()),
toolkit: provider.as_str().to_string(),
connection_id: connection_id.filter(|s| !s.trim().is_empty()),
};
let max = max.unwrap_or(config.task_sources.max_tasks_per_fetch);
let fetch_filter = filter::to_fetch_filter(&filter_spec, max);
let tasks = provider_impl
.fetch_tasks(&ctx, &fetch_filter)
.await
.map_err(|e| format!("preview fetch failed: {e}"))?;
tracing::debug!(count = tasks.len(), "[task_sources:ops] preview_filter");
Ok(RpcOutcome::new(tasks, vec![]))
}
/// Domain status: enabled flag + source counts.
pub async fn status(config: &Config) -> Result<RpcOutcome<Value>, String> {
let sources = store::list_sources(config).map_err(|e| e.to_string())?;
let enabled_count = sources.iter().filter(|s| s.enabled).count();
Ok(RpcOutcome::new(
json!({
"enabled": config.task_sources.enabled,
"defaultIntervalSecs": config.task_sources.default_interval_secs,
"sourceCount": sources.len(),
"enabledSourceCount": enabled_count,
}),
vec![],
))
}
+201
View File
@@ -0,0 +1,201 @@
//! Periodic poll scheduler for the `task_sources` domain.
//!
//! Spawned once at startup. On each tick it lists enabled sources and,
//! for any whose per-source `interval_secs` has elapsed since its last
//! poll, runs [`pipeline::run_source_once`] with
//! [`FetchReason::Periodic`]. Mirrors the composio periodic scheduler:
//! one global tick drives every source, per-source timing lives in a
//! process-global map, and errors are logged and swallowed so the loop
//! never unwinds.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::time::interval;
use crate::openhuman::config::rpc as config_rpc;
use super::pipeline;
use super::types::{FetchReason, TaskSource};
/// How often the scheduler wakes to look for due sources.
///
/// This is also the *effective lower bound* on polling frequency: a
/// source's `interval_secs` only governs how many ticks must elapse
/// before it is due again, so any `interval_secs` shorter than
/// `TICK_SECONDS` is effectively rounded up to this tick cadence (e.g. a
/// 60s source is still only polled every ~10 minutes). This keeps
/// background load bounded; sub-tick intervals are intentionally not
/// honoured more frequently.
const TICK_SECONDS: u64 = 600;
/// Floor on a source's effective poll interval, so a misconfigured
/// `interval_secs = 0` can't hammer the provider every tick.
const MIN_INTERVAL_SECONDS: u64 = 60;
static SCHEDULER_STARTED: OnceLock<()> = OnceLock::new();
type LastPollMap = Mutex<HashMap<String, Instant>>;
static LAST_POLL_AT: OnceLock<LastPollMap> = OnceLock::new();
fn last_poll_map() -> &'static LastPollMap {
LAST_POLL_AT.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Record a successful (or attempted) poll for a source id.
fn record_poll(source_id: &str) {
if let Ok(mut map) = last_poll_map().lock() {
map.insert(source_id.to_string(), Instant::now());
}
}
/// Whether `source` is due for a poll given its last-poll timestamp.
fn is_due(source: &TaskSource) -> bool {
let interval_secs = source.interval_secs.max(MIN_INTERVAL_SECONDS);
let map = match last_poll_map().lock() {
Ok(map) => map,
Err(poisoned) => poisoned.into_inner(),
};
match map.get(&source.id) {
Some(when) => when.elapsed() >= Duration::from_secs(interval_secs),
None => true, // never polled this run — fire immediately
}
}
/// Spawn the periodic poll task. Idempotent — only the first call
/// installs the loop.
pub fn start_periodic_poll() {
if SCHEDULER_STARTED.set(()).is_err() {
tracing::debug!("[task_sources:periodic] scheduler already running, skipping start");
return;
}
tokio::spawn(async move {
tracing::info!(
tick_seconds = TICK_SECONDS,
"[task_sources:periodic] scheduler starting"
);
run_loop().await;
tracing::error!("[task_sources:periodic] scheduler loop exited");
});
}
async fn run_loop() {
let mut ticker = interval(Duration::from_secs(TICK_SECONDS));
// Skip the immediate-fire tick so startup isn't slammed before the
// user signs in.
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = run_one_tick().await {
tracing::warn!(error = %e, "[task_sources:periodic] tick failed (continuing)");
}
}
}
/// Run a single scheduler tick. `pub(crate)` so tests can drive ticks
/// without the real `interval`.
pub(crate) async fn run_one_tick() -> Result<(), String> {
let config = config_rpc::load_config_with_timeout()
.await
.map_err(|e| format!("load_config: {e}"))?;
if !config.task_sources.enabled {
tracing::debug!("[task_sources:periodic] domain disabled in config, skipping tick");
return Ok(());
}
let sources = match super::store::list_sources(&config) {
Ok(sources) => sources,
Err(e) => {
tracing::debug!(error = %e, "[task_sources:periodic] list_sources failed, skipping tick");
return Ok(());
}
};
let mut considered = 0usize;
let mut fired = 0usize;
for source in sources {
if !source.enabled {
continue;
}
considered += 1;
if !is_due(&source) {
continue;
}
// Record the attempt up front so a slow/failing fetch doesn't
// re-fire every tick.
record_poll(&source.id);
let outcome = pipeline::run_source_once(&config, &source, FetchReason::Periodic).await;
fired += 1;
tracing::debug!(
source_id = %source.id,
fetched = outcome.fetched,
routed = outcome.routed,
error = ?outcome.error,
"[task_sources:periodic] source polled"
);
}
tracing::debug!(considered, fired, "[task_sources:periodic] tick complete");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::task_sources::types::{FilterSpec, ProviderSlug, SourceTarget};
use chrono::Utc;
use serde_json::json;
fn source(id: &str, interval_secs: u64) -> TaskSource {
TaskSource {
id: id.into(),
provider: ProviderSlug::Github,
connection_id: None,
name: None,
enabled: true,
filter: FilterSpec::Github {
repo: None,
labels: vec![],
assignee_is_me: true,
state: None,
extra: json!({}),
},
interval_secs,
target: SourceTarget::TodoOnly,
max_tasks_per_fetch: 25,
created_at: Utc::now(),
last_fetch_at: None,
last_status: None,
}
}
#[test]
fn tick_seconds_is_sane() {
assert!(TICK_SECONDS >= 60);
assert!(TICK_SECONDS <= 3600);
}
#[test]
fn never_polled_source_is_due() {
let s = source("ts-never-polled-xyz", 1800);
assert!(is_due(&s));
}
#[test]
fn recently_polled_source_is_not_due() {
let s = source("ts-recent-poll-xyz", 1800);
record_poll(&s.id);
assert!(!is_due(&s), "just-recorded poll should not be due again");
}
#[test]
fn zero_interval_is_floored_not_always_due() {
let s = source("ts-zero-interval-xyz", 0);
record_poll(&s.id);
// With the MIN_INTERVAL_SECONDS floor a just-polled zero-interval
// source is not immediately due again.
assert!(!is_due(&s));
}
}
+169
View File
@@ -0,0 +1,169 @@
//! The fetch → dedup → enrich → route pipeline for one task source.
//!
//! [`run_source_once`] is the single entry point shared by the periodic
//! poll, the manual `task_sources_fetch` RPC, and the
//! connection-created bus hook. It is intentionally infallible at the
//! call boundary: any error is captured into [`FetchOutcome::error`] so
//! the scheduler loop never unwinds.
use std::sync::Arc;
use chrono::Utc;
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::config::Config;
use crate::openhuman::memory_sync::composio::providers::{get_provider, ProviderContext};
use super::types::{FetchOutcome, FetchReason, TaskSource};
use super::{enrich, filter, route, store};
/// Run a single fetch pass over `source`. Captures errors into the
/// returned [`FetchOutcome`] rather than propagating them.
pub async fn run_source_once(
config: &Config,
source: &TaskSource,
reason: FetchReason,
) -> FetchOutcome {
let mut outcome = FetchOutcome {
source_id: source.id.clone(),
provider: source.provider.as_str().to_string(),
..Default::default()
};
tracing::info!(
source_id = %source.id,
provider = %source.provider.as_str(),
reason = reason.as_str(),
"[task_sources:pipeline] fetch pass starting"
);
match run_inner(config, source, reason, &mut outcome).await {
Ok(()) => {
let status = format!(
"fetched {} routed {} dupes {}",
outcome.fetched, outcome.routed, outcome.skipped_dupe
);
let _ = store::record_fetch(config, &source.id, Utc::now(), reason, &status);
publish_global(DomainEvent::TaskSourceFetched {
source_id: source.id.clone(),
provider: outcome.provider.clone(),
fetched: outcome.fetched,
routed: outcome.routed,
skipped: outcome.skipped_dupe,
});
tracing::info!(
source_id = %source.id,
fetched = outcome.fetched,
routed = outcome.routed,
skipped_dupe = outcome.skipped_dupe,
"[task_sources:pipeline] fetch pass complete"
);
}
Err(e) => {
tracing::warn!(
source_id = %source.id,
error = %e,
"[task_sources:pipeline] fetch pass failed"
);
let _ = store::record_fetch(
config,
&source.id,
Utc::now(),
reason,
&format!("error: {e}"),
);
publish_global(DomainEvent::TaskSourceFetchFailed {
source_id: source.id.clone(),
provider: outcome.provider.clone(),
error: e.clone(),
});
outcome.error = Some(e);
}
}
outcome
}
async fn run_inner(
config: &Config,
source: &TaskSource,
_reason: FetchReason,
outcome: &mut FetchOutcome,
) -> Result<(), String> {
let provider = get_provider(source.provider.as_str()).ok_or_else(|| {
format!(
"no native provider registered for '{}'",
source.provider.as_str()
)
})?;
let ctx = ProviderContext {
config: Arc::new(config.clone()),
toolkit: source.provider.as_str().to_string(),
connection_id: source.connection_id.clone(),
};
let fetch_filter = filter::to_fetch_filter(&source.filter, source.max_tasks_per_fetch);
let tasks = provider.fetch_tasks(&ctx, &fetch_filter).await?;
outcome.fetched = tasks.len();
for mut task in tasks {
// Stamp the originating source before dedup / enrichment.
task.source_id = source.id.clone();
let hash = store::content_hash(&task);
if store::is_ingested(config, &source.id, &task.external_id, &hash)
.map_err(|e| format!("dedup check failed: {e}"))?
{
outcome.skipped_dupe += 1;
continue;
}
// Look up the stale card id (if any) before enrichment so we can
// remove the old board card when re-routing an edited upstream task.
let stale_card_id = store::get_card_id(config, &source.id, &task.external_id)
.map_err(|e| format!("get_card_id failed: {e}"))?;
let enriched = enrich::enrich_task(task);
// Route first; only mark ingested on success so a routing
// failure retries on the next pass instead of being silently
// dropped.
let new_card_id = match route::route_enriched(
config,
source,
&enriched,
stale_card_id.as_deref(),
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::warn!(
source_id = %source.id,
external_id = %enriched.task.external_id,
error = %e,
"[task_sources:pipeline] routing failed (will retry next pass)"
);
continue;
}
};
store::mark_ingested(config, &source.id, &enriched.task, &new_card_id)
.map_err(|e| format!("mark_ingested failed: {e}"))?;
publish_global(DomainEvent::TaskSourceTaskIngested {
source_id: source.id.clone(),
provider: enriched.task.provider.clone(),
external_id: enriched.task.external_id.clone(),
title: enriched.task.title.clone(),
urgency: enriched.urgency,
});
outcome.routed += 1;
}
Ok(())
}
#[cfg(test)]
#[path = "pipeline_tests.rs"]
mod tests;
@@ -0,0 +1,203 @@
use super::*;
use crate::openhuman::config::Config;
use crate::openhuman::memory_sync::composio::providers::{
register_provider, ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile,
SyncOutcome, SyncReason, TaskFetchFilter,
};
use crate::openhuman::task_sources::store;
use crate::openhuman::task_sources::types::{FilterSpec, ProviderSlug, SourceTarget};
use async_trait::async_trait;
use serde_json::json;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use tempfile::TempDir;
/// Serialize pipeline tests: they register a stub provider under the
/// shared "github" registry slug, so they must not run concurrently.
fn registry_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
struct StubProvider {
tasks: Vec<NormalizedTask>,
}
#[async_trait]
impl ComposioProvider for StubProvider {
fn toolkit_slug(&self) -> &'static str {
"github"
}
async fn fetch_user_profile(
&self,
_ctx: &ProviderContext,
) -> Result<ProviderUserProfile, String> {
Ok(ProviderUserProfile::default())
}
async fn sync(
&self,
_ctx: &ProviderContext,
_reason: SyncReason,
) -> Result<SyncOutcome, String> {
Ok(SyncOutcome::default())
}
async fn fetch_tasks(
&self,
_ctx: &ProviderContext,
_filter: &TaskFetchFilter,
) -> Result<Vec<NormalizedTask>, String> {
Ok(self.tasks.clone())
}
}
fn canned_task(id: &str, title: &str, updated: &str) -> NormalizedTask {
NormalizedTask {
external_id: id.into(),
source_id: String::new(),
provider: "github".into(),
title: title.into(),
url: Some(format!("https://example.com/{id}")),
updated_at: Some(updated.into()),
..Default::default()
}
}
fn test_config(tmp: &TempDir) -> Config {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config
}
fn add_github_source(config: &Config) -> TaskSource {
store::add_source(
config,
ProviderSlug::Github,
None,
Some("Test source".into()),
FilterSpec::Github {
repo: Some("o/r".into()),
labels: vec![],
assignee_is_me: true,
state: None,
extra: json!({}),
},
1800,
// TodoOnly keeps the pass deterministic — no triage LLM turn.
SourceTarget::TodoOnly,
25,
)
.unwrap()
}
#[tokio::test]
async fn fetch_routes_cards_and_dedups_on_rerun() {
let _guard = registry_lock();
register_provider(Arc::new(StubProvider {
tasks: vec![
canned_task("1", "First task", "2025-01-01T00:00:00Z"),
canned_task("2", "Second task", "2025-01-02T00:00:00Z"),
],
}));
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let source = add_github_source(&config);
// First pass: both tasks fetched and routed onto the board.
let outcome = run_source_once(&config, &source, FetchReason::Manual).await;
assert_eq!(outcome.fetched, 2, "error={:?}", outcome.error);
assert_eq!(outcome.routed, 2);
assert_eq!(outcome.skipped_dupe, 0);
assert!(outcome.error.is_none());
let cards = route::board_cards(&config).unwrap();
assert_eq!(cards.len(), 2);
assert!(cards.iter().any(|c| c.title.contains("First task")));
assert!(cards.iter().all(|c| c.title.starts_with("[GitHub]")));
// Second pass: same tasks → all deduped, no new cards.
let outcome2 = run_source_once(&config, &source, FetchReason::Manual).await;
assert_eq!(outcome2.fetched, 2);
assert_eq!(outcome2.routed, 0);
assert_eq!(outcome2.skipped_dupe, 2);
let cards_after = route::board_cards(&config).unwrap();
assert_eq!(cards_after.len(), 2, "dedup must not add duplicate cards");
// Ingested ledger reflects both tasks.
let ingested = store::list_ingested(&config, &source.id, 10).unwrap();
assert_eq!(ingested.len(), 2);
}
#[tokio::test]
async fn edited_task_reroutes_as_new_card() {
let _guard = registry_lock();
register_provider(Arc::new(StubProvider {
tasks: vec![canned_task("7", "Original title", "2025-01-01T00:00:00Z")],
}));
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let source = add_github_source(&config);
let first = run_source_once(&config, &source, FetchReason::Manual).await;
assert_eq!(first.routed, 1);
// Re-register with an edited version (newer updated_at → new hash).
register_provider(Arc::new(StubProvider {
tasks: vec![canned_task("7", "Edited title", "2025-02-01T00:00:00Z")],
}));
let second = run_source_once(&config, &source, FetchReason::Manual).await;
assert_eq!(second.routed, 1, "edited task should re-route");
assert_eq!(second.skipped_dupe, 0);
// Board must have exactly one card: the stale card was removed before
// the fresh one was added, so no duplicate accumulation.
let cards = route::board_cards(&config).unwrap();
assert_eq!(
cards.len(),
1,
"edited task must not leave duplicate board cards"
);
assert!(cards[0].title.contains("Edited title"));
// Ledger still holds a single row for external_id 7 (upsert).
let ingested = store::list_ingested(&config, &source.id, 10).unwrap();
assert_eq!(ingested.len(), 1);
assert_eq!(ingested[0].title, "Edited title");
}
#[tokio::test]
async fn missing_provider_surfaces_error_in_outcome() {
let _guard = registry_lock();
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
// A clickup source with no registered clickup provider in the test
// binary → outcome carries the error, never panics.
let source = store::add_source(
&config,
ProviderSlug::Clickup,
None,
None,
FilterSpec::Clickup {
team_id: None,
list_id: None,
assignee_is_me: true,
extra: json!({}),
},
1800,
SourceTarget::TodoOnly,
25,
)
.unwrap();
let outcome = run_source_once(&config, &source, FetchReason::Manual).await;
assert!(outcome.error.is_some());
assert_eq!(outcome.routed, 0);
}
+239
View File
@@ -0,0 +1,239 @@
//! Route an [`EnrichedTask`] onto the agent's work surface.
//!
//! Every enriched task lands as a card on the dedicated `task-sources`
//! thread board (reusing the thread-scoped `todos` store). Sources with
//! the [`SourceTarget::AgentTodoProactive`] target additionally dispatch
//! a triage turn — the same `TriggerEnvelope` → `run_triage` →
//! `apply_decision` path Composio webhooks use — so an agent can start
//! working immediately. Triage's classifier (drop / acknowledge / react
//! / escalate) gates noise, and the proactive turn is held behind the
//! `scheduler_gate` capacity semaphore so background AI throttling is
//! respected.
use serde_json::json;
use crate::openhuman::agent::triage::{apply_decision, run_triage, TriageOutcome, TriggerEnvelope};
use crate::openhuman::config::Config;
use crate::openhuman::todos::ops::{
add as todo_add, remove as todo_remove, BoardLocation, CardPatch,
};
use crate::openhuman::{scheduler_gate, todos};
use super::types::{EnrichedTask, SourceTarget, TaskSource};
/// Stable thread id whose board collects every ingested task.
pub const TASK_SOURCES_THREAD_ID: &str = "task-sources";
/// Route an enriched task: append a todo card, then (for proactive
/// sources) dispatch a triage turn. Returns the new card id on success.
pub async fn route_enriched(
config: &Config,
source: &TaskSource,
enriched: &EnrichedTask,
stale_card_id: Option<&str>,
) -> Result<String, String> {
let card_id = add_card(config, source, enriched, stale_card_id)?;
match source.target {
SourceTarget::TodoOnly => {
tracing::debug!(
source_id = %source.id,
external_id = %enriched.task.external_id,
"[task_sources:route] todo-only target, card added (no agent turn)"
);
Ok(card_id)
}
SourceTarget::AgentTodoProactive => {
dispatch_triage(source, enriched).await?;
Ok(card_id)
}
}
}
/// Append a new card on the `task-sources` board, optionally removing a
/// stale card first (when an upstream task was edited and re-routed). Returns
/// the id of the newly created card.
///
/// Removing the stale card before adding the new one prevents duplicate board
/// entries from accumulating across edit cycles. If the stale card is already
/// gone (e.g. user manually removed it) the remove error is logged and
/// ignored so the fresh card still lands.
fn add_card(
config: &Config,
source: &TaskSource,
enriched: &EnrichedTask,
stale_card_id: Option<&str>,
) -> Result<String, String> {
let location = BoardLocation::Thread {
workspace_dir: config.workspace_dir.clone(),
thread_id: TASK_SOURCES_THREAD_ID.to_string(),
};
// Remove stale card from the previous ingestion of this task (if any)
// before creating the replacement, so the board never accumulates
// duplicate cards for the same upstream item.
if let Some(old_id) = stale_card_id {
match todo_remove(&location, old_id) {
Ok(_) => {
tracing::debug!(
source_id = %source.id,
external_id = %enriched.task.external_id,
stale_card_id = %old_id,
"[task_sources:route] stale card removed before re-routing edited task"
);
}
Err(e) => {
// Not fatal: card may have been manually removed already.
tracing::debug!(
source_id = %source.id,
external_id = %enriched.task.external_id,
stale_card_id = %old_id,
error = %e,
"[task_sources:route] stale card removal skipped (already gone?)"
);
}
}
}
let task = &enriched.task;
let label = provider_label(&task.provider);
let content = format!("[{label}] {}", task.title.trim());
let mut notes_parts: Vec<String> = Vec::new();
if enriched.summary.trim() != task.title.trim() && !enriched.summary.trim().is_empty() {
notes_parts.push(enriched.summary.trim().to_string());
}
if let Some(url) = task.url.as_deref().filter(|s| !s.trim().is_empty()) {
notes_parts.push(url.trim().to_string());
}
let notes = if notes_parts.is_empty() {
None
} else {
Some(notes_parts.join("\n"))
};
let snapshot = todo_add(
&location,
&content,
CardPatch {
notes,
..Default::default()
},
)
.map_err(|e| format!("[task_sources:route] failed to add todo card: {e}"))?;
// The newly created card is always the last one in the snapshot (add
// appends at the end). Return its id for the dedup ledger.
let new_card_id = snapshot
.cards
.last()
.map(|c| c.id.clone())
.ok_or_else(|| "[task_sources:route] add returned empty card list".to_string())?;
tracing::debug!(
external_id = %task.external_id,
card_id = %new_card_id,
cards = snapshot.cards.len(),
"[task_sources:route] card added to task-sources board"
);
Ok(new_card_id)
}
/// Dispatch a triage turn for a proactive task, gated by scheduler
/// capacity. Card creation already happened; a gated-off or deferred
/// turn is non-fatal — the task still sits on the board.
async fn dispatch_triage(source: &TaskSource, enriched: &EnrichedTask) -> Result<(), String> {
// Respect background-AI throttling. When the gate denies capacity
// (Off / paused), we keep the card but skip the proactive turn.
let Some(_permit) = scheduler_gate::wait_for_capacity().await else {
tracing::info!(
source_id = %source.id,
"[task_sources:route] scheduler gate denied capacity; card added, agent turn skipped"
);
return Ok(());
};
let task = &enriched.task;
let payload = json!({
"task": task,
"summary": enriched.summary,
"agentPrompt": enriched.agent_prompt,
"urgency": enriched.urgency,
"url": task.url,
"provider": task.provider,
"sourceId": source.id,
});
let envelope = TriggerEnvelope::from_external(
&format!("task_sources:{}", source.id),
"external task ingested",
payload,
);
let outcome = run_triage(&envelope)
.await
.map_err(|e| format!("[task_sources:route] triage evaluation failed: {e}"))?;
match outcome {
TriageOutcome::Decision(run) => {
apply_decision(run, &envelope)
.await
.map_err(|e| format!("[task_sources:route] apply_decision failed: {e}"))?;
tracing::debug!(
source_id = %source.id,
external_id = %task.external_id,
"[task_sources:route] triage decision applied"
);
}
TriageOutcome::Deferred { reason, .. } => {
tracing::debug!(
source_id = %source.id,
reason = %reason,
"[task_sources:route] triage deferred (card remains on board)"
);
}
}
Ok(())
}
/// Title-case a provider slug for display on the card.
fn provider_label(provider: &str) -> String {
match provider {
"github" => "GitHub".to_string(),
"notion" => "Notion".to_string(),
"linear" => "Linear".to_string(),
"clickup" => "ClickUp".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
}
}
/// Read the current cards on the `task-sources` board. Used by tests and
/// callers that want to inspect routed work without an RPC round-trip.
pub fn board_cards(
config: &Config,
) -> Result<Vec<crate::openhuman::agent::task_board::TaskBoardCard>, String> {
let location = BoardLocation::Thread {
workspace_dir: config.workspace_dir.clone(),
thread_id: TASK_SOURCES_THREAD_ID.to_string(),
};
todos::ops::list(&location).map(|snap| snap.cards)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_label_titlecases_known_and_unknown() {
assert_eq!(provider_label("github"), "GitHub");
assert_eq!(provider_label("clickup"), "ClickUp");
assert_eq!(provider_label("asana"), "Asana");
assert_eq!(provider_label(""), "");
}
}
+562
View File
@@ -0,0 +1,562 @@
//! JSON-RPC controller surface for the `task_sources` domain
//! (`openhuman.task_sources_*`).
//!
//! Mirrors the `cron` domain: a `schemas(fn)` metadata switch, an
//! `all_*` registry pair, and thin handlers that parse params and
//! delegate to [`super::ops`].
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
use super::ops;
use super::types::{FilterSpec, ProviderSlug, SourceTarget, TaskSourcePatch};
fn source_id_input(comment: &'static str) -> FieldSchema {
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment,
required: true,
}
}
fn provider_input() -> FieldSchema {
FieldSchema {
name: "provider",
ty: TypeSchema::Enum {
variants: vec!["github", "notion", "linear", "clickup"],
},
comment: "External tool to pull tasks from.",
required: true,
}
}
fn filter_input() -> FieldSchema {
FieldSchema {
name: "filter",
ty: TypeSchema::Ref("FilterSpec"),
comment: "Per-provider filter (tagged by `provider`).",
required: true,
}
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("list"),
schemas("get"),
schemas("add"),
schemas("update"),
schemas("remove"),
schemas("fetch"),
schemas("list_tasks"),
schemas("preview_filter"),
schemas("status"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("list"),
handler: handle_list,
},
RegisteredController {
schema: schemas("get"),
handler: handle_get,
},
RegisteredController {
schema: schemas("add"),
handler: handle_add,
},
RegisteredController {
schema: schemas("update"),
handler: handle_update,
},
RegisteredController {
schema: schemas("remove"),
handler: handle_remove,
},
RegisteredController {
schema: schemas("fetch"),
handler: handle_fetch,
},
RegisteredController {
schema: schemas("list_tasks"),
handler: handle_list_tasks,
},
RegisteredController {
schema: schemas("preview_filter"),
handler: handle_preview_filter,
},
RegisteredController {
schema: schemas("status"),
handler: handle_status,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"list" => ControllerSchema {
namespace: "task_sources",
function: "list",
description: "List all configured task sources.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "sources",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("TaskSource"))),
comment: "Configured task sources.",
required: true,
}],
},
"get" => ControllerSchema {
namespace: "task_sources",
function: "get",
description: "Fetch a single task source by id.",
inputs: vec![source_id_input("Identifier of the task source.")],
outputs: vec![FieldSchema {
name: "source",
ty: TypeSchema::Ref("TaskSource"),
comment: "The requested task source.",
required: true,
}],
},
"add" => ControllerSchema {
namespace: "task_sources",
function: "add",
description: "Create a task source. Missing schedule/target/cap use config defaults.",
inputs: vec![
provider_input(),
filter_input(),
FieldSchema {
name: "name",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional display name.",
required: false,
},
FieldSchema {
name: "connection_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional Composio connection id.",
required: false,
},
FieldSchema {
name: "interval_secs",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Poll interval in seconds; defaults from config.",
required: false,
},
FieldSchema {
name: "target",
ty: TypeSchema::Option(Box::new(TypeSchema::Enum {
variants: vec!["agent_todo_proactive", "todo_only"],
})),
comment: "Routing target; defaults from config.auto_proactive.",
required: false,
},
FieldSchema {
name: "max_tasks_per_fetch",
// TypeSchema has no U32 variant; U64 is the only unsigned
// integer type. The handler (`read_optional_u32`) checks at
// runtime that the supplied value fits in a u32 and returns
// a clear error when it does not.
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Per-fetch task cap (u32 range); defaults from config.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "source",
ty: TypeSchema::Ref("TaskSource"),
comment: "The created task source.",
required: true,
}],
},
"update" => ControllerSchema {
namespace: "task_sources",
function: "update",
description: "Apply a partial patch to a task source.",
inputs: vec![
source_id_input("Identifier of the task source to update."),
FieldSchema {
name: "patch",
ty: TypeSchema::Ref("TaskSourcePatch"),
comment: "Partial update payload.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "source",
ty: TypeSchema::Ref("TaskSource"),
comment: "The updated task source.",
required: true,
}],
},
"remove" => ControllerSchema {
namespace: "task_sources",
function: "remove",
description: "Remove a task source by id.",
inputs: vec![source_id_input("Identifier of the task source to remove.")],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Object {
fields: vec![
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "Identifier requested for removal.",
required: true,
},
FieldSchema {
name: "removed",
ty: TypeSchema::Bool,
comment: "True when the source was removed.",
required: true,
},
],
},
comment: "Removal result payload.",
required: true,
}],
},
"fetch" => ControllerSchema {
namespace: "task_sources",
function: "fetch",
description: "Fetch one source immediately and route any new tasks.",
inputs: vec![source_id_input(
"Identifier of the task source to fetch now.",
)],
outputs: vec![FieldSchema {
name: "outcome",
ty: TypeSchema::Ref("FetchOutcome"),
comment: "Fetch outcome counts.",
required: true,
}],
},
"list_tasks" => ControllerSchema {
namespace: "task_sources",
function: "list_tasks",
description: "List recently ingested tasks for a source.",
inputs: vec![
source_id_input("Identifier of the task source."),
FieldSchema {
name: "limit",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Maximum records to return; defaults to 50.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "tasks",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("NormalizedTask"))),
comment: "Recently ingested tasks, newest first.",
required: true,
}],
},
"preview_filter" => ControllerSchema {
namespace: "task_sources",
function: "preview_filter",
description: "Dry-run a filter and return matching tasks WITHOUT routing them.",
inputs: vec![
provider_input(),
filter_input(),
FieldSchema {
name: "connection_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional Composio connection id.",
required: false,
},
FieldSchema {
name: "max",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Max tasks to preview (u32 range); defaults from config.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "tasks",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("NormalizedTask"))),
comment: "Tasks that would be ingested (not routed).",
required: true,
}],
},
"status" => ControllerSchema {
namespace: "task_sources",
function: "status",
description: "Report task-sources domain status (enabled flag + source counts).",
inputs: vec![],
outputs: vec![FieldSchema {
name: "status",
ty: TypeSchema::Object {
fields: vec![
FieldSchema {
name: "enabled",
ty: TypeSchema::Bool,
comment: "Domain master switch.",
required: true,
},
FieldSchema {
name: "defaultIntervalSecs",
ty: TypeSchema::U64,
comment: "Default poll interval (seconds) from config.",
required: true,
},
FieldSchema {
name: "sourceCount",
ty: TypeSchema::U64,
comment: "Total configured sources.",
required: true,
},
FieldSchema {
name: "enabledSourceCount",
ty: TypeSchema::U64,
comment: "Configured sources currently enabled.",
required: true,
},
],
},
comment: "Domain status payload.",
required: true,
}],
},
_other => ControllerSchema {
namespace: "task_sources",
function: "unknown",
description: "Unknown task_sources controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested for schema lookup.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_list(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
let config = config_rpc::load_config_with_timeout().await?;
to_json(ops::list(&config).await?)
})
}
fn handle_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(&params, "id")?;
to_json(ops::get(&config, id.trim()).await?)
})
}
fn handle_add(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let provider = read_provider(&params)?;
let filter = read_required::<FilterSpec>(&params, "filter")?;
let name = read_optional::<String>(&params, "name")?;
let connection_id = read_optional::<String>(&params, "connection_id")?;
let interval_secs = read_optional::<u64>(&params, "interval_secs")?;
let target = read_optional::<SourceTarget>(&params, "target")?;
let max = read_optional_u32(&params, "max_tasks_per_fetch")?;
to_json(
ops::add(
&config,
provider,
connection_id,
name,
filter,
interval_secs,
target,
max,
)
.await?,
)
})
}
fn handle_update(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(&params, "id")?;
let patch = read_required::<TaskSourcePatch>(&params, "patch")?;
to_json(ops::update(&config, id.trim(), patch).await?)
})
}
fn handle_remove(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(&params, "id")?;
to_json(ops::remove(&config, id.trim()).await?)
})
}
fn handle_fetch(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(&params, "id")?;
to_json(ops::fetch(&config, id.trim()).await?)
})
}
fn handle_list_tasks(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(&params, "id")?;
let limit = match read_optional::<u64>(&params, "limit")? {
Some(n) => Some(
usize::try_from(n)
.map_err(|_| format!("invalid 'limit': {n} exceeds platform usize"))?,
),
None => None,
};
to_json(ops::list_tasks(&config, id.trim(), limit).await?)
})
}
fn handle_preview_filter(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let provider = read_provider(&params)?;
let filter = read_required::<FilterSpec>(&params, "filter")?;
let connection_id = read_optional::<String>(&params, "connection_id")?;
let max = read_optional_u32(&params, "max")?;
to_json(ops::preview_filter(&config, provider, filter, connection_id, max).await?)
})
}
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
let config = config_rpc::load_config_with_timeout().await?;
to_json(ops::status(&config).await?)
})
}
fn read_provider(params: &Map<String, Value>) -> Result<ProviderSlug, String> {
let raw = read_required::<String>(params, "provider")?;
ProviderSlug::parse(&raw)
}
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
let value = params
.get(key)
.cloned()
.ok_or_else(|| format!("missing required param '{key}'"))?;
serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}"))
}
fn read_optional<T: DeserializeOwned>(
params: &Map<String, Value>,
key: &str,
) -> Result<Option<T>, String> {
match params.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => serde_json::from_value(value.clone())
.map(Some)
.map_err(|e| format!("invalid '{key}': {e}")),
}
}
/// Read an optional unsigned integer parameter and checked-convert it to
/// `u32`. JSON integers arrive as `u64` on the wire; we accept any value
/// that fits in `u32` and reject out-of-range values with a clear error
/// rather than silently truncating.
fn read_optional_u32(params: &Map<String, Value>, key: &str) -> Result<Option<u32>, String> {
match read_optional::<u64>(params, key)? {
Some(n) => {
Ok(Some(u32::try_from(n).map_err(|_| {
format!("invalid '{key}': {n} exceeds u32 range")
})?))
}
None => Ok(None),
}
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn all_controller_schemas_covers_every_function() {
let names: Vec<_> = all_controller_schemas()
.into_iter()
.map(|s| s.function)
.collect();
assert_eq!(
names,
vec![
"list",
"get",
"add",
"update",
"remove",
"fetch",
"list_tasks",
"preview_filter",
"status"
]
);
}
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
assert_eq!(controllers.len(), 9);
assert!(controllers
.iter()
.all(|c| c.schema.namespace == "task_sources"));
}
#[test]
fn schemas_add_requires_provider_and_filter() {
let s = schemas("add");
let names: Vec<_> = s.inputs.iter().map(|f| f.name).collect();
assert!(names.contains(&"provider"));
assert!(names.contains(&"filter"));
let provider = s.inputs.iter().find(|f| f.name == "provider").unwrap();
assert!(provider.required);
}
#[test]
fn schemas_unknown_function_returns_placeholder() {
let s = schemas("nope");
assert_eq!(s.function, "unknown");
assert_eq!(s.outputs[0].name, "error");
}
#[test]
fn read_provider_parses_known_and_rejects_unknown() {
let mut params = Map::new();
params.insert("provider".into(), json!("notion"));
assert_eq!(read_provider(&params).unwrap(), ProviderSlug::Notion);
params.insert("provider".into(), json!("jira"));
assert!(read_provider(&params).is_err());
}
#[test]
fn read_optional_handles_absent_null_and_value() {
let mut params = Map::new();
assert_eq!(read_optional::<u64>(&params, "limit").unwrap(), None);
params.insert("limit".into(), Value::Null);
assert_eq!(read_optional::<u64>(&params, "limit").unwrap(), None);
params.insert("limit".into(), json!(7));
assert_eq!(read_optional::<u64>(&params, "limit").unwrap(), Some(7));
}
}
+485
View File
@@ -0,0 +1,485 @@
//! SQLite persistence for the `task_sources` domain.
//!
//! Two tables live in `<workspace>/task_sources/sources.db`:
//! * `task_sources` — the configured sources (provider + filter +
//! schedule + routing target).
//! * `ingested_tasks` — per-(source, external task) dedup ledger with
//! an edit-aware `content_hash`, plus the normalized task payload so
//! the UI can list recently ingested items.
//!
//! Mirrors the `cron` domain's `with_connection` + migrate-on-open
//! pattern.
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::openhuman::config::Config;
use crate::openhuman::memory_sync::composio::providers::NormalizedTask;
use super::types::{
FetchReason, FilterSpec, ProviderSlug, SourceTarget, TaskSource, TaskSourcePatch,
};
/// Compute an edit-aware content hash for a task. Two fetches of the
/// same `external_id` whose title/body/status/updated_at differ produce
/// different hashes, so an *edited* upstream item re-ingests.
///
/// Uses SHA-256 over a canonical field-delimited representation so the
/// digest is stable across Rust/toolchain versions (the dedup key is
/// persisted on disk; a non-deterministic hasher would force spurious
/// re-ingests after a toolchain bump).
pub fn content_hash(task: &NormalizedTask) -> String {
let canonical = format!(
"{}\u{1f}{}\u{1f}{}\u{1f}{}",
task.title,
task.body.as_deref().unwrap_or(""),
task.status.as_deref().unwrap_or(""),
task.updated_at.as_deref().unwrap_or(""),
);
let digest = Sha256::digest(canonical.as_bytes());
format!("{digest:x}")
}
/// Insert a new task source.
#[allow(clippy::too_many_arguments)]
pub fn add_source(
config: &Config,
provider: ProviderSlug,
connection_id: Option<String>,
name: Option<String>,
filter: FilterSpec,
interval_secs: u64,
target: SourceTarget,
max_tasks_per_fetch: u32,
) -> Result<TaskSource> {
if filter.provider() != provider {
anyhow::bail!(
"filter provider '{}' does not match source provider '{}'",
filter.provider().as_str(),
provider.as_str()
);
}
// Normalize blank optional fields to NULL so a whitespace-only
// connection_id can't masquerade as a real selector (mirrors
// `update_source`).
let connection_id = connection_id.filter(|s| !s.trim().is_empty());
let name = name.filter(|s| !s.trim().is_empty());
let id = Uuid::new_v4().to_string();
let now = Utc::now();
let filter_json = serde_json::to_string(&filter).context("serialize task source filter")?;
let target_json = serde_json::to_string(&target).context("serialize task source target")?;
let interval_i64 = i64::try_from(interval_secs)
.context("task source interval_secs exceeds SQLite INTEGER range")?;
with_connection(config, |conn| {
conn.execute(
"INSERT INTO task_sources (
id, provider, connection_id, name, enabled, filter, interval_secs,
target, max_tasks_per_fetch, created_at
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?9)",
params![
id,
provider.as_str(),
connection_id,
name,
filter_json,
interval_i64,
target_json,
i64::from(max_tasks_per_fetch),
now.to_rfc3339(),
],
)
.context("Failed to insert task source")?;
Ok(())
})?;
get_source(config, &id)
}
pub fn get_source(config: &Config, id: &str) -> Result<TaskSource> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(&format!("{SELECT_SOURCE_COLUMNS} WHERE id = ?1"))?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
map_source_row(row).map_err(Into::into)
} else {
anyhow::bail!("Task source '{id}' not found")
}
})
}
pub fn list_sources(config: &Config) -> Result<Vec<TaskSource>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(&format!(
"{SELECT_SOURCE_COLUMNS} ORDER BY created_at ASC, id ASC"
))?;
let rows = stmt.query_map([], map_source_row)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
})
}
/// Apply a partial patch to a task source.
///
/// **Implementation note:** this function opens three separate SQLite
/// connections (read-modify-write + read-back). At settings-panel scale the
/// overhead is acceptable, but there is a theoretical TOCTOU window between
/// the initial `get_source` and the subsequent `UPDATE`. A future refactor
/// could fold all three operations into a single `with_connection` call using
/// a SQL `UPDATE … RETURNING` pattern.
pub fn update_source(config: &Config, id: &str, patch: TaskSourcePatch) -> Result<TaskSource> {
let mut source = get_source(config, id)?;
if let Some(name) = patch.name {
source.name = Some(name).filter(|s| !s.trim().is_empty());
}
if let Some(enabled) = patch.enabled {
source.enabled = enabled;
}
if let Some(filter) = patch.filter {
if filter.provider() != source.provider {
anyhow::bail!(
"patch filter provider '{}' does not match source provider '{}'",
filter.provider().as_str(),
source.provider.as_str()
);
}
source.filter = filter;
}
if let Some(interval_secs) = patch.interval_secs {
source.interval_secs = interval_secs;
}
if let Some(target) = patch.target {
source.target = target;
}
if let Some(max) = patch.max_tasks_per_fetch {
source.max_tasks_per_fetch = max;
}
if let Some(connection_id) = patch.connection_id {
source.connection_id = Some(connection_id).filter(|s| !s.trim().is_empty());
}
let filter_json = serde_json::to_string(&source.filter).context("serialize filter")?;
let target_json = serde_json::to_string(&source.target).context("serialize target")?;
let interval_i64 = i64::try_from(source.interval_secs)
.context("task source interval_secs exceeds SQLite INTEGER range")?;
with_connection(config, |conn| {
conn.execute(
"UPDATE task_sources
SET provider = ?1, connection_id = ?2, name = ?3, enabled = ?4, filter = ?5,
interval_secs = ?6, target = ?7, max_tasks_per_fetch = ?8
WHERE id = ?9",
params![
source.provider.as_str(),
source.connection_id,
source.name,
if source.enabled { 1 } else { 0 },
filter_json,
interval_i64,
target_json,
i64::from(source.max_tasks_per_fetch),
id,
],
)
.context("Failed to update task source")?;
Ok(())
})?;
get_source(config, id)
}
pub fn remove_source(config: &Config, id: &str) -> Result<()> {
let changed = with_connection(config, |conn| {
conn.execute("DELETE FROM task_sources WHERE id = ?1", params![id])
.context("Failed to delete task source")
})?;
if changed == 0 {
anyhow::bail!("Task source '{id}' not found");
}
Ok(())
}
/// Update a source's `last_fetch_at` / `last_status` after a fetch pass.
pub fn record_fetch(
config: &Config,
id: &str,
finished_at: DateTime<Utc>,
reason: FetchReason,
status: &str,
) -> Result<()> {
let line = format!("{}: {status}", reason.as_str());
with_connection(config, |conn| {
conn.execute(
"UPDATE task_sources SET last_fetch_at = ?1, last_status = ?2 WHERE id = ?3",
params![finished_at.to_rfc3339(), line, id],
)
.context("Failed to record task source fetch")?;
Ok(())
})
}
/// True when this `(source, external_id)` was already ingested with the
/// *same* content hash. A differing hash (edited upstream) returns
/// `false` so the item re-ingests.
pub fn is_ingested(
config: &Config,
source_id: &str,
external_id: &str,
hash: &str,
) -> Result<bool> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT content_hash FROM ingested_tasks WHERE source_id = ?1 AND external_id = ?2",
)?;
let mut rows = stmt.query(params![source_id, external_id])?;
match rows.next()? {
Some(row) => {
let existing: String = row.get(0)?;
Ok(existing == hash)
}
None => Ok(false),
}
})
}
/// Record a routed task in the dedup ledger (idempotent upsert).
///
/// `card_id` is the board card UUID returned by `route::add_card`; it is
/// persisted so that a later edit of the same upstream task can remove the
/// stale card before creating a fresh one (preventing duplicate board cards).
pub fn mark_ingested(
config: &Config,
source_id: &str,
task: &NormalizedTask,
card_id: &str,
) -> Result<()> {
let hash = content_hash(task);
let payload = serde_json::to_string(task).context("serialize ingested task payload")?;
let now = Utc::now().to_rfc3339();
with_connection(config, |conn| {
conn.execute(
"INSERT INTO ingested_tasks (source_id, external_id, content_hash, title, payload, ingested_at, card_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(source_id, external_id) DO UPDATE SET
content_hash = excluded.content_hash,
title = excluded.title,
payload = excluded.payload,
ingested_at = excluded.ingested_at,
card_id = excluded.card_id",
params![source_id, task.external_id, hash, task.title, payload, now, card_id],
)
.context("Failed to mark task ingested")?;
Ok(())
})
}
/// Return the board card id previously stored for `(source_id, external_id)`,
/// if any. Used by the pipeline to remove stale board cards when an upstream
/// task is edited and re-ingested.
pub fn get_card_id(config: &Config, source_id: &str, external_id: &str) -> Result<Option<String>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT card_id FROM ingested_tasks WHERE source_id = ?1 AND external_id = ?2",
)?;
let mut rows = stmt.query(params![source_id, external_id])?;
match rows.next()? {
Some(row) => Ok(row.get(0)?),
None => Ok(None),
}
})
}
/// List the most recently ingested tasks for a source (newest first).
pub fn list_ingested(
config: &Config,
source_id: &str,
limit: usize,
) -> Result<Vec<NormalizedTask>> {
// Floor of 1: a caller passing `limit = 0` still gets at least one row
// rather than a confusing empty result; `unwrap_or(50)` is the fallback
// in the unlikely event that `limit` exceeds `i64::MAX`.
let lim = i64::try_from(limit.max(1)).unwrap_or(50);
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT payload FROM ingested_tasks
WHERE source_id = ?1 AND payload IS NOT NULL
ORDER BY ingested_at DESC LIMIT ?2",
)?;
let rows = stmt.query_map(params![source_id, lim], |row| row.get::<_, String>(0))?;
let mut out = Vec::new();
for row in rows {
let payload = row?;
if let Ok(task) = serde_json::from_str::<NormalizedTask>(&payload) {
out.push(task);
}
}
Ok(out)
})
}
/// Delete every task source (+ cascade ingested rows). Used by the E2E
/// `test_reset` RPC.
pub fn clear_all(config: &Config) -> Result<usize> {
with_connection(config, |conn| {
let removed = conn
.execute("DELETE FROM task_sources", params![])
.context("Failed to clear task sources")?;
// ingested_tasks rows cascade via the FK.
Ok(removed)
})
}
const SELECT_SOURCE_COLUMNS: &str = "SELECT id, provider, connection_id, name, enabled, filter, \
interval_secs, target, max_tasks_per_fetch, created_at, last_fetch_at, last_status \
FROM task_sources";
fn map_source_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskSource> {
let provider_raw: String = row.get(1)?;
let provider = ProviderSlug::parse(&provider_raw).map_err(sql_conv)?;
let filter_raw: String = row.get(5)?;
let filter: FilterSpec = serde_json::from_str(&filter_raw)
.map_err(|e| sql_conv(format!("invalid filter json: {e}")))?;
let target_raw: String = row.get(7)?;
let target: SourceTarget = serde_json::from_str(&target_raw)
.map_err(|e| sql_conv(format!("invalid target json: {e}")))?;
let created_at_raw: String = row.get(9)?;
let last_fetch_raw: Option<String> = row.get(10)?;
Ok(TaskSource {
id: row.get(0)?,
provider,
connection_id: row.get(2)?,
name: row.get(3)?,
enabled: row.get::<_, i64>(4)? != 0,
filter,
interval_secs: u64::try_from(row.get::<_, i64>(6)?)
.map_err(|_| sql_conv("invalid negative interval_secs in task_sources DB"))?,
target,
max_tasks_per_fetch: u32::try_from(row.get::<_, i64>(8)?)
.map_err(|_| sql_conv("invalid max_tasks_per_fetch in task_sources DB"))?,
created_at: parse_rfc3339(&created_at_raw).map_err(sql_conv)?,
last_fetch_at: match last_fetch_raw {
Some(raw) => Some(parse_rfc3339(&raw).map_err(sql_conv)?),
None => None,
},
last_status: row.get(11)?,
})
}
fn parse_rfc3339(raw: &str) -> Result<DateTime<Utc>> {
let parsed = DateTime::parse_from_rfc3339(raw)
.with_context(|| format!("Invalid RFC3339 timestamp in task_sources DB: {raw}"))?;
Ok(parsed.with_timezone(&Utc))
}
fn sql_conv<E: std::fmt::Display>(err: E) -> rusqlite::Error {
rusqlite::Error::ToSqlConversionFailure(anyhow::anyhow!("{err}").into())
}
fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
let db_path = config.workspace_dir.join("task_sources").join("sources.db");
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create task_sources directory: {}",
parent.display()
)
})?;
}
let conn = Connection::open(&db_path)
.with_context(|| format!("Failed to open task_sources DB: {}", db_path.display()))?;
// Overlapping periodic-poll + UI writes can otherwise hit
// "database is locked"; WAL + a busy timeout match the other stores.
conn.busy_timeout(Duration::from_secs(5))
.context("Failed to configure task_sources DB busy timeout")?;
conn.pragma_update(None, "journal_mode", "WAL")
.context("Failed to enable WAL for task_sources DB")?;
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS task_sources (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
connection_id TEXT,
name TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
filter TEXT NOT NULL,
interval_secs INTEGER NOT NULL,
target TEXT NOT NULL,
max_tasks_per_fetch INTEGER NOT NULL,
created_at TEXT NOT NULL,
last_fetch_at TEXT,
last_status TEXT
);
CREATE TABLE IF NOT EXISTS ingested_tasks (
source_id TEXT NOT NULL,
external_id TEXT NOT NULL,
content_hash TEXT NOT NULL,
title TEXT,
payload TEXT,
ingested_at TEXT NOT NULL,
card_id TEXT,
PRIMARY KEY (source_id, external_id),
FOREIGN KEY (source_id) REFERENCES task_sources(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ingested_source ON ingested_tasks(source_id, ingested_at);",
)
.context("Failed to initialize task_sources schema")?;
// Additive migration: add card_id to existing databases that pre-date
// this column. Tolerate "duplicate column" in case of a concurrent open.
add_column_if_missing(&conn, "ingested_tasks", "card_id", "TEXT")?;
f(&conn)
}
/// Add a column to a table only when it is absent. Mirrors the pattern used
/// in `cron/store.rs` to keep migrations idempotent across DB versions.
fn add_column_if_missing(
conn: &Connection,
table: &str,
column: &str,
sql_type: &str,
) -> Result<()> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let col_name: String = row.get(1)?;
if col_name == column {
return Ok(()); // already present
}
}
drop(rows);
drop(stmt);
match conn.execute(
&format!("ALTER TABLE {table} ADD COLUMN {column} {sql_type}"),
[],
) {
Ok(_) => Ok(()),
Err(rusqlite::Error::SqliteFailure(_, Some(ref msg)))
if msg.contains("duplicate column name") =>
{
tracing::debug!(
"[task_sources:store] column {table}.{column} already exists (concurrent migration)"
);
Ok(())
}
Err(e) => Err(e).with_context(|| format!("Failed to add {table}.{column}")),
}
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;
+272
View File
@@ -0,0 +1,272 @@
use super::*;
use crate::openhuman::config::Config;
use serde_json::json;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config
}
fn github_filter() -> FilterSpec {
FilterSpec::Github {
repo: Some("tinyhumansai/openhuman".into()),
labels: vec!["bug".into()],
assignee_is_me: true,
state: Some("open".into()),
extra: json!({}),
}
}
fn sample_task(external_id: &str, title: &str, updated: &str) -> NormalizedTask {
NormalizedTask {
external_id: external_id.into(),
source_id: String::new(),
provider: "github".into(),
title: title.into(),
updated_at: Some(updated.into()),
..Default::default()
}
}
#[test]
fn add_get_and_list_roundtrip() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let src = add_source(
&config,
ProviderSlug::Github,
None,
Some("My issues".into()),
github_filter(),
1800,
SourceTarget::AgentTodoProactive,
25,
)
.unwrap();
assert!(!src.id.is_empty());
assert_eq!(src.provider, ProviderSlug::Github);
assert!(src.enabled);
let fetched = get_source(&config, &src.id).unwrap();
assert_eq!(fetched, src);
let all = list_sources(&config).unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, src.id);
}
#[test]
fn add_rejects_provider_filter_mismatch() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let err = add_source(
&config,
ProviderSlug::Notion,
None,
None,
github_filter(), // github filter under a notion source
1800,
SourceTarget::TodoOnly,
25,
)
.unwrap_err();
assert!(err.to_string().contains("does not match"));
}
#[test]
fn update_applies_partial_patch() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let src = add_source(
&config,
ProviderSlug::Github,
None,
None,
github_filter(),
1800,
SourceTarget::AgentTodoProactive,
25,
)
.unwrap();
let patched = update_source(
&config,
&src.id,
TaskSourcePatch {
enabled: Some(false),
interval_secs: Some(600),
target: Some(SourceTarget::TodoOnly),
..Default::default()
},
)
.unwrap();
assert!(!patched.enabled);
assert_eq!(patched.interval_secs, 600);
assert_eq!(patched.target, SourceTarget::TodoOnly);
// Untouched fields preserved.
assert_eq!(patched.filter, src.filter);
}
#[test]
fn update_rejects_cross_provider_filter() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let src = add_source(
&config,
ProviderSlug::Github,
None,
None,
github_filter(),
1800,
SourceTarget::TodoOnly,
25,
)
.unwrap();
let err = update_source(
&config,
&src.id,
TaskSourcePatch {
filter: Some(FilterSpec::Notion {
database_id: None,
assigned_to_me: true,
status: None,
extra: json!({}),
}),
..Default::default()
},
)
.unwrap_err();
assert!(err.to_string().contains("does not match"));
}
#[test]
fn remove_deletes_and_cascades_ingested() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let src = add_source(
&config,
ProviderSlug::Github,
None,
None,
github_filter(),
1800,
SourceTarget::TodoOnly,
25,
)
.unwrap();
mark_ingested(
&config,
&src.id,
&sample_task("1", "A", "2025-01-01"),
"task-abc",
)
.unwrap();
remove_source(&config, &src.id).unwrap();
assert!(get_source(&config, &src.id).is_err());
// Ingested rows cascade-deleted.
assert!(list_ingested(&config, &src.id, 10).unwrap().is_empty());
// Removing again errors (not found).
assert!(remove_source(&config, &src.id).is_err());
}
#[test]
fn dedup_detects_seen_and_edited_tasks() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let src = add_source(
&config,
ProviderSlug::Github,
None,
None,
github_filter(),
1800,
SourceTarget::TodoOnly,
25,
)
.unwrap();
let task = sample_task("42", "Fix bug", "2025-01-01T00:00:00Z");
let hash = content_hash(&task);
// Not ingested yet.
assert!(!is_ingested(&config, &src.id, "42", &hash).unwrap());
mark_ingested(&config, &src.id, &task, "task-v1").unwrap();
// Same content hash → already ingested.
assert!(is_ingested(&config, &src.id, "42", &hash).unwrap());
// Edited task (newer updated_at) → different hash → not ingested.
let edited = sample_task("42", "Fix bug", "2025-02-01T00:00:00Z");
let edited_hash = content_hash(&edited);
assert_ne!(hash, edited_hash);
assert!(!is_ingested(&config, &src.id, "42", &edited_hash).unwrap());
// Re-ingesting the edit upserts (still one row).
mark_ingested(&config, &src.id, &edited, "task-v2").unwrap();
let listed = list_ingested(&config, &src.id, 10).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].external_id, "42");
}
#[test]
fn list_ingested_orders_newest_first() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let src = add_source(
&config,
ProviderSlug::Github,
None,
None,
github_filter(),
1800,
SourceTarget::TodoOnly,
25,
)
.unwrap();
mark_ingested(
&config,
&src.id,
&sample_task("1", "first", "2025-01-01"),
"task-1",
)
.unwrap();
mark_ingested(
&config,
&src.id,
&sample_task("2", "second", "2025-01-02"),
"task-2",
)
.unwrap();
let listed = list_ingested(&config, &src.id, 10).unwrap();
assert_eq!(listed.len(), 2);
// Newest ingested_at first; "2" was inserted last.
assert_eq!(listed[0].external_id, "2");
}
#[test]
fn clear_all_removes_every_source() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
add_source(
&config,
ProviderSlug::Github,
None,
None,
github_filter(),
1800,
SourceTarget::TodoOnly,
25,
)
.unwrap();
let removed = clear_all(&config).unwrap();
assert_eq!(removed, 1);
assert!(list_sources(&config).unwrap().is_empty());
}
+336
View File
@@ -0,0 +1,336 @@
//! Core types for the `task_sources` domain.
//!
//! A [`TaskSource`] is a user-configured pull of work items from an
//! external tool (GitHub, Notion, Linear, ClickUp) with a per-provider
//! [`FilterSpec`]. The periodic poll fetches matching items, normalizes
//! them ([`super::NormalizedTask`]), enriches them ([`EnrichedTask`]),
//! and routes them onto the agent's todo board.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// External tool a [`TaskSource`] pulls from. The string form matches
/// the Composio toolkit slug, so it keys directly into the provider
/// registry (`get_provider`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderSlug {
Github,
Notion,
Linear,
Clickup,
}
impl ProviderSlug {
pub fn as_str(self) -> &'static str {
match self {
Self::Github => "github",
Self::Notion => "notion",
Self::Linear => "linear",
Self::Clickup => "clickup",
}
}
/// Parse a toolkit slug into a `ProviderSlug`. Case-insensitive.
pub fn parse(raw: &str) -> Result<Self, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"github" => Ok(Self::Github),
"notion" => Ok(Self::Notion),
"linear" => Ok(Self::Linear),
"clickup" => Ok(Self::Clickup),
other => Err(format!(
"unknown task source provider '{other}' (expected github|notion|linear|clickup)"
)),
}
}
}
/// Per-provider, user-configured filter. Tagged by `provider` on the
/// wire so the frontend can render typed pickers; each variant carries a
/// free-form `extra` object as an advanced escape hatch.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum FilterSpec {
Github {
#[serde(default, skip_serializing_if = "Option::is_none")]
repo: Option<String>,
#[serde(default)]
labels: Vec<String>,
#[serde(default)]
assignee_is_me: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
state: Option<String>,
#[serde(default)]
extra: Value,
},
Notion {
#[serde(default, skip_serializing_if = "Option::is_none")]
database_id: Option<String>,
#[serde(default)]
assigned_to_me: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(default)]
extra: Value,
},
Linear {
#[serde(default, skip_serializing_if = "Option::is_none")]
team_id: Option<String>,
#[serde(default)]
assignee_is_me: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
state: Option<String>,
#[serde(default)]
extra: Value,
},
Clickup {
#[serde(default, skip_serializing_if = "Option::is_none")]
team_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
list_id: Option<String>,
#[serde(default)]
assignee_is_me: bool,
#[serde(default)]
extra: Value,
},
}
impl FilterSpec {
/// The provider this filter targets — must match the owning
/// [`TaskSource::provider`].
pub fn provider(&self) -> ProviderSlug {
match self {
Self::Github { .. } => ProviderSlug::Github,
Self::Notion { .. } => ProviderSlug::Notion,
Self::Linear { .. } => ProviderSlug::Linear,
Self::Clickup { .. } => ProviderSlug::Clickup,
}
}
}
/// How enriched tasks are routed once fetched.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceTarget {
/// Append a todo card AND dispatch a triage turn so an agent may
/// start working immediately (triage still gates noise).
AgentTodoProactive,
/// Append a todo card only; never auto-start an agent turn.
TodoOnly,
}
impl Default for SourceTarget {
fn default() -> Self {
Self::AgentTodoProactive
}
}
/// Why a fetch ran — mirrors the provider `SyncReason` semantics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FetchReason {
/// First fetch right after an OAuth connection is created.
ConnectionCreated,
/// Periodic background poll.
Periodic,
/// Explicit user / RPC trigger.
Manual,
}
impl FetchReason {
pub fn as_str(self) -> &'static str {
match self {
Self::ConnectionCreated => "connection_created",
Self::Periodic => "periodic",
Self::Manual => "manual",
}
}
}
/// A persisted task source configuration.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskSource {
pub id: String,
pub provider: ProviderSlug,
/// Composio connection id; `None` resolves the connection by toolkit
/// at fetch time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub enabled: bool,
pub filter: FilterSpec,
pub interval_secs: u64,
pub target: SourceTarget,
pub max_tasks_per_fetch: u32,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_fetch_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_status: Option<String>,
}
/// Partial update payload for [`super::store::update_source`]. Each
/// `Some` field is applied; `None` leaves the existing value untouched.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskSourcePatch {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub filter: Option<FilterSpec>,
#[serde(default)]
pub interval_secs: Option<u64>,
#[serde(default)]
pub target: Option<SourceTarget>,
#[serde(default)]
pub max_tasks_per_fetch: Option<u32>,
#[serde(default)]
pub connection_id: Option<String>,
}
/// An enriched, agent-ready task produced by [`super::enrich`].
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnrichedTask {
pub task: super::NormalizedTask,
/// One- to two-line LLM summary (falls back to the title).
pub summary: String,
/// Urgency score in `0.0..=1.0`.
pub urgency: f32,
#[serde(default)]
pub linked_people: Vec<String>,
#[serde(default)]
pub linked_memory_ids: Vec<String>,
/// Actionable prompt handed to the agent turn.
pub agent_prompt: String,
pub enriched_at: DateTime<Utc>,
}
/// Result of a single fetch pass over one source.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FetchOutcome {
pub source_id: String,
pub provider: String,
/// Tasks returned by the provider.
pub fetched: usize,
/// Tasks newly routed (enriched + carded) this pass.
pub routed: usize,
/// Tasks skipped because they were already ingested.
pub skipped_dupe: usize,
/// Optional human-readable status line.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn provider_slug_roundtrips() {
for p in [
ProviderSlug::Github,
ProviderSlug::Notion,
ProviderSlug::Linear,
ProviderSlug::Clickup,
] {
assert_eq!(ProviderSlug::parse(p.as_str()).unwrap(), p);
}
assert_eq!(ProviderSlug::parse("GitHub").unwrap(), ProviderSlug::Github);
assert!(ProviderSlug::parse("jira").is_err());
}
#[test]
fn provider_slug_serde_is_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderSlug::Clickup).unwrap(),
"\"clickup\""
);
}
#[test]
fn filter_spec_tagged_by_provider() {
let f = FilterSpec::Github {
repo: Some("owner/name".into()),
labels: vec!["bug".into()],
assignee_is_me: true,
state: Some("open".into()),
extra: json!({}),
};
let s = serde_json::to_value(&f).unwrap();
assert_eq!(s["provider"], "github");
assert_eq!(s["repo"], "owner/name");
assert_eq!(f.provider(), ProviderSlug::Github);
let back: FilterSpec = serde_json::from_value(s).unwrap();
assert_eq!(back, f);
}
#[test]
fn notion_filter_roundtrips_with_board() {
let f = FilterSpec::Notion {
database_id: Some("db-1".into()),
assigned_to_me: true,
status: Some("In Progress".into()),
extra: json!({"page_size": 10}),
};
let back: FilterSpec = serde_json::from_value(serde_json::to_value(&f).unwrap()).unwrap();
assert_eq!(back, f);
assert_eq!(back.provider(), ProviderSlug::Notion);
}
#[test]
fn source_target_defaults_to_proactive() {
assert_eq!(SourceTarget::default(), SourceTarget::AgentTodoProactive);
}
#[test]
fn fetch_reason_as_str() {
assert_eq!(FetchReason::Periodic.as_str(), "periodic");
assert_eq!(
FetchReason::ConnectionCreated.as_str(),
"connection_created"
);
assert_eq!(FetchReason::Manual.as_str(), "manual");
}
#[test]
fn task_source_serializes_camel_case() {
let src = TaskSource {
id: "s1".into(),
provider: ProviderSlug::Linear,
connection_id: None,
name: Some("My Linear".into()),
enabled: true,
filter: FilterSpec::Linear {
team_id: Some("team-1".into()),
assignee_is_me: true,
state: None,
extra: json!({}),
},
interval_secs: 1800,
target: SourceTarget::TodoOnly,
max_tasks_per_fetch: 25,
created_at: DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
last_fetch_at: None,
last_status: None,
};
let v = serde_json::to_value(&src).unwrap();
assert_eq!(v["maxTasksPerFetch"], 25);
assert_eq!(v["intervalSecs"], 1800);
assert_eq!(v["target"], "todo_only");
// connection_id / last_fetch_at omitted when None.
assert!(v.get("connectionId").is_none());
let back: TaskSource = serde_json::from_value(v).unwrap();
assert_eq!(back, src);
}
}
+357
View File
@@ -8068,3 +8068,360 @@ async fn port_conflict_recovery_core_starts_on_fallback_port_e2e() {
// Release the listener so the port is not held across tests.
drop(after_drop.listener);
}
/// Task-sources CRUD + status + dry-run over JSON-RPC.
///
/// Exercises `openhuman.task_sources_{add,list,get,update,remove,status,
/// list_tasks,preview_filter}` against an isolated HOME workspace. The
/// fetch/preview paths require no network here: with no signed-in
/// Composio session, `preview_filter` returns a clean JSON-RPC error
/// rather than hanging.
#[tokio::test]
async fn json_rpc_task_sources_crud_and_status() {
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");
write_min_config(&openhuman_home, "http://127.0.0.1:1");
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
// ── add ──────────────────────────────────────────────────────────
let add = post_json_rpc(
&rpc_base,
7301,
"openhuman.task_sources_add",
json!({
"provider": "github",
"name": "My issues",
"filter": {
"provider": "github",
"repo": "tinyhumansai/openhuman",
"labels": ["bug"],
"assignee_is_me": true
}
}),
)
.await;
let source = assert_no_jsonrpc_error(&add, "task_sources_add");
let id = source
.get("id")
.and_then(Value::as_str)
.expect("add returns source id")
.to_string();
assert_eq!(
source.get("provider").and_then(Value::as_str),
Some("github")
);
assert_eq!(source.get("enabled"), Some(&json!(true)));
// Default target follows config.auto_proactive (true → proactive).
assert_eq!(
source.get("target").and_then(Value::as_str),
Some("agent_todo_proactive")
);
// ── add with mismatched provider/filter is rejected ──────────────
let bad = post_json_rpc(
&rpc_base,
7302,
"openhuman.task_sources_add",
json!({
"provider": "notion",
"filter": { "provider": "github", "assignee_is_me": true }
}),
)
.await;
assert_jsonrpc_error(&bad, "task_sources_add mismatch");
// ── list contains the new source ─────────────────────────────────
let list = post_json_rpc(&rpc_base, 7303, "openhuman.task_sources_list", json!({})).await;
let sources = assert_no_jsonrpc_error(&list, "task_sources_list")
.as_array()
.expect("list returns array")
.clone();
assert!(sources
.iter()
.any(|s| s.get("id").and_then(Value::as_str) == Some(id.as_str())));
// ── get roundtrips ───────────────────────────────────────────────
let get = post_json_rpc(
&rpc_base,
7304,
"openhuman.task_sources_get",
json!({ "id": id }),
)
.await;
let got = assert_no_jsonrpc_error(&get, "task_sources_get");
assert_eq!(got.get("id").and_then(Value::as_str), Some(id.as_str()));
// ── update (disable + change interval) ───────────────────────────
let update = post_json_rpc(
&rpc_base,
7305,
"openhuman.task_sources_update",
json!({ "id": id, "patch": { "enabled": false, "intervalSecs": 600 } }),
)
.await;
let updated = assert_no_jsonrpc_error(&update, "task_sources_update");
assert_eq!(updated.get("enabled"), Some(&json!(false)));
assert_eq!(updated.get("intervalSecs"), Some(&json!(600)));
// ── status reflects the configured source ────────────────────────
let status = post_json_rpc(&rpc_base, 7306, "openhuman.task_sources_status", json!({})).await;
let status_result = assert_no_jsonrpc_error(&status, "task_sources_status");
assert_eq!(status_result.get("enabled"), Some(&json!(true)));
assert!(
status_result
.get("sourceCount")
.and_then(Value::as_u64)
.unwrap_or(0)
>= 1
);
// ── list_tasks is empty (nothing ingested yet) ───────────────────
let tasks = post_json_rpc(
&rpc_base,
7307,
"openhuman.task_sources_list_tasks",
json!({ "id": id }),
)
.await;
let tasks_result = assert_no_jsonrpc_error(&tasks, "task_sources_list_tasks");
assert_eq!(tasks_result.as_array().map(|a| a.len()), Some(0));
// (preview_filter is covered end to end by
// json_rpc_task_sources_fetch_pipeline_e2e with a stub provider; we
// do not assert on it here because the provider registry is global
// and shared across tests in this binary.)
// ── remove, then get is not found ────────────────────────────────
let remove = post_json_rpc(
&rpc_base,
7309,
"openhuman.task_sources_remove",
json!({ "id": id }),
)
.await;
let removed = assert_no_jsonrpc_error(&remove, "task_sources_remove");
assert_eq!(removed.get("removed"), Some(&json!(true)));
let get_after = post_json_rpc(
&rpc_base,
7310,
"openhuman.task_sources_get",
json!({ "id": id }),
)
.await;
assert_jsonrpc_error(&get_after, "task_sources_get after remove");
rpc_join.abort();
}
/// Stub Composio provider used by the task-sources fetch E2E. Returns a
/// canned set of tasks from `fetch_tasks` so the full
/// fetch → enrich → route → ingest pipeline can be exercised over RPC
/// without a live Composio connection.
mod task_sources_stub {
use async_trait::async_trait;
use openhuman_core::openhuman::memory_sync::composio::providers::{
ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome,
SyncReason, TaskFetchFilter,
};
pub struct StubGithubProvider {
pub tasks: Vec<NormalizedTask>,
}
pub fn task(external_id: &str, title: &str, updated: &str) -> NormalizedTask {
NormalizedTask {
external_id: external_id.to_string(),
provider: "github".to_string(),
title: title.to_string(),
url: Some(format!("https://example.com/{external_id}")),
updated_at: Some(updated.to_string()),
..Default::default()
}
}
#[async_trait]
impl ComposioProvider for StubGithubProvider {
fn toolkit_slug(&self) -> &'static str {
"github"
}
async fn fetch_user_profile(
&self,
_ctx: &ProviderContext,
) -> Result<ProviderUserProfile, String> {
Ok(ProviderUserProfile::default())
}
async fn sync(
&self,
_ctx: &ProviderContext,
_reason: SyncReason,
) -> Result<SyncOutcome, String> {
Ok(SyncOutcome::default())
}
async fn fetch_tasks(
&self,
_ctx: &ProviderContext,
_filter: &TaskFetchFilter,
) -> Result<Vec<NormalizedTask>, String> {
Ok(self.tasks.clone())
}
}
}
/// Full task-sources fetch pipeline over JSON-RPC: a stub provider feeds
/// `fetch_tasks`, then `task_sources_fetch` routes the tasks onto the
/// board, `list_tasks` surfaces them, a re-fetch dedups, and
/// `preview_filter` returns matches without ingesting.
#[tokio::test]
async fn json_rpc_task_sources_fetch_pipeline_e2e() {
use std::sync::Arc;
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");
write_min_config(&openhuman_home, "http://127.0.0.1:1");
// Register the stub github provider BEFORE serving so the fetch RPC
// resolves it from the global registry.
openhuman_core::openhuman::memory_sync::composio::providers::register_provider(Arc::new(
task_sources_stub::StubGithubProvider {
tasks: vec![
task_sources_stub::task("101", "Fix flaky test", "2025-01-01T00:00:00Z"),
task_sources_stub::task("102", "Update docs", "2025-01-02T00:00:00Z"),
],
},
));
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
// Add a TODO-only source (no triage LLM turn) so the pipeline runs
// deterministically end to end.
let add = post_json_rpc(
&rpc_base,
7401,
"openhuman.task_sources_add",
json!({
"provider": "github",
"name": "Pipeline source",
"target": "todo_only",
"filter": { "provider": "github", "assignee_is_me": true }
}),
)
.await;
let source = assert_no_jsonrpc_error(&add, "task_sources_add pipeline");
let id = source
.get("id")
.and_then(Value::as_str)
.expect("source id")
.to_string();
// First fetch: both stub tasks routed.
let fetch1 = post_json_rpc(
&rpc_base,
7402,
"openhuman.task_sources_fetch",
json!({ "id": id }),
)
.await;
let outcome1 = assert_no_jsonrpc_error(&fetch1, "task_sources_fetch first");
assert_eq!(
outcome1.get("error"),
None,
"fetch should not error: {outcome1}"
);
assert_eq!(outcome1.get("fetched").and_then(Value::as_u64), Some(2));
assert_eq!(outcome1.get("routed").and_then(Value::as_u64), Some(2));
assert_eq!(outcome1.get("skippedDupe").and_then(Value::as_u64), Some(0));
// list_tasks surfaces the two ingested tasks.
let tasks = post_json_rpc(
&rpc_base,
7403,
"openhuman.task_sources_list_tasks",
json!({ "id": id }),
)
.await;
let tasks_arr = assert_no_jsonrpc_error(&tasks, "task_sources_list_tasks pipeline")
.as_array()
.expect("tasks array")
.clone();
assert_eq!(tasks_arr.len(), 2);
let ids: Vec<&str> = tasks_arr
.iter()
.filter_map(|t| t.get("externalId").and_then(Value::as_str))
.collect();
assert!(ids.contains(&"101"));
assert!(ids.contains(&"102"));
// Second fetch: identical tasks → all deduped, none re-routed.
let fetch2 = post_json_rpc(
&rpc_base,
7404,
"openhuman.task_sources_fetch",
json!({ "id": id }),
)
.await;
let outcome2 = assert_no_jsonrpc_error(&fetch2, "task_sources_fetch second");
assert_eq!(outcome2.get("fetched").and_then(Value::as_u64), Some(2));
assert_eq!(outcome2.get("routed").and_then(Value::as_u64), Some(0));
assert_eq!(outcome2.get("skippedDupe").and_then(Value::as_u64), Some(2));
// preview_filter returns matches WITHOUT ingesting (count unchanged).
let preview = post_json_rpc(
&rpc_base,
7405,
"openhuman.task_sources_preview_filter",
json!({
"provider": "github",
"filter": { "provider": "github", "assignee_is_me": true }
}),
)
.await;
let preview_arr = assert_no_jsonrpc_error(&preview, "task_sources_preview_filter pipeline")
.as_array()
.expect("preview array")
.clone();
assert_eq!(preview_arr.len(), 2);
let tasks_after = post_json_rpc(
&rpc_base,
7406,
"openhuman.task_sources_list_tasks",
json!({ "id": id }),
)
.await;
let tasks_after_arr = assert_no_jsonrpc_error(&tasks_after, "list_tasks after preview")
.as_array()
.expect("tasks array")
.clone();
assert_eq!(
tasks_after_arr.len(),
2,
"preview_filter must not ingest tasks"
);
// Restore the global provider registry so the stub "github" provider
// does not leak into other tests in this binary (re-registers the
// real built-in providers).
openhuman_core::openhuman::memory_sync::composio::providers::init_default_providers();
rpc_join.abort();
}