diff --git a/app/src/components/settings/panels/TaskSourcesPanel.test.tsx b/app/src/components/settings/panels/TaskSourcesPanel.test.tsx
new file mode 100644
index 000000000..84b89244e
--- /dev/null
+++ b/app/src/components/settings/panels/TaskSourcesPanel.test.tsx
@@ -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 }) =>
{title}
,
+}));
+
+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 = {}) {
+ 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(
+
+
+
+ );
+}
+
+describe('', () => {
+ 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();
+ });
+});
diff --git a/app/src/components/settings/panels/TaskSourcesPanel.tsx b/app/src/components/settings/panels/TaskSourcesPanel.tsx
new file mode 100644
index 000000000..7885ccf0c
--- /dev/null
+++ b/app/src/components/settings/panels/TaskSourcesPanel.tsx
@@ -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(null);
+ const [sources, setSources] = useState([]);
+ const [status, setStatus] = useState(null);
+ const [busyKey, setBusyKey] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ // ── create-form state ────────────────────────────────────────────
+ const [provider, setProvider] = useState('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 (
+
+
+
+
+
+
+ {t('settings.taskSources.description')}
+
+
+ {t('settings.taskSources.connectHint')}
+
+
+
+ {status && !status.enabled && (
+
+ {t('settings.taskSources.disabledBanner')}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+
+ {/* ── Add a source ─────────────────────────────────────────── */}
+
+
+ {/* ── Configured sources ───────────────────────────────────── */}
+
+
+ {t('settings.taskSources.configured')}
+
+
+ {loading ? (
+ {t('common.loading')}
+ ) : sources.length === 0 ? (
+
+ {t('settings.taskSources.empty')}
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default TaskSourcesPanel;
diff --git a/app/src/lib/i18n/chunks/ar-5.ts b/app/src/lib/i18n/chunks/ar-5.ts
index 3a0eb7393..2d19596c6 100644
--- a/app/src/lib/i18n/chunks/ar-5.ts
+++ b/app/src/lib/i18n/chunks/ar-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/bn-5.ts b/app/src/lib/i18n/chunks/bn-5.ts
index 1c00d8839..e8274af96 100644
--- a/app/src/lib/i18n/chunks/bn-5.ts
+++ b/app/src/lib/i18n/chunks/bn-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts
index ce9344fef..abdc21d62 100644
--- a/app/src/lib/i18n/chunks/de-5.ts
+++ b/app/src/lib/i18n/chunks/de-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts
index 02c46d112..15268b370 100644
--- a/app/src/lib/i18n/chunks/en-5.ts
+++ b/app/src/lib/i18n/chunks/en-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/es-5.ts b/app/src/lib/i18n/chunks/es-5.ts
index 751c6dcb0..d02753f15 100644
--- a/app/src/lib/i18n/chunks/es-5.ts
+++ b/app/src/lib/i18n/chunks/es-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/fr-5.ts b/app/src/lib/i18n/chunks/fr-5.ts
index 1c22596ba..b67b21a52 100644
--- a/app/src/lib/i18n/chunks/fr-5.ts
+++ b/app/src/lib/i18n/chunks/fr-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/hi-5.ts b/app/src/lib/i18n/chunks/hi-5.ts
index 5a5afdcea..bebb43d2d 100644
--- a/app/src/lib/i18n/chunks/hi-5.ts
+++ b/app/src/lib/i18n/chunks/hi-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/id-5.ts b/app/src/lib/i18n/chunks/id-5.ts
index e592ea4d9..645f38e34 100644
--- a/app/src/lib/i18n/chunks/id-5.ts
+++ b/app/src/lib/i18n/chunks/id-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/it-5.ts b/app/src/lib/i18n/chunks/it-5.ts
index d16d4faf7..771b41688 100644
--- a/app/src/lib/i18n/chunks/it-5.ts
+++ b/app/src/lib/i18n/chunks/it-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/ko-5.ts b/app/src/lib/i18n/chunks/ko-5.ts
index 599b9ec8f..50ed453fb 100644
--- a/app/src/lib/i18n/chunks/ko-5.ts
+++ b/app/src/lib/i18n/chunks/ko-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/pl-5.ts b/app/src/lib/i18n/chunks/pl-5.ts
index 6bb8696ea..7c4bbcf00 100644
--- a/app/src/lib/i18n/chunks/pl-5.ts
+++ b/app/src/lib/i18n/chunks/pl-5.ts
@@ -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':
diff --git a/app/src/lib/i18n/chunks/pt-5.ts b/app/src/lib/i18n/chunks/pt-5.ts
index 54d53e33e..8c99a157b 100644
--- a/app/src/lib/i18n/chunks/pt-5.ts
+++ b/app/src/lib/i18n/chunks/pt-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/ru-5.ts b/app/src/lib/i18n/chunks/ru-5.ts
index 75dcaf7dd..77a628228 100644
--- a/app/src/lib/i18n/chunks/ru-5.ts
+++ b/app/src/lib/i18n/chunks/ru-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/chunks/zh-CN-5.ts b/app/src/lib/i18n/chunks/zh-CN-5.ts
index e6e5c0923..d91ea3bf1 100644
--- a/app/src/lib/i18n/chunks/zh-CN-5.ts
+++ b/app/src/lib/i18n/chunks/zh-CN-5.ts
@@ -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',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index ae942e309..699c587a7 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -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
diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx
index 696992c41..23338ce9f 100644
--- a/app/src/pages/Settings.tsx
+++ b/app/src/pages/Settings.tsx
@@ -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 = () => {
)} />
)} />
)} />
+ )} />
)} />
)} />
({ 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();
+ });
+});
diff --git a/app/src/utils/tauriCommands/taskSources.ts b/app/src/utils/tauriCommands/taskSources.ts
new file mode 100644
index 000000000..8e7d13515
--- /dev/null
+++ b/app/src/utils/tauriCommands/taskSources.ts
@@ -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` 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;
+ }
+ | {
+ provider: 'notion';
+ database_id?: string;
+ assigned_to_me?: boolean;
+ status?: string;
+ extra?: Record;
+ }
+ | {
+ provider: 'linear';
+ team_id?: string;
+ assignee_is_me?: boolean;
+ state?: string;
+ extra?: Record;
+ }
+ | {
+ provider: 'clickup';
+ team_id?: string;
+ list_id?: string;
+ assignee_is_me?: boolean;
+ extra?: Record;
+ };
+
+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 {
+ ensureTauri();
+ return await callCoreRpc({ method: 'openhuman.task_sources_list' });
+}
+
+export async function openhumanTaskSourcesGet(id: string): Promise {
+ ensureTauri();
+ return await callCoreRpc({ method: 'openhuman.task_sources_get', params: { id } });
+}
+
+export async function openhumanTaskSourcesAdd(params: TaskSourceAddParams): Promise {
+ ensureTauri();
+ return await callCoreRpc({
+ method: 'openhuman.task_sources_add',
+ params: params as unknown as Record,
+ });
+}
+
+export async function openhumanTaskSourcesUpdate(
+ id: string,
+ patch: TaskSourcePatch
+): Promise {
+ ensureTauri();
+ return await callCoreRpc({
+ 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 {
+ ensureTauri();
+ return await callCoreRpc({
+ method: 'openhuman.task_sources_fetch',
+ params: { id },
+ });
+}
+
+export async function openhumanTaskSourcesListTasks(
+ id: string,
+ limit = 50
+): Promise {
+ ensureTauri();
+ return await callCoreRpc({
+ method: 'openhuman.task_sources_list_tasks',
+ params: { id, limit },
+ });
+}
+
+export async function openhumanTaskSourcesPreviewFilter(
+ provider: TaskSourceProvider,
+ filter: TaskSourceFilter,
+ connectionId?: string,
+ max?: number
+): Promise {
+ ensureTauri();
+ return await callCoreRpc({
+ method: 'openhuman.task_sources_preview_filter',
+ params: { provider, filter, connection_id: connectionId, max },
+ });
+}
+
+export async function openhumanTaskSourcesStatus(): Promise {
+ ensureTauri();
+ return await callCoreRpc({ method: 'openhuman.task_sources_status' });
+}
diff --git a/src/core/all.rs b/src/core/all.rs
index f15a6f0d1..b85dafd16 100644
--- a/src/core/all.rs
+++ b/src/core/all.rs
@@ -114,6 +114,8 @@ fn build_registered_controllers() -> Vec {
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 {
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());
diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs
index a11a56771..fd7905917 100644
--- a/src/core/event_bus/events.rs
+++ b/src/core/event_bus/events.rs
@@ -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",
}
}
diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs
index 3acc7836c..a1a04aedb 100644
--- a/src/core/jsonrpc.rs
+++ b/src/core/jsonrpc.rs
@@ -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.
diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs
index a840ec57a..ff8056039 100644
--- a/src/openhuman/about_app/catalog.rs
+++ b/src/openhuman/about_app/catalog.rs
@@ -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",
diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs
index 2a3108663..5970b921e 100644
--- a/src/openhuman/channels/runtime/startup.rs
+++ b/src/openhuman/channels/runtime/startup.rs
@@ -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
diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs
index 23b877b76..e0f26240c 100644
--- a/src/openhuman/config/schema/mod.rs
+++ b/src/openhuman/config/schema/mod.rs
@@ -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,
diff --git a/src/openhuman/config/schema/task_sources.rs b/src/openhuman/config/schema/task_sources.rs
new file mode 100644
index 000000000..5f62ef273
--- /dev/null
+++ b/src/openhuman/config/schema/task_sources.rs
@@ -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);
+ }
+}
diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs
index 9c2cc51d7..7d84751db 100644
--- a/src/openhuman/config/schema/types.rs
+++ b/src/openhuman/config/schema/types.rs
@@ -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(),
diff --git a/src/openhuman/memory_sync/composio/providers/clickup/provider.rs b/src/openhuman/memory_sync/composio/providers/clickup/provider.rs
index bb23dd6ab..b237ec5e8 100644
--- a/src/openhuman/memory_sync/composio/providers/clickup/provider.rs
+++ b/src/openhuman/memory_sync/composio/providers/clickup/provider.rs
@@ -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, 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 = 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 = 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 {
+ 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 {
diff --git a/src/openhuman/memory_sync/composio/providers/github/provider.rs b/src/openhuman/memory_sync/composio/providers/github/provider.rs
index 1d2acf53f..6190951d8 100644
--- a/src/openhuman/memory_sync/composio/providers/github/provider.rs
+++ b/src/openhuman/memory_sync/composio/providers/github/provider.rs
@@ -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, 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 = 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 = 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 {
+ 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 {
+ 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 {
diff --git a/src/openhuman/memory_sync/composio/providers/helpers.rs b/src/openhuman/memory_sync/composio/providers/helpers.rs
index a4b096019..8d2432802 100644
--- a/src/openhuman/memory_sync/composio/providers/helpers.rs
+++ b/src/openhuman/memory_sync/composio/providers/helpers.rs
@@ -31,3 +31,51 @@ pub(crate) fn pick_str(value: &serde_json::Value, paths: &[&str]) -> Option Option {
+ 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
+}
diff --git a/src/openhuman/memory_sync/composio/providers/linear/provider.rs b/src/openhuman/memory_sync/composio/providers/linear/provider.rs
index 680d9a360..af9385a16 100644
--- a/src/openhuman/memory_sync/composio/providers/linear/provider.rs
+++ b/src/openhuman/memory_sync/composio/providers/linear/provider.rs
@@ -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, 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 = 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 {
+ 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 {
+ 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 {
diff --git a/src/openhuman/memory_sync/composio/providers/mod.rs b/src/openhuman/memory_sync/composio/providers/mod.rs
index a9746d60d..4cd6eb204 100644
--- a/src/openhuman/memory_sync/composio/providers/mod.rs
+++ b/src/openhuman/memory_sync/composio/providers/mod.rs
@@ -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)]
diff --git a/src/openhuman/memory_sync/composio/providers/notion/provider.rs b/src/openhuman/memory_sync/composio/providers/notion/provider.rs
index 8ad83a6d6..b5e397c72 100644
--- a/src/openhuman/memory_sync/composio/providers/notion/provider.rs
+++ b/src/openhuman/memory_sync/composio/providers/notion/provider.rs
@@ -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, 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 = 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 {
+ 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(),
+ })
+}
diff --git a/src/openhuman/memory_sync/composio/providers/traits.rs b/src/openhuman/memory_sync/composio/providers/traits.rs
index 8cb222665..95f572fe7 100644
--- a/src/openhuman/memory_sync/composio/providers/traits.rs
+++ b/src/openhuman/memory_sync/composio/providers/traits.rs
@@ -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;
+ /// 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, 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
diff --git a/src/openhuman/memory_sync/composio/providers/types.rs b/src/openhuman/memory_sync/composio/providers/types.rs
index a72d50740..503d04566 100644
--- a/src/openhuman/memory_sync/composio/providers/types.rs
+++ b/src/openhuman/memory_sync/composio/providers/types.rs
@@ -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,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub url: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub status: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub assignee: Option,
+ /// Due date as an ISO-8601 string, when the provider exposes one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub due: Option,
+ #[serde(default)]
+ pub labels: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub priority: Option,
+ /// 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,
+ /// 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,
+ /// GitHub label filter.
+ #[serde(default)]
+ pub labels: Vec,
+ /// Issue/task state filter (e.g. `"open"`, `"todo"`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub state: Option,
+ /// Notion database (board) id.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub database_id: Option,
+ /// Notion status property filter.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub status: Option,
+ /// Linear / ClickUp team (workspace) id.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub team_id: Option,
+ /// ClickUp list id.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub list_id: Option,
+ /// 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
diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs
index 0aaf627ee..2854ff8c6 100644
--- a/src/openhuman/mod.rs
+++ b/src/openhuman/mod.rs
@@ -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;
diff --git a/src/openhuman/task_sources/bus.rs b/src/openhuman/task_sources/bus.rs
new file mode 100644
index 000000000..51ddd524d
--- /dev/null
+++ b/src/openhuman/task_sources/bus.rs
@@ -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 = 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"
+ );
+ }
+ }
+}
diff --git a/src/openhuman/task_sources/enrich.rs b/src/openhuman/task_sources/enrich.rs
new file mode 100644
index 000000000..310347829
--- /dev/null
+++ b/src/openhuman/task_sources/enrich.rs
@@ -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::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"));
+ }
+}
diff --git a/src/openhuman/task_sources/filter.rs b/src/openhuman/task_sources/filter.rs
new file mode 100644
index 000000000..81e8bdaca
--- /dev/null
+++ b/src/openhuman/task_sources/filter.rs
@@ -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);
+ }
+}
diff --git a/src/openhuman/task_sources/mod.rs b/src/openhuman/task_sources/mod.rs
new file mode 100644
index 000000000..dcdb95847
--- /dev/null
+++ b/src/openhuman/task_sources/mod.rs
@@ -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,
+};
diff --git a/src/openhuman/task_sources/ops.rs b/src/openhuman/task_sources/ops.rs
new file mode 100644
index 000000000..3207adea8
--- /dev/null
+++ b/src/openhuman/task_sources/ops.rs
@@ -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>, 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, 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,
+ name: Option,
+ filter: FilterSpec,
+ interval_secs: Option,
+ target: Option,
+ max_tasks_per_fetch: Option,
+) -> Result, 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, 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, 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, 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,
+) -> Result>, 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,
+ max: Option,
+) -> Result>, 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, 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![],
+ ))
+}
diff --git a/src/openhuman/task_sources/periodic.rs b/src/openhuman/task_sources/periodic.rs
new file mode 100644
index 000000000..c8cda8a99
--- /dev/null
+++ b/src/openhuman/task_sources/periodic.rs
@@ -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>;
+static LAST_POLL_AT: OnceLock = 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));
+ }
+}
diff --git a/src/openhuman/task_sources/pipeline.rs b/src/openhuman/task_sources/pipeline.rs
new file mode 100644
index 000000000..236e3d955
--- /dev/null
+++ b/src/openhuman/task_sources/pipeline.rs
@@ -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;
diff --git a/src/openhuman/task_sources/pipeline_tests.rs b/src/openhuman/task_sources/pipeline_tests.rs
new file mode 100644
index 000000000..451d4e969
--- /dev/null
+++ b/src/openhuman/task_sources/pipeline_tests.rs
@@ -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> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|p| p.into_inner())
+}
+
+struct StubProvider {
+ tasks: Vec,
+}
+
+#[async_trait]
+impl ComposioProvider for StubProvider {
+ fn toolkit_slug(&self) -> &'static str {
+ "github"
+ }
+ async fn fetch_user_profile(
+ &self,
+ _ctx: &ProviderContext,
+ ) -> Result {
+ Ok(ProviderUserProfile::default())
+ }
+ async fn sync(
+ &self,
+ _ctx: &ProviderContext,
+ _reason: SyncReason,
+ ) -> Result {
+ Ok(SyncOutcome::default())
+ }
+ async fn fetch_tasks(
+ &self,
+ _ctx: &ProviderContext,
+ _filter: &TaskFetchFilter,
+ ) -> Result, 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);
+}
diff --git a/src/openhuman/task_sources/route.rs b/src/openhuman/task_sources/route.rs
new file mode 100644
index 000000000..115def366
--- /dev/null
+++ b/src/openhuman/task_sources/route.rs
@@ -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 {
+ 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 {
+ 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 = 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::() + 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, 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(""), "");
+ }
+}
diff --git a/src/openhuman/task_sources/schemas.rs b/src/openhuman/task_sources/schemas.rs
new file mode 100644
index 000000000..e09a11e2d
--- /dev/null
+++ b/src/openhuman/task_sources/schemas.rs
@@ -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 {
+ 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 {
+ 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) -> ControllerFuture {
+ Box::pin(async {
+ let config = config_rpc::load_config_with_timeout().await?;
+ to_json(ops::list(&config).await?)
+ })
+}
+
+fn handle_get(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let id = read_required::(¶ms, "id")?;
+ to_json(ops::get(&config, id.trim()).await?)
+ })
+}
+
+fn handle_add(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let provider = read_provider(¶ms)?;
+ let filter = read_required::(¶ms, "filter")?;
+ let name = read_optional::(¶ms, "name")?;
+ let connection_id = read_optional::(¶ms, "connection_id")?;
+ let interval_secs = read_optional::(¶ms, "interval_secs")?;
+ let target = read_optional::(¶ms, "target")?;
+ let max = read_optional_u32(¶ms, "max_tasks_per_fetch")?;
+ to_json(
+ ops::add(
+ &config,
+ provider,
+ connection_id,
+ name,
+ filter,
+ interval_secs,
+ target,
+ max,
+ )
+ .await?,
+ )
+ })
+}
+
+fn handle_update(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let id = read_required::(¶ms, "id")?;
+ let patch = read_required::(¶ms, "patch")?;
+ to_json(ops::update(&config, id.trim(), patch).await?)
+ })
+}
+
+fn handle_remove(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let id = read_required::(¶ms, "id")?;
+ to_json(ops::remove(&config, id.trim()).await?)
+ })
+}
+
+fn handle_fetch(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let id = read_required::(¶ms, "id")?;
+ to_json(ops::fetch(&config, id.trim()).await?)
+ })
+}
+
+fn handle_list_tasks(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let id = read_required::(¶ms, "id")?;
+ let limit = match read_optional::(¶ms, "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) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let provider = read_provider(¶ms)?;
+ let filter = read_required::(¶ms, "filter")?;
+ let connection_id = read_optional::(¶ms, "connection_id")?;
+ let max = read_optional_u32(¶ms, "max")?;
+ to_json(ops::preview_filter(&config, provider, filter, connection_id, max).await?)
+ })
+}
+
+fn handle_status(_params: Map) -> ControllerFuture {
+ Box::pin(async {
+ let config = config_rpc::load_config_with_timeout().await?;
+ to_json(ops::status(&config).await?)
+ })
+}
+
+fn read_provider(params: &Map) -> Result {
+ let raw = read_required::(params, "provider")?;
+ ProviderSlug::parse(&raw)
+}
+
+fn read_required(params: &Map, key: &str) -> Result {
+ 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(
+ params: &Map,
+ key: &str,
+) -> Result