349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
// 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<string, Promise<void>>();
|
|
|
|
async function withCourseLock<T>(courseId: string, fn: () => Promise<T>): Promise<T> {
|
|
const prev = courseLocks.get(courseId) ?? Promise.resolve();
|
|
let resolve: () => void;
|
|
const next = new Promise<void>((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<CourseModuleRecord> {
|
|
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<CourseRecord> {
|
|
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<CourseRecord | null> {
|
|
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<CourseRecord | null> {
|
|
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<CourseRecord>,
|
|
): Promise<CourseRecord> {
|
|
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<CourseModuleRecord>,
|
|
): Promise<CourseRecord> {
|
|
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<CourseRecord> {
|
|
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<CourseRecord[]> {
|
|
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<boolean> {
|
|
await fs.rm(courseFilePath(courseId), { force: true });
|
|
log.info(`Course record deleted: ${courseId}`);
|
|
return true;
|
|
}
|