From 12a9741ecab21614f67ee656bd05ceb608a9ae43 Mon Sep 17 00:00:00 2001 From: francy51 Date: Mon, 9 Mar 2026 18:53:41 -0400 Subject: [PATCH] Improve job status notifications --- app/workflows/task-runner.ts | 26 +- .../notifications/task-detail-modal.tsx | 137 +++++- .../task-notifications-trigger.tsx | 92 +++- .../notifications/task-stage-helpers.ts | 184 +------- components/shell/app-shell.tsx | 1 + drizzle/0009_task_notification_context.sql | 5 + drizzle/meta/_journal.json | 7 + hooks/use-task-notifications-center.ts | 101 +++-- .../api/task-workflow-hybrid.e2e.test.ts | 158 ++++++- lib/server/db/index.test.ts | 3 + lib/server/db/index.ts | 7 + lib/server/db/schema.ts | 4 + lib/server/repos/tasks.test.ts | 222 ++++++++++ lib/server/repos/tasks.ts | 110 ++++- lib/server/search.ts | 91 +++- lib/server/task-notifications.test.ts | 122 ++++++ lib/server/task-notifications.ts | 236 ++++++++++ lib/server/task-processors.outcomes.test.ts | 413 ++++++++++++++++++ lib/server/task-processors.ts | 343 +++++++++++++-- lib/server/tasks.ts | 11 +- lib/task-workflow.ts | 222 ++++++++++ lib/types.ts | 50 +++ 22 files changed, 2243 insertions(+), 302 deletions(-) create mode 100644 drizzle/0009_task_notification_context.sql create mode 100644 lib/server/repos/tasks.test.ts create mode 100644 lib/server/task-notifications.test.ts create mode 100644 lib/server/task-notifications.ts create mode 100644 lib/server/task-processors.outcomes.test.ts create mode 100644 lib/task-workflow.ts diff --git a/app/workflows/task-runner.ts b/app/workflows/task-runner.ts index c97f341..6d98f7f 100644 --- a/app/workflows/task-runner.ts +++ b/app/workflows/task-runner.ts @@ -1,4 +1,4 @@ -import { runTaskProcessor } from '@/lib/server/task-processors'; +import { runTaskProcessor, type TaskExecutionOutcome } from '@/lib/server/task-processors'; import { completeTask, getTaskById, @@ -23,14 +23,14 @@ export async function runTaskWorkflow(taskId: string) { return; } - const result = await processTaskStep(refreshedTask); - await completeTaskStep(task.id, result); + const outcome = await processTaskStep(refreshedTask); + await completeTaskStep(task.id, outcome); } catch (error) { const reason = error instanceof Error ? error.message : 'Task failed unexpectedly'; - - await markTaskFailureStep(task.id, reason); + const latestTask = await loadTaskStep(task.id); + await markTaskFailureStep(task.id, reason, latestTask); throw error; } } @@ -52,15 +52,21 @@ async function processTaskStep(task: Task) { // Keep retries at the projection workflow level to avoid duplicate side effects. ( - processTaskStep as ((task: Task) => Promise>) & { maxRetries?: number } + processTaskStep as ((task: Task) => Promise) & { maxRetries?: number } ).maxRetries = 0; -async function completeTaskStep(taskId: string, result: Record) { +async function completeTaskStep(taskId: string, outcome: TaskExecutionOutcome) { 'use step'; - await completeTask(taskId, result); + await completeTask(taskId, outcome.result, { + detail: outcome.completionDetail, + context: outcome.completionContext ?? null + }); } -async function markTaskFailureStep(taskId: string, reason: string) { +async function markTaskFailureStep(taskId: string, reason: string, latestTask: Task | null) { 'use step'; - await markTaskFailure(taskId, reason); + await markTaskFailure(taskId, reason, 'failed', { + detail: reason, + context: latestTask?.stage_context ?? null + }); } diff --git a/components/notifications/task-detail-modal.tsx b/components/notifications/task-detail-modal.tsx index 7f7d800..af09a61 100644 --- a/components/notifications/task-detail-modal.tsx +++ b/components/notifications/task-detail-modal.tsx @@ -2,11 +2,13 @@ import { format } from 'date-fns'; import { ChevronDown, LoaderCircle, X } from 'lucide-react'; +import Link from 'next/link'; import { useEffect, useMemo, useState } from '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'; +import type { Task, TaskStageContext } from '@/lib/types'; function formatTimestamp(value: string | null) { if (!value) { @@ -21,6 +23,94 @@ function formatTimestamp(value: string | null) { return format(parsed, 'MMM dd, yyyy HH:mm:ss'); } +function formatCounterLabel(value: string) { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function ProgressPanel({ task }: { task: Task }) { + const progress = task.notification.progress; + if (!progress) { + return null; + } + + return ( +
+
+ {progress.current}/{progress.total} {progress.unit} + {progress.percent ?? 0}% +
+
+
+
+
+ ); +} + +function StatsPanel({ task }: { task: Task }) { + const counters = task.stage_context?.counters ?? {}; + const counterEntries = Object.entries(counters); + + if (task.notification.stats.length === 0 && counterEntries.length === 0) { + return

No structured metrics available for this job yet.

; + } + + return ( +
+ {task.notification.stats.map((stat) => ( +
+ {stat.label} {stat.value} +
+ ))} + {counterEntries.map(([label, value]) => ( +
+ {formatCounterLabel(label)} {value} +
+ ))} +
+ ); +} + +function StageContextBlock({ context }: { context: TaskStageContext | null }) { + if (!context) { + return null; + } + + const counters = Object.entries(context.counters ?? {}); + + return ( +
+ {context.progress ? ( +
+ Progress {context.progress.current}/{context.progress.total} {context.progress.unit} +
+ ) : null} + {context.subject ? ( +
+ {[context.subject.ticker, context.subject.accessionNumber, context.subject.label].filter(Boolean).join(' · ')} +
+ ) : null} + {counters.length > 0 ? ( +
+ {counters.map(([label, value]) => ( + + {formatCounterLabel(label)}: {value} + + ))} +
+ ) : null} +
+ ); +} + type TaskDetailModalProps = { isOpen: boolean; taskId: string | null; @@ -136,6 +226,39 @@ export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProp

Attempts: {task.attempts}/{task.max_attempts}

+
+

Summary

+

{task.notification.title}

+

{task.notification.statusLine}

+ {task.notification.detailLine ? ( +

{task.notification.detailLine}

+ ) : null} +
+ +
+
+ {task.notification.actions + .filter((action) => action.id !== 'open_details' && action.href) + .map((action) => ( + + {action.label} + + ))} +
+
+ +
+

Key metrics

+ +
+

Stage timeline

    @@ -168,12 +291,7 @@ export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProp {expandedStage === item.stage ? (

    {item.detail ?? 'No additional detail for this step.'}

    - {item.stage === 'completed' && task.result ? ( -
    -

    Result detail

    -
    {JSON.stringify(task.result, null, 2)}
    -
    - ) : null} +
    ) : null} @@ -187,6 +305,13 @@ export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProp

    {task.error}

) : null} + + {task.result ? ( +
+ Debug result +
{JSON.stringify(task.result, null, 2)}
+
+ ) : null} ) : null} diff --git a/components/notifications/task-notifications-trigger.tsx b/components/notifications/task-notifications-trigger.tsx index 576fb52..0729d9d 100644 --- a/components/notifications/task-notifications-trigger.tsx +++ b/components/notifications/task-notifications-trigger.tsx @@ -4,7 +4,6 @@ import { formatDistanceToNow } from 'date-fns'; import { Bell, BellRing, LoaderCircle } from 'lucide-react'; import type { Task } from '@/lib/types'; import { StatusPill } from '@/components/ui/status-pill'; -import { taskTypeLabel } from '@/components/notifications/task-stage-helpers'; import { cn } from '@/lib/utils'; type TaskNotificationsTriggerProps = { @@ -18,11 +17,53 @@ type TaskNotificationsTriggerProps = { showReadFinished: boolean; setShowReadFinished: (value: boolean) => void; openTaskDetails: (taskId: string) => void; + openTaskAction: (task: Task, actionId?: string | null) => void; silenceTask: (taskId: string, silenced?: boolean) => Promise; markTaskRead: (taskId: string, read?: boolean) => Promise; className?: string; }; +function ProgressBar({ task }: { task: Task }) { + const progress = task.notification.progress; + if (!progress) { + return null; + } + + return ( +
+
+ {progress.current}/{progress.total} {progress.unit} + {progress.percent ?? 0}% +
+
+
+
+
+ ); +} + +function StatChips({ task }: { task: Task }) { + if (task.notification.stats.length === 0) { + return null; + } + + return ( +
+ {task.notification.stats.map((stat) => ( + + {stat.label}: {stat.value} + + ))} +
+ ); +} + export function TaskNotificationsTrigger({ unreadCount, isPopoverOpen, @@ -34,6 +75,7 @@ export function TaskNotificationsTrigger({ showReadFinished, setShowReadFinished, openTaskDetails, + openTaskAction, silenceTask, markTaskRead, className @@ -112,14 +154,32 @@ export function TaskNotificationsTrigger({ activeTasks.map((task) => (
-

{taskTypeLabel(task.task_type)}

+

{task.notification.title}

-

{task.stage_detail ?? 'Running in workflow engine.'}

+

{task.notification.statusLine}

+ {task.notification.detailLine ? ( +

{task.notification.detailLine}

+ ) : null} + +

{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}

-
+
+ {task.notification.actions + .filter((action) => action.primary && action.id !== 'open_details') + .slice(0, 1) + .map((action) => ( + + ))} + ))}