feat: productionize learning engine and classroom
This commit is contained in:
411
OpenMAIC/lib/makelore-course/finalize.ts
Normal file
411
OpenMAIC/lib/makelore-course/finalize.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import JSZip from 'jszip';
|
||||
import type { CourseRecord } from '@/lib/course-framework/types';
|
||||
import { readCourseRecord } from '@/lib/course-framework/store';
|
||||
import { runCoursePublishExclusive } from '@/lib/course-framework/publish-state';
|
||||
import { FrozenLearningCourseMutationError } from '@/lib/makelore-course/immutability';
|
||||
import {
|
||||
persistFrozenLearningPackage,
|
||||
readFrozenLearningPackageRecord,
|
||||
readUniqueMakeloreCoursePackage,
|
||||
type FrozenLearningPackageRecord,
|
||||
} from '@/lib/makelore-course/package';
|
||||
import { packagePersistedClassroom } from '@/lib/server/classroom-courseware-publish';
|
||||
import { readClassroom, type PersistedClassroomData } from '@/lib/server/classroom-storage';
|
||||
|
||||
const finalizations = new Map<string, Promise<FrozenLearningPackageRecord>>();
|
||||
|
||||
function sha256(bytes: Uint8Array | string): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function canonicalJson(_key: string, value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function stableSourceDigest(value: unknown): string {
|
||||
const serialized = JSON.stringify(value, canonicalJson);
|
||||
if (serialized === undefined) throw new Error('Learning course source cannot be serialized');
|
||||
return sha256(serialized);
|
||||
}
|
||||
|
||||
function assertSafeBundleEntry(name: string): void {
|
||||
if (
|
||||
!name ||
|
||||
name.startsWith('/') ||
|
||||
name.includes('\\') ||
|
||||
name.includes('\0') ||
|
||||
name.split('/').some((part) => part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(`Unsafe frozen bundle entry: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function archiveSignature(bytes: Uint8Array): Record<string, string> | undefined {
|
||||
const key = process.env.LEARNING_PACKAGE_SIGNING_KEY;
|
||||
if (!key) return undefined;
|
||||
return {
|
||||
algorithm: 'hmac-sha256',
|
||||
keyId: process.env.LEARNING_PACKAGE_SIGNING_KEY_ID?.trim() || 'default',
|
||||
value: createHmac('sha256', key).update(bytes).digest('base64url'),
|
||||
};
|
||||
}
|
||||
|
||||
function sceneKinds(classroom: PersistedClassroomData) {
|
||||
return [...new Set(classroom.scenes.map((scene) => scene.type))].filter((kind) =>
|
||||
['slide', 'interactive', 'quiz', 'pbl'].includes(kind),
|
||||
);
|
||||
}
|
||||
|
||||
function classroomCapabilities(
|
||||
classroom: PersistedClassroomData,
|
||||
runtimeCapabilities: unknown,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
sceneKinds: sceneKinds(classroom),
|
||||
hasAudio: classroom.scenes.some((scene) =>
|
||||
scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)),
|
||||
),
|
||||
hasWhiteboard: Boolean(
|
||||
classroom.stage.whiteboard?.length ||
|
||||
classroom.scenes.some((scene) => Boolean(scene.whiteboards?.length)),
|
||||
),
|
||||
hasAgent: Boolean(classroom.stage.generatedAgentConfigs?.length),
|
||||
runtime: runtimeCapabilities,
|
||||
};
|
||||
}
|
||||
|
||||
async function existingFinalization(
|
||||
courseId: string,
|
||||
sourceJobId: string,
|
||||
sourceDigest: string,
|
||||
mode: FrozenLearningPackageRecord['mode'],
|
||||
) {
|
||||
const record = await readFrozenLearningPackageRecord(courseId);
|
||||
if (!record) return null;
|
||||
const bytes = await readUniqueMakeloreCoursePackage(courseId);
|
||||
if (
|
||||
!bytes ||
|
||||
bytes.byteLength !== record.archiveBytes ||
|
||||
sha256(bytes) !== record.archiveSha256 ||
|
||||
record.sourceJobId !== sourceJobId ||
|
||||
record.mode !== mode ||
|
||||
record.sourceDigest !== sourceDigest
|
||||
) {
|
||||
throw new FrozenLearningCourseMutationError(courseId);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function finalizeSerially(
|
||||
key: string,
|
||||
factory: () => Promise<FrozenLearningPackageRecord>,
|
||||
): Promise<FrozenLearningPackageRecord> {
|
||||
// Different source revisions of the same course must never share an
|
||||
// in-flight promise. Serialize them, then re-evaluate the persisted cache
|
||||
// against each call's sourceDigest inside the factory.
|
||||
const previous = finalizations.get(key);
|
||||
const ready =
|
||||
previous?.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
) ?? Promise.resolve();
|
||||
const promise = ready.then(factory);
|
||||
finalizations.set(key, promise);
|
||||
const cleanup = () => {
|
||||
if (finalizations.get(key) === promise) finalizations.delete(key);
|
||||
};
|
||||
void promise.then(cleanup, cleanup);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function finalizeSingleLearningCourse(input: {
|
||||
jobId: string;
|
||||
classroom: PersistedClassroomData;
|
||||
baseUrl: string;
|
||||
requireInteractiveHtml: boolean;
|
||||
requireNarrationAudio: boolean;
|
||||
}): Promise<FrozenLearningPackageRecord> {
|
||||
return finalizeSerially(`single:${input.classroom.id}`, async () => {
|
||||
const sourceDigest = stableSourceDigest({
|
||||
sourceDigestVersion: 1,
|
||||
mode: 'single',
|
||||
courseId: input.classroom.id,
|
||||
classroom: {
|
||||
stage: input.classroom.stage,
|
||||
scenes: input.classroom.scenes,
|
||||
},
|
||||
requireInteractiveHtml: input.requireInteractiveHtml,
|
||||
requireNarrationAudio: input.requireNarrationAudio,
|
||||
});
|
||||
const existing = await existingFinalization(
|
||||
input.classroom.id,
|
||||
input.jobId,
|
||||
sourceDigest,
|
||||
'single',
|
||||
);
|
||||
if (existing) return existing;
|
||||
const packaged = await packagePersistedClassroom(input.classroom, {
|
||||
coursewareId: input.classroom.id,
|
||||
version: 1,
|
||||
baseUrl: input.baseUrl,
|
||||
requireInteractiveHtml: input.requireInteractiveHtml,
|
||||
requireNarrationAudio: input.requireNarrationAudio,
|
||||
});
|
||||
const bytes = new Uint8Array(await packaged.zip.arrayBuffer());
|
||||
const signature = archiveSignature(bytes);
|
||||
const record: FrozenLearningPackageRecord = {
|
||||
// Schema v1 keeps the existing runtime Q&A record reader compatible;
|
||||
// mode and exact frozen-archive metadata are additive.
|
||||
schemaVersion: 1,
|
||||
courseId: input.classroom.id,
|
||||
mode: 'single',
|
||||
sourceJobId: input.jobId,
|
||||
sourceDigest,
|
||||
contentHash: packaged.contentHash,
|
||||
archiveSha256: sha256(bytes),
|
||||
archiveBytes: bytes.byteLength,
|
||||
formatVersion: packaged.meta.formatVersion,
|
||||
minPlayerVersion: '2.0.0',
|
||||
title: input.classroom.stage.name,
|
||||
language: input.classroom.stage.languageDirective,
|
||||
sceneCount: input.classroom.scenes.length,
|
||||
moduleCount: 1,
|
||||
capabilities: {
|
||||
...classroomCapabilities(input.classroom, packaged.meta.runtimeCapabilities),
|
||||
...(signature ? { archiveSignature: signature } : {}),
|
||||
},
|
||||
classroom: { stage: input.classroom.stage, scenes: input.classroom.scenes },
|
||||
createdAt: packaged.meta.publishedAt,
|
||||
};
|
||||
await persistFrozenLearningPackage(record, bytes);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export async function finalizeLargeLearningCourse(input: {
|
||||
jobId: string;
|
||||
course: CourseRecord;
|
||||
baseUrl: string;
|
||||
}): Promise<FrozenLearningPackageRecord> {
|
||||
return finalizeSerially(`large:${input.course.id}`, () =>
|
||||
runCoursePublishExclusive(input.course.id, async () => {
|
||||
// The persisted record is authoritative in production. The fallback keeps
|
||||
// the pure finalizer usable for isolated packaging tests.
|
||||
const course = (await readCourseRecord(input.course.id)) ?? input.course;
|
||||
if (course.status !== 'completed' || !course.framework || course.modules.length === 0) {
|
||||
throw new Error('Large course is not complete');
|
||||
}
|
||||
|
||||
const moduleSources = [] as Array<{
|
||||
moduleRecord: CourseRecord['modules'][number];
|
||||
classroom: PersistedClassroomData;
|
||||
}>;
|
||||
for (const moduleRecord of [...course.modules].sort((a, b) => a.index - b.index)) {
|
||||
if (moduleRecord.status !== 'succeeded' || !moduleRecord.classroomId) {
|
||||
throw new Error(`Large course module ${moduleRecord.index} is not complete`);
|
||||
}
|
||||
const classroom = await readClassroom(moduleRecord.classroomId);
|
||||
if (!classroom) throw new Error(`Classroom ${moduleRecord.classroomId} is unavailable`);
|
||||
moduleSources.push({ moduleRecord, classroom });
|
||||
}
|
||||
const sourceDigest = stableSourceDigest({
|
||||
sourceDigestVersion: 1,
|
||||
mode: 'large',
|
||||
courseId: course.id,
|
||||
framework: course.framework,
|
||||
requireInteractiveHtml: course.interactiveMode ?? true,
|
||||
requireNarrationAudio: course.enableTTS ?? true,
|
||||
modules: moduleSources.map(({ moduleRecord, classroom }) => ({
|
||||
index: moduleRecord.index,
|
||||
title: moduleRecord.title,
|
||||
description: moduleRecord.description,
|
||||
classroomId: moduleRecord.classroomId,
|
||||
classroom: { stage: classroom.stage, scenes: classroom.scenes },
|
||||
})),
|
||||
});
|
||||
const existing = await existingFinalization(course.id, input.jobId, sourceDigest, 'large');
|
||||
if (existing) return existing;
|
||||
|
||||
const modulePackages = [] as Array<{
|
||||
index: number;
|
||||
title: string;
|
||||
description: string;
|
||||
coursewareId: string;
|
||||
classroomId: string;
|
||||
contentHash: string;
|
||||
archiveSha256: string;
|
||||
archiveBytes: number;
|
||||
archivePath: string;
|
||||
sceneCount: number;
|
||||
formatVersion: number;
|
||||
bytes: Uint8Array;
|
||||
capabilities: unknown;
|
||||
}>;
|
||||
for (const { moduleRecord, classroom } of moduleSources) {
|
||||
const coursewareId = `${course.id}_m${moduleRecord.index}`;
|
||||
const packaged = await packagePersistedClassroom(classroom, {
|
||||
coursewareId,
|
||||
version: 1,
|
||||
baseUrl: input.baseUrl,
|
||||
requireInteractiveHtml: course.interactiveMode ?? true,
|
||||
requireNarrationAudio: course.enableTTS ?? true,
|
||||
});
|
||||
const bytes = new Uint8Array(await packaged.zip.arrayBuffer());
|
||||
modulePackages.push({
|
||||
index: moduleRecord.index,
|
||||
title: moduleRecord.title,
|
||||
description: moduleRecord.description,
|
||||
coursewareId,
|
||||
classroomId: classroom.id,
|
||||
contentHash: packaged.contentHash,
|
||||
archiveSha256: sha256(bytes),
|
||||
archiveBytes: bytes.byteLength,
|
||||
archivePath: `modules/${coursewareId}/`,
|
||||
sceneCount: classroom.scenes.length,
|
||||
formatVersion: packaged.meta.formatVersion,
|
||||
bytes,
|
||||
capabilities: packaged.meta.runtimeCapabilities,
|
||||
});
|
||||
}
|
||||
|
||||
const structureModules = modulePackages.map(({ bytes: _bytes, capabilities, ...module }) => ({
|
||||
...module,
|
||||
capabilities,
|
||||
}));
|
||||
const contentHash = sha256(
|
||||
JSON.stringify([
|
||||
'learning-course-aggregate-v1',
|
||||
course.id,
|
||||
structureModules.map((module) => [
|
||||
module.index,
|
||||
module.coursewareId,
|
||||
module.contentHash,
|
||||
module.archiveSha256,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
const courseStructure = {
|
||||
schemaVersion: 1,
|
||||
kind: 'learning-course-aggregate',
|
||||
courseId: course.id,
|
||||
title: course.framework.courseTitle,
|
||||
summary: course.framework.summary,
|
||||
language: course.framework.languageDirective,
|
||||
targetAudience: course.framework.targetAudience,
|
||||
contentHash,
|
||||
moduleCount: structureModules.length,
|
||||
modules: structureModules,
|
||||
runtimeBoundary: {
|
||||
offline: [
|
||||
'playback',
|
||||
'media',
|
||||
'audio',
|
||||
'interactive-html',
|
||||
'objective-quiz',
|
||||
'pbl-template',
|
||||
],
|
||||
hostBridge: ['agent', 'asr', 'pbl-evaluation', 'subjective-quiz-grading'],
|
||||
htmlNetworkAccess: false,
|
||||
},
|
||||
};
|
||||
const courseDocument = {
|
||||
schemaVersion: 1,
|
||||
kind: 'learning-course-aggregate',
|
||||
courseId: course.id,
|
||||
title: course.framework.courseTitle,
|
||||
summary: course.framework.summary,
|
||||
language: course.framework.languageDirective,
|
||||
targetAudience: course.framework.targetAudience,
|
||||
contentHash,
|
||||
moduleCount: structureModules.length,
|
||||
modules: structureModules.map((module) => ({
|
||||
moduleId: module.coursewareId,
|
||||
index: module.index,
|
||||
title: module.title,
|
||||
summary: module.description,
|
||||
sceneCount: module.sceneCount,
|
||||
contentHash: module.contentHash,
|
||||
path: module.archivePath,
|
||||
})),
|
||||
runtimeBoundary: courseStructure.runtimeBoundary,
|
||||
};
|
||||
const zip = new JSZip();
|
||||
zip.file('course.json', JSON.stringify(courseDocument, null, 2));
|
||||
for (const modulePackage of modulePackages) {
|
||||
const source = await JSZip.loadAsync(modulePackage.bytes);
|
||||
for (const required of [
|
||||
'manifest.json',
|
||||
'bundle.json',
|
||||
'quiz/quiz.json',
|
||||
'knowledge/knowledge.json',
|
||||
]) {
|
||||
if (!source.file(required)) {
|
||||
throw new Error(`Module ${modulePackage.coursewareId} is missing ${required}`);
|
||||
}
|
||||
}
|
||||
for (const [name, entry] of Object.entries(source.files)) {
|
||||
if (entry.dir) continue;
|
||||
assertSafeBundleEntry(name);
|
||||
zip.file(`${modulePackage.archivePath}${name}`, await entry.async('uint8array'), {
|
||||
binary: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
const signature = archiveSignature(bytes);
|
||||
const record: FrozenLearningPackageRecord = {
|
||||
schemaVersion: 2,
|
||||
courseId: course.id,
|
||||
mode: 'large',
|
||||
sourceJobId: input.jobId,
|
||||
sourceDigest,
|
||||
contentHash,
|
||||
archiveSha256: sha256(bytes),
|
||||
archiveBytes: bytes.byteLength,
|
||||
formatVersion: 2,
|
||||
// Aggregate module playback ships in the current Makelore 2.0.0
|
||||
// consumer; do not advertise a nonexistent 3.x compatibility floor.
|
||||
minPlayerVersion: '2.0.0',
|
||||
title: course.framework.courseTitle,
|
||||
language: course.framework.languageDirective,
|
||||
sceneCount: modulePackages.reduce((total, module) => total + module.sceneCount, 0),
|
||||
moduleCount: modulePackages.length,
|
||||
capabilities: {
|
||||
modular: true,
|
||||
orderedModules: true,
|
||||
productionStagePerModule: true,
|
||||
...(signature ? { archiveSignature: signature } : {}),
|
||||
},
|
||||
courseStructure,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await persistFrozenLearningPackage(record, bytes);
|
||||
return record;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function frozenLearningFinalizeResponse(record: FrozenLearningPackageRecord) {
|
||||
return {
|
||||
contractVersion: 2,
|
||||
mode: record.mode,
|
||||
courseId: record.courseId,
|
||||
title: record.title,
|
||||
language: record.language,
|
||||
contentHash: record.contentHash,
|
||||
archiveSha256: record.archiveSha256,
|
||||
archiveBytes: record.archiveBytes,
|
||||
formatVersion: record.formatVersion,
|
||||
minPlayerVersion: record.minPlayerVersion,
|
||||
sceneCount: record.sceneCount,
|
||||
moduleCount: record.moduleCount,
|
||||
capabilities: record.capabilities,
|
||||
courseStructure: record.courseStructure,
|
||||
};
|
||||
}
|
||||
34
OpenMAIC/lib/makelore-course/immutability.ts
Normal file
34
OpenMAIC/lib/makelore-course/immutability.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CourseRecord } from '@/lib/course-framework/types';
|
||||
import { readCourseRecord } from '@/lib/course-framework/store';
|
||||
import { readFrozenLearningPackageRecord } from '@/lib/makelore-course/package';
|
||||
|
||||
type CoursePublicationState = Pick<CourseRecord, 'publication' | 'externalPublication'>;
|
||||
|
||||
/** A frozen course id is a one-shot immutable product identity. */
|
||||
export class FrozenLearningCourseMutationError extends Error {
|
||||
constructor(public readonly courseId: string) {
|
||||
super(`课程 ${courseId} 已冻结,不能继续生成或重新生成;如需修改,请新建课程`);
|
||||
this.name = 'FrozenLearningCourseMutationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the single immutable-course rule at every source mutation boundary.
|
||||
*
|
||||
* The frozen record is authoritative: Works can successfully finalize an
|
||||
* archive and then fail before writing the later external-publication receipt.
|
||||
* Legacy publication receipts remain additional evidence for courses created
|
||||
* before aggregate finalization was introduced.
|
||||
*/
|
||||
export async function assertLearningCourseMutable(
|
||||
courseId: string,
|
||||
knownRecord?: CoursePublicationState | null,
|
||||
): Promise<void> {
|
||||
const [frozen, record] = await Promise.all([
|
||||
readFrozenLearningPackageRecord(courseId),
|
||||
knownRecord === undefined ? readCourseRecord(courseId) : Promise.resolve(knownRecord),
|
||||
]);
|
||||
if (frozen || record?.publication || record?.externalPublication) {
|
||||
throw new FrozenLearningCourseMutationError(courseId);
|
||||
}
|
||||
}
|
||||
240
OpenMAIC/lib/makelore-course/package.ts
Normal file
240
OpenMAIC/lib/makelore-course/package.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import JSZip from 'jszip';
|
||||
import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract';
|
||||
import { inlineHtmlAssets } from '@/lib/export/inline-assets';
|
||||
import type { Scene, Stage } from '@/lib/types/stage';
|
||||
|
||||
export type MakeloreCoursePackage = {
|
||||
bytes: Uint8Array;
|
||||
contentHash: string;
|
||||
archiveSha256: string;
|
||||
archiveBytes: number;
|
||||
sceneCount: number;
|
||||
title: string;
|
||||
language?: string;
|
||||
capabilities: {
|
||||
sceneKinds: Array<'slide' | 'interactive' | 'quiz' | 'pbl'>;
|
||||
hasAudio: boolean;
|
||||
hasWhiteboard: boolean;
|
||||
hasAgent: boolean;
|
||||
};
|
||||
classroom: { stage: Stage; scenes: Scene[] };
|
||||
};
|
||||
|
||||
const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
export async function packageUniqueMakeloreCourse(input: {
|
||||
courseId: string;
|
||||
stage: Stage;
|
||||
scenes: Scene[];
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<MakeloreCoursePackage> {
|
||||
const scenes = await Promise.all(
|
||||
input.scenes.map(async (scene) => {
|
||||
if (scene.content.type !== 'interactive' || !scene.content.html) return scene;
|
||||
const inlined = await inlineHtmlAssets(scene.content.html, {
|
||||
fetchImpl: input.fetchImpl,
|
||||
keepImportmapFallbacks: false,
|
||||
});
|
||||
if (inlined.report.failed.length || inlined.unresolved.length) {
|
||||
throw new Error(`Interactive scene ${scene.id} is not offline-complete`);
|
||||
}
|
||||
return { ...scene, content: { ...scene.content, html: inlined.html } } as Scene;
|
||||
}),
|
||||
);
|
||||
const classroom = { stage: input.stage, scenes };
|
||||
const knowledge = extractKnowledgePack(input.courseId, input.stage, scenes);
|
||||
const quiz = extractQuizPack(scenes);
|
||||
const classroomJson = JSON.stringify(classroom);
|
||||
const knowledgeJson = JSON.stringify(knowledge);
|
||||
const quizJson = JSON.stringify(quiz);
|
||||
const contentHash = sha256(
|
||||
JSON.stringify([
|
||||
['classroom.json', sha256(classroomJson)],
|
||||
['knowledge/knowledge.json', sha256(knowledgeJson)],
|
||||
['quiz/quiz.json', sha256(quizJson)],
|
||||
]),
|
||||
);
|
||||
const sceneKinds = [...new Set(scenes.map((scene) => scene.type))].filter(
|
||||
(kind): kind is 'slide' | 'interactive' | 'quiz' | 'pbl' =>
|
||||
['slide', 'interactive', 'quiz', 'pbl'].includes(kind),
|
||||
);
|
||||
const capabilities = {
|
||||
sceneKinds,
|
||||
hasAudio: scenes.some((scene) =>
|
||||
scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)),
|
||||
),
|
||||
hasWhiteboard: Boolean(
|
||||
input.stage.whiteboard?.length || scenes.some((scene) => Boolean(scene.whiteboards?.length)),
|
||||
),
|
||||
hasAgent: Boolean(input.stage.generatedAgentConfigs?.length),
|
||||
};
|
||||
const descriptor = {
|
||||
schemaVersion: 1,
|
||||
kind: 'makelore-course-package',
|
||||
courseId: input.courseId,
|
||||
contentHash,
|
||||
formatVersion: 1,
|
||||
minPlayerVersion: '2.0.0',
|
||||
title: input.stage.name,
|
||||
language: input.stage.languageDirective,
|
||||
sceneCount: scenes.length,
|
||||
capabilities,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const zip = new JSZip();
|
||||
zip.file('descriptor.json', JSON.stringify(descriptor, null, 2));
|
||||
zip.file('classroom.json', classroomJson);
|
||||
zip.file('knowledge/knowledge.json', knowledgeJson);
|
||||
zip.file('quiz/quiz.json', quizJson);
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
return {
|
||||
bytes,
|
||||
contentHash,
|
||||
archiveSha256: sha256(bytes),
|
||||
archiveBytes: bytes.byteLength,
|
||||
sceneCount: scenes.length,
|
||||
title: input.stage.name,
|
||||
language: input.stage.languageDirective,
|
||||
capabilities,
|
||||
classroom,
|
||||
};
|
||||
}
|
||||
|
||||
function packageRoot(): string {
|
||||
const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data');
|
||||
return resolve(
|
||||
process.env.LEARNING_COURSE_PACKAGE_DIR ||
|
||||
process.env.MAKELORE_COURSE_PACKAGE_DIR ||
|
||||
resolve(dataRoot, 'makelore-packages'),
|
||||
);
|
||||
}
|
||||
|
||||
function courseStoreRoot(): string {
|
||||
const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data');
|
||||
return resolve(
|
||||
process.env.LEARNING_COURSE_STORE_DIR ||
|
||||
process.env.MAKELORE_COURSE_STORE_DIR ||
|
||||
resolve(dataRoot, 'makelore-courses'),
|
||||
);
|
||||
}
|
||||
|
||||
function assertLearningCourseId(courseId: string): void {
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(courseId)) throw new Error('Invalid learning course id');
|
||||
}
|
||||
|
||||
export async function persistUniqueMakeloreCourse(
|
||||
courseId: string,
|
||||
course: MakeloreCoursePackage,
|
||||
): Promise<string> {
|
||||
const packages = packageRoot();
|
||||
const records = courseStoreRoot();
|
||||
await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]);
|
||||
const packagePath = resolve(packages, `${courseId}.zip`);
|
||||
const packageTemp = `${packagePath}.${process.pid}.tmp`;
|
||||
const recordPath = resolve(records, `${courseId}.json`);
|
||||
const recordTemp = `${recordPath}.${process.pid}.tmp`;
|
||||
await writeFile(packageTemp, course.bytes);
|
||||
await writeFile(
|
||||
recordTemp,
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
courseId,
|
||||
contentHash: course.contentHash,
|
||||
title: course.title,
|
||||
language: course.language,
|
||||
classroom: course.classroom,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await rename(packageTemp, packagePath);
|
||||
await rename(recordTemp, recordPath);
|
||||
return packagePath;
|
||||
}
|
||||
|
||||
export async function readUniqueMakeloreCoursePackage(
|
||||
courseId: string,
|
||||
): Promise<Uint8Array | null> {
|
||||
assertLearningCourseId(courseId);
|
||||
try {
|
||||
return new Uint8Array(await readFile(resolve(packageRoot(), `${courseId}.zip`)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FrozenLearningPackageRecord {
|
||||
schemaVersion: 1 | 2;
|
||||
courseId: string;
|
||||
mode: 'single' | 'large';
|
||||
sourceJobId: string;
|
||||
/**
|
||||
* Stable digest of the exact mutable source snapshot used to build this
|
||||
* archive. Legacy records omit it and must never be reused solely by job id.
|
||||
*/
|
||||
sourceDigest?: string;
|
||||
contentHash: string;
|
||||
archiveSha256: string;
|
||||
archiveBytes: number;
|
||||
formatVersion: number;
|
||||
minPlayerVersion: string;
|
||||
title: string;
|
||||
language?: string;
|
||||
sceneCount: number;
|
||||
moduleCount: number;
|
||||
capabilities: Record<string, unknown>;
|
||||
courseStructure?: Record<string, unknown>;
|
||||
classroom?: { stage: Stage; scenes: Scene[] };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Persist exact frozen bytes plus the metadata Works uses for idempotent finalize. */
|
||||
export async function persistFrozenLearningPackage(
|
||||
record: FrozenLearningPackageRecord,
|
||||
bytes: Uint8Array,
|
||||
): Promise<void> {
|
||||
assertLearningCourseId(record.courseId);
|
||||
const packages = packageRoot();
|
||||
const records = courseStoreRoot();
|
||||
await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]);
|
||||
const packagePath = resolve(packages, `${record.courseId}.zip`);
|
||||
const recordPath = resolve(records, `${record.courseId}.json`);
|
||||
const suffix = `${process.pid}.${Date.now()}.tmp`;
|
||||
const packageTemp = `${packagePath}.${suffix}`;
|
||||
const recordTemp = `${recordPath}.${suffix}`;
|
||||
await writeFile(packageTemp, bytes);
|
||||
await writeFile(recordTemp, JSON.stringify(record, null, 2));
|
||||
await rename(packageTemp, packagePath);
|
||||
await rename(recordTemp, recordPath);
|
||||
}
|
||||
|
||||
export async function readFrozenLearningPackageRecord(
|
||||
courseId: string,
|
||||
): Promise<FrozenLearningPackageRecord | null> {
|
||||
assertLearningCourseId(courseId);
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8'),
|
||||
) as Partial<FrozenLearningPackageRecord>;
|
||||
if (
|
||||
value.courseId !== courseId ||
|
||||
(value.mode !== 'single' && value.mode !== 'large') ||
|
||||
(value.sourceDigest !== undefined && !/^[0-9a-f]{64}$/.test(value.sourceDigest)) ||
|
||||
typeof value.contentHash !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(value.contentHash) ||
|
||||
typeof value.archiveSha256 !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(value.archiveSha256) ||
|
||||
typeof value.archiveBytes !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as FrozenLearningPackageRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user