feat: migrate task jobs to workflow notifications + timeline

This commit is contained in:
2026-03-02 14:29:31 -05:00
parent 36c4ed2ee2
commit d81a681905
33 changed files with 2437 additions and 292 deletions

View File

@@ -11,15 +11,13 @@ import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import { useTaskPoller } from '@/hooks/use-task-poller';
import { queueFilingAnalysis, queueFilingSync } from '@/lib/api';
import type { Filing, Task } from '@/lib/types';
import type { Filing } from '@/lib/types';
import { formatCurrencyByScale, type NumberScaleUnit } from '@/lib/format';
import { queryKeys } from '@/lib/query/keys';
import { filingsQueryOptions, taskQueryOptions } from '@/lib/query/options';
import { filingsQueryOptions } from '@/lib/query/options';
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
{ value: 'thousands', label: 'Thousands (K)' },
@@ -115,7 +113,6 @@ function FilingsPageContent() {
const [syncTickerInput, setSyncTickerInput] = useState('');
const [filterTickerInput, setFilterTickerInput] = useState('');
const [searchTicker, setSearchTicker] = useState('');
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
useEffect(() => {
@@ -153,29 +150,16 @@ function FilingsPageContent() {
}
}, [isPending, isAuthenticated, searchTicker, loadFilings]);
const polledTask = useTaskPoller({
taskId: activeTask?.id ?? null,
onTerminalState: async () => {
setActiveTask(null);
void queryClient.invalidateQueries({ queryKey: ['filings'] });
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
await loadFilings(searchTicker || undefined);
}
});
const liveTask = polledTask ?? activeTask;
const triggerSync = async () => {
if (!syncTickerInput.trim()) {
return;
}
try {
const { task } = await queueFilingSync({ ticker: syncTickerInput.trim().toUpperCase(), limit: 20 });
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
await queueFilingSync({ ticker: syncTickerInput.trim().toUpperCase(), limit: 20 });
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: ['filings'] });
await loadFilings(searchTicker || undefined);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue filing sync');
}
@@ -183,9 +167,7 @@ function FilingsPageContent() {
const triggerAnalysis = async (accessionNumber: string) => {
try {
const { task } = await queueFilingAnalysis(accessionNumber);
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
await queueFilingAnalysis(accessionNumber);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: ['report'] });
} catch (err) {
@@ -230,16 +212,6 @@ function FilingsPageContent() {
</Button>
)}
>
{liveTask ? (
<Panel title="Active Task" subtitle={`${liveTask.task_type} is processing in the task engine.`}>
<div className="flex flex-col gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<p className="break-all text-sm text-[color:var(--terminal-bright)]">{liveTask.id}</p>
<StatusPill status={liveTask.status} />
</div>
{liveTask.error ? <p className="mt-2 text-sm text-[#ff9898]">{liveTask.error}</p> : null}
</Panel>
) : null}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1.2fr_1fr]">
<Panel title="Sync Controller" subtitle="Queue ingestion jobs by ticker symbol.">
<form

View File

