import { createLogger } from '@/lib/logger'; import { generateClassroom, type GenerateClassroomInput } from '@/lib/server/classroom-generation'; import { markClassroomGenerationJobCancelled, markClassroomGenerationJobFailed, markClassroomGenerationJobRunning, markClassroomGenerationJobSucceeded, readClassroomGenerationJob, updateClassroomGenerationJobProgress, updateClassroomGenerationJob, } from '@/lib/server/classroom-job-store'; const log = createLogger('ClassroomJob'); const runningJobs = new Map>(); const runningControllers = new Map(); function isAbortError(error: unknown): boolean { return ( (error instanceof DOMException && error.name === 'AbortError') || (error instanceof Error && error.name === 'AbortError') ); } /** * Request cancellation of a running job. Cooperative: the generation stops at * the next scene boundary (see `generateClassroom`'s signal checks) and the * job is then marked `cancelled`. No-op for jobs that are not running. */ export function cancelClassroomGenerationJob(jobId: string): boolean { const controller = runningControllers.get(jobId); if (!controller) return false; controller.abort(); return true; } /** * Resume a cancelled/failed job from its persisted input: reset it to * `queued` and re-run. The generation restarts from the beginning — server-side * generation commits atomically, so there is no partial state to continue from. */ export async function resumeClassroomGenerationJob( jobId: string, baseUrl: string, ): Promise { const job = await readClassroomGenerationJob(jobId); if (!job) return false; if (job.status !== 'failed' && job.status !== 'cancelled') return false; if (!job.input) return false; await updateClassroomGenerationJob(jobId, { status: 'queued', step: 'queued', progress: 0, message: 'Classroom generation job queued (resumed)', completedAt: undefined, error: undefined, result: undefined, }); runClassroomGenerationJob(jobId, job.input, baseUrl); return true; } export function runClassroomGenerationJob( jobId: string, input: GenerateClassroomInput, baseUrl: string, ): Promise { const existing = runningJobs.get(jobId); if (existing) { return existing; } const controller = new AbortController(); runningControllers.set(jobId, controller); const jobPromise = (async () => { try { const runningJob = await markClassroomGenerationJobRunning(jobId); const result = await generateClassroom(input, { baseUrl, signal: controller.signal, ownership: { ...(runningJob.ownerPrincipalId ? { ownerPrincipalId: runningJob.ownerPrincipalId } : {}), ...(runningJob.guestPrincipalId ? { guestPrincipalId: runningJob.guestPrincipalId } : {}), ...(runningJob.ownershipBoundAt ? { ownershipBoundAt: runningJob.ownershipBoundAt } : {}), ...(runningJob.ownershipMigratedAt ? { ownershipMigratedAt: runningJob.ownershipMigratedAt } : {}), }, onProgress: async (progress) => { // Do not keep reporting progress after a cancellation request — // the generate loop may still be finishing its current scene. if (controller.signal.aborted) return; await updateClassroomGenerationJobProgress(jobId, progress); }, }); await markClassroomGenerationJobSucceeded(jobId, result); } catch (error) { if (isAbortError(error)) { log.info(`Classroom generation job ${jobId} cancelled`); try { await markClassroomGenerationJobCancelled(jobId); } catch (markCancelledError) { log.error(`Failed to persist cancelled status for job ${jobId}:`, markCancelledError); } return; } const message = error instanceof Error ? error.message : String(error); log.error(`Classroom generation job ${jobId} failed:`, error); try { await markClassroomGenerationJobFailed(jobId, message); } catch (markFailedError) { log.error(`Failed to persist failed status for job ${jobId}:`, markFailedError); } } finally { runningJobs.delete(jobId); runningControllers.delete(jobId); } })(); runningJobs.set(jobId, jobPromise); return jobPromise; }