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