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

@@ -28,7 +28,12 @@ OLLAMA_API_KEY=ollama
# SEC API etiquette # SEC API etiquette
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local> SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>
# Workflow runtime (Local world) # Workflow runtime (Coolify / production)
WORKFLOW_TARGET_WORLD=local WORKFLOW_TARGET_WORLD=@workflow/world-postgres
WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_
# Optional local-world fallback for rollback/testing
WORKFLOW_LOCAL_DATA_DIR=.workflow-data WORKFLOW_LOCAL_DATA_DIR=.workflow-data
WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100 WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100

View File

@@ -9,10 +9,10 @@ ARG DATABASE_URL=file:data/fiscal.sqlite
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV DATABASE_URL=${DATABASE_URL} ENV DATABASE_URL=${DATABASE_URL}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV WORKFLOW_TARGET_WORLD=local ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres
ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data
COPY . . COPY . .
RUN mkdir -p public /app/.workflow-data && WORKFLOW_TARGET_WORLD=local bun run build RUN mkdir -p public /app/.workflow-data && bun run build
FROM oven/bun:1.3.5-alpine AS runner FROM oven/bun:1.3.5-alpine AS runner
WORKDIR /app WORKDIR /app
@@ -21,7 +21,7 @@ ENV NODE_ENV=production
ARG NEXT_PUBLIC_API_URL= ARG NEXT_PUBLIC_API_URL=
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV WORKFLOW_TARGET_WORLD=local ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres
ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
@@ -42,4 +42,4 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
CMD ["sh", "-c", "./node_modules/.bin/drizzle-kit migrate --config /app/drizzle.config.ts && bun server.js"] CMD ["sh", "-c", "if [ \"$WORKFLOW_TARGET_WORLD\" = \"@workflow/world-postgres\" ]; then ./node_modules/.bin/workflow-postgres-setup; fi && ./node_modules/.bin/drizzle-kit migrate --config /app/drizzle.config.ts && bun server.js"]

View File

@@ -12,8 +12,8 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with Vercel AI SDK integra
- Drizzle ORM (SQLite) + Better Auth Drizzle adapter - Drizzle ORM (SQLite) + Better Auth Drizzle adapter
- Internal API routes via Elysia app module (`lib/server/api/app.ts`) - Internal API routes via Elysia app module (`lib/server/api/app.ts`)
- Eden Treaty for type-safe frontend API calls - Eden Treaty for type-safe frontend API calls
- Workflow DevKit Local World for background task execution - Workflow DevKit Postgres World for background task execution durability
- SQLite-backed domain storage (watchlist, holdings, filings, tasks, insights) - SQLite-backed app domain storage (watchlist, holdings, filings, task projection, insights)
- Vercel AI SDK (`ai`) with dual-model routing: - Vercel AI SDK (`ai`) with dual-model routing:
- Ollama (`@ai-sdk/openai`) for lightweight filing extraction/parsing - Ollama (`@ai-sdk/openai`) for lightweight filing extraction/parsing
- Zhipu (`zhipu-ai-provider`) for heavyweight narrative reports (`https://api.z.ai/api/coding/paas/v4`) - Zhipu (`zhipu-ai-provider`) for heavyweight narrative reports (`https://api.z.ai/api/coding/paas/v4`)
@@ -51,9 +51,11 @@ The app calls Zhipu directly via AI SDK for heavy reports and calls Ollama for l
When running in Docker and Ollama runs on the host, set `OLLAMA_BASE_URL=http://host.docker.internal:11434`. When running in Docker and Ollama runs on the host, set `OLLAMA_BASE_URL=http://host.docker.internal:11434`.
Zhipu always targets the Coding API endpoint (`https://api.z.ai/api/coding/paas/v4`). Zhipu always targets the Coding API endpoint (`https://api.z.ai/api/coding/paas/v4`).
On container startup, the app applies Drizzle migrations automatically before launching Next.js. On container startup, the app applies Drizzle migrations automatically before launching Next.js.
The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow local data in `fiscal_workflow_data` (mounted to `/app/.workflow-data`). The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow world data in Postgres volume `workflow_postgres_data`.
Container startup runs:
Workflow Local World uses filesystem state plus an in-memory queue. On container restart, queued in-flight jobs may be lost. 1. `workflow-postgres-setup` (idempotent Workflow world bootstrap)
2. Drizzle migrations for SQLite app tables
3. Next.js server boot
Docker images use Bun (`oven/bun:1.3.5-alpine`) for build and runtime. Docker images use Bun (`oven/bun:1.3.5-alpine`) for build and runtime.
@@ -67,16 +69,23 @@ Required environment variables in Coolify:
- `BETTER_AUTH_SECRET=<long-random-secret>` - `BETTER_AUTH_SECRET=<long-random-secret>`
- `BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz` - `BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz`
- `BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz` - `BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz`
- `WORKFLOW_TARGET_WORLD=local` - `WORKFLOW_TARGET_WORLD=@workflow/world-postgres`
- Optional: `WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data` - `WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow`
- Optional: `WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10`
- Optional: `WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_`
Operational constraints for Coolify: Operational constraints for Coolify:
- Keep this service to a single instance/replica. SQLite is file-based and not appropriate for multi-replica shared-write deployments. - Keep this service to a single instance/replica. SQLite is file-based and not appropriate for multi-replica shared-write deployments.
- Ensure the two named volumes are persisted (`fiscal_sqlite_data`, `fiscal_workflow_data`). - Ensure both named volumes are persisted (`fiscal_sqlite_data`, `workflow_postgres_data`).
- Workflow Local queueing is in-memory; in-flight queued jobs may be lost on restarts. - Keep `WORKFLOW_POSTGRES_URL` explicit so Workflow does not fall back to `DATABASE_URL` (SQLite).
- Docker build forces `WORKFLOW_TARGET_WORLD=local` to avoid stale Coolify build args referencing `@workflow/world-postgres`. - The app `/api/health` probes Workflow backend connectivity and returns non-200 when Workflow world is unavailable.
- Runtime Compose config also pins `WORKFLOW_TARGET_WORLD=local` for the same reason.
Emergency rollback path:
1. Set `WORKFLOW_TARGET_WORLD=local`
2. Remove/disable `WORKFLOW_POSTGRES_URL`
3. Redeploy
## Environment ## Environment
@@ -100,7 +109,12 @@ OLLAMA_MODEL=qwen3:8b
OLLAMA_API_KEY=ollama OLLAMA_API_KEY=ollama
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local> SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>
WORKFLOW_TARGET_WORLD=local WORKFLOW_TARGET_WORLD=@workflow/world-postgres
WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_
# Optional local-world fallback
WORKFLOW_LOCAL_DATA_DIR=.workflow-data WORKFLOW_LOCAL_DATA_DIR=.workflow-data
WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100 WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100
``` ```

View File

