Files
openmaic/OpenMAIC/lib/bundle/packager.ts

603 lines
23 KiB
TypeScript

// Frozen courseware packager — turns a classroom payload (stage + scenes)
// into an immutable, versioned, self-contained bundle ZIP.
//
// The packager is isomorphic: it runs in the browser (ops workbench, bytes
// from IndexedDB) and on the server (publish route, bytes from the asset
// registry) — bytes always arrive through injected resolvers. The only global
// it needs beyond plain JS is WebCrypto (`crypto.subtle`), which both browsers
// and Node ≥19 provide.
//
// Publish policy ("freeze"): the bundle is complete only when every speech
// action resolves to narration bytes, every media record resolves to bytes,
// and interactive HTML inlines without failures. `requireComplete` turns any
// gap into a hard error — the publish route should pass `true` so a broken
// courseware can never be published.
import { inlineHtmlAssets, type InlineReport } from '@/lib/export/inline-assets';
import type { SpeechAction } from '@/lib/types/action';
import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage';
import type { MediaFileRecord } from '@/lib/utils/database';
import { buildManifest } from './serialize';
import { extractKnowledgePack, extractQuizPack } from './extract';
import {
FROZEN_BUNDLE_CONTENT_HASH_VERSION,
FROZEN_BUNDLE_FORMAT_VERSION,
FROZEN_BUNDLE_KIND,
FROZEN_BUNDLE_KNOWLEDGE_FILE,
FROZEN_BUNDLE_MANIFEST_FILE,
FROZEN_BUNDLE_QUIZ_FILE,
type CompletenessReport,
type FrozenBundleMeta,
type KnowledgePack,
type MissingResource,
type QuizPack,
} from './types';
export interface PackageSources {
coursewareId: string;
/**
* Publish version claim written into `bundle.json`. Server-side builders pass
* the version allocated under the registry lock. Browser packaging leaves
* this unset (defaults to 1); an unversioned upload is treated as a template
* and the registry stamps the allocated version into the stored ZIP.
*/
version?: number;
stage: Stage;
scenes: Scene[];
agentConfigs?: GeneratedAgentConfig[];
/** Media records to ship (caller-owned listing, e.g. asset registry). */
mediaRecords?: MediaFileRecord[];
/** Resolve narration bytes by audioId. Absent audioId → missing resource. */
resolveAudioBytes?: (audioId: string) => Promise<Blob | null>;
/** Resolve media bytes by record. Absent record → missing resource. */
resolveMediaBytes?: (record: MediaFileRecord) => Promise<Blob | null>;
/**
* Optional scene preparation hook (e.g. PBL scenes → design templates before
* persistence). Defaults to identity — the ops end wires its real prep.
*/
prepareScenes?: (stageId: string, scenes: readonly Scene[]) => Promise<Scene[]>;
/** Fetch implementation for inlining interactive HTML assets. Default: global fetch. */
fetchImpl?: typeof fetch;
publishedAt?: string;
appVersion?: string;
/** Fail the whole publish on any missing resource. Default false (report only). */
requireComplete?: boolean;
/** Require generated bytes for narration scripts. Disable only when TTS was intentionally off. */
requireNarrationAudio?: boolean;
/** Require at least one portable agent persona snapshot. */
requireAgentRoster?: boolean;
/** Require at least one interactive Scene with generated, non-empty HTML. */
requireInteractiveHtml?: boolean;
/** Remove online import-map fallbacks and reject every residual HTML asset URL. */
strictInteractiveAssets?: boolean;
}
export interface PackageResult {
zip: Blob;
contentHash: string;
entryCount: number;
manifest: ReturnType<typeof buildManifest>;
meta: FrozenBundleMeta;
completeness: CompletenessReport;
quiz: QuizPack;
knowledge: KnowledgePack;
inlineReport: InlineReport;
}
interface CollectedAudio {
path: string;
bytes: Uint8Array;
sha: string;
ext: string;
audioId: string;
}
interface CollectedMedia {
path: string;
bytes: Uint8Array;
sha: string;
ext: string;
elementId: string;
posterPath?: string;
posterBytes?: Uint8Array;
record: MediaFileRecord;
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
// Isomorphic: WebCrypto exists in browsers and Node ≥19. The cast covers the
// TS 5.7 `Uint8Array<ArrayBufferLike>` → `BufferSource` mismatch; callers
// always hold real ArrayBuffer-backed views (jszip output / arrayBuffer()).
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes as unknown as BufferSource);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function contentHashVersion(value: unknown): 1 | typeof FROZEN_BUNDLE_CONTENT_HASH_VERSION {
if (value === undefined) return 1;
if (value === 1 || value === FROZEN_BUNDLE_CONTENT_HASH_VERSION) return value;
throw new Error(`Unsupported frozen bundle content hash version: ${String(value)}`);
}
/** Hash one payload file under the selected backward-compatible algorithm. */
async function hashPayloadFile(
name: string,
bytes: Uint8Array,
version: 1 | typeof FROZEN_BUNDLE_CONTENT_HASH_VERSION,
): Promise<string> {
if (version >= 2 && name === 'manifest.json') {
const parsed = JSON.parse(decoder.decode(bytes)) as Record<string, unknown>;
// exportedAt records transport time, not classroom content. Normalizing it
// makes a lost-response retry package the same immutable payload identity.
delete parsed.exportedAt;
return sha256Hex(encoder.encode(JSON.stringify(parsed)));
}
return sha256Hex(bytes);
}
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer());
}
function missing(
kind: MissingResource['kind'],
ref: string,
reason: string,
sceneId?: string,
): MissingResource {
return { kind, ref, reason, ...(sceneId ? { sceneId } : {}) };
}
function residualExecutableNetworkDependencies(html: string): string[] {
const dependencies = new Set<string>();
for (const match of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
const script = match[1] ?? '';
for (const url of script.match(/https?:\/\/[^\s'"`<>\\)]+/gi) ?? []) {
dependencies.add(url);
}
// Inlining pyodide.js does not inline its wasm/stdlib downloads. Until the
// player ships a frozen local Pyodide distribution, treating loadPyodide
// as complete would create a package that predictably fails under the
// offline CSP (`connect-src 'none'`).
if (/\bloadPyodide\s*\(/.test(script)) dependencies.add('dynamic:loadPyodide');
}
return [...dependencies];
}
/**
* Package a classroom into a frozen bundle. See module doc for policy.
*/
export async function packageCourseware(sources: PackageSources): Promise<PackageResult> {
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
const {
coursewareId,
version = 1,
stage,
scenes: rawScenes,
mediaRecords = [],
resolveAudioBytes,
resolveMediaBytes,
prepareScenes,
fetchImpl,
publishedAt = new Date().toISOString(),
appVersion = '0.0.0',
requireComplete = false,
requireNarrationAudio = true,
requireAgentRoster = false,
requireInteractiveHtml = false,
strictInteractiveAssets = false,
} = sources;
const scenes = prepareScenes ? await prepareScenes(stage.id, rawScenes) : [...rawScenes];
const agentConfigs = sources.agentConfigs ?? stage.generatedAgentConfigs ?? [];
if (requireAgentRoster && agentConfigs.length === 0) {
throw new Error(`Frozen bundle has no portable agent roster (${coursewareId} v${version})`);
}
const missingResources: MissingResource[] = [];
// ── 1. Collect narration audio ─────────────────────────────────────────
const audioIdToPath = new Map<string, string>();
const collectedAudio: CollectedAudio[] = [];
for (const scene of scenes) {
for (const action of scene.actions ?? []) {
if (action.type !== 'speech') continue;
const speech = action as SpeechAction;
if (!speech.audioId) {
if (requireNarrationAudio) {
missingResources.push(
missing(
'audio',
`${scene.id}:${speech.text.slice(0, 40)}`,
'speech action without audioId',
scene.id,
),
);
}
continue;
}
if (audioIdToPath.has(speech.audioId)) continue;
const record = await resolveAudioBytes?.(speech.audioId);
if (!record) {
if (requireNarrationAudio) {
missingResources.push(missing('audio', speech.audioId, 'no narration bytes', scene.id));
}
continue;
}
const ext = record.type.split('/')[1]?.replace('mpeg', 'mp3') || 'mp3';
const path = `audio/${speech.audioId}.${ext}`;
const bytes = await blobToBytes(record);
audioIdToPath.set(speech.audioId, path);
collectedAudio.push({
path,
bytes,
sha: await sha256Hex(bytes),
ext,
audioId: speech.audioId,
});
zip.file(path, bytes);
}
}
// ── 2. Collect media ───────────────────────────────────────────────────
const collectedMedia: CollectedMedia[] = [];
for (const record of mediaRecords) {
if (record.error) {
missingResources.push(missing('media', record.id, 'media task failed', record.stageId));
continue;
}
const elementId = record.id.includes(':') ? record.id.split(':').slice(1).join(':') : record.id;
const blob = await resolveMediaBytes?.(record);
if (!blob) {
missingResources.push(missing('media', elementId, 'no media bytes', record.stageId));
continue;
}
const ext = record.mimeType?.split('/')[1] || 'jpg';
const path = `media/${elementId}.${ext}`;
const bytes = await blobToBytes(blob);
let posterPath: string | undefined;
let posterBytes: Uint8Array | undefined;
if (record.poster) {
posterPath = path.replace(/\.\w+$/, '.poster.jpg');
posterBytes = await blobToBytes(record.poster);
}
collectedMedia.push({
path,
bytes,
sha: await sha256Hex(bytes),
ext,
elementId,
posterPath,
posterBytes,
record,
});
zip.file(path, bytes);
if (posterPath && posterBytes) zip.file(posterPath, posterBytes);
}
// ── 3. Inline interactive HTML ─────────────────────────────────────────
const inlineReport: InlineReport = { inlined: [], failed: [] };
const preparedScenes: Scene[] = (await Promise.all(
scenes.map(async (scene) => {
const content = scene.content;
if (
content?.type !== 'interactive' ||
!('html' in content) ||
typeof content.html !== 'string' ||
!content.html
) {
return scene;
}
const { html, report, unresolved } = await inlineHtmlAssets(content.html, {
...(fetchImpl ? { fetchImpl } : {}),
...(strictInteractiveAssets ? { keepImportmapFallbacks: false } : {}),
});
inlineReport.inlined.push(...report.inlined);
inlineReport.failed.push(...report.failed);
if (strictInteractiveAssets) {
for (const url of unresolved) {
missingResources.push(
missing('interactive', url, 'interactive asset remained external', scene.id),
);
}
for (const dependency of residualExecutableNetworkDependencies(html)) {
missingResources.push(
missing(
'interactive',
dependency,
dependency === 'dynamic:loadPyodide'
? 'Pyodide runtime is not frozen for offline playback'
: 'inline script retains an external runtime URL',
scene.id,
),
);
}
}
return { ...scene, content: { ...content, html } } as Scene;
}),
)).map((scene) => {
if (requireNarrationAudio || !scene.actions?.length) return scene;
const actions = scene.actions.map((action) => {
if (action.type !== 'speech' || (action.audioId && audioIdToPath.has(action.audioId))) {
return action;
}
// A deliberately silent bundle must not retain a remote audio URL. The
// player will use its narration-duration fallback and never reach the
// network for a resource the operator explicitly chose not to generate.
const { audioUrl: _audioUrl, ...silentSpeech } = action;
return silentSpeech;
});
return { ...scene, actions } as Scene;
});
for (const failure of inlineReport.failed) {
missingResources.push(
missing('interactive', failure.url, 'interactive asset could not be inlined'),
);
}
if (requireInteractiveHtml) {
const interactiveHtmlScenes = preparedScenes.filter(
(scene) =>
scene.content?.type === 'interactive' &&
typeof scene.content.html === 'string' &&
scene.content.html.trim().length > 0,
);
if (interactiveHtmlScenes.length === 0) {
missingResources.push(
missing('interactive', coursewareId, 'no interactive scene with generated HTML'),
);
}
}
// ── 4. Media index (mirrors the interactive-ZIP export) ────────────────
const mediaIndex: Record<string, import('@/lib/export/classroom-zip-types').MediaIndexEntry> = {};
for (const audio of collectedAudio) {
mediaIndex[audio.path] = { type: 'audio', format: audio.ext };
}
for (const media of collectedMedia) {
mediaIndex[media.path] = {
type: 'generated',
mimeType: media.record.mimeType,
size: media.record.size,
prompt: media.record.prompt,
};
}
if (requireNarrationAudio) {
// Strict narration packages expose the gap to the player and to the
// completeness gate. TTS-disabled packages intentionally omit the ref.
for (const scene of scenes) {
for (const action of scene.actions ?? []) {
if (action.type === 'speech') {
const audioId = (action as SpeechAction).audioId;
if (audioId && !audioIdToPath.has(audioId)) {
mediaIndex[`audio/${audioId}.mp3`] = { type: 'audio', missing: true };
}
}
}
}
}
// ── 5. Manifest ────────────────────────────────────────────────────────
const manifest = buildManifest(
stage,
preparedScenes,
{
appVersion,
audioIdToPath,
agentConfigs,
exportedAt: publishedAt,
},
mediaIndex,
);
// ── 6. Quiz + knowledge packs ──────────────────────────────────────────
const quiz = extractQuizPack(preparedScenes);
const knowledge: KnowledgePack = extractKnowledgePack(coursewareId, stage, preparedScenes);
// ── 7. Completeness ────────────────────────────────────────────────────
const completeness: CompletenessReport = {
complete: missingResources.length === 0,
missing: missingResources,
audioCount: collectedAudio.length,
mediaCount: collectedMedia.length,
interactiveScenes: preparedScenes.filter((s) => s.content?.type === 'interactive').length,
};
if (requireComplete && !completeness.complete) {
const detail = completeness.missing.map((m) => `${m.kind}:${m.ref} (${m.reason})`).join('; ');
throw new Error(`Frozen bundle incomplete (${coursewareId} v${version}): ${detail}`);
}
// ── 8. Deterministic content hash over the payload ─────────────────────
// Every file except bundle.json (which carries the hash) contributes its
// byte-sha256 under its ZIP path; paths are sorted before hashing so the
// hash is independent of ZIP internals and verifiable after upload by
// {@link computeBundleContentHash}.
const manifestJson = JSON.stringify(manifest, null, 2);
const quizJson = JSON.stringify(quiz, null, 2);
const knowledgeJson = JSON.stringify(knowledge, null, 2);
const hashParts: Array<[string, string]> = [
[
'manifest.json',
await hashPayloadFile(
'manifest.json',
encoder.encode(manifestJson),
FROZEN_BUNDLE_CONTENT_HASH_VERSION,
),
],
[FROZEN_BUNDLE_QUIZ_FILE, await sha256Hex(new TextEncoder().encode(quizJson))],
[FROZEN_BUNDLE_KNOWLEDGE_FILE, await sha256Hex(new TextEncoder().encode(knowledgeJson))],
];
for (const audio of collectedAudio) hashParts.push([audio.path, audio.sha]);
for (const media of collectedMedia) {
hashParts.push([media.path, media.sha]);
if (media.posterPath && media.posterBytes) {
hashParts.push([media.posterPath, await sha256Hex(media.posterBytes)]);
}
}
hashParts.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const contentHash = await sha256Hex(new TextEncoder().encode(JSON.stringify(hashParts)));
// ── 9. Bundle metadata + files ─────────────────────────────────────────
const meta: FrozenBundleMeta = {
kind: FROZEN_BUNDLE_KIND,
formatVersion: FROZEN_BUNDLE_FORMAT_VERSION,
coursewareId,
version,
publishedAt,
appVersion,
stageName: stage.name,
...(stage.languageDirective ? { language: stage.languageDirective } : {}),
sceneCount: preparedScenes.length,
contentHash,
contentHashVersion: FROZEN_BUNDLE_CONTENT_HASH_VERSION,
knowledgeVersion: 1,
runtimeCapabilities: {
offlinePlayback: true,
htmlSandbox: { externalNetwork: false, hostBridgeOnly: true },
packaged: {
audio: collectedAudio.length > 0,
media: collectedMedia.length > 0,
interactiveHtml: preparedScenes.some((scene) => scene.content?.type === 'interactive'),
pblTemplate: preparedScenes.some((scene) => scene.content?.type === 'pbl'),
objectiveQuizGrading: preparedScenes.some((scene) => scene.content?.type === 'quiz'),
},
hostBridges: {
agent: 'online-optional',
asr: 'online-optional',
pblEvaluation: 'online-optional',
subjectiveQuizGrading: 'online-optional',
},
},
};
zip.file(FROZEN_BUNDLE_MANIFEST_FILE, JSON.stringify({ meta, completeness }, null, 2));
zip.file('manifest.json', manifestJson);
zip.file(FROZEN_BUNDLE_QUIZ_FILE, quizJson);
zip.file(FROZEN_BUNDLE_KNOWLEDGE_FILE, knowledgeJson);
const bytes = await zip.generateAsync({ type: 'uint8array' });
const zipBlob = new Blob([bytes as unknown as BlobPart], { type: 'application/zip' });
const entryCount = Object.values(zip.files).filter((file) => !file.dir).length;
return {
zip: zipBlob,
contentHash,
entryCount,
manifest,
meta,
completeness,
quiz,
knowledge,
inlineReport,
};
}
/** Normalize a ZIP source for jszip (Node's Blob is not directly consumable). */
async function toZipInput(
zipBlob: Blob | Uint8Array | ArrayBuffer,
): Promise<ArrayBuffer | Uint8Array> {
if (typeof Blob !== 'undefined' && zipBlob instanceof Blob) return zipBlob.arrayBuffer();
// The guard above can't statically exclude Blob (Blob may be undefined at
// runtime, defeating the instanceof narrowing); callers never pass a Blob
// here when the global is absent.
return zipBlob as Uint8Array | ArrayBuffer;
}
/**
* Recompute the deterministic payload hash from a ZIP's files (everything
* except `bundle.json`). Must agree with `packageCourseware().contentHash`;
* the publish route uses this to reject corrupted or tampered uploads.
*/
export async function computeBundleContentHash(
zipBlob: Blob | Uint8Array | ArrayBuffer,
): Promise<string> {
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(await toZipInput(zipBlob));
const bundleFile = zip.file(FROZEN_BUNDLE_MANIFEST_FILE);
if (!bundleFile) {
throw new Error(`Frozen bundle is missing ${FROZEN_BUNDLE_MANIFEST_FILE}`);
}
const bundleDocument = JSON.parse(await bundleFile.async('string')) as {
meta?: Pick<FrozenBundleMeta, 'contentHashVersion'>;
};
const hashVersion = contentHashVersion(bundleDocument.meta?.contentHashVersion);
const hashParts: Array<[string, string]> = [];
for (const [name, file] of Object.entries(zip.files)) {
if (file.dir || name === FROZEN_BUNDLE_MANIFEST_FILE) continue;
const bytes = new Uint8Array(await file.async('uint8array'));
hashParts.push([name, await hashPayloadFile(name, bytes, hashVersion)]);
}
hashParts.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return sha256Hex(new TextEncoder().encode(JSON.stringify(hashParts)));
}
/**
* Rewrite only the registry-owned publish version in `bundle.json`.
*
* Browser packaging cannot safely predict the next monotonic registry
* version. The publish service calls this after allocating the version under
* its per-courseware lock. `bundle.json` is deliberately excluded from the
* deterministic payload hash, so this operation preserves `contentHash` while
* making the stored ZIP's immutable identity agree with the registry record.
*/
export async function rewriteFrozenBundleVersion(
zipBlob: Blob | Uint8Array | ArrayBuffer,
version: number,
): Promise<Uint8Array> {
if (!Number.isInteger(version) || version < 1) {
throw new Error(`Invalid frozen bundle version: ${version}`);
}
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(await toZipInput(zipBlob));
const manifestFile = zip.file(FROZEN_BUNDLE_MANIFEST_FILE);
if (!manifestFile) {
throw new Error(`Frozen bundle is missing ${FROZEN_BUNDLE_MANIFEST_FILE}`);
}
const document = JSON.parse(await manifestFile.async('string')) as {
meta?: FrozenBundleMeta;
completeness?: CompletenessReport;
};
if (!document.meta || !document.completeness) {
throw new Error(`Frozen bundle has invalid ${FROZEN_BUNDLE_MANIFEST_FILE}`);
}
document.meta = { ...document.meta, version };
zip.file(FROZEN_BUNDLE_MANIFEST_FILE, JSON.stringify(document, null, 2));
return zip.generateAsync({ type: 'uint8array' });
}
/** Unzip a frozen bundle's document files (manifest/bundle/quiz/knowledge) without extracting media. */
export async function readFrozenBundleDocuments(zipBlob: Blob | Uint8Array | ArrayBuffer): Promise<{
manifest: ReturnType<typeof buildManifest>;
meta: FrozenBundleMeta;
completeness: CompletenessReport;
quiz: QuizPack;
knowledge: KnowledgePack;
entryCount: number;
}> {
const JSZip = (await import('jszip')).default;
const data = await toZipInput(zipBlob);
const zip = await JSZip.loadAsync(data);
const parse = async <T>(name: string): Promise<T> =>
JSON.parse(await zip.file(name)!.async('string')) as T;
const [manifest, bundleDocument, quiz, knowledge] = await Promise.all([
parse<ReturnType<typeof buildManifest>>('manifest.json'),
parse<{ meta: FrozenBundleMeta; completeness: CompletenessReport }>(
FROZEN_BUNDLE_MANIFEST_FILE,
),
parse<QuizPack>(FROZEN_BUNDLE_QUIZ_FILE),
parse<KnowledgePack>(FROZEN_BUNDLE_KNOWLEDGE_FILE),
]);
return {
manifest,
meta: bundleDocument.meta,
completeness: bundleDocument.completeness,
quiz,
knowledge,
entryCount: Object.values(zip.files).filter((file) => !file.dir).length,
};
}