feat: migrate task jobs to workflow notifications + timeline
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
40
app/page.tsx
40
app/page.tsx
@@ -9,10 +9,8 @@ import { Panel } from '@/components/ui/panel';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
128
bun.lock
@@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
137
components/notifications/task-detail-modal.tsx
Normal file
137
components/notifications/task-detail-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
components/notifications/task-notifications-drawer.tsx
Normal file
181
components/notifications/task-notifications-drawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
components/notifications/task-notifications-trigger.tsx
Normal file
180
components/notifications/task-notifications-trigger.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/notifications/task-stage-helpers.ts
Normal file
150
components/notifications/task-stage-helpers.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
11
drizzle/0002_workflow_task_projection_metadata.sql
Normal file
11
drizzle/0002_workflow_task_projection_metadata.sql
Normal 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`);
|
||||||
15
drizzle/0003_task_stage_event_timeline.sql
Normal file
15
drizzle/0003_task_stage_event_timeline.sql
Normal 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`);
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
405
hooks/use-task-notifications-center.ts
Normal file
405
hooks/use-task-notifications-center.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
25
lib/api.ts
25
lib/api.ts
@@ -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 }
|
||||||
|
: {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
319
lib/server/api/task-workflow-hybrid.e2e.test.ts
Normal file
319
lib/server/api/task-workflow-hybrid.e2e.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
40
lib/types.ts
40
lib/types.ts
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user