Files
openmaic/OpenMAIC/lib/course-framework/publish-validation.ts
2026-08-16 14:58:47 +08:00

195 lines
6.6 KiB
TypeScript

import { buildCourseModuleOutputDigest, computeClassroomSourceRevisionHash } from './module-digest';
import type {
CourseModuleContinuityInputRef,
CourseModuleOutputDigest,
CourseModuleRecord,
CourseRecord,
} from './types';
import { readClassroom, type PersistedClassroomData } from '@/lib/server/classroom-storage';
import { readCourseRecordReconciled } from './store';
export interface ValidatedPublishModule {
module: CourseModuleRecord;
classroom: PersistedClassroomData;
outputDigest: CourseModuleOutputDigest;
sourceRevisionHash: string;
}
export interface CoursePublishSnapshot {
courseId: string;
fingerprint: string;
modules: ValidatedPublishModule[];
}
export class CoursePublishValidationError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly moduleIndex?: number,
) {
super(message);
this.name = 'CoursePublishValidationError';
}
}
function sameContinuityRefs(
actual: readonly CourseModuleContinuityInputRef[] | undefined,
expected: readonly CourseModuleContinuityInputRef[],
): boolean {
if ((actual?.length ?? 0) !== expected.length) return false;
return expected.every((expectedRef, index) => {
const actualRef = actual?.[index];
return (
actualRef?.moduleIndex === expectedRef.moduleIndex &&
actualRef.classroomId === expectedRef.classroomId &&
actualRef.semanticHash === expectedRef.semanticHash
);
});
}
function publishFingerprint(
record: CourseRecord,
modules: readonly ValidatedPublishModule[],
): string {
return JSON.stringify({
framework: record.framework,
modules: modules.map(({ module, outputDigest, sourceRevisionHash }) => ({
index: module.index,
classroomId: module.classroomId,
semanticHash: outputDigest.semanticHash,
sourceRevisionHash,
continuityInputRefs: module.continuityInputRefs ?? [],
})),
});
}
export async function validateCoursePublishSnapshot(
record: CourseRecord,
): Promise<CoursePublishSnapshot> {
if (!record.framework || record.framework.modules.length === 0) {
throw new CoursePublishValidationError(
'FRAMEWORK_MISSING',
'Course framework is missing or empty',
);
}
if (record.modules.length !== record.framework.modules.length) {
throw new CoursePublishValidationError(
'MODULE_LAYOUT_MISMATCH',
'Course runtime modules no longer match the approved framework',
);
}
const runtimeModules = [...record.modules].sort((a, b) => a.index - b.index);
const frameworkModules = [...record.framework.modules].sort((a, b) => a.index - b.index);
const validated: ValidatedPublishModule[] = [];
for (let position = 0; position < runtimeModules.length; position += 1) {
const moduleRecord = runtimeModules[position];
const spec = frameworkModules[position];
if (moduleRecord.index !== position + 1 || spec.index !== moduleRecord.index) {
throw new CoursePublishValidationError(
'MODULE_LAYOUT_MISMATCH',
`Module ${moduleRecord.index} is out of framework order`,
moduleRecord.index,
);
}
if (moduleRecord.status !== 'succeeded' || !moduleRecord.classroomId) {
throw new CoursePublishValidationError(
'MODULE_NOT_GENERATED',
`Module ${moduleRecord.index} has no successful classroom`,
moduleRecord.index,
);
}
const classroom = await readClassroom(moduleRecord.classroomId).catch(() => null);
if (!classroom) {
throw new CoursePublishValidationError(
'MODULE_CLASSROOM_MISSING',
`Module ${moduleRecord.index} classroom ${moduleRecord.classroomId} is unavailable`,
moduleRecord.index,
);
}
const outputDigest = buildCourseModuleOutputDigest(classroom);
if (!moduleRecord.outputDigest) {
throw new CoursePublishValidationError(
'MODULE_OUTPUT_UNVERIFIED',
`Module ${moduleRecord.index} has no persisted output digest`,
moduleRecord.index,
);
}
const sourceRevisionHash = computeClassroomSourceRevisionHash(classroom);
if (!moduleRecord.outputDigest.sourceRevisionHash) {
throw new CoursePublishValidationError(
'MODULE_OUTPUT_UNVERIFIED',
`Module ${moduleRecord.index} predates exact source revision tracking; regenerate it before publishing`,
moduleRecord.index,
);
}
if (
moduleRecord.outputDigest.semanticHash !== outputDigest.semanticHash ||
moduleRecord.outputDigest.sourceRevisionHash !== sourceRevisionHash
) {
throw new CoursePublishValidationError(
'MODULE_OUTPUT_CHANGED',
`Module ${moduleRecord.index} classroom changed after generation; regenerate it before publishing`,
moduleRecord.index,
);
}
const hasInteractiveHtml = classroom.scenes.some(
(scene) =>
scene.content.type === 'interactive' &&
typeof scene.content.html === 'string' &&
scene.content.html.trim().length > 0,
);
if (!hasInteractiveHtml) {
throw new CoursePublishValidationError(
'INTERACTIVE_HTML_MISSING',
`Module ${moduleRecord.index} has no generated interactive HTML scene`,
moduleRecord.index,
);
}
const expectedRefs = validated.map(({ module, outputDigest: priorDigest }) => ({
moduleIndex: module.index,
classroomId: priorDigest.classroomId,
semanticHash: priorDigest.semanticHash,
}));
if (!sameContinuityRefs(moduleRecord.continuityInputRefs, expectedRefs)) {
throw new CoursePublishValidationError(
moduleRecord.continuityInputRefs === undefined
? 'CONTINUITY_UNVERIFIED'
: 'CONTINUITY_STALE',
`Module ${moduleRecord.index} was not generated from the current preceding module outputs`,
moduleRecord.index,
);
}
validated.push({ module: moduleRecord, classroom, outputDigest, sourceRevisionHash });
}
return {
courseId: record.id,
fingerprint: publishFingerprint(record, validated),
modules: validated,
};
}
export async function revalidateCoursePublishSnapshot(
snapshot: CoursePublishSnapshot,
): Promise<CoursePublishSnapshot> {
const current = await readCourseRecordReconciled(snapshot.courseId);
if (!current) {
throw new CoursePublishValidationError(
'COURSE_CHANGED_DURING_PUBLISH',
`Course ${snapshot.courseId} disappeared during publish`,
);
}
const revalidated = await validateCoursePublishSnapshot(current);
if (revalidated.fingerprint !== snapshot.fingerprint) {
throw new CoursePublishValidationError(
'COURSE_CHANGED_DURING_PUBLISH',
`Course ${snapshot.courseId} changed while its modules were being frozen`,
);
}
return revalidated;
}