mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(task-sources): sync and prune stale tasks (#3418)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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': 'استباقي',
|
||||
|
||||
@@ -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': 'আকর্ষণীয়',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'सक्रिय',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '능동형',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Проактивный',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¤t_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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user