Files
openmaic/OpenMAIC/lib/course-framework/store.ts

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;
}