@@ -29,6 +29,23 @@ body {
padding: 0;
}
[data-sonner-toaster] {
--normal-bg: rgba(7, 22, 31, 0.96);
--normal-text: #e8fff8;
--normal-border: rgba(123, 255, 217, 0.45);
--success-bg: rgba(8, 58, 42, 0.96);
--success-text: #d0ffe9;
--success-border: rgba(104, 255, 213, 0.7);
--error-bg: rgba(67, 22, 22, 0.96);
--error-text: #ffd6d6;
--error-border: rgba(255, 112, 112, 0.8);
}
[data-sonner-toast] {
backdrop-filter: blur(8px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
}
body {
min-height: 100vh;
font-family: var(--font-display), sans-serif;

View File

@@ -9,10 +9,8 @@ import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { MetricCard } from '@/components/dashboard/metric-card';
import { TaskFeed } from '@/components/dashboard/task-feed';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import { useTaskPoller } from '@/hooks/use-task-poller';
import {
queuePortfolioInsights,
queuePriceRefresh
@@ -25,7 +23,6 @@ import {
latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions,
recentTasksQueryOptions,
taskQueryOptions,
watchlistQueryOptions
} from '@/lib/query/options';
@@ -58,7 +55,6 @@ export default function CommandCenterPage() {
const [state, setState] = useState<DashboardState>(EMPTY_STATE);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const loadData = useCallback(async () => {
const summaryOptions = portfolioSummaryQueryOptions();
@@ -102,30 +98,16 @@ export default function CommandCenterPage() {
}
}, [isPending, isAuthenticated, loadData]);
const trackedTask = useTaskPoller({
taskId: activeTaskId,
onTerminalState: () => {
setActiveTaskId(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: ['filings'] });
void loadData();
}
});
const headerActions = (
<>
<Button
variant="secondary"
onClick={async () => {
try {
const { task } = await queuePriceRefresh();
setActiveTaskId(task.id);
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setState((prev) => ({ ...prev, tasks: [latest.task, ...prev.tasks] }));
await queuePriceRefresh();
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue price refresh');
}
@@ -137,12 +119,10 @@ export default function CommandCenterPage() {
<Button
onClick={async () => {
try {
const { task } = await queuePortfolioInsights();
setActiveTaskId(task.id);
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setState((prev) => ({ ...prev, tasks: [latest.task, ...prev.tasks] }));
await queuePortfolioInsights();
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue AI insight');
}
@@ -169,18 +149,6 @@ export default function CommandCenterPage() {
subtitle={`Welcome back${session?.user?.name ? `, ${session.user.name}` : ''}. Review tasks, portfolio health, and AI outputs.`}
actions={headerActions}
>
{activeTaskId && trackedTask ? (
<Panel title="Live Task" subtitle={`Task ${activeTaskId.slice(0, 8)} is active.`}>
<div className="flex items-center justify-between gap-3 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{trackedTask.task_type.replace('_', ' ')}</p>
<StatusPill status={trackedTask.status} />
</div>
{trackedTask.error ? (
<p className="mt-3 text-sm text-[#ff9898]">{trackedTask.error}</p>
) : null}
</Panel>
) : null}
{error ? (
<Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p>

View File

@@ -8,23 +8,20 @@ import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import {
deleteHolding,
queuePortfolioInsights,
queuePriceRefresh,
upsertHolding
} from '@/lib/api';
import type { Holding, PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
import type { Holding, PortfolioInsight, PortfolioSummary } from '@/lib/types';
import { asNumber, formatCurrency, formatPercent } from '@/lib/format';
import { queryKeys } from '@/lib/query/keys';
import {
holdingsQueryOptions,
latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions,
taskQueryOptions
portfolioSummaryQueryOptions
} from '@/lib/query/options';
type FormState = {
@@ -58,7 +55,6 @@ export default function PortfolioPage() {
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
const loadPortfolio = useCallback(async () => {
@@ -95,20 +91,6 @@ export default function PortfolioPage() {
}
}, [isPending, isAuthenticated, loadPortfolio]);
const polledTask = useTaskPoller({
taskId: activeTask?.id ?? null,
onTerminalState: async () => {
setActiveTask(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
await loadPortfolio();
}
});
const liveTask = polledTask ?? activeTask;
const allocationData = useMemo(
() => holdings.map((holding) => ({
name: holding.ticker,
@@ -147,11 +129,10 @@ export default function PortfolioPage() {
const queueRefresh = async () => {
try {
const { task } = await queuePriceRefresh();
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
await queuePriceRefresh();
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue price refresh');
}
@@ -159,11 +140,10 @@ export default function PortfolioPage() {
const queueInsights = async () => {
try {
const { task } = await queuePortfolioInsights();
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
await queuePortfolioInsights();
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue portfolio insights');
}
@@ -190,16 +170,6 @@ export default function PortfolioPage() {
</>
)}
>
{liveTask ? (
<Panel title="Task Runner" subtitle={liveTask.id}>
<div className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{liveTask.task_type}</p>
<StatusPill status={liveTask.status} />
</div>
{liveTask.error ? <p className="mt-2 text-sm text-[#ff9f9f]">{liveTask.error}</p> : null}
</Panel>
) : null}
{error ? (
<Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p>

View File

@@ -8,14 +8,12 @@ import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import { deleteWatchlistItem, queueFilingSync, upsertWatchlistItem } from '@/lib/api';
import type { Task, WatchlistItem } from '@/lib/types';
import type { WatchlistItem } from '@/lib/types';
import { queryKeys } from '@/lib/query/keys';
import { taskQueryOptions, watchlistQueryOptions } from '@/lib/query/options';
import { watchlistQueryOptions } from '@/lib/query/options';
type FormState = {
ticker: string;
@@ -31,7 +29,6 @@ export default function WatchlistPage() {
const [items, setItems] = useState<WatchlistItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [form, setForm] = useState<FormState>({ ticker: '', companyName: '', sector: '' });
const loadWatchlist = useCallback(async () => {
@@ -59,17 +56,6 @@ export default function WatchlistPage() {
}
}, [isPending, isAuthenticated, loadWatchlist]);
const polledTask = useTaskPoller({
taskId: activeTask?.id ?? null,
onTerminalState: () => {
setActiveTask(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: ['filings'] });
}
});
const liveTask = polledTask ?? activeTask;
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -90,10 +76,9 @@ export default function WatchlistPage() {
const queueSync = async (ticker: string) => {
try {
const { task } = await queueFilingSync({ ticker, limit: 20 });
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
await queueFilingSync({ ticker, limit: 20 });
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: ['filings'] });
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to queue sync for ${ticker}`);
}
@@ -108,15 +93,6 @@ export default function WatchlistPage() {
title="Watchlist"
subtitle="Track symbols, company context, and trigger filing ingestion jobs from one surface."
>
{liveTask ? (
<Panel title="Queue Status">
<div className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{liveTask.task_type}</p>
<StatusPill status={liveTask.status} />
</div>
</Panel>
) : null}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.6fr_1fr]">
<Panel title="Symbols" subtitle="Your monitored universe.">
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}

View File

@@ -1,41 +1,48 @@
import { sleep } from 'workflow';
import { start } from 'workflow/api';
import { runTaskProcessor } from '@/lib/server/task-processors';
import {
claimQueuedTask,
completeTask,
markTaskFailure
getTaskById,
markTaskFailure,
markTaskRunning
} from '@/lib/server/repos/tasks';
import type { Task } from '@/lib/types';
export async function runTaskWorkflow(taskId: string) {
'use workflow';
const task = await claimQueuedTaskStep(taskId);
const task = await loadTaskStep(taskId);
if (!task) {
return;
}
await markTaskRunningStep(task.id);
try {
const result = await processTaskStep(task);
const refreshedTask = await loadTaskStep(task.id);
if (!refreshedTask) {
return;
}
const result = await processTaskStep(refreshedTask);
await completeTaskStep(task.id, result);
} catch (error) {
const reason = error instanceof Error
? error.message
: 'Task failed unexpectedly';
const nextState = await markTaskFailureStep(task.id, reason);
if (nextState.shouldRetry) {
await sleep('1200ms');
await restartTaskWorkflowStep(task.id);
}
await markTaskFailureStep(task.id, reason);
throw error;
}
}
async function claimQueuedTaskStep(taskId: string) {
async function loadTaskStep(taskId: string) {
'use step';
return await claimQueuedTask(taskId);
return await getTaskById(taskId);
}
async function markTaskRunningStep(taskId: string) {
'use step';
await markTaskRunning(taskId);
}
async function processTaskStep(task: Task) {
@@ -43,7 +50,7 @@ async function processTaskStep(task: Task) {
return await runTaskProcessor(task);
}
// Step-level retries duplicate task-level retry handling and can create noisy AI failure loops.
// Keep retries at the projection workflow level to avoid duplicate side effects.
(
processTaskStep as ((task: Task) => Promise<Record<string, unknown>>) & { maxRetries?: number }
).maxRetries = 0;
@@ -55,10 +62,5 @@ async function completeTaskStep(taskId: string, result: Record<string, unknown>)
async function markTaskFailureStep(taskId: string, reason: string) {
'use step';
return await markTaskFailure(taskId, reason);
}
async function restartTaskWorkflowStep(taskId: string) {
'use step';
await start(runTaskWorkflow, [taskId]);
await markTaskFailure(taskId, reason);
}