feat: migrate task jobs to workflow notifications + timeline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
40
app/page.tsx
40
app/page.tsx
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user