// Large-course mode — course record storage (ops side). // // One JSON file per course in `data/course-frameworks/` (env COURSE_FRAMEWORK_DIR). // Writes are atomic (tmp + rename) and serialized per record. The store also // performs read-time self-healing of module states by consulting the module's // classroom job (see reconcileFromClassroomJobs). import { promises as fs } from 'fs'; import path from 'path'; import { createLogger } from '@/lib/logger'; import { readClassroom, writeJsonFileAtomic } from '@/lib/server/classroom-storage'; import { readClassroomGenerationJob } from '@/lib/server/classroom-job-store'; import type { CourseRecord, CourseStatus, CourseModuleRecord } from './types'; import { normalizePersistedCourseRecord } from './compat'; import { buildCourseModuleOutputDigest } from './module-digest'; const log = createLogger('CourseStore'); export const COURSE_FRAMEWORK_DIR = process.env.COURSE_FRAMEWORK_DIR ?? path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'course-frameworks'); export function isValidCourseId(id: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(id); } function courseFilePath(courseId: string): string { return path.join(COURSE_FRAMEWORK_DIR, `${courseId}.json`); } /** Simple per-course mutex to serialize read-modify-write on the same file. */ const courseLocks = new Map>(); async function withCourseLock(courseId: string, fn: () => Promise): Promise { const prev = courseLocks.get(courseId) ?? Promise.resolve(); let resolve: () => void; const next = new Promise((r) => { resolve = r; }); courseLocks.set(courseId, next); try { await prev; return await fn(); } finally { resolve!(); if (courseLocks.get(courseId) === next) courseLocks.delete(courseId); } } /** Max age (ms) before a module stuck in `generating` without a live runner is reconciled. */ const STALE_COURSE_TIMEOUT_MS = 30 * 60 * 1000; /** * Reconcile a module whose status is `generating`: consult its classroom job. * - job succeeded → module succeeded (classroomId from the job result) * - job failed/cancelled → module failed / back to pending * - job still running → keep generating * - no job record + stale updatedAt → failed (process restarted mid-run) */ async function reconcileModule( record: CourseRecord, moduleRec: CourseModuleRecord, now: number, ): Promise { if (moduleRec.status === 'succeeded' && moduleRec.classroomId && !moduleRec.outputDigest) { const classroom = await readClassroom(moduleRec.classroomId).catch(() => null); if (classroom) { return { ...moduleRec, outputDigest: buildCourseModuleOutputDigest(classroom) }; } return moduleRec; } if (moduleRec.status !== 'generating' || !moduleRec.jobId) return moduleRec; const job = await readClassroomGenerationJob(moduleRec.jobId).catch(() => null); if (job) { if (job.status === 'succeeded' && job.result?.classroomId) { const classroom = await readClassroom(job.result.classroomId).catch(() => null); if (!classroom) { return { ...moduleRec, status: 'failed', jobId: undefined, error: `Generated classroom ${job.result.classroomId} is unavailable for continuity digest`, }; } const outputDigest = buildCourseModuleOutputDigest(classroom); return { ...moduleRec, status: 'succeeded', classroomId: job.result.classroomId, jobId: undefined, outputDigest, completedAt: job.completedAt ?? new Date().toISOString(), }; } if (job.status === 'failed') { return { ...moduleRec, status: 'failed', jobId: undefined, error: job.error ?? 'Module generation failed', }; } if (job.status === 'cancelled') { return { ...moduleRec, status: 'pending', error: undefined, jobId: undefined }; } // queued / running — keep generating return moduleRec; } // No job record: the process may have restarted. Give it the stale window, // then mark failed so the operator can retry. const updatedAt = new Date(moduleRec.startedAt ?? record.updatedAt).getTime(); if (now - updatedAt > STALE_COURSE_TIMEOUT_MS) { return { ...moduleRec, status: 'failed', error: 'Module generation interrupted (process restart); retry the module', }; } return moduleRec; } /** * Derive the course status from module states + the record's explicit phase. * Phases that live BEFORE the module pipeline (queued / framework generating / * framework awaiting confirmation / cancelled) are explicit and kept; once the * framework exists, module-driven states take over: completed when every * module succeeded, generating while a module runs (or the module pipeline was * started), failed when a module failed. */ export function deriveCourseStatus(record: CourseRecord): CourseStatus { const { framework, modules, status } = record; if (!framework || modules.length === 0) { return status; // queued / framework_generating / failed / cancelled } if (modules.every((m) => m.status === 'succeeded')) return 'completed'; if (modules.some((m) => m.status === 'generating')) return 'generating'; if (modules.some((m) => m.status === 'failed')) return 'failed'; // All modules pending: the explicit phase decides. if (status === 'cancelled') return 'cancelled'; if (status === 'generating') return 'generating'; return 'framework_ready'; } export async function createCourseRecord( courseId: string, requirement: string, options: { ownerPrincipalId?: string; enableWebSearch?: boolean; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; interactiveMode?: boolean; taskEngineMode?: boolean; pdfText?: string; } = {}, ): Promise { const now = new Date().toISOString(); const record: CourseRecord = { id: courseId, status: 'queued', requirement, modules: [], ...(options.ownerPrincipalId ? { ownerPrincipalId: options.ownerPrincipalId } : {}), ...(options.enableWebSearch ? { enableWebSearch: true } : {}), ...(options.enableImageGeneration ? { enableImageGeneration: true } : {}), ...(options.enableVideoGeneration ? { enableVideoGeneration: true } : {}), // Persist the explicit false value: finalization uses it to decide whether // narration bytes are mandatory or intentionally omitted. enableTTS: options.enableTTS ?? true, interactiveMode: options.interactiveMode ?? true, ...(options.taskEngineMode ? { taskEngineMode: true } : {}), ...(options.pdfText ? { pdfText: options.pdfText } : {}), createdAt: now, updatedAt: now, }; await fs.mkdir(COURSE_FRAMEWORK_DIR, { recursive: true }); await writeJsonFileAtomic(courseFilePath(courseId), record); return record; } export async function readCourseRecord(courseId: string): Promise { try { const content = await fs.readFile(courseFilePath(courseId), 'utf-8'); return normalizePersistedCourseRecord(JSON.parse(content) as CourseRecord); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; } } /** Read + reconcile (self-heal) a course record. */ export async function readCourseRecordReconciled(courseId: string): Promise { return withCourseLock(courseId, async () => { const record = await readCourseRecord(courseId); if (!record) return null; const now = Date.now(); let changed = false; const modules: CourseModuleRecord[] = []; for (const moduleRec of record.modules) { const reconciled = await reconcileModule(record, moduleRec, now); if (reconciled !== moduleRec) changed = true; modules.push(reconciled); } // Framework phase stuck without a live runner (process restart) → failed. if (record.status === 'framework_generating') { const updatedAt = new Date(record.updatedAt).getTime(); if (now - updatedAt > STALE_COURSE_TIMEOUT_MS) { const failed: CourseRecord = { ...record, modules, status: 'failed', error: '框架生成中断(进程重启),请重新生成框架', updatedAt: new Date().toISOString(), }; await writeJsonFileAtomic(courseFilePath(courseId), failed); return failed; } } if (!changed) return record; const updated: CourseRecord = { ...record, modules, status: deriveCourseStatus({ ...record, modules }), updatedAt: new Date().toISOString(), }; await writeJsonFileAtomic(courseFilePath(courseId), updated); return updated; }); } export async function updateCourseRecord( courseId: string, patch: Partial, ): Promise { return withCourseLock(courseId, async () => { const existing = await readCourseRecord(courseId); if (!existing) throw new Error(`Course not found: ${courseId}`); const updated: CourseRecord = { ...existing, ...patch, updatedAt: new Date().toISOString(), }; await writeJsonFileAtomic(courseFilePath(courseId), updated); return updated; }); } /** Update one module by index (read-modify-write under lock). */ export async function updateCourseModule( courseId: string, index: number, patch: Partial, ): Promise { return withCourseLock(courseId, async () => { const existing = await readCourseRecord(courseId); if (!existing) throw new Error(`Course not found: ${courseId}`); const modules = existing.modules.map((m) => (m.index === index ? { ...m, ...patch } : m)); const updated: CourseRecord = { ...existing, modules, status: deriveCourseStatus({ ...existing, modules }), updatedAt: new Date().toISOString(), }; await writeJsonFileAtomic(courseFilePath(courseId), updated); return updated; }); } /** * Atomically prepare an ordered regeneration cascade. * * Regenerating module N changes the actual continuity input for every later * module, so N..end must stop advertising their previous classrooms as valid * before the new run starts. Earlier successful modules are deliberately kept * because they are the trusted inputs used to rebuild module N. * * The prompt snapshot is reset to the currently approved framework prompt, * matching the state of a freshly approved framework and avoiding reuse of a * legacy attempt snapshot. All attempt/output fields are otherwise cleared. */ export async function invalidateCourseModulesFrom( courseId: string, startIndex: number, ): Promise { return withCourseLock(courseId, async () => { const existing = await readCourseRecord(courseId); if (!existing) throw new Error(`Course not found: ${courseId}`); if (!existing.framework) { throw new Error(`Course ${courseId}: framework missing, cannot regenerate modules`); } if (!existing.modules.some((moduleRec) => moduleRec.index === startIndex)) { throw new Error(`Module ${startIndex} not found`); } const prompts = new Map( existing.framework.modules.map((spec) => [spec.index, spec.generationPrompt] as const), ); const modules = existing.modules.map((moduleRec): CourseModuleRecord => { if (moduleRec.index < startIndex) return moduleRec; const generationPromptSnapshot = prompts.get(moduleRec.index)?.trim(); return { index: moduleRec.index, title: moduleRec.title, description: moduleRec.description, status: 'pending', ...(generationPromptSnapshot ? { generationPromptSnapshot } : {}), }; }); const updated: CourseRecord = { ...existing, modules, publication: undefined, externalPublication: undefined, status: 'generating', error: undefined, updatedAt: new Date().toISOString(), }; await writeJsonFileAtomic(courseFilePath(courseId), updated); return updated; }); } export async function listCourseRecords(limit = 50): Promise { const dirEntries = await fs.readdir(COURSE_FRAMEWORK_DIR).catch(() => [] as string[]); const files = dirEntries.filter((name) => name.endsWith('.json')); const records: CourseRecord[] = []; for (const file of files) { try { const content = await fs.readFile(path.join(COURSE_FRAMEWORK_DIR, file), 'utf-8'); records.push(normalizePersistedCourseRecord(JSON.parse(content) as CourseRecord)); } catch { // corrupt course files must not take the listing down continue; } } return records.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)).slice(0, Math.max(1, limit)); } export async function deleteCourseRecord(courseId: string): Promise { await fs.rm(courseFilePath(courseId), { force: true }); log.info(`Course record deleted: ${courseId}`); return true; }