@@ -11,15 +11,13 @@ import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel'; import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard'; import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import { useTaskPoller } from '@/hooks/use-task-poller';
import { queueFilingAnalysis, queueFilingSync } from '@/lib/api'; 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 { formatCurrencyByScale, type NumberScaleUnit } from '@/lib/format';
import { queryKeys } from '@/lib/query/keys'; 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 }> = [ const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
{ value: 'thousands', label: 'Thousands (K)' }, { value: 'thousands', label: 'Thousands (K)' },
@@ -115,7 +113,6 @@ function FilingsPageContent() {
const [syncTickerInput, setSyncTickerInput] = useState(''); const [syncTickerInput, setSyncTickerInput] = useState('');
const [filterTickerInput, setFilterTickerInput] = useState(''); const [filterTickerInput, setFilterTickerInput] = useState('');
const [searchTicker, setSearchTicker] = useState(''); const [searchTicker, setSearchTicker] = useState('');
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions'); const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
useEffect(() => { useEffect(() => {
@@ -153,29 +150,16 @@ function FilingsPageContent() {
} }
}, [isPending, isAuthenticated, searchTicker, loadFilings]); }, [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 () => { const triggerSync = async () => {
if (!syncTickerInput.trim()) { if (!syncTickerInput.trim()) {
return; return;
} }
try { try {
const { task } = await queueFilingSync({ ticker: syncTickerInput.trim().toUpperCase(), limit: 20 }); await queueFilingSync({ ticker: syncTickerInput.trim().toUpperCase(), limit: 20 });
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: ['filings'] }); void queryClient.invalidateQueries({ queryKey: ['filings'] });
await loadFilings(searchTicker || undefined);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue filing sync'); setError(err instanceof Error ? err.message : 'Failed to queue filing sync');
} }
@@ -183,9 +167,7 @@ function FilingsPageContent() {
const triggerAnalysis = async (accessionNumber: string) => { const triggerAnalysis = async (accessionNumber: string) => {
try { try {
const { task } = await queueFilingAnalysis(accessionNumber); await queueFilingAnalysis(accessionNumber);
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: ['report'] }); void queryClient.invalidateQueries({ queryKey: ['report'] });
} catch (err) { } catch (err) {
@@ -230,16 +212,6 @@ function FilingsPageContent() {
</Button> </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]"> <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."> <Panel title="Sync Controller" subtitle="Queue ingestion jobs by ticker symbol.">
<form <form

View File

@@ -29,6 +29,23 @@ body {
padding: 0; 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 { body {
min-height: 100vh; min-height: 100vh;
font-family: var(--font-display), sans-serif; 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 { Button } from '@/components/ui/button';
import { MetricCard } from '@/components/dashboard/metric-card'; import { MetricCard } from '@/components/dashboard/metric-card';
import { TaskFeed } from '@/components/dashboard/task-feed'; import { TaskFeed } from '@/components/dashboard/task-feed';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard'; import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import { useTaskPoller } from '@/hooks/use-task-poller';
import { import {
queuePortfolioInsights, queuePortfolioInsights,
queuePriceRefresh queuePriceRefresh
@@ -25,7 +23,6 @@ import {
latestPortfolioInsightQueryOptions, latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions, portfolioSummaryQueryOptions,
recentTasksQueryOptions, recentTasksQueryOptions,
taskQueryOptions,
watchlistQueryOptions watchlistQueryOptions
} from '@/lib/query/options'; } from '@/lib/query/options';
@@ -58,7 +55,6 @@ export default function CommandCenterPage() {
const [state, setState] = useState<DashboardState>(EMPTY_STATE); const [state, setState] = useState<DashboardState>(EMPTY_STATE);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
const summaryOptions = portfolioSummaryQueryOptions(); const summaryOptions = portfolioSummaryQueryOptions();
@@ -102,30 +98,16 @@ export default function CommandCenterPage() {
} }
}, [isPending, isAuthenticated, loadData]); }, [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 = ( const headerActions = (
<> <>
<Button <Button
variant="secondary" variant="secondary"
onClick={async () => { onClick={async () => {
try { try {
const { task } = await queuePriceRefresh(); await queuePriceRefresh();
setActiveTaskId(task.id);
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setState((prev) => ({ ...prev, tasks: [latest.task, ...prev.tasks] }));
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() }); void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadData();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue price refresh'); setError(err instanceof Error ? err.message : 'Failed to queue price refresh');
} }
@@ -137,12 +119,10 @@ export default function CommandCenterPage() {
<Button <Button
onClick={async () => { onClick={async () => {
try { try {
const { task } = await queuePortfolioInsights(); await queuePortfolioInsights();
setActiveTaskId(task.id);
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setState((prev) => ({ ...prev, tasks: [latest.task, ...prev.tasks] }));
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() }); void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
await loadData();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue AI insight'); 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.`} subtitle={`Welcome back${session?.user?.name ? `, ${session.user.name}` : ''}. Review tasks, portfolio health, and AI outputs.`}
actions={headerActions} 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 ? ( {error ? (
<Panel> <Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p> <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 { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard'; import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import { import {
deleteHolding, deleteHolding,
queuePortfolioInsights, queuePortfolioInsights,
queuePriceRefresh, queuePriceRefresh,
upsertHolding upsertHolding
} from '@/lib/api'; } 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 { asNumber, formatCurrency, formatPercent } from '@/lib/format';
import { queryKeys } from '@/lib/query/keys'; import { queryKeys } from '@/lib/query/keys';
import { import {
holdingsQueryOptions, holdingsQueryOptions,
latestPortfolioInsightQueryOptions, latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions, portfolioSummaryQueryOptions
taskQueryOptions
} from '@/lib/query/options'; } from '@/lib/query/options';
type FormState = { type FormState = {
@@ -58,7 +55,6 @@ export default function PortfolioPage() {
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null); const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' }); const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
const loadPortfolio = useCallback(async () => { const loadPortfolio = useCallback(async () => {
@@ -95,20 +91,6 @@ export default function PortfolioPage() {
} }
}, [isPending, isAuthenticated, loadPortfolio]); }, [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( const allocationData = useMemo(
() => holdings.map((holding) => ({ () => holdings.map((holding) => ({
name: holding.ticker, name: holding.ticker,
@@ -147,11 +129,10 @@ export default function PortfolioPage() {
const queueRefresh = async () => { const queueRefresh = async () => {
try { try {
const { task } = await queuePriceRefresh(); await queuePriceRefresh();
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() }); void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue price refresh'); setError(err instanceof Error ? err.message : 'Unable to queue price refresh');
} }
@@ -159,11 +140,10 @@ export default function PortfolioPage() {
const queueInsights = async () => { const queueInsights = async () => {
try { try {
const { task } = await queuePortfolioInsights(); await queuePortfolioInsights();
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() }); void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
await loadPortfolio();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue portfolio insights'); 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 ? ( {error ? (
<Panel> <Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p> <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 { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import { useAuthGuard } from '@/hooks/use-auth-guard'; import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import { deleteWatchlistItem, queueFilingSync, upsertWatchlistItem } from '@/lib/api'; 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 { queryKeys } from '@/lib/query/keys';
import { taskQueryOptions, watchlistQueryOptions } from '@/lib/query/options'; import { watchlistQueryOptions } from '@/lib/query/options';
type FormState = { type FormState = {
ticker: string; ticker: string;
@@ -31,7 +29,6 @@ export default function WatchlistPage() {
const [items, setItems] = useState<WatchlistItem[]>([]); const [items, setItems] = useState<WatchlistItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [form, setForm] = useState<FormState>({ ticker: '', companyName: '', sector: '' }); const [form, setForm] = useState<FormState>({ ticker: '', companyName: '', sector: '' });
const loadWatchlist = useCallback(async () => { const loadWatchlist = useCallback(async () => {
@@ -59,17 +56,6 @@ export default function WatchlistPage() {
} }
}, [isPending, isAuthenticated, loadWatchlist]); }, [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>) => { const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -90,10 +76,9 @@ export default function WatchlistPage() {
const queueSync = async (ticker: string) => { const queueSync = async (ticker: string) => {
try { try {
const { task } = await queueFilingSync({ ticker, limit: 20 }); await queueFilingSync({ ticker, limit: 20 });
const latest = await queryClient.fetchQuery(taskQueryOptions(task.id));
setActiveTask(latest.task);
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) }); void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: ['filings'] });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : `Failed to queue sync for ${ticker}`); setError(err instanceof Error ? err.message : `Failed to queue sync for ${ticker}`);
} }
@@ -108,15 +93,6 @@ export default function WatchlistPage() {
title="Watchlist" title="Watchlist"
subtitle="Track symbols, company context, and trigger filing ingestion jobs from one surface." 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]"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.6fr_1fr]">
<Panel title="Symbols" subtitle="Your monitored universe."> <Panel title="Symbols" subtitle="Your monitored universe.">
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null} {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 { runTaskProcessor } from '@/lib/server/task-processors';
import { import {
claimQueuedTask,
completeTask, completeTask,
markTaskFailure getTaskById,
markTaskFailure,
markTaskRunning
} from '@/lib/server/repos/tasks'; } from '@/lib/server/repos/tasks';
import type { Task } from '@/lib/types'; import type { Task } from '@/lib/types';
export async function runTaskWorkflow(taskId: string) { export async function runTaskWorkflow(taskId: string) {
'use workflow'; 'use workflow';
const task = await claimQueuedTaskStep(taskId); const task = await loadTaskStep(taskId);
if (!task) { if (!task) {
return; return;
} }
await markTaskRunningStep(task.id);
try { 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); await completeTaskStep(task.id, result);
} catch (error) { } catch (error) {
const reason = error instanceof Error const reason = error instanceof Error
? error.message ? error.message
: 'Task failed unexpectedly'; : 'Task failed unexpectedly';
const nextState = await markTaskFailureStep(task.id, reason); await markTaskFailureStep(task.id, reason);
throw error;
if (nextState.shouldRetry) {
await sleep('1200ms');
await restartTaskWorkflowStep(task.id);
}
} }
} }
async function claimQueuedTaskStep(taskId: string) { async function loadTaskStep(taskId: string) {
'use step'; '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) { async function processTaskStep(task: Task) {
@@ -43,7 +50,7 @@ async function processTaskStep(task: Task) {
return await runTaskProcessor(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 } processTaskStep as ((task: Task) => Promise<Record<string, unknown>>) & { maxRetries?: number }
).maxRetries = 0; ).maxRetries = 0;
@@ -55,10 +62,5 @@ async function completeTaskStep(taskId: string, result: Record<string, unknown>)
async function markTaskFailureStep(taskId: string, reason: string) { async function markTaskFailureStep(taskId: string, reason: string) {
'use step'; 'use step';
return await markTaskFailure(taskId, reason); await markTaskFailure(taskId, reason);
}
async function restartTaskWorkflowStep(taskId: string) {
'use step';
await start(runTaskWorkflow, [taskId]);
} }

128
bun.lock
View File

@@ -10,6 +10,7 @@
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@workflow/world-postgres": "^4.1.0-beta.34",
"ai": "^6.0.104", "ai": "^6.0.104",
"better-auth": "^1.4.19", "better-auth": "^1.4.19",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -21,6 +22,7 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"sonner": "^2.0.7",
"workflow": "^4.1.0-beta.60", "workflow": "^4.1.0-beta.60",
"zhipu-ai-provider": "^0.2.2", "zhipu-ai-provider": "^0.2.2",
}, },
@@ -192,6 +194,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@graphile/logger": ["@graphile/logger@0.2.0", "", {}, "sha512-jjcWBokl9eb1gVJ85QmoaQ73CQ52xAaOCF29ukRbYNl6lY+ts0ErTaDYOBlejcbUs2OpaiqYLO5uDhyLFzWw4w=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -542,10 +546,14 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
"@types/interpret": ["@types/interpret@1.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-r+tPKWHYqaxJOYA3Eik0mMi+SEREqOXLmsooRFmc6GHv7nWUDixFtKN+cegvsPlDcEZd9wxsdp041v2imQuvag=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
@@ -556,6 +564,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -564,9 +574,9 @@
"@vercel/functions": ["@vercel/functions@3.4.3", "", { "dependencies": { "@vercel/oidc": "3.2.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw=="], "@vercel/functions": ["@vercel/functions@3.4.3", "", { "dependencies": { "@vercel/oidc": "3.2.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vercel/queue": ["@vercel/queue@0.0.0-alpha.38", "", { "dependencies": { "@vercel/oidc": "^3.0.5", "mixpart": "0.0.5-alpha.1" } }, "sha512-gSYpZrYy1LpzfXqDMUZX7xEIQxyhelvMDgthxijs495kHIxWC65S0C3vaAw5+3c1ujbPeJgLz8fn3SDTJspssw=="], "@vercel/queue": ["@vercel/queue@0.1.1", "", { "dependencies": { "@vercel/oidc": "^3.0.5", "mixpart": "0.0.5" } }, "sha512-ozO0tSBXUYN4gUkK65GbcqgxpC55qaaiY9MzNuXW4cvOSJ5nCkcgO+DQXcfyfL7h+0uIC5HTcP0mPvQ3dW3EhQ=="],
"@workflow/astro": ["@workflow/astro@4.0.0-beta.34", "", { "dependencies": { "@swc/core": "1.15.3", "@workflow/builders": "4.0.1-beta.51", "@workflow/rollup": "4.0.0-beta.17", "@workflow/swc-plugin": "4.1.0-beta.18", "@workflow/vite": "4.0.0-beta.10", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-K/53Op6ierhQk8tHy/C5ze3bRrrMNWDodpXWLlOOEB0nOxhZPR8IVClrHT+Pol0BaH+1rL4IQDLN6behvNHsSQ=="], "@workflow/astro": ["@workflow/astro@4.0.0-beta.34", "", { "dependencies": { "@swc/core": "1.15.3", "@workflow/builders": "4.0.1-beta.51", "@workflow/rollup": "4.0.0-beta.17", "@workflow/swc-plugin": "4.1.0-beta.18", "@workflow/vite": "4.0.0-beta.10", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-K/53Op6ierhQk8tHy/C5ze3bRrrMNWDodpXWLlOOEB0nOxhZPR8IVClrHT+Pol0BaH+1rL4IQDLN6behvNHsSQ=="],
@@ -576,7 +586,7 @@
"@workflow/core": ["@workflow/core@4.1.0-beta.60", "", { "dependencies": { "@aws-sdk/credential-provider-web-identity": "3.609.0", "@jridgewell/trace-mapping": "0.3.31", "@standard-schema/spec": "1.0.0", "@types/ms": "2.1.0", "@vercel/functions": "^3.1.4", "@workflow/errors": "4.1.0-beta.16", "@workflow/serde": "4.1.0-beta.2", "@workflow/utils": "4.1.0-beta.12", "@workflow/world": "4.1.0-beta.6", "@workflow/world-local": "4.1.0-beta.34", "@workflow/world-vercel": "4.1.0-beta.34", "debug": "4.4.3", "devalue": "5.6.0", "ms": "2.1.3", "nanoid": "5.1.6", "seedrandom": "3.0.5", "ulid": "3.0.1", "zod": "4.1.11" }, "peerDependencies": { "@opentelemetry/api": "1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-UNVp72l5uDTTMTL6GQlPrfmsiV7kLoWlYT2PdlgSsqdppsQ5zGFFshcRp3+dbSZ0dfUNoTKdiWJxVyOnISOojQ=="], "@workflow/core": ["@workflow/core@4.1.0-beta.60", "", { "dependencies": { "@aws-sdk/credential-provider-web-identity": "3.609.0", "@jridgewell/trace-mapping": "0.3.31", "@standard-schema/spec": "1.0.0", "@types/ms": "2.1.0", "@vercel/functions": "^3.1.4", "@workflow/errors": "4.1.0-beta.16", "@workflow/serde": "4.1.0-beta.2", "@workflow/utils": "4.1.0-beta.12", "@workflow/world": "4.1.0-beta.6", "@workflow/world-local": "4.1.0-beta.34", "@workflow/world-vercel": "4.1.0-beta.34", "debug": "4.4.3", "devalue": "5.6.0", "ms": "2.1.3", "nanoid": "5.1.6", "seedrandom": "3.0.5", "ulid": "3.0.1", "zod": "4.1.11" }, "peerDependencies": { "@opentelemetry/api": "1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-UNVp72l5uDTTMTL6GQlPrfmsiV7kLoWlYT2PdlgSsqdppsQ5zGFFshcRp3+dbSZ0dfUNoTKdiWJxVyOnISOojQ=="],
"@workflow/errors": ["@workflow/errors@4.1.0-beta.16", "", { "dependencies": { "@workflow/utils": "4.1.0-beta.12", "ms": "2.1.3" } }, "sha512-kx7XG77Vch3zX20DmN7S3htyFGcNw99KPjmmKPYVCm9i27XOnbNB9SuUYHMOWBW3zQQe6qIv95Vd9qOefPPimw=="], "@workflow/errors": ["@workflow/errors@4.1.0-beta.17", "", { "dependencies": { "@workflow/utils": "4.1.0-beta.13", "ms": "2.1.3" } }, "sha512-ctDx9PrTCAkfsGqs6PgYAMGSaOmHESTMJEdj+d+RU0qEDfXWBZmM586hkf9hXw3jwXnw0VMp9X01jLsnWPyZcA=="],
"@workflow/nest": ["@workflow/nest@0.0.0-beta.9", "", { "dependencies": { "@swc/core": "1.15.3", "@workflow/builders": "4.0.1-beta.51", "@workflow/swc-plugin": "4.1.0-beta.18", "pathe": "2.0.3" }, "peerDependencies": { "@nestjs/common": ">=10.0.0", "@nestjs/core": ">=10.0.0", "@swc/cli": ">=0.4.0" }, "bin": { "workflow-nest": "dist/cli.js" } }, "sha512-oBx8e3TScs/BJ5VpSIuA1vC5GyabVi4d6pZhVEAr9JFpbyRw51xoN9bQlNX/xXOKNinJSNlhHAR2JNRADdf70w=="], "@workflow/nest": ["@workflow/nest@0.0.0-beta.9", "", { "dependencies": { "@swc/core": "1.15.3", "@workflow/builders": "4.0.1-beta.51", "@workflow/swc-plugin": "4.1.0-beta.18", "pathe": "2.0.3" }, "peerDependencies": { "@nestjs/common": ">=10.0.0", "@nestjs/core": ">=10.0.0", "@swc/cli": ">=0.4.0" }, "bin": { "workflow-nest": "dist/cli.js" } }, "sha512-oBx8e3TScs/BJ5VpSIuA1vC5GyabVi4d6pZhVEAr9JFpbyRw51xoN9bQlNX/xXOKNinJSNlhHAR2JNRADdf70w=="],
@@ -596,15 +606,17 @@
"@workflow/typescript-plugin": ["@workflow/typescript-plugin@4.0.1-beta.4", "", { "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-AkZ3wHbPJq0ZhswR9ctdysJ1ZSW3lmYII+spnbgS72zxkwgl1MNwPtlFt1+lANLDLx6638IbRFwFvsqLtQLqrQ=="], "@workflow/typescript-plugin": ["@workflow/typescript-plugin@4.0.1-beta.4", "", { "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-AkZ3wHbPJq0ZhswR9ctdysJ1ZSW3lmYII+spnbgS72zxkwgl1MNwPtlFt1+lANLDLx6638IbRFwFvsqLtQLqrQ=="],
"@workflow/utils": ["@workflow/utils@4.1.0-beta.12", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-8UGkq7HOzWj6Ulz5mlBAfX4g8Ju+9yECE+qHKi2K3xt5zC9JJcxgE4mHOOCOElYoI9lIQKNYgdV5bIftmRltvg=="], "@workflow/utils": ["@workflow/utils@4.1.0-beta.13", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-3vVuXZVfLVeJ78MM6D0gNXg6hMZdDYAzmF92p+HxItI0B2Yk1EuDIIUfBXKWwTOKCCuKF4iroZt2u9BFqrs2AQ=="],
"@workflow/vite": ["@workflow/vite@4.0.0-beta.10", "", { "dependencies": { "@workflow/builders": "4.0.1-beta.51" } }, "sha512-LNtQk2MHHPZn6yZTDPZ9sPwvn1vXJZe/73vK07aF35PWbQV1nXbrABbM4dblALoBkwpInFefRhUwzACOg73uQA=="], "@workflow/vite": ["@workflow/vite@4.0.0-beta.10", "", { "dependencies": { "@workflow/builders": "4.0.1-beta.51" } }, "sha512-LNtQk2MHHPZn6yZTDPZ9sPwvn1vXJZe/73vK07aF35PWbQV1nXbrABbM4dblALoBkwpInFefRhUwzACOg73uQA=="],
"@workflow/web": ["@workflow/web@4.1.0-beta.34", "", { "dependencies": { "@react-router/node": "7.13.0", "express": "^4.21.0", "isbot": "^5" } }, "sha512-blnMKI2T2rijuW3ZxVVTONp29IyZVwTxf/cLOctKnNAPy3HjwqWIfO4ot68dA3vdJ57HDDCmXe0eRBft4N3n8Q=="], "@workflow/web": ["@workflow/web@4.1.0-beta.34", "", { "dependencies": { "@react-router/node": "7.13.0", "express": "^4.21.0", "isbot": "^5" } }, "sha512-blnMKI2T2rijuW3ZxVVTONp29IyZVwTxf/cLOctKnNAPy3HjwqWIfO4ot68dA3vdJ57HDDCmXe0eRBft4N3n8Q=="],
"@workflow/world": ["@workflow/world@4.1.0-beta.6", "", { "peerDependencies": { "zod": "4.1.11" } }, "sha512-eaafOR9uuczZjs4i/qfcBe34kA7tIz+RiVzYtAmU4r7+SPEy9suqz+4pPVzY1rHXOvD1RL2RL6tyLqCmlvS+pw=="], "@workflow/world": ["@workflow/world@4.1.0-beta.8", "", { "peerDependencies": { "zod": "4.3.6" } }, "sha512-zzN0cGqjg0fBI0vlufEW8wz/Rl1vJyGKpy8KQSYAqjBEaHCqey6+2/YPZyQzFR0X2jqxcc45yHw8e3qHjsG1+A=="],
"@workflow/world-local": ["@workflow/world-local@4.1.0-beta.34", "", { "dependencies": { "@vercel/queue": "0.0.0-alpha.38", "@workflow/errors": "4.1.0-beta.16", "@workflow/utils": "4.1.0-beta.12", "@workflow/world": "4.1.0-beta.6", "async-sema": "3.1.1", "ulid": "3.0.1", "undici": "6.22.0", "zod": "4.1.11" }, "peerDependencies": { "@opentelemetry/api": "1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-ZwIWBs4m/1HNy8PeP0h63Q47Sx1XragGzTF+saiHiCZP5sLZJz8veOrXhJ7tqdJBoIhp3pm5GsTes/NpGYXClg=="], "@workflow/world-local": ["@workflow/world-local@4.1.0-beta.36", "", { "dependencies": { "@vercel/queue": "0.1.1", "@workflow/errors": "4.1.0-beta.17", "@workflow/utils": "4.1.0-beta.13", "@workflow/world": "4.1.0-beta.8", "async-sema": "3.1.1", "ulid": "3.0.1", "undici": "7.22.0", "zod": "4.3.6" }, "peerDependencies": { "@opentelemetry/api": "1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-eOavTINKlpepB4MJyQr0RoUVRnFZM3nP4T+AUMrRjm9qhZvZB5g5TgHBfkrOzdqqOHtuj7dfOccNc76hywZ5Fw=="],
"@workflow/world-postgres": ["@workflow/world-postgres@4.1.0-beta.38", "", { "dependencies": { "@vercel/queue": "0.1.1", "@workflow/errors": "4.1.0-beta.17", "@workflow/world": "4.1.0-beta.8", "@workflow/world-local": "4.1.0-beta.36", "cbor-x": "1.6.0", "dotenv": "17.3.1", "drizzle-orm": "0.45.1", "graphile-worker": "0.16.6", "postgres": "3.4.8", "ulid": "3.0.1", "zod": "4.3.6" }, "bin": { "workflow-postgres-setup": "bin/setup.js" } }, "sha512-ldx0R3DHcud9nAQzJ5uqhvJaPxYCnQmN6JXxoHUZJEaeVO+XJ4o/F59zFU3zb4JRrOk/K9WtQi6dQ1UXVbz8YQ=="],
"@workflow/world-vercel": ["@workflow/world-vercel@4.1.0-beta.34", "", { "dependencies": { "@vercel/oidc": "3.0.5", "@vercel/queue": "0.0.0-alpha.38", "@workflow/errors": "4.1.0-beta.16", "@workflow/world": "4.1.0-beta.6", "cbor-x": "1.6.0", "zod": "4.1.11" }, "peerDependencies": { "@opentelemetry/api": "1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-1BmBZ4MsuvcHCk0sOL1lX5JyxH3qs0oB1ZeWaKykJWhFn2Cl6t2fLBSl9lbBLDTwHMiK7caowGPnzH2Yxw4Etg=="], "@workflow/world-vercel": ["@workflow/world-vercel@4.1.0-beta.34", "", { "dependencies": { "@vercel/oidc": "3.0.5", "@vercel/queue": "0.0.0-alpha.38", "@workflow/errors": "4.1.0-beta.16", "@workflow/world": "4.1.0-beta.6", "cbor-x": "1.6.0", "zod": "4.1.11" }, "peerDependencies": { "@opentelemetry/api": "1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-1BmBZ4MsuvcHCk0sOL1lX5JyxH3qs0oB1ZeWaKykJWhFn2Cl6t2fLBSl9lbBLDTwHMiK7caowGPnzH2Yxw4Etg=="],
@@ -740,6 +752,8 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -762,7 +776,7 @@
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], "cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="],
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
@@ -826,7 +840,7 @@
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
@@ -946,6 +960,8 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -972,6 +988,10 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphile-config": ["graphile-config@0.0.1-beta.18", "", { "dependencies": { "@types/interpret": "^1.1.3", "@types/node": "^22.16.3", "@types/semver": "^7.7.0", "chalk": "^4.1.2", "debug": "^4.4.1", "interpret": "^3.1.1", "semver": "^7.7.2", "tslib": "^2.8.1", "yargs": "^17.7.2" } }, "sha512-uMdF9Rt8/NwT1wVXNleYgM5ro2hHDodHiKA3efJhgdU8iP+r/hksnghOHreMva0sF5tV73f4TpiELPUR0g7O9w=="],
"graphile-worker": ["graphile-worker@0.16.6", "", { "dependencies": { "@graphile/logger": "^0.2.0", "@types/debug": "^4.1.10", "@types/pg": "^8.10.5", "cosmiconfig": "^8.3.6", "graphile-config": "^0.0.1-beta.4", "json5": "^2.2.3", "pg": "^8.11.3", "tslib": "^2.6.2", "yargs": "^17.7.2" }, "bin": { "graphile-worker": "dist/cli.js" } }, "sha512-e7gGYDmGqzju2l83MpzX8vNG/lOtVJiSzI3eZpAFubSxh/cxs7sRrRGBGjzBP1kNG0H+c95etPpNRNlH65PYhw=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
@@ -1004,6 +1024,8 @@
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"interpret": ["interpret@3.1.1", "", {}, "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
@@ -1138,7 +1160,7 @@
"minimatch": ["minimatch@9.0.8", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw=="], "minimatch": ["minimatch@9.0.8", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw=="],
"mixpart": ["mixpart@0.0.4", "", {}, "sha512-RAoaOSXnMLrfUfmFbNynRYjeMru/bhgAYRy/GQVI8gmRq7vm9V9c2gGVYnYoQ008X6YTmRIu5b0397U7vb0bIA=="], "mixpart": ["mixpart@0.0.5", "", {}, "sha512-TpWi9/2UIr7VWCVAM7NB4WR4yOglAetBkuKfxs3K0vFcUukqAaW1xsgX0v1gNGiDKzYhPHFcHgarC7jmnaOy4w=="],
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
@@ -1238,7 +1260,7 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
@@ -1284,6 +1306,8 @@
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
@@ -1350,6 +1374,8 @@
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="], "sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
"sort-keys-length": ["sort-keys-length@1.0.1", "", { "dependencies": { "sort-keys": "^1.0.0" } }, "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw=="], "sort-keys-length": ["sort-keys-length@1.0.1", "", { "dependencies": { "sort-keys": "^1.0.0" } }, "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw=="],
@@ -1432,7 +1458,7 @@
"unctx": ["unctx@2.5.0", "", { "dependencies": { "acorn": "^8.15.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21", "unplugin": "^2.3.11" } }, "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg=="], "unctx": ["unctx@2.5.0", "", { "dependencies": { "acorn": "^8.15.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21", "unplugin": "^2.3.11" } }, "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg=="],
"undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -1488,6 +1514,12 @@
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yauzl": ["yauzl@3.2.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "pend": "~1.2.0" } }, "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w=="], "yauzl": ["yauzl@3.2.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "pend": "~1.2.0" } }, "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
@@ -1500,6 +1532,8 @@
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="], "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.3", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ=="], "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.3", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -1614,6 +1648,8 @@
"@nuxt/kit/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "@nuxt/kit/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"@oclif/core/cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@smithy/abort-controller/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], "@smithy/abort-controller/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="],
@@ -1686,32 +1722,54 @@
"@vercel/cli-auth/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@vercel/cli-auth/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@workflow/builders/@workflow/errors": ["@workflow/errors@4.1.0-beta.16", "", { "dependencies": { "@workflow/utils": "4.1.0-beta.12", "ms": "2.1.3" } }, "sha512-kx7XG77Vch3zX20DmN7S3htyFGcNw99KPjmmKPYVCm9i27XOnbNB9SuUYHMOWBW3zQQe6qIv95Vd9qOefPPimw=="],
"@vercel/queue/@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@workflow/builders/@workflow/utils": ["@workflow/utils@4.1.0-beta.12", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-8UGkq7HOzWj6Ulz5mlBAfX4g8Ju+9yECE+qHKi2K3xt5zC9JJcxgE4mHOOCOElYoI9lIQKNYgdV5bIftmRltvg=="],
"@vercel/queue/mixpart": ["mixpart@0.0.5-alpha.1", "", {}, "sha512-2ZfG/NO2SVE9HLk1/W+yOrIOA0d674ljZExLdievZQpYjbJYQjIdye8vNMR63yF7nN/NbO9q8mp16JUEYBCilg=="],
"@workflow/builders/enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], "@workflow/builders/enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
"@workflow/cli/@workflow/errors": ["@workflow/errors@4.1.0-beta.16", "", { "dependencies": { "@workflow/utils": "4.1.0-beta.12", "ms": "2.1.3" } }, "sha512-kx7XG77Vch3zX20DmN7S3htyFGcNw99KPjmmKPYVCm9i27XOnbNB9SuUYHMOWBW3zQQe6qIv95Vd9qOefPPimw=="],
"@workflow/cli/@workflow/utils": ["@workflow/utils@4.1.0-beta.12", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-8UGkq7HOzWj6Ulz5mlBAfX4g8Ju+9yECE+qHKi2K3xt5zC9JJcxgE4mHOOCOElYoI9lIQKNYgdV5bIftmRltvg=="],
"@workflow/cli/@workflow/world": ["@workflow/world@4.1.0-beta.6", "", { "peerDependencies": { "zod": "4.1.11" } }, "sha512-eaafOR9uuczZjs4i/qfcBe34kA7tIz+RiVzYtAmU4r7+SPEy9suqz+4pPVzY1rHXOvD1RL2RL6tyLqCmlvS+pw=="],
"@workflow/cli/@workflow/world-local": ["@workflow/world-local@4.1.0-beta.34", "", { "dependencies": { "@vercel/queue": "0.0.0-alpha.38", "@workflow/errors": "4.1.0-beta.16", "@workflow/utils": "4.1.0-beta.12", "@workflow/world": "4.1.0-beta.6", "async-sema": "3.1.1", "ulid": "3.0.1", "undici": "6.22.0", "zod": "4.1.11" }, "peerDependencies": { "@opentelemetry/api": "1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-ZwIWBs4m/1HNy8PeP0h63Q47Sx1XragGzTF+saiHiCZP5sLZJz8veOrXhJ7tqdJBoIhp3pm5GsTes/NpGYXClg=="],
"@workflow/cli/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"@workflow/cli/enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], "@workflow/cli/enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
"@workflow/cli/mixpart": ["mixpart@0.0.4", "", {}, "sha512-RAoaOSXnMLrfUfmFbNynRYjeMru/bhgAYRy/GQVI8gmRq7vm9V9c2gGVYnYoQ008X6YTmRIu5b0397U7vb0bIA=="],
"@workflow/cli/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@workflow/cli/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@workflow/core/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@workflow/core/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@workflow/core/@workflow/errors": ["@workflow/errors@4.1.0-beta.16", "", { "dependencies": { "@workflow/utils": "4.1.0-beta.12", "ms": "2.1.3" } }, "sha512-kx7XG77Vch3zX20DmN7S3htyFGcNw99KPjmmKPYVCm9i27XOnbNB9SuUYHMOWBW3zQQe6qIv95Vd9qOefPPimw=="],
"@workflow/core/@workflow/utils": ["@workflow/utils@4.1.0-beta.12", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-8UGkq7HOzWj6Ulz5mlBAfX4g8Ju+9yECE+qHKi2K3xt5zC9JJcxgE4mHOOCOElYoI9lIQKNYgdV5bIftmRltvg=="],
"@workflow/core/@workflow/world": ["@workflow/world@4.1.0-beta.6", "", { "peerDependencies": { "zod": "4.1.11" } }, "sha512-eaafOR9uuczZjs4i/qfcBe34kA7tIz+RiVzYtAmU4r7+SPEy9suqz+4pPVzY1rHXOvD1RL2RL6tyLqCmlvS+pw=="],
"@workflow/core/@workflow/world-local": ["@workflow/world-local@4.1.0-beta.34", "", { "dependencies": { "@vercel/queue": "0.0.0-alpha.38", "@workflow/errors": "4.1.0-beta.16", "@workflow/utils": "4.1.0-beta.12", "@workflow/world": "4.1.0-beta.6", "async-sema": "3.1.1", "ulid": "3.0.1", "undici": "6.22.0", "zod": "4.1.11" }, "peerDependencies": { "@opentelemetry/api": "1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-ZwIWBs4m/1HNy8PeP0h63Q47Sx1XragGzTF+saiHiCZP5sLZJz8veOrXhJ7tqdJBoIhp3pm5GsTes/NpGYXClg=="],
"@workflow/core/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], "@workflow/core/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"@workflow/core/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@workflow/core/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@workflow/next/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@workflow/next/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@workflow/world/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@workflow/world-postgres/drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"@workflow/world-local/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@workflow/world-vercel/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], "@workflow/world-vercel/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
"@workflow/world-vercel/@vercel/queue": ["@vercel/queue@0.0.0-alpha.38", "", { "dependencies": { "@vercel/oidc": "^3.0.5", "mixpart": "0.0.5-alpha.1" } }, "sha512-gSYpZrYy1LpzfXqDMUZX7xEIQxyhelvMDgthxijs495kHIxWC65S0C3vaAw5+3c1ujbPeJgLz8fn3SDTJspssw=="],
"@workflow/world-vercel/@workflow/errors": ["@workflow/errors@4.1.0-beta.16", "", { "dependencies": { "@workflow/utils": "4.1.0-beta.12", "ms": "2.1.3" } }, "sha512-kx7XG77Vch3zX20DmN7S3htyFGcNw99KPjmmKPYVCm9i27XOnbNB9SuUYHMOWBW3zQQe6qIv95Vd9qOefPPimw=="],
"@workflow/world-vercel/@workflow/world": ["@workflow/world@4.1.0-beta.6", "", { "peerDependencies": { "zod": "4.1.11" } }, "sha512-eaafOR9uuczZjs4i/qfcBe34kA7tIz+RiVzYtAmU4r7+SPEy9suqz+4pPVzY1rHXOvD1RL2RL6tyLqCmlvS+pw=="],
"@workflow/world-vercel/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@workflow/world-vercel/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@xhmikosr/archive-type/file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="], "@xhmikosr/archive-type/file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
@@ -1744,10 +1802,10 @@
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"c12/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"c12/exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "c12/exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
@@ -1768,6 +1826,10 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"graphile-config/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
"graphile-config/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -1804,6 +1866,8 @@
"terminal-link/ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "terminal-link/ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"workflow/@workflow/errors": ["@workflow/errors@4.1.0-beta.16", "", { "dependencies": { "@workflow/utils": "4.1.0-beta.12", "ms": "2.1.3" } }, "sha512-kx7XG77Vch3zX20DmN7S3htyFGcNw99KPjmmKPYVCm9i27XOnbNB9SuUYHMOWBW3zQQe6qIv95Vd9qOefPPimw=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
@@ -1864,6 +1928,20 @@
"@vercel/cli-auth/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "@vercel/cli-auth/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"@workflow/cli/@workflow/world-local/@vercel/queue": ["@vercel/queue@0.0.0-alpha.38", "", { "dependencies": { "@vercel/oidc": "^3.0.5", "mixpart": "0.0.5-alpha.1" } }, "sha512-gSYpZrYy1LpzfXqDMUZX7xEIQxyhelvMDgthxijs495kHIxWC65S0C3vaAw5+3c1ujbPeJgLz8fn3SDTJspssw=="],
"@workflow/cli/@workflow/world-local/undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
"@workflow/core/@workflow/world-local/@vercel/queue": ["@vercel/queue@0.0.0-alpha.38", "", { "dependencies": { "@vercel/oidc": "^3.0.5", "mixpart": "0.0.5-alpha.1" } }, "sha512-gSYpZrYy1LpzfXqDMUZX7xEIQxyhelvMDgthxijs495kHIxWC65S0C3vaAw5+3c1ujbPeJgLz8fn3SDTJspssw=="],
"@workflow/core/@workflow/world-local/undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
"@workflow/world-vercel/@vercel/queue/@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@workflow/world-vercel/@vercel/queue/mixpart": ["mixpart@0.0.5-alpha.1", "", {}, "sha512-2ZfG/NO2SVE9HLk1/W+yOrIOA0d674ljZExLdievZQpYjbJYQjIdye8vNMR63yF7nN/NbO9q8mp16JUEYBCilg=="],
"@workflow/world-vercel/@workflow/errors/@workflow/utils": ["@workflow/utils@4.1.0-beta.12", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-8UGkq7HOzWj6Ulz5mlBAfX4g8Ju+9yECE+qHKi2K3xt5zC9JJcxgE4mHOOCOElYoI9lIQKNYgdV5bIftmRltvg=="],
"@xhmikosr/archive-type/file-type/@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], "@xhmikosr/archive-type/file-type/@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
"@xhmikosr/decompress-tar/file-type/@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], "@xhmikosr/decompress-tar/file-type/@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
@@ -1890,16 +1968,26 @@
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"graphile-config/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"graphile-config/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"workflow/@workflow/errors/@workflow/utils": ["@workflow/utils@4.1.0-beta.12", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-8UGkq7HOzWj6Ulz5mlBAfX4g8Ju+9yECE+qHKi2K3xt5zC9JJcxgE4mHOOCOElYoI9lIQKNYgdV5bIftmRltvg=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@workflow/cli/@workflow/world-local/@vercel/queue/mixpart": ["mixpart@0.0.5-alpha.1", "", {}, "sha512-2ZfG/NO2SVE9HLk1/W+yOrIOA0d674ljZExLdievZQpYjbJYQjIdye8vNMR63yF7nN/NbO9q8mp16JUEYBCilg=="],
"@workflow/core/@workflow/world-local/@vercel/queue/mixpart": ["mixpart@0.0.5-alpha.1", "", {}, "sha512-2ZfG/NO2SVE9HLk1/W+yOrIOA0d674ljZExLdievZQpYjbJYQjIdye8vNMR63yF7nN/NbO9q8mp16JUEYBCilg=="],
"filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
} }
} }

View File

@@ -0,0 +1,137 @@
'use client';
import { format } from 'date-fns';
import { LoaderCircle, X } from 'lucide-react';
import { useTaskTimelineQuery } from '@/hooks/use-api-queries';
import { buildStageTimeline, stageLabel, taskTypeLabel } from '@/components/notifications/task-stage-helpers';
import { StatusPill } from '@/components/ui/status-pill';
import { Button } from '@/components/ui/button';
function formatTimestamp(value: string | null) {
if (!value) {
return 'n/a';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return 'n/a';
}
return format(parsed, 'MMM dd, yyyy HH:mm:ss');
}
type TaskDetailModalProps = {
isOpen: boolean;
taskId: string | null;
onClose: () => void;
};
export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProps) {
const { data, isLoading, error } = useTaskTimelineQuery(taskId ?? '', isOpen && Boolean(taskId));
if (!isOpen || !taskId) {
return null;
}
const task = data?.task ?? null;
const events = data?.events ?? [];
const timeline = task ? buildStageTimeline(task, events) : [];
return (
<div className="fixed inset-0 z-[60]">
<button
type="button"
aria-label="Close task detail modal"
className="absolute inset-0 bg-[color:rgba(0,0,0,0.7)]"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 w-[95vw] max-w-4xl -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_25px_70px_rgba(0,0,0,0.55)]">
<header className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Job details</p>
<h3 className="text-xl font-semibold text-[color:var(--terminal-bright)]">{task ? taskTypeLabel(task.task_type) : 'Task'}</h3>
<p className="mt-1 break-all text-xs text-[color:var(--terminal-muted)]">{taskId}</p>
</div>
<div className="flex items-center gap-2">
{task ? <StatusPill status={task.status} /> : null}
<button
type="button"
aria-label="Close task detail modal"
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-[color:var(--line-weak)] text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
onClick={onClose}
>
<X className="size-4" />
</button>
</div>
</header>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-[color:var(--terminal-muted)]">
<LoaderCircle className="size-4 animate-spin" />
Loading task timeline...
</div>
) : null}
{error ? (
<p className="text-sm text-[#ffb5b5]">Unable to load task timeline.</p>
) : null}
{task ? (
<>
<div className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3 md:grid-cols-2">
<p className="text-xs text-[color:var(--terminal-muted)]">Stage: <span className="text-[color:var(--terminal-bright)]">{stageLabel(task.stage)}</span></p>
<p className="text-xs text-[color:var(--terminal-muted)]">Workflow run: <span className="text-[color:var(--terminal-bright)]">{task.workflow_run_id ?? 'n/a'}</span></p>
<p className="text-xs text-[color:var(--terminal-muted)]">Created: <span className="text-[color:var(--terminal-bright)]">{formatTimestamp(task.created_at)}</span></p>
<p className="text-xs text-[color:var(--terminal-muted)]">Finished: <span className="text-[color:var(--terminal-bright)]">{formatTimestamp(task.finished_at)}</span></p>
<p className="text-xs text-[color:var(--terminal-muted)]">Updated: <span className="text-[color:var(--terminal-bright)]">{formatTimestamp(task.updated_at)}</span></p>
<p className="text-xs text-[color:var(--terminal-muted)]">Attempts: <span className="text-[color:var(--terminal-bright)]">{task.attempts}/{task.max_attempts}</span></p>
</div>
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<p className="mb-2 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Stage timeline</p>
<ol className="space-y-2">
{timeline.map((item) => (
<li key={item.stage} className="rounded-lg border border-[color:var(--line-weak)] px-3 py-2">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-[color:var(--terminal-bright)]">{item.label}</p>
<span className={item.state === 'active'
? 'text-xs uppercase tracking-[0.12em] text-[#9fffcf]'
: item.state === 'completed'
? 'text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]'
: 'text-xs uppercase tracking-[0.12em] text-[#6f8791]'}
>
{item.state}
</span>
</div>
{item.detail ? <p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{item.detail}</p> : null}
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{formatTimestamp(item.timestamp)}</p>
</li>
))}
</ol>
</div>
{task.error ? (
<div className="mb-3 rounded-lg border border-[#6f2f2f] bg-[color:rgba(70,20,20,0.45)] p-3">
<p className="text-xs uppercase tracking-[0.12em] text-[#ffbbbb]">Error</p>
<p className="mt-1 text-sm text-[#ffd6d6]">{task.error}</p>
</div>
) : null}
{task.result ? (
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Result summary</p>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words text-xs text-[color:var(--terminal-bright)]">{JSON.stringify(task.result, null, 2)}</pre>
</div>
) : null}
</>
) : null}
<div className="mt-4 flex justify-end">
<Button variant="ghost" onClick={onClose}>Close</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
'use client';
import { formatDistanceToNow } from 'date-fns';
import { BellOff, CheckCheck, EyeOff, X } from 'lucide-react';
import type { Task } from '@/lib/types';
import { taskTypeLabel } from '@/components/notifications/task-stage-helpers';
import { Button } from '@/components/ui/button';
import { StatusPill } from '@/components/ui/status-pill';
type TaskNotificationsDrawerProps = {
isOpen: boolean;
onClose: () => void;
activeTasks: Task[];
visibleFinishedTasks: Task[];
awaitingReviewTasks: Task[];
showReadFinished: boolean;
setShowReadFinished: (value: boolean) => void;
openTaskDetails: (taskId: string) => void;
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
};
function TaskRow({
task,
openTaskDetails,
markTaskRead,
silenceTask
}: {
task: Task;
openTaskDetails: (taskId: string) => void;
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
}) {
const isTerminal = task.status === 'completed' || task.status === 'failed';
const isRead = task.notification_read_at !== null;
return (
<article className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-[color:var(--terminal-bright)]">{taskTypeLabel(task.task_type)}</p>
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{task.stage_detail ?? task.stage}</p>
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">
Updated {formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}
</p>
</div>
<StatusPill status={task.status} />
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="secondary"
className="px-2 py-1 text-xs"
onClick={() => openTaskDetails(task.id)}
>
Details
</Button>
{isTerminal ? (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
void markTaskRead(task.id, !isRead);
}}
>
{isRead ? <EyeOff className="size-3" /> : <CheckCheck className="size-3" />}
{isRead ? 'Mark unread' : 'Mark read'}
</Button>
) : (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
void silenceTask(task.id, true);
}}
>
<BellOff className="size-3" />
Silence
</Button>
)}
</div>
</article>
);
}
export function TaskNotificationsDrawer({
isOpen,
onClose,
activeTasks,
visibleFinishedTasks,
awaitingReviewTasks,
showReadFinished,
setShowReadFinished,
openTaskDetails,
markTaskRead,
silenceTask
}: TaskNotificationsDrawerProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close notifications drawer"
className="absolute inset-0 bg-[color:rgba(0,0,0,0.62)]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-[28rem] border-l border-[color:var(--line-weak)] bg-[color:var(--panel)] p-4 shadow-[0_25px_60px_rgba(0,0,0,0.5)]">
<header className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Notifications box</p>
<h3 className="text-lg font-semibold text-[color:var(--terminal-bright)]">Job inbox</h3>
</div>
<button
type="button"
aria-label="Close notifications drawer"
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-[color:var(--line-weak)] text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
onClick={onClose}
>
<X className="size-4" />
</button>
</header>
<div className="mb-4 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<label className="flex items-center justify-between gap-2 text-sm text-[color:var(--terminal-bright)]">
<span>Show read finished jobs</span>
<input
type="checkbox"
checked={showReadFinished}
onChange={(event) => setShowReadFinished(event.target.checked)}
className="h-4 w-4 accent-[color:var(--accent)]"
/>
</label>
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">Unread finished: {awaitingReviewTasks.length}</p>
</div>
<section className="mb-4">
<h4 className="mb-2 text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Active jobs</h4>
<div className="space-y-2">
{activeTasks.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No active jobs.</p>
) : (
activeTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
openTaskDetails={openTaskDetails}
markTaskRead={markTaskRead}
silenceTask={silenceTask}
/>
))
)}
</div>
</section>
<section className="h-[calc(100%-19rem)] overflow-y-auto">
<h4 className="mb-2 text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Awaiting review</h4>
<div className="space-y-2">
{visibleFinishedTasks.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No finished jobs to review.</p>
) : (
visibleFinishedTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
openTaskDetails={openTaskDetails}
markTaskRead={markTaskRead}
silenceTask={silenceTask}
/>
))
)}
</div>
</section>
</aside>
</div>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { formatDistanceToNow } from 'date-fns';
import { Bell, BellRing, ChevronRight } from 'lucide-react';
import type { Task } from '@/lib/types';
import { Button } from '@/components/ui/button';
import { StatusPill } from '@/components/ui/status-pill';
import { taskTypeLabel } from '@/components/notifications/task-stage-helpers';
import { cn } from '@/lib/utils';
type TaskNotificationsTriggerProps = {
unreadCount: number;
isPopoverOpen: boolean;
setIsPopoverOpen: (value: boolean) => void;
activeTasks: Task[];
awaitingReviewTasks: Task[];
openDrawer: () => void;
openTaskDetails: (taskId: string) => void;
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
className?: string;
mobile?: boolean;
};
export function TaskNotificationsTrigger({
unreadCount,
isPopoverOpen,
setIsPopoverOpen,
activeTasks,
awaitingReviewTasks,
openDrawer,
openTaskDetails,
silenceTask,
markTaskRead,
className,
mobile = false
}: TaskNotificationsTriggerProps) {
const showPopover = !mobile;
const button = (
<button
type="button"
aria-label="Open notifications"
onClick={() => {
if (showPopover) {
setIsPopoverOpen(!isPopoverOpen);
return;
}
openDrawer();
}}
className={cn(
'relative inline-flex items-center justify-center rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]',
mobile ? 'min-w-0 flex-1 gap-1 px-2 py-1.5 text-[11px]' : 'h-10 w-10',
className
)}
>
{unreadCount > 0 ? <BellRing className="size-4" /> : <Bell className="size-4" />}
{mobile ? <span>Alerts</span> : null}
{unreadCount > 0 ? (
<span className={cn(
'absolute inline-flex min-w-[1.15rem] items-center justify-center rounded-full bg-[color:var(--accent)] px-1 text-[10px] font-semibold text-[#00241d]',
mobile ? 'right-1 top-1' : '-right-1.5 -top-1.5'
)}>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
);
if (!showPopover) {
return button;
}
return (
<div className="relative">
{button}
{isPopoverOpen ? (
<>
<button
type="button"
aria-label="Close notifications popover"
className="fixed inset-0 z-40 cursor-default bg-transparent"
onClick={() => setIsPopoverOpen(false)}
/>
<div className="absolute right-0 z-50 mt-2 w-[22rem] rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3 shadow-[0_18px_50px_rgba(0,0,0,0.45)]">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">Job notifications</p>
<span className="text-xs text-[color:var(--terminal-muted)]">{unreadCount} unread</span>
</div>
<div className="space-y-2">
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Active</p>
{activeTasks.length === 0 ? (
<p className="text-xs text-[color:var(--terminal-muted)]">No active jobs.</p>
) : (
activeTasks.slice(0, 3).map((task) => (
<article key={task.id} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{taskTypeLabel(task.task_type)}</p>
<StatusPill status={task.status} />
</div>
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{task.stage_detail ?? 'Running in workflow engine.'}</p>
<div className="mt-2 flex items-center justify-between">
<button
type="button"
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
onClick={() => openTaskDetails(task.id)}
>
Open details
</button>
<button
type="button"
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
onClick={() => {
void silenceTask(task.id, true);
}}
>
Silence
</button>
</div>
</article>
))
)}
</div>
<div className="mt-3 space-y-2">
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Awaiting review</p>
{awaitingReviewTasks.length === 0 ? (
<p className="text-xs text-[color:var(--terminal-muted)]">No unread finished jobs.</p>
) : (
awaitingReviewTasks.slice(0, 3).map((task) => (
<article key={task.id} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{taskTypeLabel(task.task_type)}</p>
<StatusPill status={task.status} />
</div>
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">
{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}
</p>
<div className="mt-2 flex items-center justify-between">
<button
type="button"
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
onClick={() => openTaskDetails(task.id)}
>
Open details
</button>
<button
type="button"
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
onClick={() => {
void markTaskRead(task.id, true);
}}
>
Mark read
</button>
</div>
</article>
))
)}
</div>
<Button
variant="secondary"
className="mt-3 w-full"
onClick={() => {
setIsPopoverOpen(false);
openDrawer();
}}
>
Open notifications box
<ChevronRight className="size-4" />
</Button>
</div>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,150 @@
import type { Task, TaskStage, TaskStageEvent, TaskType } from '@/lib/types';
export type StageTimelineItem = {
stage: TaskStage;
label: string;
state: 'completed' | 'active' | 'pending';
detail: string | null;
timestamp: string | null;
};
const TASK_TYPE_LABELS: Record<TaskType, string> = {
sync_filings: 'Filing sync',
refresh_prices: 'Price refresh',
analyze_filing: 'Filing analysis',
portfolio_insights: 'Portfolio insight'
};
const STAGE_LABELS: Record<TaskStage, string> = {
queued: 'Queued',
running: 'Running',
completed: 'Completed',
failed: 'Failed',
'sync.fetch_filings': 'Fetch filings',
'sync.fetch_metrics': 'Fetch filing metrics',
'sync.persist_filings': 'Persist filings',
'sync.hydrate_statements': 'Hydrate statements',
'refresh.load_holdings': 'Load holdings',
'refresh.fetch_quotes': 'Fetch quotes',
'refresh.persist_prices': 'Persist prices',
'analyze.load_filing': 'Load filing',
'analyze.fetch_document': 'Fetch primary document',
'analyze.extract': 'Extract context',
'analyze.generate_report': 'Generate report',
'analyze.persist_report': 'Persist report',
'insights.load_holdings': 'Load holdings',
'insights.generate': 'Generate insight',
'insights.persist': 'Persist insight'
};
const TASK_STAGE_ORDER: Record<TaskType, TaskStage[]> = {
sync_filings: [
'queued',
'running',
'sync.fetch_filings',
'sync.fetch_metrics',
'sync.persist_filings',
'sync.hydrate_statements',
'completed'
],
refresh_prices: [
'queued',
'running',
'refresh.load_holdings',
'refresh.fetch_quotes',
'refresh.persist_prices',
'completed'
],
analyze_filing: [
'queued',
'running',
'analyze.load_filing',
'analyze.fetch_document',
'analyze.extract',
'analyze.generate_report',
'analyze.persist_report',
'completed'
],
portfolio_insights: [
'queued',
'running',
'insights.load_holdings',
'insights.generate',
'insights.persist',
'completed'
]
};
export function taskTypeLabel(taskType: TaskType) {
return TASK_TYPE_LABELS[taskType];
}
export function stageLabel(stage: TaskStage) {
return STAGE_LABELS[stage] ?? stage;
}
export function buildStageTimeline(task: Task, events: TaskStageEvent[]): StageTimelineItem[] {
const baseOrder = TASK_STAGE_ORDER[task.task_type] ?? ['queued', 'running', 'completed'];
const orderedStages = [...baseOrder];
if (task.status === 'failed' && !orderedStages.includes('failed')) {
orderedStages.push('failed');
}
const latestEventByStage = new Map<TaskStage, TaskStageEvent>();
for (const event of events) {
latestEventByStage.set(event.stage, event);
}
return orderedStages.map((stage) => {
const event = latestEventByStage.get(stage);
if (task.status === 'queued' || task.status === 'running') {
if (stage === task.stage) {
return {
stage,
label: stageLabel(stage),
state: 'active' as const,
detail: event?.stage_detail ?? task.stage_detail,
timestamp: event?.created_at ?? null
};
}
if (event) {
return {
stage,
label: stageLabel(stage),
state: 'completed' as const,
detail: event.stage_detail,
timestamp: event.created_at
};
}
return {
stage,
label: stageLabel(stage),
state: 'pending' as const,
detail: null,
timestamp: null
};
}
if (stage === task.stage || event) {
return {
stage,
label: stageLabel(stage),
state: 'completed' as const,
detail: event?.stage_detail ?? task.stage_detail,
timestamp: event?.created_at ?? task.finished_at
};
}
return {
stage,
label: stageLabel(stage),
state: 'pending' as const,
detail: null,
timestamp: null
};
});
}

View File

@@ -2,6 +2,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { Toaster } from 'sonner';
type QueryProviderProps = { type QueryProviderProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -20,6 +21,15 @@ export function QueryProvider({ children }: QueryProviderProps) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
<Toaster
position="top-right"
closeButton
richColors
toastOptions={{
className: 'sonner-toast',
descriptionClassName: 'sonner-toast-description'
}}
/>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@@ -7,6 +7,9 @@ import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { TaskDetailModal } from '@/components/notifications/task-detail-modal';
import { TaskNotificationsDrawer } from '@/components/notifications/task-notifications-drawer';
import { TaskNotificationsTrigger } from '@/components/notifications/task-notifications-trigger';
import { import {
companyAnalysisQueryOptions, companyAnalysisQueryOptions,
filingsQueryOptions, filingsQueryOptions,
@@ -18,6 +21,7 @@ import {
} from '@/lib/query/options'; } from '@/lib/query/options';
import type { ActiveContext, NavGroup, NavItem } from '@/lib/types'; import type { ActiveContext, NavGroup, NavItem } from '@/lib/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTaskNotificationsCenter } from '@/hooks/use-task-notifications-center';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type AppShellProps = { type AppShellProps = {
@@ -186,6 +190,7 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
const [isSigningOut, setIsSigningOut] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false);
const [isMoreOpen, setIsMoreOpen] = useState(false); const [isMoreOpen, setIsMoreOpen] = useState(false);
const notifications = useTaskNotificationsCenter();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null; const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null;
@@ -338,6 +343,11 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
}; };
}, [navEntries]); }, [navEntries]);
useEffect(() => {
notifications.setIsPopoverOpen(false);
setIsMoreOpen(false);
}, [pathname]);
const signOut = async () => { const signOut = async () => {
if (isSigningOut) { if (isSigningOut) {
return; return;
@@ -422,6 +432,17 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
) : null} ) : null}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<TaskNotificationsTrigger
unreadCount={notifications.unreadCount}
isPopoverOpen={notifications.isPopoverOpen}
setIsPopoverOpen={notifications.setIsPopoverOpen}
activeTasks={notifications.activeTasks}
awaitingReviewTasks={notifications.awaitingReviewTasks}
openDrawer={() => notifications.setIsDrawerOpen(true)}
openTaskDetails={notifications.openTaskDetails}
silenceTask={notifications.silenceTask}
markTaskRead={notifications.markTaskRead}
/>
{actions} {actions}
<Button variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}> <Button variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
<LogOut className="size-4" /> <LogOut className="size-4" />
@@ -494,6 +515,19 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
); );
})} })}
<TaskNotificationsTrigger
mobile
unreadCount={notifications.unreadCount}
isPopoverOpen={notifications.isPopoverOpen}
setIsPopoverOpen={notifications.setIsPopoverOpen}
activeTasks={notifications.activeTasks}
awaitingReviewTasks={notifications.awaitingReviewTasks}
openDrawer={() => notifications.setIsDrawerOpen(true)}
openTaskDetails={notifications.openTaskDetails}
silenceTask={notifications.silenceTask}
markTaskRead={notifications.markTaskRead}
/>
<button <button
type="button" type="button"
aria-haspopup="dialog" aria-haspopup="dialog"
@@ -555,6 +589,25 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
</div> </div>
</div> </div>
) : null} ) : null}
<TaskNotificationsDrawer
isOpen={notifications.isDrawerOpen}
onClose={() => notifications.setIsDrawerOpen(false)}
activeTasks={notifications.activeTasks}
visibleFinishedTasks={notifications.visibleFinishedTasks}
awaitingReviewTasks={notifications.awaitingReviewTasks}
showReadFinished={notifications.showReadFinished}
setShowReadFinished={notifications.setShowReadFinished}
openTaskDetails={notifications.openTaskDetails}
markTaskRead={notifications.markTaskRead}
silenceTask={notifications.silenceTask}
/>
<TaskDetailModal
isOpen={notifications.isDetailOpen}
taskId={notifications.detailTaskId}
onClose={() => notifications.setIsDetailOpen(false)}
/>
</div> </div>
); );
} }

View File

@@ -1,10 +1,28 @@
services: services:
workflow-postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${WORKFLOW_POSTGRES_DB:-workflow}
POSTGRES_USER: ${WORKFLOW_POSTGRES_USER:-workflow}
POSTGRES_PASSWORD: ${WORKFLOW_POSTGRES_PASSWORD:-workflow}
volumes:
- workflow_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${WORKFLOW_POSTGRES_USER:-workflow} -d ${WORKFLOW_POSTGRES_DB:-workflow} -h 127.0.0.1 || exit 1"]
interval: 10s
timeout: 5s
retries: 12
app: app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
depends_on:
workflow-postgres:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- path: ./.env - path: ./.env
@@ -25,12 +43,14 @@ services:
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:8b} OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:8b}
OLLAMA_API_KEY: ${OLLAMA_API_KEY:-ollama} OLLAMA_API_KEY: ${OLLAMA_API_KEY:-ollama}
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@fiscal.local>} SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@fiscal.local>}
WORKFLOW_TARGET_WORLD: local WORKFLOW_TARGET_WORLD: ${WORKFLOW_TARGET_WORLD:-@workflow/world-postgres}
WORKFLOW_POSTGRES_URL: ${WORKFLOW_POSTGRES_URL:-postgres://workflow:workflow@workflow-postgres:5432/workflow}
WORKFLOW_POSTGRES_WORKER_CONCURRENCY: ${WORKFLOW_POSTGRES_WORKER_CONCURRENCY:-10}
WORKFLOW_POSTGRES_JOB_PREFIX: ${WORKFLOW_POSTGRES_JOB_PREFIX:-fiscal_}
WORKFLOW_LOCAL_DATA_DIR: ${WORKFLOW_LOCAL_DATA_DIR:-/app/.workflow-data} WORKFLOW_LOCAL_DATA_DIR: ${WORKFLOW_LOCAL_DATA_DIR:-/app/.workflow-data}
WORKFLOW_LOCAL_QUEUE_CONCURRENCY: ${WORKFLOW_LOCAL_QUEUE_CONCURRENCY:-100} WORKFLOW_LOCAL_QUEUE_CONCURRENCY: ${WORKFLOW_LOCAL_QUEUE_CONCURRENCY:-100}
volumes: volumes:
- fiscal_sqlite_data:/app/data - fiscal_sqlite_data:/app/data
- fiscal_workflow_data:/app/.workflow-data
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"] test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"]
interval: 30s interval: 30s
@@ -41,4 +61,4 @@ services:
volumes: volumes:
fiscal_sqlite_data: fiscal_sqlite_data:
fiscal_workflow_data: workflow_postgres_data:

View File

@@ -0,0 +1,11 @@
ALTER TABLE `task_run` ADD `stage` text NOT NULL DEFAULT 'queued';
--> statement-breakpoint
ALTER TABLE `task_run` ADD `stage_detail` text;
--> statement-breakpoint
ALTER TABLE `task_run` ADD `resource_key` text;
--> statement-breakpoint
ALTER TABLE `task_run` ADD `notification_read_at` text;
--> statement-breakpoint
ALTER TABLE `task_run` ADD `notification_silenced_at` text;
--> statement-breakpoint
CREATE INDEX `task_user_resource_status_idx` ON `task_run` (`user_id`,`task_type`,`resource_key`,`status`,`created_at`);

View File

@@ -0,0 +1,15 @@
CREATE TABLE `task_stage_event` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`task_id` text NOT NULL,
`user_id` text NOT NULL,
`stage` text NOT NULL,
`stage_detail` text,
`status` text NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`task_id`) REFERENCES `task_run`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `task_stage_event_task_created_idx` ON `task_stage_event` (`task_id`,`created_at`);
--> statement-breakpoint
CREATE INDEX `task_stage_event_user_created_idx` ON `task_stage_event` (`user_id`,`created_at`);

View File

@@ -15,6 +15,20 @@
"when": 1772417400000, "when": 1772417400000,
"tag": "0001_glossy_statement_snapshots", "tag": "0001_glossy_statement_snapshots",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1772450400000,
"tag": "0002_workflow_task_projection_metadata",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1772486100000,
"tag": "0003_task_stage_event_timeline",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,6 +10,7 @@ import {
portfolioSummaryQueryOptions, portfolioSummaryQueryOptions,
recentTasksQueryOptions, recentTasksQueryOptions,
taskQueryOptions, taskQueryOptions,
taskTimelineQueryOptions,
watchlistQueryOptions watchlistQueryOptions
} from '@/lib/query/options'; } from '@/lib/query/options';
@@ -69,6 +70,13 @@ export function useTaskQuery(taskId: string, enabled = true) {
}); });
} }
export function useTaskTimelineQuery(taskId: string, enabled = true) {
return useQuery({
...taskTimelineQueryOptions(taskId),
enabled: enabled && taskId.length > 0
});
}
export function useRecentTasksQuery(limit = 20, enabled = true) { export function useRecentTasksQuery(limit = 20, enabled = true) {
return useQuery({ return useQuery({
...recentTasksQueryOptions(limit), ...recentTasksQueryOptions(limit),

View File

@@ -0,0 +1,405 @@
'use client';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
listRecentTasks,
updateTaskNotificationState
} from '@/lib/api';
import type { Task, TaskStatus } from '@/lib/types';
const ACTIVE_STATUSES: TaskStatus[] = ['queued', 'running'];
const TERMINAL_STATUSES: TaskStatus[] = ['completed', 'failed'];
function isTerminalTask(task: Task) {
return TERMINAL_STATUSES.includes(task.status);
}
function taskSignature(task: Task) {
return `${task.status}|${task.stage}|${task.stage_detail ?? ''}|${task.error ?? ''}`;
}
function taskTitle(task: Task) {
switch (task.task_type) {
case 'sync_filings':
return 'Filing sync';
case 'refresh_prices':
return 'Price refresh';
case 'analyze_filing':
return 'Filing analysis';
case 'portfolio_insights':
return 'Portfolio insight';
default:
return 'Task';
}
}
function taskDescription(task: Task) {
if (task.error && task.status === 'failed') {
return task.error;
}
if (task.stage_detail) {
return task.stage_detail;
}
switch (task.status) {
case 'queued':
return 'Queued and waiting for execution.';
case 'running':
return 'Running in workflow engine.';
case 'completed':
return 'Task finished successfully.';
case 'failed':
return 'Task failed.';
default:
return 'Task status changed.';
}
}
function shouldNotifyTask(task: Task) {
return !task.notification_silenced_at;
}
function isUnread(task: Task) {
return task.notification_read_at === null;
}
type UseTaskNotificationsCenterResult = {
activeTasks: Task[];
finishedTasks: Task[];
unreadCount: number;
awaitingReviewTasks: Task[];
visibleFinishedTasks: Task[];
showReadFinished: boolean;
setShowReadFinished: (value: boolean) => void;
isPopoverOpen: boolean;
setIsPopoverOpen: (value: boolean) => void;
isDrawerOpen: boolean;
setIsDrawerOpen: (value: boolean) => void;
detailTaskId: string | null;
setDetailTaskId: (value: string | null) => void;
isDetailOpen: boolean;
setIsDetailOpen: (value: boolean) => void;
openTaskDetails: (taskId: string) => void;
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
refreshTasks: () => Promise<void>;
};
export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
const queryClient = useQueryClient();
const [activeTasks, setActiveTasks] = useState<Task[]>([]);
const [finishedTasks, setFinishedTasks] = useState<Task[]>([]);
const [showReadFinished, setShowReadFinished] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const activeLoadedRef = useRef(false);
const finishedLoadedRef = useRef(false);
const stateSignaturesRef = useRef(new Map<string, string>());
const invalidatedTerminalRef = useRef(new Set<string>());
const activeSnapshotRef = useRef<Task[]>([]);
const finishedSnapshotRef = useRef<Task[]>([]);
const applyTaskLocally = useCallback((task: Task) => {
setActiveTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
setFinishedTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
}, []);
const invalidateForTerminalTask = useCallback((task: Task) => {
const key = `${task.id}:${task.status}`;
if (invalidatedTerminalRef.current.has(key)) {
return;
}
invalidatedTerminalRef.current.add(key);
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
switch (task.task_type) {
case 'sync_filings': {
void queryClient.invalidateQueries({ queryKey: ['filings'] });
void queryClient.invalidateQueries({ queryKey: ['analysis'] });
void queryClient.invalidateQueries({ queryKey: ['financials-v2'] });
break;
}
case 'analyze_filing': {
void queryClient.invalidateQueries({ queryKey: ['filings'] });
void queryClient.invalidateQueries({ queryKey: ['report'] });
void queryClient.invalidateQueries({ queryKey: ['analysis'] });
break;
}
case 'refresh_prices': {
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'holdings'] });
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'summary'] });
break;
}
case 'portfolio_insights': {
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'insights', 'latest'] });
break;
}
default:
break;
}
}, [queryClient]);
const openTaskDetails = useCallback((taskId: string) => {
setDetailTaskId(taskId);
setIsDetailOpen(true);
setIsDrawerOpen(true);
setIsPopoverOpen(false);
}, []);
const silenceTask = useCallback(async (taskId: string, silenced = true) => {
try {
const { task } = await updateTaskNotificationState(taskId, { silenced });
applyTaskLocally(task);
toast.dismiss(taskId);
} catch {
toast.error('Unable to update notification state');
}
}, [applyTaskLocally]);
const markTaskRead = useCallback(async (taskId: string, read = true) => {
try {
const { task } = await updateTaskNotificationState(taskId, { read });
applyTaskLocally(task);
if (read) {
toast.dismiss(taskId);
}
} catch {
toast.error('Unable to update notification state');
}
}, [applyTaskLocally]);
const emitTaskToast = useCallback((task: Task) => {
if (!shouldNotifyTask(task)) {
toast.dismiss(task.id);
return;
}
if (task.status === 'queued' || task.status === 'running') {
toast(taskTitle(task), {
id: task.id,
duration: Number.POSITIVE_INFINITY,
description: taskDescription(task),
action: {
label: 'Open details',
onClick: () => openTaskDetails(task.id)
},
cancel: {
label: 'Silence',
onClick: () => {
void silenceTask(task.id, true);
}
}
});
return;
}
const toastBuilder = task.status === 'completed' ? toast.success : toast.error;
toastBuilder(taskTitle(task), {
id: task.id,
duration: 10_000,
description: taskDescription(task),
action: {
label: 'Open details',
onClick: () => openTaskDetails(task.id)
},
cancel: {
label: 'Mark read',
onClick: () => {
void markTaskRead(task.id, true);
}
}
});
}, [markTaskRead, openTaskDetails, silenceTask]);
const processSnapshots = useCallback(() => {
const active = activeSnapshotRef.current;
const finished = finishedSnapshotRef.current;
const all = [...active, ...finished];
if (!activeLoadedRef.current || !finishedLoadedRef.current) {
return;
}
if (stateSignaturesRef.current.size === 0) {
for (const task of all) {
stateSignaturesRef.current.set(task.id, taskSignature(task));
}
return;
}
for (const task of all) {
const signature = taskSignature(task);
const previousSignature = stateSignaturesRef.current.get(task.id);
const wasKnown = previousSignature !== undefined;
if (!wasKnown || previousSignature !== signature) {
emitTaskToast(task);
if (isTerminalTask(task)) {
invalidateForTerminalTask(task);
}
}
stateSignaturesRef.current.set(task.id, signature);
}
const currentIds = new Set(all.map((task) => task.id));
for (const knownId of [...stateSignaturesRef.current.keys()]) {
if (!currentIds.has(knownId)) {
toast.dismiss(knownId);
}
}
}, [emitTaskToast, invalidateForTerminalTask]);
const refreshTasks = useCallback(async () => {
try {
const [activeRes, finishedRes] = await Promise.all([
listRecentTasks({
limit: 80,
statuses: ACTIVE_STATUSES
}),
listRecentTasks({
limit: 120,
statuses: TERMINAL_STATUSES
})
]);
activeSnapshotRef.current = activeRes.tasks;
finishedSnapshotRef.current = finishedRes.tasks;
activeLoadedRef.current = true;
finishedLoadedRef.current = true;
setActiveTasks(activeRes.tasks);
setFinishedTasks(finishedRes.tasks);
processSnapshots();
} catch {
// ignore transient polling failures
}
}, [processSnapshots]);
useEffect(() => {
let cancelled = false;
let activeTimer: ReturnType<typeof setTimeout> | null = null;
let terminalTimer: ReturnType<typeof setTimeout> | null = null;
const runActiveLoop = async () => {
if (cancelled) {
return;
}
try {
const response = await listRecentTasks({
limit: 80,
statuses: ACTIVE_STATUSES
});
if (cancelled) {
return;
}
activeSnapshotRef.current = response.tasks;
activeLoadedRef.current = true;
setActiveTasks(response.tasks);
processSnapshots();
} catch {
// ignore transient polling failures
}
activeTimer = setTimeout(runActiveLoop, 2_000);
};
const runTerminalLoop = async () => {
if (cancelled) {
return;
}
try {
const response = await listRecentTasks({
limit: 120,
statuses: TERMINAL_STATUSES
});
if (cancelled) {
return;
}
finishedSnapshotRef.current = response.tasks;
finishedLoadedRef.current = true;
setFinishedTasks(response.tasks);
processSnapshots();
} catch {
// ignore transient polling failures
}
terminalTimer = setTimeout(runTerminalLoop, 4_000);
};
void runActiveLoop();
void runTerminalLoop();
return () => {
cancelled = true;
if (activeTimer) {
clearTimeout(activeTimer);
}
if (terminalTimer) {
clearTimeout(terminalTimer);
}
};
}, [processSnapshots]);
const normalizedActiveTasks = useMemo(() => {
return activeTasks.filter((task) => ACTIVE_STATUSES.includes(task.status));
}, [activeTasks]);
const normalizedFinishedTasks = useMemo(() => {
return finishedTasks.filter((task) => TERMINAL_STATUSES.includes(task.status));
}, [finishedTasks]);
const awaitingReviewTasks = useMemo(() => {
return normalizedFinishedTasks.filter((task) => isUnread(task));
}, [normalizedFinishedTasks]);
const visibleFinishedTasks = useMemo(() => {
if (showReadFinished) {
return normalizedFinishedTasks;
}
return awaitingReviewTasks;
}, [awaitingReviewTasks, normalizedFinishedTasks, showReadFinished]);
const unreadCount = useMemo(() => {
const unreadTerminal = normalizedFinishedTasks.filter((task) => isUnread(task)).length;
const unreadActive = normalizedActiveTasks.filter((task) => isUnread(task) && !task.notification_silenced_at).length;
return unreadTerminal + unreadActive;
}, [normalizedActiveTasks, normalizedFinishedTasks]);
return {
activeTasks: normalizedActiveTasks,
finishedTasks: normalizedFinishedTasks,
unreadCount,
awaitingReviewTasks,
visibleFinishedTasks,
showReadFinished,
setShowReadFinished,
isPopoverOpen,
setIsPopoverOpen,
isDrawerOpen,
setIsDrawerOpen,
detailTaskId,
setDetailTaskId,
isDetailOpen,
setIsDetailOpen,
openTaskDetails,
markTaskRead,
silenceTask,
refreshTasks
};
}

View File

@@ -12,6 +12,8 @@ import type {
PortfolioInsight, PortfolioInsight,
PortfolioSummary, PortfolioSummary,
Task, Task,
TaskStatus,
TaskTimeline,
User, User,
WatchlistItem WatchlistItem
} from './types'; } from './types';
@@ -244,10 +246,29 @@ export async function getTask(taskId: string) {
return await unwrapData<{ task: Task }>(result, 'Unable to fetch task'); return await unwrapData<{ task: Task }>(result, 'Unable to fetch task');
} }
export async function listRecentTasks(limit = 20) { export async function getTaskTimeline(taskId: string) {
const result = await client.api.tasks[taskId].timeline.get();
return await unwrapData<TaskTimeline>(result, 'Unable to fetch task timeline');
}
export async function updateTaskNotificationState(
taskId: string,
input: { read?: boolean; silenced?: boolean }
) {
const result = await client.api.tasks[taskId].notification.patch(input);
return await unwrapData<{ task: Task }>(result, 'Unable to update task notification state');
}
export async function listRecentTasks(input: {
limit?: number;
statuses?: TaskStatus[];
} = {}) {
const result = await client.api.tasks.get({ const result = await client.api.tasks.get({
$query: { $query: {
limit limit: input.limit ?? 20,
...(input.statuses && input.statuses.length > 0
? { status: input.statuses }
: {})
} }
}); });

View File

@@ -16,5 +16,6 @@ export const queryKeys = {
portfolioSummary: () => ['portfolio', 'summary'] as const, portfolioSummary: () => ['portfolio', 'summary'] as const,
latestPortfolioInsight: () => ['portfolio', 'insights', 'latest'] as const, latestPortfolioInsight: () => ['portfolio', 'insights', 'latest'] as const,
task: (taskId: string) => ['tasks', 'detail', taskId] as const, task: (taskId: string) => ['tasks', 'detail', taskId] as const,
taskTimeline: (taskId: string) => ['tasks', 'timeline', taskId] as const,
recentTasks: (limit: number) => ['tasks', 'recent', limit] as const recentTasks: (limit: number) => ['tasks', 'recent', limit] as const
}; };

View File

@@ -6,6 +6,7 @@ import {
getLatestPortfolioInsight, getLatestPortfolioInsight,
getPortfolioSummary, getPortfolioSummary,
getTask, getTask,
getTaskTimeline,
listFilings, listFilings,
listHoldings, listHoldings,
listRecentTasks, listRecentTasks,
@@ -126,10 +127,18 @@ export function taskQueryOptions(taskId: string) {
}); });
} }
export function recentTasksQueryOptions(limit = 20) { export function taskTimelineQueryOptions(taskId: string) {
return queryOptions({ return queryOptions({
queryKey: queryKeys.recentTasks(limit), queryKey: queryKeys.taskTimeline(taskId),
queryFn: () => listRecentTasks(limit), queryFn: () => getTaskTimeline(taskId),
staleTime: 5_000
});
}
export function recentTasksQueryOptions(limit = 20) {
return queryOptions({
queryKey: queryKeys.recentTasks(limit),
queryFn: () => listRecentTasks({ limit }),
staleTime: 5_000 staleTime: 5_000
}); });
} }

View File

@@ -1,4 +1,5 @@
import { Elysia, t } from 'elysia'; import { Elysia, t } from 'elysia';
import { getWorld } from 'workflow/runtime';
import type { import type {
Filing, Filing,
FinancialHistoryWindow, FinancialHistoryWindow,
@@ -31,9 +32,12 @@ import {
import { getPriceHistory, getQuote } from '@/lib/server/prices'; import { getPriceHistory, getQuote } from '@/lib/server/prices';
import { import {
enqueueTask, enqueueTask,
findInFlightTask,
getTaskById, getTaskById,
getTaskTimeline,
getTaskQueueSnapshot, getTaskQueueSnapshot,
listRecentTasks listRecentTasks,
updateTaskNotification
} from '@/lib/server/tasks'; } from '@/lib/server/tasks';
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed']; const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
@@ -120,7 +124,8 @@ async function queueAutoFilingSync(userId: string, ticker: string) {
ticker, ticker,
limit: AUTO_FILING_SYNC_LIMIT limit: AUTO_FILING_SYNC_LIMIT
}, },
priority: 90 priority: 90,
resourceKey: `sync_filings:${ticker}`
}); });
return true; return true;
@@ -132,18 +137,63 @@ async function queueAutoFilingSync(userId: string, ticker: string) {
const authHandler = ({ request }: { request: Request }) => auth.handler(request); const authHandler = ({ request }: { request: Request }) => auth.handler(request);
async function checkWorkflowBackend() {
try {
const world = getWorld();
await world.runs.list({
pagination: { limit: 1 },
resolveData: 'none'
});
return { ok: true } as const;
} catch (error) {
return {
ok: false,
reason: asErrorMessage(error, 'Workflow backend unavailable')
} as const;
}
}
export const app = new Elysia({ prefix: '/api' }) export const app = new Elysia({ prefix: '/api' })
.all('/auth', authHandler) .all('/auth', authHandler)
.all('/auth/*', authHandler) .all('/auth/*', authHandler)
.get('/health', async () => { .get('/health', async () => {
const queue = await getTaskQueueSnapshot(); try {
const [queue, workflowBackend] = await Promise.all([
getTaskQueueSnapshot(),
checkWorkflowBackend()
]);
return Response.json({ if (!workflowBackend.ok) {
status: 'ok', return Response.json({
version: '4.0.0', status: 'degraded',
timestamp: new Date().toISOString(), version: '4.0.0',
queue timestamp: new Date().toISOString(),
}); queue,
workflow: {
ok: false,
reason: workflowBackend.reason
}
}, { status: 503 });
}
return Response.json({
status: 'ok',
version: '4.0.0',
timestamp: new Date().toISOString(),
queue,
workflow: {
ok: true
}
});
} catch (error) {
return Response.json({
status: 'degraded',
version: '4.0.0',
timestamp: new Date().toISOString(),
error: asErrorMessage(error, 'Health check failed')
}, { status: 503 });
}
}) })
.get('/me', async () => { .get('/me', async () => {
const { session, response } = await requireAuthenticatedSession(); const { session, response } = await requireAuthenticatedSession();
@@ -375,7 +425,8 @@ export const app = new Elysia({ prefix: '/api' })
userId: session.user.id, userId: session.user.id,
taskType: 'refresh_prices', taskType: 'refresh_prices',
payload: {}, payload: {},
priority: 80 priority: 80,
resourceKey: 'refresh_prices:portfolio'
}); });
return Response.json({ task }); return Response.json({ task });
@@ -394,7 +445,8 @@ export const app = new Elysia({ prefix: '/api' })
userId: session.user.id, userId: session.user.id,
taskType: 'portfolio_insights', taskType: 'portfolio_insights',
payload: {}, payload: {},
priority: 70 priority: 70,
resourceKey: 'portfolio_insights:portfolio'
}); });
return Response.json({ task }); return Response.json({ task });
@@ -543,7 +595,8 @@ export const app = new Elysia({ prefix: '/api' })
ticker, ticker,
limit: defaultFinancialSyncLimit(window) limit: defaultFinancialSyncLimit(window)
}, },
priority: 88 priority: 88,
resourceKey: `sync_filings:${ticker}`
}); });
queuedSync = true; queuedSync = true;
} catch (error) { } catch (error) {
@@ -668,7 +721,8 @@ export const app = new Elysia({ prefix: '/api' })
ticker, ticker,
limit: Number.isFinite(limit) ? limit : 20 limit: Number.isFinite(limit) ? limit : 20
}, },
priority: 90 priority: 90,
resourceKey: `sync_filings:${ticker}`
}); });
return Response.json({ task }); return Response.json({ task });
@@ -693,11 +747,23 @@ export const app = new Elysia({ prefix: '/api' })
} }
try { try {
const resourceKey = `analyze_filing:${accessionNumber}`;
const existing = await findInFlightTask(
session.user.id,
'analyze_filing',
resourceKey
);
if (existing) {
return Response.json({ task: existing });
}
const task = await enqueueTask({ const task = await enqueueTask({
userId: session.user.id, userId: session.user.id,
taskType: 'analyze_filing', taskType: 'analyze_filing',
payload: { accessionNumber }, payload: { accessionNumber },
priority: 65 priority: 65,
resourceKey
}); });
return Response.json({ task }); return Response.json({ task });
@@ -760,6 +826,56 @@ export const app = new Elysia({ prefix: '/api' })
params: t.Object({ params: t.Object({
taskId: t.String({ minLength: 1 }) taskId: t.String({ minLength: 1 })
}) })
})
.get('/tasks/:taskId/timeline', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const timeline = await getTaskTimeline(params.taskId, session.user.id);
if (!timeline) {
return jsonError('Task not found', 404);
}
return Response.json(timeline);
}, {
params: t.Object({
taskId: t.String({ minLength: 1 })
})
})
.patch('/tasks/:taskId/notification', async ({ params, body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const payload = asRecord(body);
const read = typeof payload.read === 'boolean' ? payload.read : undefined;
const silenced = typeof payload.silenced === 'boolean' ? payload.silenced : undefined;
if (read === undefined && silenced === undefined) {
return jsonError('read or silenced must be provided');
}
const task = await updateTaskNotification(session.user.id, params.taskId, {
read,
silenced
});
if (!task) {
return jsonError('Task not found', 404);
}
return Response.json({ task });
}, {
params: t.Object({
taskId: t.String({ minLength: 1 })
}),
body: t.Object({
read: t.Optional(t.Boolean()),
silenced: t.Optional(t.Boolean())
})
}); });
export type App = typeof app; export type App = typeof app;

