task-lifecycle.ts
1 import { genId } from '@/lib/id' 2 import type { BoardTask, BoardTaskStatus, Schedule, TaskComment } from '@/types' 3 4 import { ensureTaskCompletionReport, type TaskReportArtifact } from '@/lib/server/tasks/task-reports' 5 import { 6 formatValidationFailure, 7 validateTaskCompletion, 8 type TaskCompletionValidation, 9 } from '@/lib/server/tasks/task-validation' 10 11 export interface BuildBoardTaskInput { 12 id?: string 13 title: string 14 description?: string | null 15 agentId: string 16 now: number 17 status?: BoardTaskStatus 18 seed?: Record<string, unknown> 19 } 20 21 export function buildBoardTask(input: BuildBoardTaskInput): BoardTask { 22 const id = input.id || genId() 23 const seed = input.seed ? { ...input.seed } : {} 24 const seedStatus = typeof seed.status === 'string' ? seed.status as BoardTaskStatus : undefined 25 const task = { 26 sessionId: null, 27 result: null, 28 error: null, 29 createdAt: input.now, 30 updatedAt: input.now, 31 queuedAt: null, 32 startedAt: null, 33 completedAt: null, 34 ...seed, 35 id, 36 title: input.title, 37 description: input.description ?? '', 38 status: input.status ?? seedStatus ?? 'backlog', 39 agentId: input.agentId, 40 } as BoardTask 41 return task 42 } 43 44 export interface ResetTaskForRerunOptions { 45 title: string 46 now: number 47 runNumber?: number | null 48 } 49 50 export function resetTaskForRerun(task: BoardTask, options: ResetTaskForRerunOptions): BoardTask { 51 const stats = task as unknown as Record<string, unknown> 52 stats.totalRuns = ((stats.totalRuns as number) || 0) + 1 53 if (task.status === 'completed') stats.totalCompleted = ((stats.totalCompleted as number) || 0) + 1 54 if (task.status === 'failed') stats.totalFailed = ((stats.totalFailed as number) || 0) + 1 55 56 task.status = 'backlog' 57 task.title = options.title 58 task.result = null 59 task.error = null 60 task.outputFiles = [] 61 task.artifacts = [] 62 task.sessionId = null 63 task.completionReportPath = null 64 task.updatedAt = options.now 65 task.queuedAt = null 66 task.startedAt = null 67 task.completedAt = null 68 task.archivedAt = null 69 task.attempts = 0 70 task.retryScheduledAt = null 71 task.deadLetteredAt = null 72 task.validation = null 73 if (options.runNumber !== undefined) stats.runNumber = options.runNumber 74 return task 75 } 76 77 export interface PrepareScheduledTaskRunOptions { 78 schedule: Pick< 79 Schedule, 80 | 'id' 81 | 'name' 82 | 'agentId' 83 | 'taskPrompt' 84 | 'linkedTaskId' 85 | 'runNumber' 86 | 'createdInSessionId' 87 | 'createdByAgentId' 88 | 'followupConnectorId' 89 | 'followupChannelId' 90 | 'followupThreadId' 91 | 'followupSenderId' 92 | 'followupSenderName' 93 > 94 tasks: Record<string, BoardTask> 95 now: number 96 scheduleSignature?: string | null 97 } 98 99 export function prepareScheduledTaskRun(params: PrepareScheduledTaskRunOptions): { taskId: string; task: BoardTask } { 100 const { schedule, tasks, now, scheduleSignature } = params 101 const title = `[Sched] ${schedule.name} (run #${schedule.runNumber})` 102 const existingTaskId = typeof schedule.linkedTaskId === 'string' ? schedule.linkedTaskId : '' 103 const existingTask = existingTaskId ? tasks[existingTaskId] : null 104 105 if (existingTask && existingTask.status !== 'queued' && existingTask.status !== 'running') { 106 return { 107 taskId: existingTaskId, 108 task: resetTaskForRerun(existingTask, { 109 title, 110 now, 111 runNumber: schedule.runNumber, 112 }), 113 } 114 } 115 116 const task = buildBoardTask({ 117 title, 118 description: schedule.taskPrompt || '', 119 agentId: schedule.agentId, 120 now, 121 seed: { 122 sourceType: 'schedule', 123 sourceScheduleId: schedule.id, 124 sourceScheduleName: schedule.name, 125 sourceScheduleKey: scheduleSignature || null, 126 createdInSessionId: schedule.createdInSessionId || null, 127 createdByAgentId: schedule.createdByAgentId || null, 128 followupConnectorId: schedule.followupConnectorId || null, 129 followupChannelId: schedule.followupChannelId || null, 130 followupThreadId: schedule.followupThreadId || null, 131 followupSenderId: schedule.followupSenderId || null, 132 followupSenderName: schedule.followupSenderName || null, 133 runNumber: schedule.runNumber, 134 }, 135 }) 136 tasks[task.id] = task 137 schedule.linkedTaskId = task.id 138 return { taskId: task.id, task } 139 } 140 141 function sameValidationReasons(a?: string[] | null, b?: string[] | null): boolean { 142 const av = Array.isArray(a) ? a : [] 143 const bv = Array.isArray(b) ? b : [] 144 if (av.length !== bv.length) return false 145 for (let i = 0; i < av.length; i++) { 146 if (av[i] !== bv[i]) return false 147 } 148 return true 149 } 150 151 export function didTaskValidationChange( 152 previous: TaskCompletionValidation | null | undefined, 153 next: TaskCompletionValidation, 154 ): boolean { 155 return !previous 156 || previous.ok !== next.ok 157 || !sameValidationReasons(previous.reasons, next.reasons) 158 } 159 160 export function refreshTaskCompletionValidation( 161 task: BoardTask, 162 settings?: AppSettings | Record<string, unknown> | null, 163 ): { report: TaskReportArtifact | null; validation: TaskCompletionValidation } { 164 const report = ensureTaskCompletionReport(task) 165 if (report?.relativePath) task.completionReportPath = report.relativePath 166 const validation = validateTaskCompletion(task, { report, settings: settings || null }) 167 task.validation = validation 168 return { report, validation } 169 } 170 171 export function markValidatedTaskCompleted( 172 task: BoardTask, 173 options: { now: number; preserveCompletedAt?: boolean } , 174 ): BoardTask { 175 task.status = 'completed' 176 task.completedAt = options.preserveCompletedAt ? (task.completedAt || options.now) : options.now 177 task.updatedAt = options.now 178 task.error = null 179 task.checkoutRunId = null 180 return task 181 } 182 183 export function markInvalidCompletedTaskFailed( 184 task: BoardTask, 185 validation: TaskCompletionValidation, 186 options: { now: number; comment?: Omit<TaskComment, 'id' | 'createdAt'> & { text: string } } , 187 ): BoardTask { 188 task.status = 'failed' 189 task.completedAt = null 190 task.updatedAt = options.now 191 task.checkoutRunId = null 192 task.error = formatValidationFailure(validation.reasons).slice(0, 500) 193 if (options.comment) { 194 if (!task.comments) task.comments = [] 195 task.comments.push({ 196 id: genId(), 197 createdAt: options.now, 198 ...options.comment, 199 }) 200 } 201 return task 202 } 203 import type { AppSettings } from '@/types'