/ src / lib / server / tasks / task-lifecycle.ts
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'