View File

@@ -0,0 +1,319 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
mock
} from 'bun:test';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { WorkflowRunStatus } from '@workflow/world';
const TEST_USER_ID = 'e2e-user';
const TEST_USER_EMAIL = 'e2e@example.com';
const TEST_USER_NAME = 'E2E User';
const runStatuses = new Map<string, WorkflowRunStatus>();
let runCounter = 0;
let workflowBackendHealthy = true;
let tempDir: string | null = null;
let sqliteClient: { exec: (query: string) => void; close: () => void } | null = null;
let app: { handle: (request: Request) => Promise<Response> } | null = null;
mock.module('workflow/api', () => ({
start: mock(async () => {
runCounter += 1;
const runId = `run-${runCounter}`;
runStatuses.set(runId, 'pending');
return { runId };
}),
getRun: mock((runId: string) => ({
get status() {
return Promise.resolve(runStatuses.get(runId) ?? 'pending');
}
}))
}));
mock.module('workflow/runtime', () => ({
getWorld: () => ({
runs: {
list: async () => {
if (!workflowBackendHealthy) {
throw new Error('Workflow backend unavailable');
}
return {
data: []
};
}
}
})
}));
mock.module('@/lib/server/auth-session', () => ({
requireAuthenticatedSession: async () => ({
session: {
user: {
id: TEST_USER_ID,
email: TEST_USER_EMAIL,
name: TEST_USER_NAME,
image: null
}
},
response: null
})
}));
function resetDbSingletons() {
const globalState = globalThis as typeof globalThis & {
__fiscalSqliteClient?: { close?: () => void };
__fiscalDrizzleDb?: unknown;
};
globalState.__fiscalSqliteClient?.close?.();
globalState.__fiscalSqliteClient = undefined;
globalState.__fiscalDrizzleDb = undefined;
}
function applySqlMigrations(client: { exec: (query: string) => void }) {
const migrationFiles = [
'0000_cold_silver_centurion.sql',
'0001_glossy_statement_snapshots.sql',
'0002_workflow_task_projection_metadata.sql',
'0003_task_stage_event_timeline.sql'
];
for (const file of migrationFiles) {
const sql = readFileSync(join(process.cwd(), 'drizzle', file), 'utf8');
client.exec(sql);
}
}
function ensureTestUser(client: { exec: (query: string) => void }) {
const now = Date.now();
client.exec(`
INSERT OR REPLACE INTO user (
id, name, email, emailVerified, image, createdAt, updatedAt, role, banned, banReason, banExpires
) VALUES (
'${TEST_USER_ID}',
'${TEST_USER_NAME}',
'${TEST_USER_EMAIL}',
1,
NULL,
${now},
${now},
NULL,
0,
NULL,
NULL
);
`);
}
function clearProjectionTables(client: { exec: (query: string) => void }) {
client.exec('DELETE FROM task_stage_event;');
client.exec('DELETE FROM task_run;');
}
async function jsonRequest(
method: 'GET' | 'POST' | 'PATCH',
path: string,
body?: Record<string, unknown>
) {
if (!app) {
throw new Error('app not initialized');
}
const response = await app.handle(new Request(`http://localhost${path}`, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
}));
return {
response,
json: await response.json()
};
}
if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
describe('task workflow hybrid migration e2e', () => {
beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), 'fiscal-task-e2e-'));
const env = process.env as Record<string, string | undefined>;
env.DATABASE_URL = `file:${join(tempDir, 'e2e.sqlite')}`;
env.NODE_ENV = 'test';
resetDbSingletons();
const dbModule = await import('@/lib/server/db');
sqliteClient = dbModule.getSqliteClient();
applySqlMigrations(sqliteClient);
ensureTestUser(sqliteClient);
const appModule = await import('./app');
app = appModule.app;
});
afterAll(() => {
resetDbSingletons();
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
}
});
beforeEach(() => {
if (!sqliteClient) {
throw new Error('sqlite client not initialized');
}
clearProjectionTables(sqliteClient);
runStatuses.clear();
runCounter = 0;
workflowBackendHealthy = true;
});
it('queues multiple analyze jobs and suppresses duplicate in-flight analyze jobs', async () => {
const first = await jsonRequest('POST', '/api/filings/0000000000-26-000001/analyze');
expect(first.response.status).toBe(200);
const firstTaskId = (first.json as { task: { id: string } }).task.id;
const [second, third] = await Promise.all([
jsonRequest('POST', '/api/filings/0000000000-26-000002/analyze'),
jsonRequest('POST', '/api/filings/0000000000-26-000003/analyze')
]);
expect(second.response.status).toBe(200);
expect(third.response.status).toBe(200);
const duplicate = await jsonRequest('POST', '/api/filings/0000000000-26-000001/analyze');
expect(duplicate.response.status).toBe(200);
expect((duplicate.json as { task: { id: string } }).task.id).toBe(firstTaskId);
const tasksResponse = await jsonRequest('GET', '/api/tasks?limit=10');
expect(tasksResponse.response.status).toBe(200);
const tasks = (tasksResponse.json as {
tasks: Array<{
id: string;
status: string;
stage: string;
workflow_run_id?: string | null;
}>;
}).tasks;
expect(tasks.length).toBe(3);
expect(tasks.every((task) => task.status === 'queued')).toBe(true);
expect(tasks.every((task) => task.stage === 'queued')).toBe(true);
expect(tasks.every((task) => typeof task.workflow_run_id === 'string' && task.workflow_run_id.length > 0)).toBe(true);
});
it('updates notification read and silenced state via patch endpoint', async () => {
const created = await jsonRequest('POST', '/api/filings/0000000000-26-000010/analyze');
const taskId = (created.json as { task: { id: string } }).task.id;
const readUpdate = await jsonRequest('PATCH', `/api/tasks/${taskId}/notification`, { read: true });
expect(readUpdate.response.status).toBe(200);
const readTask = (readUpdate.json as {
task: {
notification_read_at: string | null;
notification_silenced_at: string | null;
};
}).task;
expect(readTask.notification_read_at).toBeTruthy();
expect(readTask.notification_silenced_at).toBeNull();
const silencedUpdate = await jsonRequest('PATCH', `/api/tasks/${taskId}/notification`, {
silenced: true
});
expect(silencedUpdate.response.status).toBe(200);
const silencedTask = (silencedUpdate.json as {
task: {
notification_read_at: string | null;
notification_silenced_at: string | null;
};
}).task;
expect(silencedTask.notification_read_at).toBeTruthy();
expect(silencedTask.notification_silenced_at).toBeTruthy();
const resetUpdate = await jsonRequest('PATCH', `/api/tasks/${taskId}/notification`, {
read: false,
silenced: false
});
expect(resetUpdate.response.status).toBe(200);
const resetTask = (resetUpdate.json as {
task: {
notification_read_at: string | null;
notification_silenced_at: string | null;
};
}).task;
expect(resetTask.notification_read_at).toBeNull();
expect(resetTask.notification_silenced_at).toBeNull();
});
it('reconciles workflow run status into projection state and degrades health when workflow backend is down', async () => {
const created = await jsonRequest('POST', '/api/filings/0000000000-26-000100/analyze');
const task = (created.json as {
task: { id: string; workflow_run_id: string };
}).task;
runStatuses.set(task.workflow_run_id, 'running');
const running = await jsonRequest('GET', `/api/tasks/${task.id}`);
expect(running.response.status).toBe(200);
const runningTask = (running.json as { task: { status: string; stage: string } }).task;
expect(runningTask.status).toBe('running');
expect(runningTask.stage).toBe('running');
runStatuses.set(task.workflow_run_id, 'completed');
const completed = await jsonRequest('GET', `/api/tasks/${task.id}`);
expect(completed.response.status).toBe(200);
const completedTask = (completed.json as {
task: {
status: string;
stage: string;
finished_at: string | null;
};
}).task;
expect(completedTask.status).toBe('completed');
expect(completedTask.stage).toBe('completed');
expect(completedTask.finished_at).toBeTruthy();
const timeline = await jsonRequest('GET', `/api/tasks/${task.id}/timeline`);
expect(timeline.response.status).toBe(200);
const events = (timeline.json as {
events: Array<{
stage: string;
status: string;
}>;
}).events;
expect(events.length).toBeGreaterThanOrEqual(3);
expect(events.some((event) => event.status === 'queued')).toBe(true);
expect(events.some((event) => event.status === 'running')).toBe(true);
expect(events.some((event) => event.status === 'completed')).toBe(true);
const healthy = await jsonRequest('GET', '/api/health');
expect(healthy.response.status).toBe(200);
expect((healthy.json as { status: string; workflow: { ok: boolean } }).status).toBe('ok');
expect((healthy.json as { status: string; workflow: { ok: boolean } }).workflow.ok).toBe(true);
workflowBackendHealthy = false;
const degraded = await jsonRequest('GET', '/api/health');
expect(degraded.response.status).toBe(503);
expect((degraded.json as {
status: string;
workflow: { ok: boolean; reason: string };
}).status).toBe('degraded');
expect((degraded.json as {
status: string;
workflow: { ok: boolean; reason: string };
}).workflow.ok).toBe(false);
});
});
}

