223 lines
7.5 KiB
TypeScript
223 lines
7.5 KiB
TypeScript
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<void>;
|
|
};
|
|
|
|
export class LargeCourseControlError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
public readonly status: number,
|
|
message: string,
|
|
public readonly details?: Record<string, unknown>,
|
|
) {
|
|
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<LargeCourseControlResult> {
|
|
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 },
|
|
);
|
|
}
|