Files
openmaic/OpenMAIC/lib/courseware-repo/migrate.ts
2026-08-16 14:58:47 +08:00

169 lines
5.7 KiB
TypeScript

// Legacy classroom migration — re-package existing server-side classrooms
// (`data/classrooms/*.json`, stage + scenes) into published frozen bundles.
//
// The packager is byte-agnostic: media/narration bytes arrive through injected
// resolvers. Deployments with server persistence resolve them from the asset
// registry; otherwise (bytes only in learner browsers) the migration reports
// the classroom as "re-publish from the ops workbench" instead of publishing a
// broken bundle. The publish gate (`requireComplete`) is always on here.
import { packageCourseware, type PackageResult } from '@/lib/bundle/packager';
import { publishCourseware } from '@/lib/courseware-repo';
import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import {
COURSEWARE_BUNDLES_DIR,
createFileBundleByteStore,
} from '@/lib/courseware-repo/bundle-store';
import { CLASSROOMS_DIR, type PersistedClassroomData } from '@/lib/server/classroom-storage';
import { promises as fs } from 'fs';
import path from 'path';
import { createLogger } from '@/lib/logger';
import type { CoursewareRepo } from '@/lib/courseware-repo/types';
import type { BundleByteStore } from '@/lib/courseware-repo/bundle-store';
import type { MediaFileRecord } from '@/lib/utils/database';
const log = createLogger('MigrateCoursewares');
export interface ClassroomByteResolvers {
/** Resolve narration bytes by audioId (server asset registry, …). */
resolveAudioBytes?: (audioId: string) => Promise<Blob | null>;
/** Resolve media bytes by record shape (elementId + mimeType). */
resolveMediaBytes?: (record: MediaFileRecord) => Promise<Blob | null>;
}
export interface MigrateClassroomOptions {
/** Override the published courseware id (defaults to the classroom id). */
coursewareId?: string;
/** Explicit version (defaults to the repo's next monotonic version). */
version?: number;
baseUrl: string;
resolvers?: ClassroomByteResolvers;
repos?: {
records?: CoursewareRepo;
bytes?: BundleByteStore;
};
}
export type MigrateClassroomResult =
| {
status: 'published';
classroomId: string;
coursewareId: string;
version: number;
record: Awaited<ReturnType<typeof publishCourseware>>['record'];
}
| {
status: 'incomplete';
classroomId: string;
coursewareId: string;
/** Human-readable missing-resource summary for the report. */
missing: string[];
completeness: PackageResult['completeness'];
};
/**
* Re-package one persisted classroom and publish it. Never publishes a broken
* bundle: missing audio/media produce an `incomplete` result instead.
*/
export async function migrateClassroomToCourseware(
classroom: PersistedClassroomData,
options: MigrateClassroomOptions,
): Promise<MigrateClassroomResult> {
const coursewareId = options.coursewareId ?? classroom.id;
const { stage, scenes } = classroom;
const resolvers = options.resolvers ?? {};
const packaged = await packageCourseware({
coursewareId,
version: options.version,
stage,
scenes,
agentConfigs: stage.generatedAgentConfigs ?? [],
resolveAudioBytes: resolvers.resolveAudioBytes,
resolveMediaBytes: resolvers.resolveMediaBytes,
requireComplete: false, // report instead of throwing; the gate below decides
});
if (!packaged.completeness.complete) {
const missing = packaged.completeness.missing.map(
(m) => `${m.kind}:${m.ref}${m.sceneId ? ` (scene ${m.sceneId})` : ''}`,
);
log.warn(`Classroom ${classroom.id} incomplete — not published (${missing.length} gaps)`);
return {
status: 'incomplete',
classroomId: classroom.id,
coursewareId,
missing,
completeness: packaged.completeness,
};
}
const { record } = await publishCourseware({
zipBytes: new Uint8Array(await packaged.zip.arrayBuffer()),
coursewareId,
baseUrl: options.baseUrl,
version: options.version,
repos: {
records: options.repos?.records ?? createFileCoursewareRepo(COURSEWARES_DIR),
bytes: options.repos?.bytes ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR),
},
});
log.info(`Published ${classroom.id} as ${coursewareId} v${record.version}`);
return {
status: 'published',
classroomId: classroom.id,
coursewareId,
version: record.version,
record,
};
}
/** Scan + migrate every classroom in the server classrooms dir. */
export async function migrateAllClassrooms(options: {
baseUrl: string;
resolvers?: ClassroomByteResolvers;
classroomsDir?: string;
repos?: MigrateClassroomOptions['repos'];
}): Promise<{
published: MigrateClassroomResult[];
incomplete: MigrateClassroomResult[];
skipped: string[];
}> {
const dir = options.classroomsDir ?? CLASSROOMS_DIR;
let files: string[];
try {
files = await fs.readdir(dir);
} catch {
return { published: [], incomplete: [], skipped: [] };
}
const results: MigrateClassroomResult[] = [];
const skipped: string[] = [];
for (const file of files.filter((f) => f.endsWith('.json')).sort()) {
const classroomId = path.basename(file, '.json');
let classroom: PersistedClassroomData;
try {
const content = await fs.readFile(path.join(dir, file), 'utf-8');
classroom = JSON.parse(content) as PersistedClassroomData;
} catch {
skipped.push(classroomId);
continue;
}
results.push(
await migrateClassroomToCourseware(classroom, {
coursewareId: classroomId,
baseUrl: options.baseUrl,
resolvers: options.resolvers,
repos: options.repos,
}),
);
}
return {
published: results.filter((r) => r.status === 'published'),
incomplete: results.filter((r) => r.status === 'incomplete'),
skipped,
};
}