View File

@@ -288,6 +288,11 @@ export const taskRun = sqliteTable('task_run', {
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
task_type: text('task_type').$type<'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights'>().notNull(), task_type: text('task_type').$type<'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights'>().notNull(),
status: text('status').$type<'queued' | 'running' | 'completed' | 'failed'>().notNull(), status: text('status').$type<'queued' | 'running' | 'completed' | 'failed'>().notNull(),
stage: text('stage').notNull(),
stage_detail: text('stage_detail'),
resource_key: text('resource_key'),
notification_read_at: text('notification_read_at'),
notification_silenced_at: text('notification_silenced_at'),
priority: integer('priority').notNull(), priority: integer('priority').notNull(),
payload: text('payload', { mode: 'json' }).$type<Record<string, unknown>>().notNull(), payload: text('payload', { mode: 'json' }).$type<Record<string, unknown>>().notNull(),
result: text('result', { mode: 'json' }).$type<Record<string, unknown> | null>(), result: text('result', { mode: 'json' }).$type<Record<string, unknown> | null>(),
@@ -301,9 +306,29 @@ export const taskRun = sqliteTable('task_run', {
}, (table) => ({ }, (table) => ({
taskUserCreatedIndex: index('task_user_created_idx').on(table.user_id, table.created_at), taskUserCreatedIndex: index('task_user_created_idx').on(table.user_id, table.created_at),
taskStatusIndex: index('task_status_idx').on(table.status), taskStatusIndex: index('task_status_idx').on(table.status),
taskUserResourceStatusIndex: index('task_user_resource_status_idx').on(
table.user_id,
table.task_type,
table.resource_key,
table.status,
table.created_at
),
taskWorkflowRunUnique: uniqueIndex('task_workflow_run_uidx').on(table.workflow_run_id) taskWorkflowRunUnique: uniqueIndex('task_workflow_run_uidx').on(table.workflow_run_id)
})); }));
export const taskStageEvent = sqliteTable('task_stage_event', {
id: integer('id').primaryKey({ autoIncrement: true }),
task_id: text('task_id').notNull().references(() => taskRun.id, { onDelete: 'cascade' }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
stage: text('stage').notNull(),
stage_detail: text('stage_detail'),
status: text('status').$type<'queued' | 'running' | 'completed' | 'failed'>().notNull(),
created_at: text('created_at').notNull()
}, (table) => ({
taskStageEventTaskCreatedIndex: index('task_stage_event_task_created_idx').on(table.task_id, table.created_at),
taskStageEventUserCreatedIndex: index('task_stage_event_user_created_idx').on(table.user_id, table.created_at)
}));
export const portfolioInsight = sqliteTable('portfolio_insight', { export const portfolioInsight = sqliteTable('portfolio_insight', {
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
@@ -332,6 +357,7 @@ export const appSchema = {
filingStatementSnapshot, filingStatementSnapshot,
filingLink, filingLink,
taskRun, taskRun,
taskStageEvent,
portfolioInsight portfolioInsight
}; };

View File

@@ -1,9 +1,10 @@
import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import type { Task, TaskStatus, TaskType } from '@/lib/types'; import type { Task, TaskStage, TaskStageEvent, TaskStatus, TaskType } from '@/lib/types';
import { db } from '@/lib/server/db'; import { db } from '@/lib/server/db';
import { taskRun } from '@/lib/server/db/schema'; import { taskRun, taskStageEvent } from '@/lib/server/db/schema';
type TaskRow = typeof taskRun.$inferSelect; type TaskRow = typeof taskRun.$inferSelect;
type TaskStageEventRow = typeof taskStageEvent.$inferSelect;
type CreateTaskInput = { type CreateTaskInput = {
id: string; id: string;
@@ -12,14 +13,36 @@ type CreateTaskInput = {
payload: Record<string, unknown>; payload: Record<string, unknown>;
priority: number; priority: number;
max_attempts: number; max_attempts: number;
resource_key?: string | null;
}; };
type UpdateTaskNotificationStateInput = {
read?: boolean;
silenced?: boolean;
};
type EventInsertInput = {
task_id: string;
user_id: string;
stage: TaskStage;
stage_detail: string | null;
status: TaskStatus;
created_at: string;
};
type InsertExecutor = Pick<typeof db, 'insert'>;
function toTask(row: TaskRow): Task { function toTask(row: TaskRow): Task {
return { return {
id: row.id, id: row.id,
user_id: row.user_id, user_id: row.user_id,
task_type: row.task_type, task_type: row.task_type,
status: row.status, status: row.status,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
resource_key: row.resource_key,
notification_read_at: row.notification_read_at,
notification_silenced_at: row.notification_silenced_at,
priority: row.priority, priority: row.priority,
payload: row.payload, payload: row.payload,
result: row.result, result: row.result,
@@ -33,30 +56,84 @@ function toTask(row: TaskRow): Task {
}; };
} }
function toTaskStageEvent(row: TaskStageEventRow): TaskStageEvent {
return {
id: row.id,
task_id: row.task_id,
user_id: row.user_id,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
status: row.status as TaskStatus,
created_at: row.created_at
};
}
function statusToStage(status: TaskStatus): TaskStage {
switch (status) {
case 'queued':
return 'queued';
case 'running':
return 'running';
case 'completed':
return 'completed';
case 'failed':
return 'failed';
default:
return 'failed';
}
}
async function insertTaskStageEvent(executor: InsertExecutor, input: EventInsertInput) {
await executor.insert(taskStageEvent).values({
task_id: input.task_id,
user_id: input.user_id,
stage: input.stage,
stage_detail: input.stage_detail,
status: input.status,
created_at: input.created_at
});
}
export async function createTaskRunRecord(input: CreateTaskInput) { export async function createTaskRunRecord(input: CreateTaskInput) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const [row] = await db return await db.transaction(async (tx) => {
.insert(taskRun) const [row] = await tx
.values({ .insert(taskRun)
id: input.id, .values({
user_id: input.user_id, id: input.id,
task_type: input.task_type, user_id: input.user_id,
status: 'queued', task_type: input.task_type,
priority: input.priority, status: 'queued',
payload: input.payload, stage: 'queued',
result: null, stage_detail: null,
error: null, resource_key: input.resource_key ?? null,
attempts: 0, notification_read_at: null,
max_attempts: input.max_attempts, notification_silenced_at: null,
workflow_run_id: null, priority: input.priority,
created_at: now, payload: input.payload,
updated_at: now, result: null,
finished_at: null error: null,
}) attempts: 0,
.returning(); max_attempts: input.max_attempts,
workflow_run_id: null,
created_at: now,
updated_at: now,
finished_at: null
})
.returning();
return toTask(row); await insertTaskStageEvent(tx, {
task_id: row.id,
user_id: row.user_id,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
status: row.status,
created_at: now
});
return toTask(row);
});
} }
export async function setTaskWorkflowRunId(taskId: string, workflowRunId: string) { export async function setTaskWorkflowRunId(taskId: string, workflowRunId: string) {
@@ -121,67 +198,268 @@ export async function countTasksByStatus() {
return queue; return queue;
} }
export async function claimQueuedTask(taskId: string) { export async function findInFlightTaskByResourceKey(
userId: string,
taskType: TaskType,
resourceKey: string
) {
const [row] = await db const [row] = await db
.update(taskRun) .select()
.set({ .from(taskRun)
status: 'running', .where(and(
attempts: sql`${taskRun.attempts} + 1`, eq(taskRun.user_id, userId),
updated_at: new Date().toISOString() eq(taskRun.task_type, taskType),
}) eq(taskRun.resource_key, resourceKey),
.where(and(eq(taskRun.id, taskId), eq(taskRun.status, 'queued'))) inArray(taskRun.status, ['queued', 'running'])
.returning(); ))
.orderBy(desc(taskRun.created_at))
.limit(1);
return row ? toTask(row) : null; return row ? toTask(row) : null;
} }
export async function markTaskRunning(taskId: string) {
const now = new Date().toISOString();
return await db.transaction(async (tx) => {
const [row] = await tx
.update(taskRun)
.set({
status: 'running',
stage: 'running',
stage_detail: 'Workflow task is now running',
attempts: sql`${taskRun.attempts} + 1`,
updated_at: now,
finished_at: null
})
.where(eq(taskRun.id, taskId))
.returning();
if (!row) {
return null;
}
await insertTaskStageEvent(tx, {
task_id: row.id,
user_id: row.user_id,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
status: row.status,
created_at: now
});
return toTask(row);
});
}
export async function updateTaskStage(taskId: string, stage: TaskStage, detail: string | null = null) {
const now = new Date().toISOString();
return await db.transaction(async (tx) => {
const [row] = await tx
.update(taskRun)
.set({
stage,
stage_detail: detail,
updated_at: now
})
.where(eq(taskRun.id, taskId))
.returning();
if (!row) {
return null;
}
await insertTaskStageEvent(tx, {
task_id: row.id,
user_id: row.user_id,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
status: row.status,
created_at: now
});
return toTask(row);
});
}
export async function completeTask(taskId: string, result: Record<string, unknown>) { export async function completeTask(taskId: string, result: Record<string, unknown>) {
const now = new Date().toISOString();
return await db.transaction(async (tx) => {
const [row] = await tx
.update(taskRun)
.set({
status: 'completed',
stage: 'completed',
stage_detail: null,
result,
error: null,
updated_at: now,
finished_at: now
})
.where(eq(taskRun.id, taskId))
.returning();
if (!row) {
return null;
}
await insertTaskStageEvent(tx, {
task_id: row.id,
user_id: row.user_id,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
status: row.status,
created_at: now
});
return toTask(row);
});
}
export async function markTaskFailure(taskId: string, reason: string, stage: TaskStage = 'failed') {
const now = new Date().toISOString();
return await db.transaction(async (tx) => {
const [row] = await tx
.update(taskRun)
.set({
status: 'failed',
stage,
stage_detail: null,
error: reason,
updated_at: now,
finished_at: now
})
.where(eq(taskRun.id, taskId))
.returning();
if (!row) {
return null;
}
await insertTaskStageEvent(tx, {
task_id: row.id,
user_id: row.user_id,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
status: row.status,
created_at: now
});
return toTask(row);
});
}
export async function setTaskStatusFromWorkflow(
taskId: string,
status: TaskStatus,
error?: string | null
) {
const isTerminal = status === 'completed' || status === 'failed';
const nextStage = statusToStage(status);
const nextError = status === 'failed' ? (error ?? 'Workflow run failed') : null;
return await db.transaction(async (tx) => {
const [current] = await tx
.select()
.from(taskRun)
.where(eq(taskRun.id, taskId))
.limit(1);
if (!current) {
return null;
}
const hasNoStateChange = current.status === status
&& current.stage === nextStage
&& (current.error ?? null) === nextError
&& current.stage_detail === null
&& (isTerminal ? current.finished_at !== null : current.finished_at === null);
if (hasNoStateChange) {
return toTask(current);
}
const now = new Date().toISOString();
const [row] = await tx
.update(taskRun)
.set({
status,
stage: nextStage,
stage_detail: null,
error: nextError,
updated_at: now,
finished_at: isTerminal ? now : null
})
.where(eq(taskRun.id, taskId))
.returning();
if (!row) {
return null;
}
await insertTaskStageEvent(tx, {
task_id: row.id,
user_id: row.user_id,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
status: row.status,
created_at: now
});
return toTask(row);
});
}
export async function updateTaskNotificationState(
taskId: string,
userId: string,
input: UpdateTaskNotificationStateInput
) {
const now = new Date().toISOString();
const patch: Partial<typeof taskRun.$inferInsert> = {
updated_at: now
};
let hasMutation = false;
if (typeof input.read === 'boolean') {
patch.notification_read_at = input.read ? now : null;
hasMutation = true;
}
if (typeof input.silenced === 'boolean') {
patch.notification_silenced_at = input.silenced ? now : null;
hasMutation = true;
if (input.silenced) {
patch.notification_read_at = now;
}
}
if (!hasMutation) {
return await getTaskByIdForUser(taskId, userId);
}
const [row] = await db const [row] = await db
.update(taskRun) .update(taskRun)
.set({ .set(patch)
status: 'completed', .where(and(eq(taskRun.id, taskId), eq(taskRun.user_id, userId)))
result,
error: null,
updated_at: new Date().toISOString(),
finished_at: new Date().toISOString()
})
.where(eq(taskRun.id, taskId))
.returning(); .returning();
return row ? toTask(row) : null; return row ? toTask(row) : null;
} }
export async function markTaskFailure(taskId: string, reason: string) { export async function listTaskStageEventsForTask(taskId: string, userId: string) {
const [current] = await db const rows = await db
.select() .select()
.from(taskRun) .from(taskStageEvent)
.where(eq(taskRun.id, taskId)) .where(and(eq(taskStageEvent.task_id, taskId), eq(taskStageEvent.user_id, userId)))
.limit(1); .orderBy(asc(taskStageEvent.created_at), asc(taskStageEvent.id));
if (!current) { return rows.map(toTaskStageEvent);
return {
task: null,
shouldRetry: false
};
}
const shouldRetry = current.attempts < current.max_attempts;
const [updated] = await db
.update(taskRun)
.set({
status: shouldRetry ? 'queued' : 'failed',
error: reason,
updated_at: new Date().toISOString(),
finished_at: shouldRetry ? null : new Date().toISOString()
})
.where(eq(taskRun.id, taskId))
.returning();
return {
task: updated ? toTask(updated) : null,
shouldRetry
};
} }
export async function getTaskById(taskId: string) { export async function getTaskById(taskId: string) {

View File

@@ -3,7 +3,8 @@ import type {
FilingExtraction, FilingExtraction,
FilingExtractionMeta, FilingExtractionMeta,
Holding, Holding,
Task Task,
TaskStage
} from '@/lib/types'; } from '@/lib/types';
import { runAiAnalysis } from '@/lib/server/ai'; import { runAiAnalysis } from '@/lib/server/ai';
import { buildPortfolioSummary } from '@/lib/server/portfolio'; import { buildPortfolioSummary } from '@/lib/server/portfolio';
@@ -24,6 +25,7 @@ import {
listUserHoldings listUserHoldings
} from '@/lib/server/repos/holdings'; } from '@/lib/server/repos/holdings';
import { createPortfolioInsight } from '@/lib/server/repos/insights'; import { createPortfolioInsight } from '@/lib/server/repos/insights';
import { updateTaskStage } from '@/lib/server/repos/tasks';
import { import {
fetchFilingMetricsForFilings, fetchFilingMetricsForFilings,
fetchPrimaryFilingText, fetchPrimaryFilingText,
@@ -130,6 +132,10 @@ function toTaskResult(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
async function setProjectionStage(task: Task, stage: TaskStage, detail: string | null = null) {
await updateTaskStage(task.id, stage, detail);
}
function parseTicker(raw: unknown) { function parseTicker(raw: unknown) {
if (typeof raw !== 'string' || raw.trim().length < 1) { if (typeof raw !== 'string' || raw.trim().length < 1) {
throw new Error('Ticker is required'); throw new Error('Ticker is required');
@@ -513,6 +519,8 @@ function filingLinks(filing: {
async function processSyncFilings(task: Task) { async function processSyncFilings(task: Task) {
const ticker = parseTicker(task.payload.ticker); const ticker = parseTicker(task.payload.ticker);
const limit = parseLimit(task.payload.limit, 20, 1, 50); const limit = parseLimit(task.payload.limit, 20, 1, 50);
await setProjectionStage(task, 'sync.fetch_filings', `Fetching up to ${limit} filings for ${ticker}`);
const filings = await fetchRecentFilings(ticker, limit); const filings = await fetchRecentFilings(ticker, limit);
const metricsByAccession = new Map<string, Filing['metrics']>(); const metricsByAccession = new Map<string, Filing['metrics']>();
const filingsByCik = new Map<string, typeof filings>(); const filingsByCik = new Map<string, typeof filings>();
@@ -527,6 +535,7 @@ async function processSyncFilings(task: Task) {
filingsByCik.set(filing.cik, [filing]); filingsByCik.set(filing.cik, [filing]);
} }
await setProjectionStage(task, 'sync.fetch_metrics', `Computing financial metrics for ${filings.length} filings`);
for (const [cik, filingsForCik] of filingsByCik) { for (const [cik, filingsForCik] of filingsByCik) {
const filingsForFinancialMetrics = filingsForCik.filter((filing) => isFinancialMetricsForm(filing.filingType)); const filingsForFinancialMetrics = filingsForCik.filter((filing) => isFinancialMetricsForm(filing.filingType));
if (filingsForFinancialMetrics.length === 0) { if (filingsForFinancialMetrics.length === 0) {
@@ -548,6 +557,7 @@ async function processSyncFilings(task: Task) {
} }
} }
await setProjectionStage(task, 'sync.persist_filings', 'Persisting filings and links');
const saveResult = await upsertFilingsRecords( const saveResult = await upsertFilingsRecords(
filings.map((filing) => ({ filings.map((filing) => ({
ticker: filing.ticker, ticker: filing.ticker,
@@ -574,6 +584,7 @@ async function processSyncFilings(task: Task) {
return filing.filing_type === '10-K' || filing.filing_type === '10-Q'; return filing.filing_type === '10-K' || filing.filing_type === '10-Q';
}); });
await setProjectionStage(task, 'sync.hydrate_statements', `Hydrating statement snapshots for ${hydrateCandidates.length} candidate filings`);
for (const filing of hydrateCandidates) { for (const filing of hydrateCandidates) {
const existingSnapshot = await getFilingStatementSnapshotByFilingId(filing.id); const existingSnapshot = await getFilingStatementSnapshotByFilingId(filing.id);
const shouldRefresh = !existingSnapshot const shouldRefresh = !existingSnapshot
@@ -634,15 +645,18 @@ async function processRefreshPrices(task: Task) {
throw new Error('Task is missing user scope'); throw new Error('Task is missing user scope');
} }
await setProjectionStage(task, 'refresh.load_holdings', 'Loading holdings for price refresh');
const userHoldings = await listHoldingsForPriceRefresh(userId); const userHoldings = await listHoldingsForPriceRefresh(userId);
const tickers = [...new Set(userHoldings.map((entry) => entry.ticker))]; const tickers = [...new Set(userHoldings.map((entry) => entry.ticker))];
const quotes = new Map<string, number>(); const quotes = new Map<string, number>();
await setProjectionStage(task, 'refresh.fetch_quotes', `Fetching quotes for ${tickers.length} tickers`);
for (const ticker of tickers) { for (const ticker of tickers) {
const quote = await getQuote(ticker); const quote = await getQuote(ticker);
quotes.set(ticker, quote); quotes.set(ticker, quote);
} }
await setProjectionStage(task, 'refresh.persist_prices', 'Writing refreshed prices to holdings');
const updatedCount = await applyRefreshedPrices(userId, quotes, new Date().toISOString()); const updatedCount = await applyRefreshedPrices(userId, quotes, new Date().toISOString());
return { return {
@@ -660,6 +674,7 @@ async function processAnalyzeFiling(task: Task) {
throw new Error('accessionNumber is required'); throw new Error('accessionNumber is required');
} }
await setProjectionStage(task, 'analyze.load_filing', `Loading filing ${accessionNumber}`);
const filing = await getFilingByAccession(accessionNumber); const filing = await getFilingByAccession(accessionNumber);
if (!filing) { if (!filing) {
@@ -676,6 +691,7 @@ async function processAnalyzeFiling(task: Task) {
}; };
try { try {
await setProjectionStage(task, 'analyze.fetch_document', 'Fetching primary filing document');
const filingDocument = await fetchPrimaryFilingText({ const filingDocument = await fetchPrimaryFilingText({
filingUrl: filing.filing_url, filingUrl: filing.filing_url,
cik: filing.cik, cik: filing.cik,
@@ -684,6 +700,7 @@ async function processAnalyzeFiling(task: Task) {
}); });
if (filingDocument?.text) { if (filingDocument?.text) {
await setProjectionStage(task, 'analyze.extract', 'Generating extraction context from filing text');
const ruleBasedExtraction = buildRuleBasedExtraction(filing, filingDocument.text); const ruleBasedExtraction = buildRuleBasedExtraction(filing, filingDocument.text);
extraction = ruleBasedExtraction; extraction = ruleBasedExtraction;
extractionMeta = { extractionMeta = {
@@ -720,12 +737,14 @@ async function processAnalyzeFiling(task: Task) {
}; };
} }
await setProjectionStage(task, 'analyze.generate_report', 'Generating final filing analysis report');
const analysis = await runAiAnalysis( const analysis = await runAiAnalysis(
reportPrompt(filing, extraction, extractionMeta), reportPrompt(filing, extraction, extractionMeta),
'Use concise institutional analyst language.', 'Use concise institutional analyst language.',
{ workload: 'report' } { workload: 'report' }
); );
await setProjectionStage(task, 'analyze.persist_report', 'Persisting filing analysis output');
await saveFilingAnalysis(accessionNumber, { await saveFilingAnalysis(accessionNumber, {
provider: analysis.provider, provider: analysis.provider,
model: analysis.model, model: analysis.model,
@@ -761,6 +780,7 @@ async function processPortfolioInsights(task: Task) {
throw new Error('Task is missing user scope'); throw new Error('Task is missing user scope');
} }
await setProjectionStage(task, 'insights.load_holdings', 'Loading holdings for portfolio insight generation');
const userHoldings = await listUserHoldings(userId); const userHoldings = await listUserHoldings(userId);
const summary = buildPortfolioSummary(userHoldings); const summary = buildPortfolioSummary(userHoldings);
@@ -771,12 +791,14 @@ async function processPortfolioInsights(task: Task) {
'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.' 'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.'
].join('\n'); ].join('\n');
await setProjectionStage(task, 'insights.generate', 'Generating portfolio AI insight');
const analysis = await runAiAnalysis( const analysis = await runAiAnalysis(
prompt, prompt,
'Act as a risk-aware buy-side analyst.', 'Act as a risk-aware buy-side analyst.',
{ workload: 'report' } { workload: 'report' }
); );
await setProjectionStage(task, 'insights.persist', 'Persisting generated portfolio insight');
await createPortfolioInsight({ await createPortfolioInsight({
userId, userId,
provider: analysis.provider, provider: analysis.provider,

View File

@@ -1,14 +1,19 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { start } from 'workflow/api'; import { getRun, start } from 'workflow/api';
import type { Task, TaskStatus, TaskType } from '@/lib/types'; import type { WorkflowRunStatus } from '@workflow/world';
import type { Task, TaskStatus, TaskTimeline, TaskType } from '@/lib/types';
import { runTaskWorkflow } from '@/app/workflows/task-runner'; import { runTaskWorkflow } from '@/app/workflows/task-runner';
import { import {
countTasksByStatus, countTasksByStatus,
createTaskRunRecord, createTaskRunRecord,
findInFlightTaskByResourceKey,
getTaskByIdForUser, getTaskByIdForUser,
listTaskStageEventsForTask,
listRecentTasksForUser, listRecentTasksForUser,
markTaskFailure, markTaskFailure,
setTaskWorkflowRunId setTaskStatusFromWorkflow,
setTaskWorkflowRunId,
updateTaskNotificationState
} from '@/lib/server/repos/tasks'; } from '@/lib/server/repos/tasks';
type EnqueueTaskInput = { type EnqueueTaskInput = {
@@ -17,8 +22,71 @@ type EnqueueTaskInput = {
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
priority?: number; priority?: number;
maxAttempts?: number; maxAttempts?: number;
resourceKey?: string;
}; };
type UpdateTaskNotificationInput = {
read?: boolean;
silenced?: boolean;
};
function mapWorkflowStatus(status: WorkflowRunStatus): TaskStatus {
switch (status) {
case 'pending':
return 'queued';
case 'running':
return 'running';
case 'completed':
return 'completed';
case 'failed':
case 'cancelled':
return 'failed';
default:
return 'failed';
}
}
function isProjectionPendingSync(task: Task) {
return task.status === 'queued' || task.status === 'running';
}
async function reconcileTaskWithWorkflow(task: Task) {
if (!task.workflow_run_id || !isProjectionPendingSync(task)) {
return task;
}
try {
const run = getRun(task.workflow_run_id);
const workflowStatus = await run.status;
const nextStatus = mapWorkflowStatus(workflowStatus);
if (nextStatus === task.status) {
return task;
}
const nextError = nextStatus === 'failed'
? workflowStatus === 'cancelled'
? 'Workflow run cancelled'
: 'Workflow run failed'
: null;
const updated = await setTaskStatusFromWorkflow(task.id, nextStatus, nextError);
return updated ?? {
...task,
status: nextStatus,
stage: nextStatus,
stage_detail: null,
error: nextError,
finished_at: nextStatus === 'queued' || nextStatus === 'running'
? null
: task.finished_at ?? new Date().toISOString()
};
} catch {
return task;
}
}
export async function enqueueTask(input: EnqueueTaskInput) { export async function enqueueTask(input: EnqueueTaskInput) {
const task = await createTaskRunRecord({ const task = await createTaskRunRecord({
id: randomUUID(), id: randomUUID(),
@@ -26,7 +94,8 @@ export async function enqueueTask(input: EnqueueTaskInput) {
task_type: input.taskType, task_type: input.taskType,
payload: input.payload ?? {}, payload: input.payload ?? {},
priority: input.priority ?? 50, priority: input.priority ?? 50,
max_attempts: input.maxAttempts ?? 3 max_attempts: input.maxAttempts ?? 3,
resource_key: input.resourceKey ?? null
}); });
try { try {
@@ -41,17 +110,61 @@ export async function enqueueTask(input: EnqueueTaskInput) {
const reason = error instanceof Error const reason = error instanceof Error
? error.message ? error.message
: 'Failed to start workflow'; : 'Failed to start workflow';
await markTaskFailure(task.id, reason); await markTaskFailure(task.id, reason, 'failed');
throw error; throw error;
} }
} }
export async function findInFlightTask(userId: string, taskType: TaskType, resourceKey: string) {
const task = await findInFlightTaskByResourceKey(userId, taskType, resourceKey);
if (!task) {
return null;
}
return await reconcileTaskWithWorkflow(task);
}
export async function getTaskById(taskId: string, userId: string) { export async function getTaskById(taskId: string, userId: string) {
return await getTaskByIdForUser(taskId, userId); const task = await getTaskByIdForUser(taskId, userId);
if (!task) {
return null;
}
return await reconcileTaskWithWorkflow(task);
} }
export async function listRecentTasks(userId: string, limit = 20, statuses?: TaskStatus[]) { export async function listRecentTasks(userId: string, limit = 20, statuses?: TaskStatus[]) {
return await listRecentTasksForUser(userId, limit, statuses); const tasks = await listRecentTasksForUser(userId, limit, statuses);
return await Promise.all(tasks.map((task) => reconcileTaskWithWorkflow(task)));
}
export async function updateTaskNotification(
userId: string,
taskId: string,
input: UpdateTaskNotificationInput
) {
const task = await updateTaskNotificationState(taskId, userId, input);
if (!task) {
return null;
}
return await reconcileTaskWithWorkflow(task);
}
export async function getTaskTimeline(taskId: string, userId: string): Promise<TaskTimeline | null> {
const task = await getTaskById(taskId, userId);
if (!task) {
return null;
}
const events = await listTaskStageEventsForTask(taskId, userId);
return {
task,
events
};
} }
export async function getTaskQueueSnapshot() { export async function getTaskQueueSnapshot() {

View File

@@ -90,12 +90,37 @@ export type Filing = {
export type TaskStatus = 'queued' | 'running' | 'completed' | 'failed'; export type TaskStatus = 'queued' | 'running' | 'completed' | 'failed';
export type TaskType = 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights'; export type TaskType = 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights';
export type TaskStage =
| 'queued'
| 'running'
| 'completed'
| 'failed'
| 'sync.fetch_filings'
| 'sync.fetch_metrics'
| 'sync.persist_filings'
| 'sync.hydrate_statements'
| 'refresh.load_holdings'
| 'refresh.fetch_quotes'
| 'refresh.persist_prices'
| 'analyze.load_filing'
| 'analyze.fetch_document'
| 'analyze.extract'
| 'analyze.generate_report'
| 'analyze.persist_report'
| 'insights.load_holdings'
| 'insights.generate'
| 'insights.persist';
export type Task = { export type Task = {
id: string; id: string;
user_id: string; user_id: string;
task_type: TaskType; task_type: TaskType;
status: TaskStatus; status: TaskStatus;
stage: TaskStage;
stage_detail: string | null;
resource_key: string | null;
notification_read_at: string | null;
notification_silenced_at: string | null;
priority: number; priority: number;
payload: Record<string, unknown>; payload: Record<string, unknown>;
result: Record<string, unknown> | null; result: Record<string, unknown> | null;
@@ -108,6 +133,21 @@ export type Task = {
finished_at: string | null; finished_at: string | null;
}; };
export type TaskStageEvent = {
id: number;
task_id: string;
user_id: string;
stage: TaskStage;
stage_detail: string | null;
status: TaskStatus;
created_at: string;
};
export type TaskTimeline = {
task: Task;
events: TaskStageEvent[];
};
export type PortfolioInsight = { export type PortfolioInsight = {
id: number; id: number;
user_id: string; user_id: string;

View File

@@ -8,10 +8,12 @@
"build": "bun --bun next build --turbopack", "build": "bun --bun next build --turbopack",
"start": "bun --bun next start", "start": "bun --bun next start",
"lint": "bun --bun tsc --noEmit", "lint": "bun --bun tsc --noEmit",
"workflow:setup": "workflow-postgres-setup",
"backfill:filing-metrics": "bun run scripts/backfill-filing-metrics.ts", "backfill:filing-metrics": "bun run scripts/backfill-filing-metrics.ts",
"backfill:filing-statements": "bun run scripts/backfill-filing-statements.ts", "backfill:filing-statements": "bun run scripts/backfill-filing-statements.ts",
"db:generate": "bun x drizzle-kit generate", "db:generate": "bun x drizzle-kit generate",
"db:migrate": "bun x drizzle-kit migrate" "db:migrate": "bun x drizzle-kit migrate",
"test:e2e:workflow": "RUN_TASK_WORKFLOW_E2E=1 bun test lib/server/api/task-workflow-hybrid.e2e.test.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^2.0.62", "@ai-sdk/openai": "^2.0.62",
@@ -19,6 +21,7 @@
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@workflow/world-postgres": "^4.1.0-beta.34",
"ai": "^6.0.104", "ai": "^6.0.104",
"better-auth": "^1.4.19", "better-auth": "^1.4.19",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -30,6 +33,7 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"sonner": "^2.0.7",
"workflow": "^4.1.0-beta.60", "workflow": "^4.1.0-beta.60",
"zhipu-ai-provider": "^0.2.2" "zhipu-ai-provider": "^0.2.2"
}, },