import { cancelCourseGenerationJob, isCourseGenerationRunning, regenerateCourseFramework, runCourseFrameworkGeneration, runCourseGenerationJob, runCourseModuleGeneration, } from '@/lib/course-framework/runner'; import { isValidCourseId, readCourseRecordReconciled, updateCourseRecord, } from '@/lib/course-framework/store'; import { CourseMutationInProgressError, isCoursePublishing, } from '@/lib/course-framework/publish-state'; import { assertLearningCourseMutable, FrozenLearningCourseMutationError, } from '@/lib/makelore-course/immutability'; export const LARGE_COURSE_CONTROL_ACTIONS = [ 'start', 'cancel', 'resume', 'framework-regenerate', 'module-regenerate', ] as const; export type LargeCourseControlAction = (typeof LARGE_COURSE_CONTROL_ACTIONS)[number]; export type LargeCourseControlResult = { courseId: string; action: LargeCourseControlAction; status: 'framework_generating' | 'generating' | 'cancelling'; message: string; moduleIndex?: number; run?: Promise; }; export class LargeCourseControlError extends Error { constructor( public readonly code: string, public readonly status: number, message: string, public readonly details?: Record, ) { super(message); this.name = 'LargeCourseControlError'; } } function conflict(message: string, code = 'invalid_course_state'): never { throw new LargeCourseControlError(code, 409, message); } /** * The only process-local entry point for large-course runner mutations. * * Engine runtime routes, including the legacy Works cancel/resume paths, call * this function so the controller maps and their cancellation signals always * belong to the same Node process. */ export async function controlLargeCourse(options: { courseId: string; ownerPrincipalId: string; action: LargeCourseControlAction; baseUrl: string; moduleIndex?: number; }): Promise { const { courseId, ownerPrincipalId, action, baseUrl, moduleIndex } = options; if (!isValidCourseId(courseId) || !ownerPrincipalId.trim()) { throw new LargeCourseControlError('invalid_request', 400, 'Invalid course or owner'); } const record = await readCourseRecordReconciled(courseId); if (!record || record.ownerPrincipalId !== ownerPrincipalId) { throw new LargeCourseControlError('job_not_found', 404, 'Course generation job not found'); } try { // Cancellation remains available to stop an in-flight finalize race. All // actions that can create new source state are forbidden after freezing. if (action !== 'cancel') await assertLearningCourseMutable(courseId, record); switch (action) { case 'cancel': { if (!isCourseGenerationRunning(courseId) || !cancelCourseGenerationJob(courseId)) { conflict('Course generation is not running', 'job_not_running'); } await updateCourseRecord(courseId, { status: 'cancelled' }); return { courseId, action, status: 'cancelling', message: 'Cancellation requested', }; } case 'start': { if (!record.framework) conflict('课程框架尚未生成,请等待框架生成完成'); if (record.status !== 'framework_ready') { conflict(`当前状态(${record.status})不允许启动模块生成`); } if ( isCourseGenerationRunning(courseId) || isCoursePublishing(courseId) || record.modules.some((entry) => entry.status === 'generating') ) { conflict('该课程已有生成任务正在运行', 'job_already_running'); } return { courseId, action, status: 'generating', message: '模块生成已开始', run: runCourseModuleGeneration(courseId, baseUrl), }; } case 'resume': { if (record.status === 'framework_ready') { throw new LargeCourseControlError( 'review_required', 409, 'Course framework requires operations review', { reviewUrl: `/learning-ops/courses/${record.id}` }, ); } if (record.status === 'completed') conflict('Course is already completed', 'job_not_resumable'); if (isCourseGenerationRunning(courseId) || isCoursePublishing(courseId)) { conflict('该课程已有生成任务正在运行', 'job_not_resumable'); } const hasFramework = Boolean(record.framework); return { courseId, action, status: hasFramework ? 'generating' : 'framework_generating', message: hasFramework ? '模块生成已继续' : '框架生成已继续', run: hasFramework ? runCourseModuleGeneration(courseId, baseUrl) : runCourseFrameworkGeneration(courseId, baseUrl), }; } case 'framework-regenerate': { if ( isCourseGenerationRunning(courseId) || isCoursePublishing(courseId) || record.modules.some((entry) => entry.status === 'generating') ) { conflict('模块正在生成中,无法重新生成框架', 'job_already_running'); } return { courseId, action, status: 'framework_generating', message: '框架重新生成已开始', run: regenerateCourseFramework(courseId, baseUrl), }; } case 'module-regenerate': { if (!Number.isInteger(moduleIndex) || moduleIndex! < 1) { throw new LargeCourseControlError('invalid_request', 400, 'Invalid module index'); } if (!record.framework) { conflict('Course framework not generated yet; cannot regenerate a module'); } if (!record.modules.some((entry) => entry.index === moduleIndex)) { throw new LargeCourseControlError( 'module_not_found', 404, `Module ${moduleIndex} not found`, ); } if ( isCourseGenerationRunning(courseId) || isCoursePublishing(courseId) || record.modules.some((entry) => entry.status === 'generating') ) { conflict('该课程已有生成任务正在运行', 'job_already_running'); } return { courseId, action, moduleIndex, status: 'generating', message: `Module ${moduleIndex} and downstream regeneration started`, run: runCourseGenerationJob(courseId, baseUrl, { onlyModuleIndex: moduleIndex }), }; } } } catch (error) { if (error instanceof LargeCourseControlError) throw error; if (error instanceof FrozenLearningCourseMutationError) { throw new LargeCourseControlError('course_frozen', 409, error.message); } if (error instanceof CourseMutationInProgressError) { throw new LargeCourseControlError('job_already_running', 409, error.message); } if (error instanceof Error && error.message.includes('already has an active generation run')) { throw new LargeCourseControlError('job_already_running', 409, error.message); } throw error; } } export function largeCourseControlErrorResponse(error: unknown): Response { if (error instanceof LargeCourseControlError) { return Response.json( { error: error.code, message: error.message, ...error.details }, { status: error.status }, ); } return Response.json( { error: 'internal_error', message: error instanceof Error ? error.message : 'Large-course control failed', }, { status: 500 }, ); }