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 ─────────────────────────────────────────── */} +
+

+ {t('settings.taskSources.addTitle')} +

+ + + + + + + + {provider === 'github' && ( + + )} + + + +
+ + +
+
+ + {/* ── Configured sources ───────────────────────────────────── */} +
+

+ {t('settings.taskSources.configured')} +

+ + {loading ? ( +

{t('common.loading')}

+ ) : sources.length === 0 ? ( +

+ {t('settings.taskSources.empty')} +

+ ) : ( +
    + {sources.map(source => ( +
  • +
    +
    +

    + {source.name || providerLabel(source.provider, t)} +

    +

    + {providerLabel(source.provider, t)} + {source.target === 'agent_todo_proactive' + ? ` · ${t('settings.taskSources.proactive')}` + : ''} +

    +

    + {t('settings.taskSources.lastFetch')}:{' '} + {source.lastFetchAt + ? new Date(source.lastFetchAt).toLocaleString() + : t('settings.taskSources.never')} +

    +
    + + {source.enabled + ? t('settings.taskSources.statusEnabled') + : t('settings.taskSources.statusDisabled')} + +
    + +
    + + + +
    +
  • + ))} +
+ )} + + +
+
+
+ ); +}; + +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, String> { + match params.get(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => serde_json::from_value(value.clone()) + .map(Some) + .map_err(|e| format!("invalid '{key}': {e}")), + } +} + +/// Read an optional unsigned integer parameter and checked-convert it to +/// `u32`. JSON integers arrive as `u64` on the wire; we accept any value +/// that fits in `u32` and reject out-of-range values with a clear error +/// rather than silently truncating. +fn read_optional_u32(params: &Map, key: &str) -> Result, String> { + match read_optional::(params, key)? { + Some(n) => { + Ok(Some(u32::try_from(n).map_err(|_| { + format!("invalid '{key}': {n} exceeds u32 range") + })?)) + } + None => Ok(None), + } +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn all_controller_schemas_covers_every_function() { + let names: Vec<_> = all_controller_schemas() + .into_iter() + .map(|s| s.function) + .collect(); + assert_eq!( + names, + vec![ + "list", + "get", + "add", + "update", + "remove", + "fetch", + "list_tasks", + "preview_filter", + "status" + ] + ); + } + + #[test] + fn all_registered_controllers_has_handler_per_schema() { + let controllers = all_registered_controllers(); + assert_eq!(controllers.len(), 9); + assert!(controllers + .iter() + .all(|c| c.schema.namespace == "task_sources")); + } + + #[test] + fn schemas_add_requires_provider_and_filter() { + let s = schemas("add"); + let names: Vec<_> = s.inputs.iter().map(|f| f.name).collect(); + assert!(names.contains(&"provider")); + assert!(names.contains(&"filter")); + let provider = s.inputs.iter().find(|f| f.name == "provider").unwrap(); + assert!(provider.required); + } + + #[test] + fn schemas_unknown_function_returns_placeholder() { + let s = schemas("nope"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.outputs[0].name, "error"); + } + + #[test] + fn read_provider_parses_known_and_rejects_unknown() { + let mut params = Map::new(); + params.insert("provider".into(), json!("notion")); + assert_eq!(read_provider(¶ms).unwrap(), ProviderSlug::Notion); + + params.insert("provider".into(), json!("jira")); + assert!(read_provider(¶ms).is_err()); + } + + #[test] + fn read_optional_handles_absent_null_and_value() { + let mut params = Map::new(); + assert_eq!(read_optional::(¶ms, "limit").unwrap(), None); + params.insert("limit".into(), Value::Null); + assert_eq!(read_optional::(¶ms, "limit").unwrap(), None); + params.insert("limit".into(), json!(7)); + assert_eq!(read_optional::(¶ms, "limit").unwrap(), Some(7)); + } +} diff --git a/src/openhuman/task_sources/store.rs b/src/openhuman/task_sources/store.rs new file mode 100644 index 000000000..de2dce7c1 --- /dev/null +++ b/src/openhuman/task_sources/store.rs @@ -0,0 +1,485 @@ +//! SQLite persistence for the `task_sources` domain. +//! +//! Two tables live in `/task_sources/sources.db`: +//! * `task_sources` — the configured sources (provider + filter + +//! schedule + routing target). +//! * `ingested_tasks` — per-(source, external task) dedup ledger with +//! an edit-aware `content_hash`, plus the normalized task payload so +//! the UI can list recently ingested items. +//! +//! Mirrors the `cron` domain's `with_connection` + migrate-on-open +//! pattern. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::openhuman::config::Config; +use crate::openhuman::memory_sync::composio::providers::NormalizedTask; + +use super::types::{ + FetchReason, FilterSpec, ProviderSlug, SourceTarget, TaskSource, TaskSourcePatch, +}; + +/// Compute an edit-aware content hash for a task. Two fetches of the +/// same `external_id` whose title/body/status/updated_at differ produce +/// different hashes, so an *edited* upstream item re-ingests. +/// +/// Uses SHA-256 over a canonical field-delimited representation so the +/// digest is stable across Rust/toolchain versions (the dedup key is +/// persisted on disk; a non-deterministic hasher would force spurious +/// re-ingests after a toolchain bump). +pub fn content_hash(task: &NormalizedTask) -> String { + let canonical = format!( + "{}\u{1f}{}\u{1f}{}\u{1f}{}", + task.title, + task.body.as_deref().unwrap_or(""), + task.status.as_deref().unwrap_or(""), + task.updated_at.as_deref().unwrap_or(""), + ); + let digest = Sha256::digest(canonical.as_bytes()); + format!("{digest:x}") +} + +/// Insert a new task source. +#[allow(clippy::too_many_arguments)] +pub fn add_source( + config: &Config, + provider: ProviderSlug, + connection_id: Option, + name: Option, + filter: FilterSpec, + interval_secs: u64, + target: SourceTarget, + max_tasks_per_fetch: u32, +) -> Result { + if filter.provider() != provider { + anyhow::bail!( + "filter provider '{}' does not match source provider '{}'", + filter.provider().as_str(), + provider.as_str() + ); + } + // Normalize blank optional fields to NULL so a whitespace-only + // connection_id can't masquerade as a real selector (mirrors + // `update_source`). + let connection_id = connection_id.filter(|s| !s.trim().is_empty()); + let name = name.filter(|s| !s.trim().is_empty()); + let id = Uuid::new_v4().to_string(); + let now = Utc::now(); + let filter_json = serde_json::to_string(&filter).context("serialize task source filter")?; + let target_json = serde_json::to_string(&target).context("serialize task source target")?; + let interval_i64 = i64::try_from(interval_secs) + .context("task source interval_secs exceeds SQLite INTEGER range")?; + + with_connection(config, |conn| { + conn.execute( + "INSERT INTO task_sources ( + id, provider, connection_id, name, enabled, filter, interval_secs, + target, max_tasks_per_fetch, created_at + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?9)", + params![ + id, + provider.as_str(), + connection_id, + name, + filter_json, + interval_i64, + target_json, + i64::from(max_tasks_per_fetch), + now.to_rfc3339(), + ], + ) + .context("Failed to insert task source")?; + Ok(()) + })?; + + get_source(config, &id) +} + +pub fn get_source(config: &Config, id: &str) -> Result { + with_connection(config, |conn| { + let mut stmt = conn.prepare(&format!("{SELECT_SOURCE_COLUMNS} WHERE id = ?1"))?; + let mut rows = stmt.query(params![id])?; + if let Some(row) = rows.next()? { + map_source_row(row).map_err(Into::into) + } else { + anyhow::bail!("Task source '{id}' not found") + } + }) +} + +pub fn list_sources(config: &Config) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare(&format!( + "{SELECT_SOURCE_COLUMNS} ORDER BY created_at ASC, id ASC" + ))?; + let rows = stmt.query_map([], map_source_row)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + }) +} + +/// Apply a partial patch to a task source. +/// +/// **Implementation note:** this function opens three separate SQLite +/// connections (read-modify-write + read-back). At settings-panel scale the +/// overhead is acceptable, but there is a theoretical TOCTOU window between +/// the initial `get_source` and the subsequent `UPDATE`. A future refactor +/// could fold all three operations into a single `with_connection` call using +/// a SQL `UPDATE … RETURNING` pattern. +pub fn update_source(config: &Config, id: &str, patch: TaskSourcePatch) -> Result { + let mut source = get_source(config, id)?; + + if let Some(name) = patch.name { + source.name = Some(name).filter(|s| !s.trim().is_empty()); + } + if let Some(enabled) = patch.enabled { + source.enabled = enabled; + } + if let Some(filter) = patch.filter { + if filter.provider() != source.provider { + anyhow::bail!( + "patch filter provider '{}' does not match source provider '{}'", + filter.provider().as_str(), + source.provider.as_str() + ); + } + source.filter = filter; + } + if let Some(interval_secs) = patch.interval_secs { + source.interval_secs = interval_secs; + } + if let Some(target) = patch.target { + source.target = target; + } + if let Some(max) = patch.max_tasks_per_fetch { + source.max_tasks_per_fetch = max; + } + if let Some(connection_id) = patch.connection_id { + source.connection_id = Some(connection_id).filter(|s| !s.trim().is_empty()); + } + + let filter_json = serde_json::to_string(&source.filter).context("serialize filter")?; + let target_json = serde_json::to_string(&source.target).context("serialize target")?; + let interval_i64 = i64::try_from(source.interval_secs) + .context("task source interval_secs exceeds SQLite INTEGER range")?; + + with_connection(config, |conn| { + conn.execute( + "UPDATE task_sources + SET provider = ?1, connection_id = ?2, name = ?3, enabled = ?4, filter = ?5, + interval_secs = ?6, target = ?7, max_tasks_per_fetch = ?8 + WHERE id = ?9", + params![ + source.provider.as_str(), + source.connection_id, + source.name, + if source.enabled { 1 } else { 0 }, + filter_json, + interval_i64, + target_json, + i64::from(source.max_tasks_per_fetch), + id, + ], + ) + .context("Failed to update task source")?; + Ok(()) + })?; + + get_source(config, id) +} + +pub fn remove_source(config: &Config, id: &str) -> Result<()> { + let changed = with_connection(config, |conn| { + conn.execute("DELETE FROM task_sources WHERE id = ?1", params![id]) + .context("Failed to delete task source") + })?; + if changed == 0 { + anyhow::bail!("Task source '{id}' not found"); + } + Ok(()) +} + +/// Update a source's `last_fetch_at` / `last_status` after a fetch pass. +pub fn record_fetch( + config: &Config, + id: &str, + finished_at: DateTime, + reason: FetchReason, + status: &str, +) -> Result<()> { + let line = format!("{}: {status}", reason.as_str()); + with_connection(config, |conn| { + conn.execute( + "UPDATE task_sources SET last_fetch_at = ?1, last_status = ?2 WHERE id = ?3", + params![finished_at.to_rfc3339(), line, id], + ) + .context("Failed to record task source fetch")?; + Ok(()) + }) +} + +/// True when this `(source, external_id)` was already ingested with the +/// *same* content hash. A differing hash (edited upstream) returns +/// `false` so the item re-ingests. +pub fn is_ingested( + config: &Config, + source_id: &str, + external_id: &str, + hash: &str, +) -> Result { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT content_hash FROM ingested_tasks WHERE source_id = ?1 AND external_id = ?2", + )?; + let mut rows = stmt.query(params![source_id, external_id])?; + match rows.next()? { + Some(row) => { + let existing: String = row.get(0)?; + Ok(existing == hash) + } + None => Ok(false), + } + }) +} + +/// Record a routed task in the dedup ledger (idempotent upsert). +/// +/// `card_id` is the board card UUID returned by `route::add_card`; it is +/// persisted so that a later edit of the same upstream task can remove the +/// stale card before creating a fresh one (preventing duplicate board cards). +pub fn mark_ingested( + config: &Config, + source_id: &str, + task: &NormalizedTask, + card_id: &str, +) -> Result<()> { + let hash = content_hash(task); + let payload = serde_json::to_string(task).context("serialize ingested task payload")?; + let now = Utc::now().to_rfc3339(); + with_connection(config, |conn| { + conn.execute( + "INSERT INTO ingested_tasks (source_id, external_id, content_hash, title, payload, ingested_at, card_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(source_id, external_id) DO UPDATE SET + content_hash = excluded.content_hash, + title = excluded.title, + payload = excluded.payload, + ingested_at = excluded.ingested_at, + card_id = excluded.card_id", + params![source_id, task.external_id, hash, task.title, payload, now, card_id], + ) + .context("Failed to mark task ingested")?; + Ok(()) + }) +} + +/// Return the board card id previously stored for `(source_id, external_id)`, +/// if any. Used by the pipeline to remove stale board cards when an upstream +/// task is edited and re-ingested. +pub fn get_card_id(config: &Config, source_id: &str, external_id: &str) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT card_id FROM ingested_tasks WHERE source_id = ?1 AND external_id = ?2", + )?; + let mut rows = stmt.query(params![source_id, external_id])?; + match rows.next()? { + Some(row) => Ok(row.get(0)?), + None => Ok(None), + } + }) +} + +/// List the most recently ingested tasks for a source (newest first). +pub fn list_ingested( + config: &Config, + source_id: &str, + limit: usize, +) -> Result> { + // Floor of 1: a caller passing `limit = 0` still gets at least one row + // rather than a confusing empty result; `unwrap_or(50)` is the fallback + // in the unlikely event that `limit` exceeds `i64::MAX`. + let lim = i64::try_from(limit.max(1)).unwrap_or(50); + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT payload FROM ingested_tasks + WHERE source_id = ?1 AND payload IS NOT NULL + ORDER BY ingested_at DESC LIMIT ?2", + )?; + let rows = stmt.query_map(params![source_id, lim], |row| row.get::<_, String>(0))?; + let mut out = Vec::new(); + for row in rows { + let payload = row?; + if let Ok(task) = serde_json::from_str::(&payload) { + out.push(task); + } + } + Ok(out) + }) +} + +/// Delete every task source (+ cascade ingested rows). Used by the E2E +/// `test_reset` RPC. +pub fn clear_all(config: &Config) -> Result { + with_connection(config, |conn| { + let removed = conn + .execute("DELETE FROM task_sources", params![]) + .context("Failed to clear task sources")?; + // ingested_tasks rows cascade via the FK. + Ok(removed) + }) +} + +const SELECT_SOURCE_COLUMNS: &str = "SELECT id, provider, connection_id, name, enabled, filter, \ + interval_secs, target, max_tasks_per_fetch, created_at, last_fetch_at, last_status \ + FROM task_sources"; + +fn map_source_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let provider_raw: String = row.get(1)?; + let provider = ProviderSlug::parse(&provider_raw).map_err(sql_conv)?; + + let filter_raw: String = row.get(5)?; + let filter: FilterSpec = serde_json::from_str(&filter_raw) + .map_err(|e| sql_conv(format!("invalid filter json: {e}")))?; + + let target_raw: String = row.get(7)?; + let target: SourceTarget = serde_json::from_str(&target_raw) + .map_err(|e| sql_conv(format!("invalid target json: {e}")))?; + + let created_at_raw: String = row.get(9)?; + let last_fetch_raw: Option = row.get(10)?; + + Ok(TaskSource { + id: row.get(0)?, + provider, + connection_id: row.get(2)?, + name: row.get(3)?, + enabled: row.get::<_, i64>(4)? != 0, + filter, + interval_secs: u64::try_from(row.get::<_, i64>(6)?) + .map_err(|_| sql_conv("invalid negative interval_secs in task_sources DB"))?, + target, + max_tasks_per_fetch: u32::try_from(row.get::<_, i64>(8)?) + .map_err(|_| sql_conv("invalid max_tasks_per_fetch in task_sources DB"))?, + created_at: parse_rfc3339(&created_at_raw).map_err(sql_conv)?, + last_fetch_at: match last_fetch_raw { + Some(raw) => Some(parse_rfc3339(&raw).map_err(sql_conv)?), + None => None, + }, + last_status: row.get(11)?, + }) +} + +fn parse_rfc3339(raw: &str) -> Result> { + let parsed = DateTime::parse_from_rfc3339(raw) + .with_context(|| format!("Invalid RFC3339 timestamp in task_sources DB: {raw}"))?; + Ok(parsed.with_timezone(&Utc)) +} + +fn sql_conv(err: E) -> rusqlite::Error { + rusqlite::Error::ToSqlConversionFailure(anyhow::anyhow!("{err}").into()) +} + +fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { + let db_path = config.workspace_dir.join("task_sources").join("sources.db"); + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create task_sources directory: {}", + parent.display() + ) + })?; + } + let conn = Connection::open(&db_path) + .with_context(|| format!("Failed to open task_sources DB: {}", db_path.display()))?; + // Overlapping periodic-poll + UI writes can otherwise hit + // "database is locked"; WAL + a busy timeout match the other stores. + conn.busy_timeout(Duration::from_secs(5)) + .context("Failed to configure task_sources DB busy timeout")?; + conn.pragma_update(None, "journal_mode", "WAL") + .context("Failed to enable WAL for task_sources DB")?; + + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS task_sources ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + connection_id TEXT, + name TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + filter TEXT NOT NULL, + interval_secs INTEGER NOT NULL, + target TEXT NOT NULL, + max_tasks_per_fetch INTEGER NOT NULL, + created_at TEXT NOT NULL, + last_fetch_at TEXT, + last_status TEXT + ); + CREATE TABLE IF NOT EXISTS ingested_tasks ( + source_id TEXT NOT NULL, + external_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + title TEXT, + payload TEXT, + ingested_at TEXT NOT NULL, + card_id TEXT, + PRIMARY KEY (source_id, external_id), + FOREIGN KEY (source_id) REFERENCES task_sources(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_ingested_source ON ingested_tasks(source_id, ingested_at);", + ) + .context("Failed to initialize task_sources schema")?; + + // Additive migration: add card_id to existing databases that pre-date + // this column. Tolerate "duplicate column" in case of a concurrent open. + add_column_if_missing(&conn, "ingested_tasks", "card_id", "TEXT")?; + + f(&conn) +} + +/// Add a column to a table only when it is absent. Mirrors the pattern used +/// in `cron/store.rs` to keep migrations idempotent across DB versions. +fn add_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + sql_type: &str, +) -> Result<()> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let col_name: String = row.get(1)?; + if col_name == column { + return Ok(()); // already present + } + } + drop(rows); + drop(stmt); + match conn.execute( + &format!("ALTER TABLE {table} ADD COLUMN {column} {sql_type}"), + [], + ) { + Ok(_) => Ok(()), + Err(rusqlite::Error::SqliteFailure(_, Some(ref msg))) + if msg.contains("duplicate column name") => + { + tracing::debug!( + "[task_sources:store] column {table}.{column} already exists (concurrent migration)" + ); + Ok(()) + } + Err(e) => Err(e).with_context(|| format!("Failed to add {table}.{column}")), + } +} + +#[cfg(test)] +#[path = "store_tests.rs"] +mod tests; diff --git a/src/openhuman/task_sources/store_tests.rs b/src/openhuman/task_sources/store_tests.rs new file mode 100644 index 000000000..c45f6f85b --- /dev/null +++ b/src/openhuman/task_sources/store_tests.rs @@ -0,0 +1,272 @@ +use super::*; +use crate::openhuman::config::Config; +use serde_json::json; +use tempfile::TempDir; + +fn test_config(tmp: &TempDir) -> Config { + let config = Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + }; + std::fs::create_dir_all(&config.workspace_dir).unwrap(); + config +} + +fn github_filter() -> FilterSpec { + FilterSpec::Github { + repo: Some("tinyhumansai/openhuman".into()), + labels: vec!["bug".into()], + assignee_is_me: true, + state: Some("open".into()), + extra: json!({}), + } +} + +fn sample_task(external_id: &str, title: &str, updated: &str) -> NormalizedTask { + NormalizedTask { + external_id: external_id.into(), + source_id: String::new(), + provider: "github".into(), + title: title.into(), + updated_at: Some(updated.into()), + ..Default::default() + } +} + +#[test] +fn add_get_and_list_roundtrip() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let src = add_source( + &config, + ProviderSlug::Github, + None, + Some("My issues".into()), + github_filter(), + 1800, + SourceTarget::AgentTodoProactive, + 25, + ) + .unwrap(); + assert!(!src.id.is_empty()); + assert_eq!(src.provider, ProviderSlug::Github); + assert!(src.enabled); + + let fetched = get_source(&config, &src.id).unwrap(); + assert_eq!(fetched, src); + + let all = list_sources(&config).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, src.id); +} + +#[test] +fn add_rejects_provider_filter_mismatch() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = add_source( + &config, + ProviderSlug::Notion, + None, + None, + github_filter(), // github filter under a notion source + 1800, + SourceTarget::TodoOnly, + 25, + ) + .unwrap_err(); + assert!(err.to_string().contains("does not match")); +} + +#[test] +fn update_applies_partial_patch() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let src = add_source( + &config, + ProviderSlug::Github, + None, + None, + github_filter(), + 1800, + SourceTarget::AgentTodoProactive, + 25, + ) + .unwrap(); + + let patched = update_source( + &config, + &src.id, + TaskSourcePatch { + enabled: Some(false), + interval_secs: Some(600), + target: Some(SourceTarget::TodoOnly), + ..Default::default() + }, + ) + .unwrap(); + assert!(!patched.enabled); + assert_eq!(patched.interval_secs, 600); + assert_eq!(patched.target, SourceTarget::TodoOnly); + // Untouched fields preserved. + assert_eq!(patched.filter, src.filter); +} + +#[test] +fn update_rejects_cross_provider_filter() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let src = add_source( + &config, + ProviderSlug::Github, + None, + None, + github_filter(), + 1800, + SourceTarget::TodoOnly, + 25, + ) + .unwrap(); + let err = update_source( + &config, + &src.id, + TaskSourcePatch { + filter: Some(FilterSpec::Notion { + database_id: None, + assigned_to_me: true, + status: None, + extra: json!({}), + }), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("does not match")); +} + +#[test] +fn remove_deletes_and_cascades_ingested() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let src = add_source( + &config, + ProviderSlug::Github, + None, + None, + github_filter(), + 1800, + SourceTarget::TodoOnly, + 25, + ) + .unwrap(); + mark_ingested( + &config, + &src.id, + &sample_task("1", "A", "2025-01-01"), + "task-abc", + ) + .unwrap(); + + remove_source(&config, &src.id).unwrap(); + assert!(get_source(&config, &src.id).is_err()); + // Ingested rows cascade-deleted. + assert!(list_ingested(&config, &src.id, 10).unwrap().is_empty()); + // Removing again errors (not found). + assert!(remove_source(&config, &src.id).is_err()); +} + +#[test] +fn dedup_detects_seen_and_edited_tasks() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let src = add_source( + &config, + ProviderSlug::Github, + None, + None, + github_filter(), + 1800, + SourceTarget::TodoOnly, + 25, + ) + .unwrap(); + + let task = sample_task("42", "Fix bug", "2025-01-01T00:00:00Z"); + let hash = content_hash(&task); + // Not ingested yet. + assert!(!is_ingested(&config, &src.id, "42", &hash).unwrap()); + + mark_ingested(&config, &src.id, &task, "task-v1").unwrap(); + // Same content hash → already ingested. + assert!(is_ingested(&config, &src.id, "42", &hash).unwrap()); + + // Edited task (newer updated_at) → different hash → not ingested. + let edited = sample_task("42", "Fix bug", "2025-02-01T00:00:00Z"); + let edited_hash = content_hash(&edited); + assert_ne!(hash, edited_hash); + assert!(!is_ingested(&config, &src.id, "42", &edited_hash).unwrap()); + + // Re-ingesting the edit upserts (still one row). + mark_ingested(&config, &src.id, &edited, "task-v2").unwrap(); + let listed = list_ingested(&config, &src.id, 10).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].external_id, "42"); +} + +#[test] +fn list_ingested_orders_newest_first() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let src = add_source( + &config, + ProviderSlug::Github, + None, + None, + github_filter(), + 1800, + SourceTarget::TodoOnly, + 25, + ) + .unwrap(); + + mark_ingested( + &config, + &src.id, + &sample_task("1", "first", "2025-01-01"), + "task-1", + ) + .unwrap(); + mark_ingested( + &config, + &src.id, + &sample_task("2", "second", "2025-01-02"), + "task-2", + ) + .unwrap(); + let listed = list_ingested(&config, &src.id, 10).unwrap(); + assert_eq!(listed.len(), 2); + // Newest ingested_at first; "2" was inserted last. + assert_eq!(listed[0].external_id, "2"); +} + +#[test] +fn clear_all_removes_every_source() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + add_source( + &config, + ProviderSlug::Github, + None, + None, + github_filter(), + 1800, + SourceTarget::TodoOnly, + 25, + ) + .unwrap(); + let removed = clear_all(&config).unwrap(); + assert_eq!(removed, 1); + assert!(list_sources(&config).unwrap().is_empty()); +} diff --git a/src/openhuman/task_sources/types.rs b/src/openhuman/task_sources/types.rs new file mode 100644 index 000000000..f060711f3 --- /dev/null +++ b/src/openhuman/task_sources/types.rs @@ -0,0 +1,336 @@ +//! Core types for the `task_sources` domain. +//! +//! A [`TaskSource`] is a user-configured pull of work items from an +//! external tool (GitHub, Notion, Linear, ClickUp) with a per-provider +//! [`FilterSpec`]. The periodic poll fetches matching items, normalizes +//! them ([`super::NormalizedTask`]), enriches them ([`EnrichedTask`]), +//! and routes them onto the agent's todo board. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// External tool a [`TaskSource`] pulls from. The string form matches +/// the Composio toolkit slug, so it keys directly into the provider +/// registry (`get_provider`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderSlug { + Github, + Notion, + Linear, + Clickup, +} + +impl ProviderSlug { + pub fn as_str(self) -> &'static str { + match self { + Self::Github => "github", + Self::Notion => "notion", + Self::Linear => "linear", + Self::Clickup => "clickup", + } + } + + /// Parse a toolkit slug into a `ProviderSlug`. Case-insensitive. + pub fn parse(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "github" => Ok(Self::Github), + "notion" => Ok(Self::Notion), + "linear" => Ok(Self::Linear), + "clickup" => Ok(Self::Clickup), + other => Err(format!( + "unknown task source provider '{other}' (expected github|notion|linear|clickup)" + )), + } + } +} + +/// Per-provider, user-configured filter. Tagged by `provider` on the +/// wire so the frontend can render typed pickers; each variant carries a +/// free-form `extra` object as an advanced escape hatch. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "provider", rename_all = "snake_case")] +pub enum FilterSpec { + Github { + #[serde(default, skip_serializing_if = "Option::is_none")] + repo: Option, + #[serde(default)] + labels: Vec, + #[serde(default)] + assignee_is_me: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + state: Option, + #[serde(default)] + extra: Value, + }, + Notion { + #[serde(default, skip_serializing_if = "Option::is_none")] + database_id: Option, + #[serde(default)] + assigned_to_me: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(default)] + extra: Value, + }, + Linear { + #[serde(default, skip_serializing_if = "Option::is_none")] + team_id: Option, + #[serde(default)] + assignee_is_me: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + state: Option, + #[serde(default)] + extra: Value, + }, + Clickup { + #[serde(default, skip_serializing_if = "Option::is_none")] + team_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + list_id: Option, + #[serde(default)] + assignee_is_me: bool, + #[serde(default)] + extra: Value, + }, +} + +impl FilterSpec { + /// The provider this filter targets — must match the owning + /// [`TaskSource::provider`]. + pub fn provider(&self) -> ProviderSlug { + match self { + Self::Github { .. } => ProviderSlug::Github, + Self::Notion { .. } => ProviderSlug::Notion, + Self::Linear { .. } => ProviderSlug::Linear, + Self::Clickup { .. } => ProviderSlug::Clickup, + } + } +} + +/// How enriched tasks are routed once fetched. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceTarget { + /// Append a todo card AND dispatch a triage turn so an agent may + /// start working immediately (triage still gates noise). + AgentTodoProactive, + /// Append a todo card only; never auto-start an agent turn. + TodoOnly, +} + +impl Default for SourceTarget { + fn default() -> Self { + Self::AgentTodoProactive + } +} + +/// Why a fetch ran — mirrors the provider `SyncReason` semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FetchReason { + /// First fetch right after an OAuth connection is created. + ConnectionCreated, + /// Periodic background poll. + Periodic, + /// Explicit user / RPC trigger. + Manual, +} + +impl FetchReason { + pub fn as_str(self) -> &'static str { + match self { + Self::ConnectionCreated => "connection_created", + Self::Periodic => "periodic", + Self::Manual => "manual", + } + } +} + +/// A persisted task source configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskSource { + pub id: String, + pub provider: ProviderSlug, + /// Composio connection id; `None` resolves the connection by toolkit + /// at fetch time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub enabled: bool, + pub filter: FilterSpec, + pub interval_secs: u64, + pub target: SourceTarget, + pub max_tasks_per_fetch: u32, + pub created_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fetch_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_status: Option, +} + +/// Partial update payload for [`super::store::update_source`]. Each +/// `Some` field is applied; `None` leaves the existing value untouched. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskSourcePatch { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub filter: Option, + #[serde(default)] + pub interval_secs: Option, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub max_tasks_per_fetch: Option, + #[serde(default)] + pub connection_id: Option, +} + +/// An enriched, agent-ready task produced by [`super::enrich`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnrichedTask { + pub task: super::NormalizedTask, + /// One- to two-line LLM summary (falls back to the title). + pub summary: String, + /// Urgency score in `0.0..=1.0`. + pub urgency: f32, + #[serde(default)] + pub linked_people: Vec, + #[serde(default)] + pub linked_memory_ids: Vec, + /// Actionable prompt handed to the agent turn. + pub agent_prompt: String, + pub enriched_at: DateTime, +} + +/// Result of a single fetch pass over one source. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchOutcome { + pub source_id: String, + pub provider: String, + /// Tasks returned by the provider. + pub fetched: usize, + /// Tasks newly routed (enriched + carded) this pass. + pub routed: usize, + /// Tasks skipped because they were already ingested. + pub skipped_dupe: usize, + /// Optional human-readable status line. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn provider_slug_roundtrips() { + for p in [ + ProviderSlug::Github, + ProviderSlug::Notion, + ProviderSlug::Linear, + ProviderSlug::Clickup, + ] { + assert_eq!(ProviderSlug::parse(p.as_str()).unwrap(), p); + } + assert_eq!(ProviderSlug::parse("GitHub").unwrap(), ProviderSlug::Github); + assert!(ProviderSlug::parse("jira").is_err()); + } + + #[test] + fn provider_slug_serde_is_snake_case() { + assert_eq!( + serde_json::to_string(&ProviderSlug::Clickup).unwrap(), + "\"clickup\"" + ); + } + + #[test] + fn filter_spec_tagged_by_provider() { + let f = FilterSpec::Github { + repo: Some("owner/name".into()), + labels: vec!["bug".into()], + assignee_is_me: true, + state: Some("open".into()), + extra: json!({}), + }; + let s = serde_json::to_value(&f).unwrap(); + assert_eq!(s["provider"], "github"); + assert_eq!(s["repo"], "owner/name"); + assert_eq!(f.provider(), ProviderSlug::Github); + + let back: FilterSpec = serde_json::from_value(s).unwrap(); + assert_eq!(back, f); + } + + #[test] + fn notion_filter_roundtrips_with_board() { + let f = FilterSpec::Notion { + database_id: Some("db-1".into()), + assigned_to_me: true, + status: Some("In Progress".into()), + extra: json!({"page_size": 10}), + }; + let back: FilterSpec = serde_json::from_value(serde_json::to_value(&f).unwrap()).unwrap(); + assert_eq!(back, f); + assert_eq!(back.provider(), ProviderSlug::Notion); + } + + #[test] + fn source_target_defaults_to_proactive() { + assert_eq!(SourceTarget::default(), SourceTarget::AgentTodoProactive); + } + + #[test] + fn fetch_reason_as_str() { + assert_eq!(FetchReason::Periodic.as_str(), "periodic"); + assert_eq!( + FetchReason::ConnectionCreated.as_str(), + "connection_created" + ); + assert_eq!(FetchReason::Manual.as_str(), "manual"); + } + + #[test] + fn task_source_serializes_camel_case() { + let src = TaskSource { + id: "s1".into(), + provider: ProviderSlug::Linear, + connection_id: None, + name: Some("My Linear".into()), + enabled: true, + filter: FilterSpec::Linear { + team_id: Some("team-1".into()), + assignee_is_me: true, + state: None, + extra: json!({}), + }, + interval_secs: 1800, + target: SourceTarget::TodoOnly, + max_tasks_per_fetch: 25, + created_at: DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + last_fetch_at: None, + last_status: None, + }; + let v = serde_json::to_value(&src).unwrap(); + assert_eq!(v["maxTasksPerFetch"], 25); + assert_eq!(v["intervalSecs"], 1800); + assert_eq!(v["target"], "todo_only"); + // connection_id / last_fetch_at omitted when None. + assert!(v.get("connectionId").is_none()); + let back: TaskSource = serde_json::from_value(v).unwrap(); + assert_eq!(back, src); + } +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 786c927ac..a77e8f799 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -8068,3 +8068,360 @@ async fn port_conflict_recovery_core_starts_on_fallback_port_e2e() { // Release the listener so the port is not held across tests. drop(after_drop.listener); } + +/// Task-sources CRUD + status + dry-run over JSON-RPC. +/// +/// Exercises `openhuman.task_sources_{add,list,get,update,remove,status, +/// list_tasks,preview_filter}` against an isolated HOME workspace. The +/// fetch/preview paths require no network here: with no signed-in +/// Composio session, `preview_filter` returns a clean JSON-RPC error +/// rather than hanging. +#[tokio::test] +async fn json_rpc_task_sources_crud_and_status() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + write_min_config(&openhuman_home, "http://127.0.0.1:1"); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + // ── add ────────────────────────────────────────────────────────── + let add = post_json_rpc( + &rpc_base, + 7301, + "openhuman.task_sources_add", + json!({ + "provider": "github", + "name": "My issues", + "filter": { + "provider": "github", + "repo": "tinyhumansai/openhuman", + "labels": ["bug"], + "assignee_is_me": true + } + }), + ) + .await; + let source = assert_no_jsonrpc_error(&add, "task_sources_add"); + let id = source + .get("id") + .and_then(Value::as_str) + .expect("add returns source id") + .to_string(); + assert_eq!( + source.get("provider").and_then(Value::as_str), + Some("github") + ); + assert_eq!(source.get("enabled"), Some(&json!(true))); + // Default target follows config.auto_proactive (true → proactive). + assert_eq!( + source.get("target").and_then(Value::as_str), + Some("agent_todo_proactive") + ); + + // ── add with mismatched provider/filter is rejected ────────────── + let bad = post_json_rpc( + &rpc_base, + 7302, + "openhuman.task_sources_add", + json!({ + "provider": "notion", + "filter": { "provider": "github", "assignee_is_me": true } + }), + ) + .await; + assert_jsonrpc_error(&bad, "task_sources_add mismatch"); + + // ── list contains the new source ───────────────────────────────── + let list = post_json_rpc(&rpc_base, 7303, "openhuman.task_sources_list", json!({})).await; + let sources = assert_no_jsonrpc_error(&list, "task_sources_list") + .as_array() + .expect("list returns array") + .clone(); + assert!(sources + .iter() + .any(|s| s.get("id").and_then(Value::as_str) == Some(id.as_str()))); + + // ── get roundtrips ─────────────────────────────────────────────── + let get = post_json_rpc( + &rpc_base, + 7304, + "openhuman.task_sources_get", + json!({ "id": id }), + ) + .await; + let got = assert_no_jsonrpc_error(&get, "task_sources_get"); + assert_eq!(got.get("id").and_then(Value::as_str), Some(id.as_str())); + + // ── update (disable + change interval) ─────────────────────────── + let update = post_json_rpc( + &rpc_base, + 7305, + "openhuman.task_sources_update", + json!({ "id": id, "patch": { "enabled": false, "intervalSecs": 600 } }), + ) + .await; + let updated = assert_no_jsonrpc_error(&update, "task_sources_update"); + assert_eq!(updated.get("enabled"), Some(&json!(false))); + assert_eq!(updated.get("intervalSecs"), Some(&json!(600))); + + // ── status reflects the configured source ──────────────────────── + let status = post_json_rpc(&rpc_base, 7306, "openhuman.task_sources_status", json!({})).await; + let status_result = assert_no_jsonrpc_error(&status, "task_sources_status"); + assert_eq!(status_result.get("enabled"), Some(&json!(true))); + assert!( + status_result + .get("sourceCount") + .and_then(Value::as_u64) + .unwrap_or(0) + >= 1 + ); + + // ── list_tasks is empty (nothing ingested yet) ─────────────────── + let tasks = post_json_rpc( + &rpc_base, + 7307, + "openhuman.task_sources_list_tasks", + json!({ "id": id }), + ) + .await; + let tasks_result = assert_no_jsonrpc_error(&tasks, "task_sources_list_tasks"); + assert_eq!(tasks_result.as_array().map(|a| a.len()), Some(0)); + + // (preview_filter is covered end to end by + // json_rpc_task_sources_fetch_pipeline_e2e with a stub provider; we + // do not assert on it here because the provider registry is global + // and shared across tests in this binary.) + + // ── remove, then get is not found ──────────────────────────────── + let remove = post_json_rpc( + &rpc_base, + 7309, + "openhuman.task_sources_remove", + json!({ "id": id }), + ) + .await; + let removed = assert_no_jsonrpc_error(&remove, "task_sources_remove"); + assert_eq!(removed.get("removed"), Some(&json!(true))); + + let get_after = post_json_rpc( + &rpc_base, + 7310, + "openhuman.task_sources_get", + json!({ "id": id }), + ) + .await; + assert_jsonrpc_error(&get_after, "task_sources_get after remove"); + + rpc_join.abort(); +} + +/// Stub Composio provider used by the task-sources fetch E2E. Returns a +/// canned set of tasks from `fetch_tasks` so the full +/// fetch → enrich → route → ingest pipeline can be exercised over RPC +/// without a live Composio connection. +mod task_sources_stub { + use async_trait::async_trait; + use openhuman_core::openhuman::memory_sync::composio::providers::{ + ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome, + SyncReason, TaskFetchFilter, + }; + + pub struct StubGithubProvider { + pub tasks: Vec, + } + + pub fn task(external_id: &str, title: &str, updated: &str) -> NormalizedTask { + NormalizedTask { + external_id: external_id.to_string(), + provider: "github".to_string(), + title: title.to_string(), + url: Some(format!("https://example.com/{external_id}")), + updated_at: Some(updated.to_string()), + ..Default::default() + } + } + + #[async_trait] + impl ComposioProvider for StubGithubProvider { + fn toolkit_slug(&self) -> &'static str { + "github" + } + async fn fetch_user_profile( + &self, + _ctx: &ProviderContext, + ) -> Result { + 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()) + } + } +} + +/// Full task-sources fetch pipeline over JSON-RPC: a stub provider feeds +/// `fetch_tasks`, then `task_sources_fetch` routes the tasks onto the +/// board, `list_tasks` surfaces them, a re-fetch dedups, and +/// `preview_filter` returns matches without ingesting. +#[tokio::test] +async fn json_rpc_task_sources_fetch_pipeline_e2e() { + use std::sync::Arc; + + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + write_min_config(&openhuman_home, "http://127.0.0.1:1"); + + // Register the stub github provider BEFORE serving so the fetch RPC + // resolves it from the global registry. + openhuman_core::openhuman::memory_sync::composio::providers::register_provider(Arc::new( + task_sources_stub::StubGithubProvider { + tasks: vec![ + task_sources_stub::task("101", "Fix flaky test", "2025-01-01T00:00:00Z"), + task_sources_stub::task("102", "Update docs", "2025-01-02T00:00:00Z"), + ], + }, + )); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + // Add a TODO-only source (no triage LLM turn) so the pipeline runs + // deterministically end to end. + let add = post_json_rpc( + &rpc_base, + 7401, + "openhuman.task_sources_add", + json!({ + "provider": "github", + "name": "Pipeline source", + "target": "todo_only", + "filter": { "provider": "github", "assignee_is_me": true } + }), + ) + .await; + let source = assert_no_jsonrpc_error(&add, "task_sources_add pipeline"); + let id = source + .get("id") + .and_then(Value::as_str) + .expect("source id") + .to_string(); + + // First fetch: both stub tasks routed. + let fetch1 = post_json_rpc( + &rpc_base, + 7402, + "openhuman.task_sources_fetch", + json!({ "id": id }), + ) + .await; + let outcome1 = assert_no_jsonrpc_error(&fetch1, "task_sources_fetch first"); + assert_eq!( + outcome1.get("error"), + None, + "fetch should not error: {outcome1}" + ); + assert_eq!(outcome1.get("fetched").and_then(Value::as_u64), Some(2)); + assert_eq!(outcome1.get("routed").and_then(Value::as_u64), Some(2)); + assert_eq!(outcome1.get("skippedDupe").and_then(Value::as_u64), Some(0)); + + // list_tasks surfaces the two ingested tasks. + let tasks = post_json_rpc( + &rpc_base, + 7403, + "openhuman.task_sources_list_tasks", + json!({ "id": id }), + ) + .await; + let tasks_arr = assert_no_jsonrpc_error(&tasks, "task_sources_list_tasks pipeline") + .as_array() + .expect("tasks array") + .clone(); + assert_eq!(tasks_arr.len(), 2); + let ids: Vec<&str> = tasks_arr + .iter() + .filter_map(|t| t.get("externalId").and_then(Value::as_str)) + .collect(); + assert!(ids.contains(&"101")); + assert!(ids.contains(&"102")); + + // Second fetch: identical tasks → all deduped, none re-routed. + let fetch2 = post_json_rpc( + &rpc_base, + 7404, + "openhuman.task_sources_fetch", + json!({ "id": id }), + ) + .await; + let outcome2 = assert_no_jsonrpc_error(&fetch2, "task_sources_fetch second"); + assert_eq!(outcome2.get("fetched").and_then(Value::as_u64), Some(2)); + assert_eq!(outcome2.get("routed").and_then(Value::as_u64), Some(0)); + assert_eq!(outcome2.get("skippedDupe").and_then(Value::as_u64), Some(2)); + + // preview_filter returns matches WITHOUT ingesting (count unchanged). + let preview = post_json_rpc( + &rpc_base, + 7405, + "openhuman.task_sources_preview_filter", + json!({ + "provider": "github", + "filter": { "provider": "github", "assignee_is_me": true } + }), + ) + .await; + let preview_arr = assert_no_jsonrpc_error(&preview, "task_sources_preview_filter pipeline") + .as_array() + .expect("preview array") + .clone(); + assert_eq!(preview_arr.len(), 2); + + let tasks_after = post_json_rpc( + &rpc_base, + 7406, + "openhuman.task_sources_list_tasks", + json!({ "id": id }), + ) + .await; + let tasks_after_arr = assert_no_jsonrpc_error(&tasks_after, "list_tasks after preview") + .as_array() + .expect("tasks array") + .clone(); + assert_eq!( + tasks_after_arr.len(), + 2, + "preview_filter must not ingest tasks" + ); + + // Restore the global provider registry so the stub "github" provider + // does not leak into other tests in this binary (re-registers the + // real built-in providers). + openhuman_core::openhuman::memory_sync::composio::providers::init_default_providers(); + + rpc_join.abort(); +}