fix(task-sources): sync and prune stale tasks (#3418)

This commit is contained in:
Steven Enamakel
2026-06-05 21:04:06 -04:00
committed by GitHub
parent 4bd115e6a2
commit 8b0bf9d3dc
28 changed files with 547 additions and 69 deletions
@@ -20,6 +20,7 @@ const addMock = vi.fn();
const updateMock = vi.fn();
const removeMock = vi.fn();
const fetchMock = vi.fn();
const syncMock = vi.fn();
const previewMock = vi.fn();
vi.mock('../../../utils/tauriCommands', () => ({
@@ -29,6 +30,7 @@ vi.mock('../../../utils/tauriCommands', () => ({
openhumanTaskSourcesUpdate: (id: string, patch: unknown) => updateMock(id, patch),
openhumanTaskSourcesRemove: (id: string) => removeMock(id),
openhumanTaskSourcesFetch: (id: string) => fetchMock(id),
openhumanTaskSourcesSync: () => syncMock(),
openhumanTaskSourcesPreviewFilter: (...args: unknown[]) => previewMock(...args),
}));
@@ -76,7 +78,11 @@ describe('<TaskSourcesPanel />', () => {
fetched: 3,
routed: 2,
skippedDupe: 1,
pruned: 0,
});
syncMock.mockResolvedValue([
{ sourceId: 's-1', provider: 'github', fetched: 3, routed: 2, skippedDupe: 1, pruned: 1 },
]);
previewMock.mockResolvedValue([{ externalId: '1' }, { externalId: '2' }]);
});
@@ -163,7 +169,40 @@ describe('<TaskSourcesPanel />', () => {
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();
expect(
await screen.findByText(/Routed 2 of 3 task\(s\); removed 0 stale task\(s\)/)
).toBeInTheDocument();
});
it('syncs all sources and surfaces routed/fetched/pruned counts', async () => {
renderPanel();
await screen.findByTestId('task-source-s-1');
fireEvent.click(screen.getByRole('button', { name: 'Sync all' }));
await waitFor(() => expect(syncMock).toHaveBeenCalled());
expect(
await screen.findByText(/Routed 2 of 3 task\(s\); removed 1 stale task\(s\)/)
).toBeInTheDocument();
});
it('disables refresh while sync is in progress', async () => {
let resolveSync: (value: Awaited<ReturnType<typeof syncMock>>) => void = () => {};
syncMock.mockReturnValue(
new Promise(resolve => {
resolveSync = resolve;
})
);
renderPanel();
await screen.findByTestId('task-source-s-1');
fireEvent.click(screen.getByRole('button', { name: 'Sync all' }));
await waitFor(() => expect(syncMock).toHaveBeenCalled());
expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled();
resolveSync([
{ sourceId: 's-1', provider: 'github', fetched: 3, routed: 2, skippedDupe: 1, pruned: 1 },
]);
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled());
});
it('surfaces a fetch outcome error', async () => {
@@ -173,6 +212,7 @@ describe('<TaskSourcesPanel />', () => {
fetched: 0,
routed: 0,
skippedDupe: 0,
pruned: 0,
error: 'no connection',
});
renderPanel();
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
@@ -9,6 +9,7 @@ import {
openhumanTaskSourcesPreviewFilter,
openhumanTaskSourcesRemove,
openhumanTaskSourcesStatus,
openhumanTaskSourcesSync,
openhumanTaskSourcesUpdate,
type TaskContainer,
type TaskSource,
@@ -76,6 +77,21 @@ function buildFilter(
}
}
function formatSyncNotice(outcomes: Array<{ fetched: number; routed: number; pruned?: number }>): {
fetched: number;
routed: number;
pruned: number;
} {
return outcomes.reduce<{ fetched: number; routed: number; pruned: number }>(
(totals, outcome) => ({
fetched: totals.fetched + outcome.fetched,
routed: totals.routed + outcome.routed,
pruned: totals.pruned + (outcome.pruned ?? 0),
}),
{ fetched: 0, routed: 0, pruned: 0 }
);
}
const TaskSourcesPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
@@ -86,6 +102,16 @@ const TaskSourcesPanel = () => {
const [status, setStatus] = useState<TaskSourcesStatus | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const loadingRef = useRef(loading);
const busyKeyRef = useRef(busyKey);
useEffect(() => {
loadingRef.current = loading;
}, [loading]);
useEffect(() => {
busyKeyRef.current = busyKey;
}, [busyKey]);
// ── create-form state ────────────────────────────────────────────
const [provider, setProvider] = useState<TaskSourceProvider>('github');
@@ -102,27 +128,31 @@ const TaskSourcesPanel = () => {
setDatabases([]);
}, [provider]);
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]);
const load = useCallback(
async (options?: { force?: boolean }) => {
if (!options?.force && (loadingRef.current || busyKeyRef.current !== null)) return;
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();
void load({ force: true });
}, [load]);
const primaryLabel = useMemo(() => {
@@ -141,6 +171,7 @@ const TaskSourcesPanel = () => {
}, [provider, t]);
const addSource = async () => {
if (busyKey) return;
setBusyKey('add');
setError(null);
setNotice(null);
@@ -153,7 +184,7 @@ const TaskSourcesPanel = () => {
setName('');
setPrimary('');
setLabels('');
await load();
await load({ force: true });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -162,6 +193,7 @@ const TaskSourcesPanel = () => {
};
const previewFilter = async () => {
if (busyKey) return;
setBusyKey('preview');
setError(null);
setNotice(null);
@@ -181,6 +213,7 @@ const TaskSourcesPanel = () => {
// Fetch the databases the connected account exposes (Notion) so the user can
// pick one instead of pasting a raw id.
const browseDatabases = async () => {
if (busyKey) return;
setBusyKey('databases');
setError(null);
setNotice(null);
@@ -198,6 +231,7 @@ const TaskSourcesPanel = () => {
};
const toggleSource = async (source: TaskSource) => {
if (busyKey) return;
setBusyKey(`toggle:${source.id}`);
setError(null);
try {
@@ -211,6 +245,7 @@ const TaskSourcesPanel = () => {
};
const fetchNow = async (source: TaskSource) => {
if (busyKey) return;
setBusyKey(`fetch:${source.id}`);
setError(null);
setNotice(null);
@@ -219,7 +254,7 @@ const TaskSourcesPanel = () => {
// 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();
await load({ force: true });
if (outcome.error) {
setError(outcome.error);
} else {
@@ -227,6 +262,34 @@ const TaskSourcesPanel = () => {
t('settings.taskSources.fetchResult')
.replace('{routed}', String(outcome.routed))
.replace('{fetched}', String(outcome.fetched))
.replace('{pruned}', String(outcome.pruned ?? 0))
);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
};
const syncAll = async () => {
if (busyKey) return;
setBusyKey('sync');
setError(null);
setNotice(null);
try {
const outcomes = await openhumanTaskSourcesSync();
await load({ force: true });
const firstError = outcomes.find(outcome => outcome.error)?.error;
if (firstError) {
setError(firstError);
} else {
const totals = formatSyncNotice(outcomes);
setNotice(
t('settings.taskSources.fetchResult')
.replace('{routed}', String(totals.routed))
.replace('{fetched}', String(totals.fetched))
.replace('{pruned}', String(totals.pruned))
);
}
} catch (err) {
@@ -237,6 +300,7 @@ const TaskSourcesPanel = () => {
};
const removeSource = async (source: TaskSource) => {
if (busyKey) return;
if (!window.confirm(t('settings.taskSources.removeConfirm'))) return;
setBusyKey(`remove:${source.id}`);
setError(null);
@@ -331,7 +395,7 @@ const TaskSourcesPanel = () => {
<button
type="button"
className="btn btn-outline btn-sm"
disabled={busyKey === 'databases'}
disabled={busyKey !== null}
onClick={() => void browseDatabases()}>
{busyKey === 'databases'
? t('settings.taskSources.notion.loadingDatabases')
@@ -378,14 +442,14 @@ const TaskSourcesPanel = () => {
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busyKey === 'add'}
disabled={busyKey !== null}
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'}
disabled={busyKey !== null}
onClick={() => void previewFilter()}>
{t('settings.taskSources.preview')}
</button>
@@ -397,6 +461,15 @@ const TaskSourcesPanel = () => {
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.taskSources.configured')}
</h3>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={loading || busyKey !== null || sources.length === 0}
onClick={() => void syncAll()}>
{busyKey === 'sync'
? t('settings.taskSources.syncing')
: t('settings.taskSources.syncAll')}
</button>
{loading ? (
<p className="text-sm text-stone-400 dark:text-neutral-500">{t('common.loading')}</p>
@@ -445,7 +518,7 @@ const TaskSourcesPanel = () => {
<button
type="button"
className="btn btn-outline btn-xs"
disabled={busyKey === `toggle:${source.id}`}
disabled={busyKey !== null}
onClick={() => void toggleSource(source)}>
{source.enabled
? t('settings.taskSources.disable')
@@ -454,7 +527,7 @@ const TaskSourcesPanel = () => {
<button
type="button"
className="btn btn-outline btn-xs"
disabled={busyKey === `fetch:${source.id}`}
disabled={busyKey !== null}
onClick={() => void fetchNow(source)}>
{busyKey === `fetch:${source.id}`
? t('settings.taskSources.fetching')
@@ -463,7 +536,7 @@ const TaskSourcesPanel = () => {
<button
type="button"
className="btn btn-ghost btn-xs text-red-600 dark:text-red-400"
disabled={busyKey === `remove:${source.id}`}
disabled={busyKey !== null}
onClick={() => void removeSource(source)}>
{t('settings.taskSources.remove')}
</button>
@@ -473,7 +546,11 @@ const TaskSourcesPanel = () => {
</ul>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void load()}>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={loading || busyKey !== null}
onClick={() => void load()}>
{t('settings.taskSources.refresh')}
</button>
</section>
+4 -1
View File
@@ -4393,7 +4393,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': 'مُهمة (مُهمات) مُطابقة لهذا التصفية',
'settings.taskSources.fetchNow': 'أحضر الآن',
'settings.taskSources.fetching': '....',
'settings.taskSources.fetchResult': 'تم توجيه {routed} من أصل {fetched} مهمة/مهام',
'settings.taskSources.fetchResult':
'تم توجيه {routed} من أصل {fetched} مهمة/مهام؛ تمت إزالة {pruned} مهمة/مهام قديمة',
'settings.taskSources.syncAll': 'مزامنة الكل',
'settings.taskSources.syncing': 'جارٍ المزامنة…',
'settings.taskSources.configured': 'المصادر المحظورة',
'settings.taskSources.empty': 'لم يتم تشكيل مصادر المهمة بعد',
'settings.taskSources.proactive': 'استباقي',
+4 -1
View File
@@ -4475,7 +4475,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': 'এই ফিল্টারের জন্য xqxqx প্রচেষ্টা করুন',
'settings.taskSources.fetchNow': 'এখন নিয়ে এসো',
'settings.taskSources.fetching': 'প্রাপ্ত করা হচ্ছে...',
'settings.taskSources.fetchResult': 'xxqxqx এর রুট',
'settings.taskSources.fetchResult':
'{fetched}টি কাজের মধ্যে {routed}টি রাউট করা হয়েছে; {pruned}টি পুরোনো কাজ সরানো হয়েছে',
'settings.taskSources.syncAll': 'সব সিঙ্ক করুন',
'settings.taskSources.syncing': 'সিঙ্ক হচ্ছে...',
'settings.taskSources.configured': '%d-টি উৎসস্থল কনফিগার করুন',
'settings.taskSources.empty': 'কোনো কর্ম কনফিগার করা হয়নি।',
'settings.taskSources.proactive': 'আকর্ষণীয়',
+4 -1
View File
@@ -4597,7 +4597,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': '{count}-Aufgabe(n) entsprechen diesem Filter',
'settings.taskSources.fetchNow': 'Jetzt abholen',
'settings.taskSources.fetching': 'Abrufen…',
'settings.taskSources.fetchResult': 'Gerouteter {routed} von {fetched}-Aufgabe(n)',
'settings.taskSources.fetchResult':
'{routed} von {fetched} Aufgabe(n) weitergeleitet; {pruned} veraltete Aufgabe(n) entfernt',
'settings.taskSources.syncAll': 'Alle synchronisieren',
'settings.taskSources.syncing': 'Synchronisierung…',
'settings.taskSources.configured': 'Konfigurierte Quellen',
'settings.taskSources.empty': 'Noch keine Aufgabenquellen konfiguriert.',
'settings.taskSources.proactive': 'Proaktiv',
+4 -1
View File
@@ -5014,7 +5014,10 @@ const en: TranslationMap = {
'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.fetchResult':
'Routed {routed} of {fetched} task(s); removed {pruned} stale task(s)',
'settings.taskSources.syncAll': 'Sync all',
'settings.taskSources.syncing': 'Syncing…',
'settings.taskSources.configured': 'Configured sources',
'settings.taskSources.empty': 'No task sources configured yet.',
'settings.taskSources.proactive': 'Proactive',
+4 -1
View File
@@ -4567,7 +4567,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': 'La(s) tarea(s) {count} coinciden con este filtro',
'settings.taskSources.fetchNow': 'Trae ahora',
'settings.taskSources.fetching': 'Obteniendo…',
'settings.taskSources.fetchResult': 'Enrutado {routed} de la(s) tarea(s) {fetched}',
'settings.taskSources.fetchResult':
'Se enrutaron {routed} de {fetched} tarea(s); se eliminaron {pruned} tarea(s) obsoletas',
'settings.taskSources.syncAll': 'Sincronizar todo',
'settings.taskSources.syncing': 'Sincronizando…',
'settings.taskSources.configured': 'Fuentes configuradas',
'settings.taskSources.empty': 'Aún no se han configurado fuentes de tareas.',
'settings.taskSources.proactive': 'proactivo',
+4 -1
View File
@@ -4581,7 +4581,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': 'La ou les tâches {count} correspondent à ce filtre',
'settings.taskSources.fetchNow': 'Ramasse maintenant',
'settings.taskSources.fetching': 'Récupération…',
'settings.taskSources.fetchResult': 'Acheminé {routed} des tâches {fetched}',
'settings.taskSources.fetchResult':
'{routed} tâche(s) acheminée(s) sur {fetched}; {pruned} tâche(s) obsolète(s) supprimée(s)',
'settings.taskSources.syncAll': 'Tout synchroniser',
'settings.taskSources.syncing': 'Synchronisation…',
'settings.taskSources.configured': 'Sources configurées',
'settings.taskSources.empty': 'Aucune source de tâche configurée pour le moment.',
'settings.taskSources.proactive': 'proactif',
+4 -1
View File
@@ -4485,7 +4485,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': '{count} कार्य इस फ़िल्टर से मेल खाता है',
'settings.taskSources.fetchNow': 'अभी लेना',
'settings.taskSources.fetching': '...',
'settings.taskSources.fetchResult': '{fetched} कार्य (s) के रूटेड {routed}',
'settings.taskSources.fetchResult':
'{fetched} कार्यों में से {routed} रूट किए गए; {pruned} पुराने कार्य हटाए गए',
'settings.taskSources.syncAll': 'सभी सिंक करें',
'settings.taskSources.syncing': 'सिंक हो रहा है...',
'settings.taskSources.configured': 'संरूपित सूत्र',
'settings.taskSources.empty': 'अभी तक कोई कार्य स्रोत नहीं है।',
'settings.taskSources.proactive': 'सक्रिय',
+4 -1
View File
@@ -4493,7 +4493,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': 'Tugas {count} cocok dengan penyaring ini',
'settings.taskSources.fetchNow': 'Ambil sekarang',
'settings.taskSources.fetching': 'Mengambil...',
'settings.taskSources.fetchResult': 'Karang tugas {routed} dari {fetched}',
'settings.taskSources.fetchResult':
'Merutekan {routed} dari {fetched} tugas; menghapus {pruned} tugas usang',
'settings.taskSources.syncAll': 'Sinkronkan semua',
'settings.taskSources.syncing': 'Menyinkronkan...',
'settings.taskSources.configured': 'Sumber yang dikonfigurasi',
'settings.taskSources.empty': 'Belum ada sumber tugas yang dikonfigurasi.',
'settings.taskSources.proactive': 'Proaktif',
+4 -1
View File
@@ -4558,7 +4558,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': 'Il/i compito/i {count} corrispondono a questo filtro',
'settings.taskSources.fetchNow': 'Prendi adesso',
'settings.taskSources.fetching': 'Recupero…',
'settings.taskSources.fetchResult': 'Instradato {routed} di {fetched} attività',
'settings.taskSources.fetchResult':
'Instradate {routed} di {fetched} attività; rimosse {pruned} attività obsolete',
'settings.taskSources.syncAll': 'Sincronizza tutto',
'settings.taskSources.syncing': 'Sincronizzazione…',
'settings.taskSources.configured': 'Fonti configurate',
'settings.taskSources.empty': 'Nessuna fonte attività configurata ancora.',
'settings.taskSources.proactive': 'Proattivo',
+4 -1
View File
@@ -4434,7 +4434,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': '{count}개 작업이 이 필터와 일치합니다',
'settings.taskSources.fetchNow': '지금 가져오기',
'settings.taskSources.fetching': '가져오는 중…',
'settings.taskSources.fetchResult': '{fetched}개 작업 중 {routed}개 라우팅됨',
'settings.taskSources.fetchResult':
'{fetched}개 작업 중 {routed}개 라우팅됨; 오래된 작업 {pruned}개 제거됨',
'settings.taskSources.syncAll': '모두 동기화',
'settings.taskSources.syncing': '동기화 중…',
'settings.taskSources.configured': '구성된 소스',
'settings.taskSources.empty': '아직 구성된 작업 소스가 없습니다.',
'settings.taskSources.proactive': '능동형',
+4 -1
View File
@@ -4554,7 +4554,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': '{count} zadań pasuje do tego filtra',
'settings.taskSources.fetchNow': 'Pobierz teraz',
'settings.taskSources.fetching': 'Pobieranie…',
'settings.taskSources.fetchResult': 'Skierowano {routed} z {fetched} zadań',
'settings.taskSources.fetchResult':
'Skierowano {routed} z {fetched} zadań; usunięto {pruned} nieaktualnych zadań',
'settings.taskSources.syncAll': 'Synchronizuj wszystko',
'settings.taskSources.syncing': 'Synchronizowanie…',
'settings.taskSources.configured': 'Skonfigurowane źródła',
'settings.taskSources.empty': 'Nie skonfigurowano jeszcze źródeł zadań.',
'settings.taskSources.proactive': 'Proaktywne',
+4 -1
View File
@@ -4554,7 +4554,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': 'A tarefa(s) {count} corresponde(m) a este filtro',
'settings.taskSources.fetchNow': 'Buscar agora',
'settings.taskSources.fetching': 'Buscando…',
'settings.taskSources.fetchResult': 'Roteado {routed} de {fetched} tarefa(s)',
'settings.taskSources.fetchResult':
'Roteado {routed} de {fetched} tarefa(s); removidas {pruned} tarefa(s) antigas',
'settings.taskSources.syncAll': 'Sincronizar tudo',
'settings.taskSources.syncing': 'Sincronizando…',
'settings.taskSources.configured': 'Fontes configuradas',
'settings.taskSources.empty': 'Nenhuma fonte de tarefa configurada ainda.',
'settings.taskSources.proactive': 'Proativo',
+4 -1
View File
@@ -4520,7 +4520,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': 'Задачи {count} соответствуют этому фильтру',
'settings.taskSources.fetchNow': 'Получить сейчас',
'settings.taskSources.fetching': 'Получение…',
'settings.taskSources.fetchResult': 'Маршрутизация {routed} из задач {fetched}',
'settings.taskSources.fetchResult':
'Направлено {routed} из {fetched} задач; удалено устаревших задач: {pruned}',
'settings.taskSources.syncAll': 'Синхронизировать все',
'settings.taskSources.syncing': 'Синхронизация…',
'settings.taskSources.configured': 'Настроенные источники',
'settings.taskSources.empty': 'Источники задач пока не настроены.',
'settings.taskSources.proactive': 'Проактивный',
+4 -1
View File
@@ -4258,7 +4258,10 @@ const messages: TranslationMap = {
'settings.taskSources.previewResult': '{count} 个任务匹配此筛选器',
'settings.taskSources.fetchNow': '立即获取',
'settings.taskSources.fetching': '获取中…',
'settings.taskSources.fetchResult': '已路由 {routed}/{fetched} 个任务',
'settings.taskSources.fetchResult':
'已路由 {routed}/{fetched} 个任务;已移除 {pruned} 个过期任务',
'settings.taskSources.syncAll': '全部同步',
'settings.taskSources.syncing': '正在同步…',
'settings.taskSources.configured': '已配置来源',
'settings.taskSources.empty': '尚未配置任务来源。',
'settings.taskSources.proactive': '主动',
@@ -7,6 +7,7 @@ import {
openhumanTaskSourcesFetch,
openhumanTaskSourcesList,
openhumanTaskSourcesStatus,
openhumanTaskSourcesSync,
openhumanTaskSourcesUpdate,
} from '../../../utils/tauriCommands';
import { TaskKanbanBoard } from './TaskKanbanBoard';
@@ -18,6 +19,7 @@ vi.mock('../../../utils/tauriCommands', () => ({
openhumanTaskSourcesFetch: vi.fn(),
openhumanTaskSourcesList: vi.fn(),
openhumanTaskSourcesStatus: vi.fn(),
openhumanTaskSourcesSync: vi.fn(),
openhumanTaskSourcesUpdate: vi.fn(),
}));
@@ -73,7 +75,11 @@ describe('TaskKanbanBoard approval surface', () => {
fetched: 3,
routed: 2,
skippedDupe: 1,
pruned: 0,
});
vi.mocked(openhumanTaskSourcesSync).mockResolvedValue([
{ sourceId: 'src-1', provider: 'github', fetched: 3, routed: 2, skippedDupe: 1, pruned: 1 },
]);
vi.mocked(openhumanTaskSourcesUpdate).mockResolvedValue({
id: 'src-1',
provider: 'github',
@@ -186,6 +192,9 @@ describe('TaskKanbanBoard approval surface', () => {
fireEvent.click(screen.getByText('settings.taskSources.fetchNow'));
await waitFor(() => expect(openhumanTaskSourcesFetch).toHaveBeenCalledWith('src-1'));
fireEvent.click(screen.getByText('settings.taskSources.syncAll'));
await waitFor(() => expect(openhumanTaskSourcesSync).toHaveBeenCalled());
fireEvent.click(screen.getByText('settings.taskSources.disable'));
await waitFor(() =>
expect(openhumanTaskSourcesUpdate).toHaveBeenCalledWith('src-1', { enabled: false })
@@ -23,6 +23,7 @@ import {
openhumanTaskSourcesFetch,
openhumanTaskSourcesList,
openhumanTaskSourcesStatus,
openhumanTaskSourcesSync,
openhumanTaskSourcesUpdate,
type TaskSource,
type TaskSourcesStatus,
@@ -569,7 +570,23 @@ function formatUrgency(
function formatFetchNotice(outcome: FetchOutcome, t: (key: string) => string): string {
return t('settings.taskSources.fetchResult')
.replace('{routed}', String(outcome.routed))
.replace('{fetched}', String(outcome.fetched));
.replace('{fetched}', String(outcome.fetched))
.replace('{pruned}', String(outcome.pruned ?? 0));
}
function formatSyncNotice(outcomes: FetchOutcome[], t: (key: string) => string): string {
const totals = outcomes.reduce(
(acc, outcome) => ({
fetched: acc.fetched + outcome.fetched,
routed: acc.routed + outcome.routed,
pruned: acc.pruned + (outcome.pruned ?? 0),
}),
{ fetched: 0, routed: 0, pruned: 0 }
);
return t('settings.taskSources.fetchResult')
.replace('{routed}', String(totals.routed))
.replace('{fetched}', String(totals.fetched))
.replace('{pruned}', String(totals.pruned));
}
function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact: boolean }) {
@@ -614,6 +631,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
}, [load]);
const toggleSource = async (source: TaskSource) => {
if (busyKey) return;
setBusyKey(`toggle:${source.id}`);
setError(null);
setNotice(null);
@@ -628,6 +646,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
};
const fetchSource = async (source: TaskSource) => {
if (busyKey) return;
setBusyKey(`fetch:${source.id}`);
setError(null);
setNotice(null);
@@ -646,6 +665,27 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
}
};
const syncSources = async () => {
if (busyKey) return;
setBusyKey('sync');
setError(null);
setNotice(null);
try {
const outcomes = await openhumanTaskSourcesSync();
await load();
const firstError = outcomes.find(outcome => outcome.error)?.error;
if (firstError) {
setError(firstError);
} else {
setNotice(formatSyncNotice(outcomes, t));
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyKey(null);
}
};
return (
<section className="mb-3 rounded-lg border border-stone-200 bg-white p-3 dark:border-neutral-800 dark:bg-neutral-900">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -668,6 +708,16 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
className="text-[11px] font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
{t('conversations.taskKanban.sources.manage')}
</button>
<button
type="button"
disabled={disabled || loading || busyKey !== null || sources.length === 0}
onClick={() => void syncSources()}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuRefreshCw className="h-3 w-3" />
{busyKey === 'sync'
? t('settings.taskSources.syncing')
: t('settings.taskSources.syncAll')}
</button>
<button
type="button"
aria-label={t('settings.taskSources.refresh')}
@@ -728,7 +778,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
<div className="mt-2 flex flex-wrap gap-1.5">
<button
type="button"
disabled={disabled || busyKey === `fetch:${source.id}`}
disabled={disabled || busyKey !== null}
onClick={() => void fetchSource(source)}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuRefreshCw className="h-3 w-3" />
@@ -738,7 +788,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
</button>
<button
type="button"
disabled={disabled || busyKey === `toggle:${source.id}`}
disabled={disabled || busyKey !== null}
onClick={() => void toggleSource(source)}
className="rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
{source.enabled
@@ -19,6 +19,7 @@ import {
openhumanTaskSourcesPreviewFilter,
openhumanTaskSourcesRemove,
openhumanTaskSourcesStatus,
openhumanTaskSourcesSync,
openhumanTaskSourcesUpdate,
} from './taskSources';
@@ -87,6 +88,12 @@ describe('tauriCommands/taskSources', () => {
});
});
test('sync forwards the sync method with no params', async () => {
mockCallCoreRpc.mockResolvedValue([]);
await openhumanTaskSourcesSync();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.task_sources_sync' });
});
test('listTasks forwards id + default limit', async () => {
mockCallCoreRpc.mockResolvedValue([]);
await openhumanTaskSourcesListTasks('s-1');
@@ -139,6 +146,7 @@ describe('tauriCommands/taskSources', () => {
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(openhumanTaskSourcesSync()).rejects.toThrow('Not running in Tauri');
await expect(openhumanTaskSourcesListTasks('x')).rejects.toThrow('Not running in Tauri');
await expect(
openhumanTaskSourcesPreviewFilter('github', { provider: 'github' })
+8 -2
View File
@@ -92,6 +92,7 @@ export interface FetchOutcome {
fetched: number;
routed: number;
skippedDupe: number;
pruned: number;
error?: string;
}
@@ -162,9 +163,9 @@ export async function openhumanTaskSourcesUpdate(
export async function openhumanTaskSourcesRemove(
id: string
): Promise<{ id: string; removed: boolean }> {
): Promise<{ id: string; removed: boolean; pruned?: number }> {
ensureTauri();
return await callCoreRpc<{ id: string; removed: boolean }>({
return await callCoreRpc<{ id: string; removed: boolean; pruned?: number }>({
method: 'openhuman.task_sources_remove',
params: { id },
});
@@ -178,6 +179,11 @@ export async function openhumanTaskSourcesFetch(id: string): Promise<FetchOutcom
});
}
export async function openhumanTaskSourcesSync(): Promise<FetchOutcome[]> {
ensureTauri();
return await callCoreRpc<FetchOutcome[]>({ method: 'openhuman.task_sources_sync' });
}
export async function openhumanTaskSourcesListTasks(
id: string,
limit = 50
+33 -3
View File
@@ -18,7 +18,7 @@ use crate::rpc::RpcOutcome;
use super::types::{
FetchReason, FilterSpec, ProviderSlug, SourceTarget, TaskSource, TaskSourcePatch,
};
use super::{filter, pipeline, store};
use super::{filter, pipeline, route, store};
/// List all configured task sources.
pub async fn list(config: &Config) -> Result<RpcOutcome<Vec<TaskSource>>, String> {
@@ -104,10 +104,20 @@ pub async fn update(
/// Remove a source by id.
pub async fn remove(config: &Config, id: &str) -> Result<RpcOutcome<Value>, String> {
let ingested = store::list_ingested_refs(config, id).map_err(|e| e.to_string())?;
let mut pruned = 0usize;
for item in ingested {
if let Some(card_id) = item.card_id.as_deref().filter(|id| !id.trim().is_empty()) {
route::remove_card(config, card_id)?;
}
if store::remove_ingested(config, id, &item.external_id).map_err(|e| e.to_string())? {
pruned += 1;
}
}
store::remove_source(config, id).map_err(|e| e.to_string())?;
tracing::debug!(source_id = %id, "[task_sources:ops] removed");
tracing::debug!(source_id = %id, pruned, "[task_sources:ops] removed");
Ok(RpcOutcome::new(
json!({ "id": id, "removed": true }),
json!({ "id": id, "removed": true, "pruned": pruned }),
vec![],
))
}
@@ -119,6 +129,26 @@ pub async fn fetch(config: &Config, id: &str) -> Result<RpcOutcome<super::FetchO
Ok(RpcOutcome::new(outcome, vec![]))
}
/// Manually sync all enabled task sources now.
pub async fn sync(config: &Config) -> Result<RpcOutcome<Vec<super::FetchOutcome>>, String> {
let sources = store::list_sources(config).map_err(|e| e.to_string())?;
let mut outcomes = Vec::new();
for source in sources.into_iter().filter(|source| source.enabled) {
outcomes.push(pipeline::run_source_once(config, &source, FetchReason::Manual).await);
}
tracing::info!(
source_count = outcomes.len(),
fetched = outcomes
.iter()
.map(|outcome| outcome.fetched)
.sum::<usize>(),
routed = outcomes.iter().map(|outcome| outcome.routed).sum::<usize>(),
pruned = outcomes.iter().map(|outcome| outcome.pruned).sum::<usize>(),
"[task_sources:ops] sync completed"
);
Ok(RpcOutcome::new(outcomes, vec![]))
}
/// Recently ingested tasks for a source (newest first).
pub async fn list_tasks(
config: &Config,
+47 -2
View File
@@ -9,6 +9,7 @@
use std::sync::Arc;
use chrono::Utc;
use std::collections::HashSet;
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::config::Config;
@@ -40,8 +41,8 @@ pub async fn run_source_once(
match run_inner(config, source, reason, &mut outcome).await {
Ok(()) => {
let status = format!(
"fetched {} routed {} dupes {}",
outcome.fetched, outcome.routed, outcome.skipped_dupe
"fetched {} routed {} dupes {} pruned {}",
outcome.fetched, outcome.routed, outcome.skipped_dupe, outcome.pruned
);
let _ = store::record_fetch(config, &source.id, Utc::now(), reason, &status);
publish_global(DomainEvent::TaskSourceFetched {
@@ -56,6 +57,7 @@ pub async fn run_source_once(
fetched = outcome.fetched,
routed = outcome.routed,
skipped_dupe = outcome.skipped_dupe,
pruned = outcome.pruned,
"[task_sources:pipeline] fetch pass complete"
);
}
@@ -109,6 +111,8 @@ async fn run_inner(
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();
let current_external_ids: HashSet<String> =
tasks.iter().map(|task| task.external_id.clone()).collect();
for mut task in tasks {
// Stamp the originating source before dedup / enrichment.
@@ -188,9 +192,50 @@ async fn run_inner(
outcome.routed += 1;
}
outcome.pruned = reconcile_missing_tasks(config, source, &current_external_ids)?;
Ok(())
}
fn reconcile_missing_tasks(
config: &Config,
source: &TaskSource,
current_external_ids: &HashSet<String>,
) -> Result<usize, String> {
let ingested = store::list_ingested_refs(config, &source.id)
.map_err(|e| format!("list_ingested_refs failed: {e}"))?;
let mut pruned = 0usize;
for item in ingested {
if current_external_ids.contains(&item.external_id) {
continue;
}
if let Some(card_id) = item.card_id.as_deref().filter(|id| !id.trim().is_empty()) {
route::remove_card(config, card_id).map_err(|e| {
format!(
"remove stale card '{}' for source '{}' external task '{}': {e}",
card_id, source.id, item.external_id
)
})?;
}
if store::remove_ingested(config, &source.id, &item.external_id)
.map_err(|e| format!("remove_ingested failed: {e}"))?
{
pruned += 1;
tracing::debug!(
source_id = %source.id,
provider = %source.provider.as_str(),
external_id = %item.external_id,
"[task_sources:pipeline] pruned task absent from latest source fetch"
);
}
}
Ok(pruned)
}
#[cfg(test)]
#[path = "pipeline_tests.rs"]
mod tests;
@@ -174,6 +174,46 @@ async fn edited_task_reroutes_as_new_card() {
assert_eq!(ingested[0].title, "Edited title");
}
#[tokio::test]
async fn task_missing_from_latest_fetch_is_pruned() {
let _guard = registry_lock();
register_provider(Arc::new(StubProvider {
tasks: vec![
canned_task("1", "Keep task", "2025-01-01T00:00:00Z"),
canned_task("2", "Closed task", "2025-01-02T00: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, 2, "error={:?}", first.error);
assert_eq!(route::board_cards(&config).unwrap().len(), 2);
// Simulate the provider returning only currently-open/matching tasks:
// task 2 was closed or no longer matches the source filter.
register_provider(Arc::new(StubProvider {
tasks: vec![canned_task("1", "Keep task", "2025-01-01T00:00:00Z")],
}));
let second = run_source_once(&config, &source, FetchReason::Manual).await;
assert_eq!(second.fetched, 1);
assert_eq!(second.routed, 0);
assert_eq!(second.skipped_dupe, 1);
assert_eq!(second.pruned, 1);
assert!(second.error.is_none());
let cards = route::board_cards(&config).unwrap();
assert_eq!(cards.len(), 1);
assert!(cards[0].title.contains("Keep task"));
let ingested = store::list_ingested(&config, &source.id, 10).unwrap();
assert_eq!(ingested.len(), 1);
assert_eq!(ingested[0].external_id, "1");
}
#[tokio::test]
async fn missing_provider_surfaces_error_in_outcome() {
let _guard = registry_lock();
+29 -13
View File
@@ -25,6 +25,13 @@ use super::TaskKind;
/// Stable thread id whose board collects every ingested task.
pub const TASK_SOURCES_THREAD_ID: &str = "task-sources";
fn task_sources_location(config: &Config) -> BoardLocation {
BoardLocation::Thread {
workspace_dir: config.workspace_dir.clone(),
thread_id: TASK_SOURCES_THREAD_ID.to_string(),
}
}
/// 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(
@@ -65,16 +72,13 @@ fn add_card(
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(),
};
let location = task_sources_location(config);
// 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) {
match remove_card(config, old_id) {
Ok(_) => {
tracing::debug!(
source_id = %source.id,
@@ -230,10 +234,7 @@ async fn dispatch_triage(
// Link the envelope to the board card so triage's escalation arm routes
// it through the deterministic dispatcher (claim → autonomous run →
// write-back) instead of the one-shot triage sub-agent.
let location = BoardLocation::Thread {
workspace_dir: config.workspace_dir.clone(),
thread_id: TASK_SOURCES_THREAD_ID.to_string(),
};
let location = task_sources_location(config);
let envelope = TriggerEnvelope::from_external(
&format!("task_sources:{}", source.id),
"external task ingested",
@@ -289,13 +290,28 @@ fn provider_label(provider: &str) -> String {
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(),
};
let location = task_sources_location(config);
todos::ops::list(&location).map(|snap| snap.cards)
}
/// Remove a task-source board card. Missing cards are treated as already
/// reconciled so ledger cleanup can still proceed.
pub fn remove_card(config: &Config, card_id: &str) -> Result<bool, String> {
let location = task_sources_location(config);
match todo_remove(&location, card_id) {
Ok(_) => Ok(true),
Err(e) if e.contains("not found") => {
tracing::debug!(
card_id,
error = %e,
"[task_sources:route] card already absent during reconciliation"
);
Ok(false)
}
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
+33 -2
View File
@@ -53,6 +53,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("update"),
schemas("remove"),
schemas("fetch"),
schemas("sync"),
schemas("list_tasks"),
schemas("preview_filter"),
schemas("list_databases"),
@@ -86,6 +87,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("fetch"),
handler: handle_fetch,
},
RegisteredController {
schema: schemas("sync"),
handler: handle_sync,
},
RegisteredController {
schema: schemas("list_tasks"),
handler: handle_list_tasks,
@@ -230,6 +235,12 @@ pub fn schemas(function: &str) -> ControllerSchema {
comment: "True when the source was removed.",
required: true,
},
FieldSchema {
name: "pruned",
ty: TypeSchema::U64,
comment: "Count of ingested refs/cards pruned during removal.",
required: true,
},
],
},
comment: "Removal result payload.",
@@ -239,7 +250,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
"fetch" => ControllerSchema {
namespace: "task_sources",
function: "fetch",
description: "Fetch one source immediately and route any new tasks.",
description: "Fetch one source immediately, route new tasks, and prune tasks no longer returned by the source.",
inputs: vec![source_id_input(
"Identifier of the task source to fetch now.",
)],
@@ -250,6 +261,18 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"sync" => ControllerSchema {
namespace: "task_sources",
function: "sync",
description: "Fetch every enabled source immediately, route new tasks, and prune tasks no longer returned by their source.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "outcomes",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("FetchOutcome"))),
comment: "Fetch outcome counts for each enabled source.",
required: true,
}],
},
"list_tasks" => ControllerSchema {
namespace: "task_sources",
function: "list_tasks",
@@ -444,6 +467,13 @@ fn handle_fetch(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_sync(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(ops::sync(&config).await?)
})
}
fn handle_list_tasks(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
@@ -550,6 +580,7 @@ mod tests {
"update",
"remove",
"fetch",
"sync",
"list_tasks",
"preview_filter",
"list_databases",
@@ -561,7 +592,7 @@ mod tests {
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
assert_eq!(controllers.len(), 10);
assert_eq!(controllers.len(), all_controller_schemas().len());
assert!(controllers
.iter()
.all(|c| c.schema.namespace == "task_sources"));
+41
View File
@@ -25,6 +25,12 @@ use super::types::{
FetchReason, FilterSpec, ProviderSlug, SourceTarget, TaskSource, TaskSourcePatch,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IngestedTaskRef {
pub external_id: String,
pub card_id: Option<String>,
}
/// Compute an edit-aware content hash for a task. Two fetches of the
/// same `external_id` whose title/body/status/updated_at/url differ produce
/// different hashes, so an *edited* upstream item re-ingests.
@@ -309,6 +315,41 @@ pub fn get_card_id(config: &Config, source_id: &str, external_id: &str) -> Resul
})
}
/// Return ingested task ids/card ids for one source. Used by reconciliation
/// to prune board cards that no longer match the upstream source/filter.
pub fn list_ingested_refs(config: &Config, source_id: &str) -> Result<Vec<IngestedTaskRef>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT external_id, card_id FROM ingested_tasks
WHERE source_id = ?1
ORDER BY ingested_at ASC, external_id ASC",
)?;
let rows = stmt.query_map(params![source_id], |row| {
Ok(IngestedTaskRef {
external_id: row.get(0)?,
card_id: row.get(1)?,
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
})
}
/// Delete one ingested ledger row after its board card has been reconciled.
pub fn remove_ingested(config: &Config, source_id: &str, external_id: &str) -> Result<bool> {
let changed = with_connection(config, |conn| {
conn.execute(
"DELETE FROM ingested_tasks WHERE source_id = ?1 AND external_id = ?2",
params![source_id, external_id],
)
.context("Failed to delete ingested task")
})?;
Ok(changed > 0)
}
/// List the most recently ingested tasks for a source (newest first).
pub fn list_ingested(
config: &Config,
+39
View File
@@ -264,6 +264,45 @@ async fn add_with_assigned_executor_persists_and_filters_blank() {
assert_eq!(blank.value.assigned_executor, None);
}
#[tokio::test]
async fn ops_remove_prunes_routed_cards_for_source() {
use crate::openhuman::task_sources::{ops, route};
use crate::openhuman::todos::ops::{add as todo_add, BoardLocation, CardPatch};
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 location = BoardLocation::Thread {
workspace_dir: config.workspace_dir.clone(),
thread_id: route::TASK_SOURCES_THREAD_ID.to_string(),
};
let snapshot = todo_add(&location, "[GitHub] A", CardPatch::default()).unwrap();
let card_id = snapshot.cards.last().unwrap().id.clone();
mark_ingested(
&config,
&src.id,
&sample_task("1", "A", "2025-01-01"),
&card_id,
)
.unwrap();
let out = ops::remove(&config, &src.id).await.expect("remove source");
assert_eq!(out.value["removed"], true);
assert_eq!(out.value["pruned"], 1);
assert!(route::board_cards(&config).unwrap().is_empty());
assert!(list_ingested(&config, &src.id, 10).unwrap().is_empty());
}
#[test]
fn content_hash_changes_when_only_url_changes() {
// `url` is load-bearing downstream (source_metadata / external write-back),
+4
View File
@@ -239,6 +239,10 @@ pub struct FetchOutcome {
pub routed: usize,
/// Tasks skipped because they were already ingested.
pub skipped_dupe: usize,
/// Previously ingested tasks removed because the upstream source no longer
/// returns them for this filter/status.
#[serde(default)]
pub pruned: usize,
/// Optional human-readable status line.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,