feat: migrate task jobs to workflow notifications + timeline

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

View File

@@ -1,14 +1,19 @@
import { randomUUID } from 'node:crypto';
import { start } from 'workflow/api';
import type { Task, TaskStatus, TaskType } from '@/lib/types';
import { getRun, start } from 'workflow/api';
import type { WorkflowRunStatus } from '@workflow/world';
import type { Task, TaskStatus, TaskTimeline, TaskType } from '@/lib/types';
import { runTaskWorkflow } from '@/app/workflows/task-runner';
import {
countTasksByStatus,
createTaskRunRecord,
findInFlightTaskByResourceKey,
getTaskByIdForUser,
listTaskStageEventsForTask,
listRecentTasksForUser,
markTaskFailure,
setTaskWorkflowRunId
setTaskStatusFromWorkflow,
setTaskWorkflowRunId,
updateTaskNotificationState
} from '@/lib/server/repos/tasks';
type EnqueueTaskInput = {
@@ -17,8 +22,71 @@ type EnqueueTaskInput = {
payload?: Record<string, unknown>;
priority?: 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) {
const task = await createTaskRunRecord({
id: randomUUID(),
@@ -26,7 +94,8 @@ export async function enqueueTask(input: EnqueueTaskInput) {
task_type: input.taskType,
payload: input.payload ?? {},
priority: input.priority ?? 50,
max_attempts: input.maxAttempts ?? 3
max_attempts: input.maxAttempts ?? 3,
resource_key: input.resourceKey ?? null
});
try {
@@ -41,17 +110,61 @@ export async function enqueueTask(input: EnqueueTaskInput) {
const reason = error instanceof Error
? error.message
: 'Failed to start workflow';
await markTaskFailure(task.id, reason);
await markTaskFailure(task.id, reason, 'failed');
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) {
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[]) {
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() {