import { restoreAgentSelection } from '@/lib/orchestration/registry/agent-selection'; import { applyGeneratedAgentsToRegistry } from '@/lib/orchestration/registry/store'; import { applyHydratedClassroomFallbackScenes, hydrateClassroomFallbackChats, type ApplyHydratedClassroomFallbackScenesArgs, } from '@/lib/classroom/pbl-fallback-hydration'; import type { ChatStorageSnapshot } from '@/lib/utils/chat-storage'; import type { ChatSession } from '@/lib/types/chat'; import { useMediaGenerationStore, type MediaTask } from '@/lib/store/media-generation'; import { markStagePersistenceDirty, useStageStore, type StageSceneLoadToken, } from '@/lib/store/stage'; import { resolveStageFallbackAccess } from '@/lib/classroom/stage-ownership-signal'; import type { MediaFileRecord } from '@/lib/utils/database'; import { unmarkStageDeleted } from '@/lib/utils/deleted-stages'; import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage'; import type { DocumentMigrationDeps } from '@/lib/document-store/migration'; import type { PPTElement, Slide } from '@openmaic/dsl'; import { collectDocumentMediaElements, withDocumentLegacyVideoRecovery, } from '@/lib/media/media-task-resolution'; import { slideMediaReferenceSlots } from '@/lib/media/slide-media-slots'; import { isConcreteMediaAddress } from '@/lib/media/resolve-media-ref'; import { createLogger } from '@/lib/logger'; const moduleLog = createLogger('ClassroomLoad'); export interface ClassroomPayload { stage: Stage; scenes: Scene[]; } interface Logger { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; } interface ClassroomLoadSettings { agentMode: 'preset' | 'auto'; selectedAgentIds: string[]; agentSelectionIsUserSet: boolean; setAgentMode: (mode: 'preset' | 'auto') => void; setSelectedAgentIds: (ids: string[]) => void; setAgentSelectionIsUserSet: (isUserSet: boolean) => void; } interface AgentLookupResult { isGenerated?: boolean; } export interface RunClassroomLoadArgs { classroomId: string; loadToken: StageSceneLoadToken; isCurrent: () => boolean; loadFromStorage: (classroomId: string, loadToken: StageSceneLoadToken) => Promise; getCurrentStage: () => Stage | null; fetchClassroom: ( classroomId: string, shouldConvert?: () => boolean, ) => Promise; applyFallbackScenes: (args: { loadToken: StageSceneLoadToken; stage: Stage; scenes: readonly Scene[]; }) => Promise; loadRestoredMediaTasks: (stageId: string) => Promise; applyRestoredMediaTasks: (tasks: TMediaTasks) => void; discardRestoredMediaTasks: (tasks: TMediaTasks) => void; /** * Read the legacy per-stage roster mirror (read-only migration source) as * contract-shaped configs. Used only to backfill a document whose configs * predate the single-source model (missing roster or missing voice fields). * Returns `null` when the read FAILED (as opposed to an empty mirror), so * the caller can retry on a later load instead of memoizing the failure. */ loadLegacyAgentFallbacks: (stageId: string) => Promise; /** Commit lazily migrated configs onto the in-memory stage + mark it dirty. */ commitMigratedAgentConfigs: (stageId: string, configs: GeneratedAgentConfig[]) => void; /** Synchronously mirror the roster into the in-memory agent registry. */ applyGeneratedAgents: (stageId: string, configs: readonly GeneratedAgentConfig[]) => string[]; getSettings: () => ClassroomLoadSettings; getAgent: (agentId: string) => AgentLookupResult | undefined; restoreAgentSelection: typeof restoreAgentSelection; setError: (message: string) => void; setLoading: (loading: boolean) => void; log: Logger; } /** * Stages whose legacy-mirror probe SUCCEEDED and came back with nothing to * merge this session. `rosterNeedsLegacyFallback` cannot distinguish * "predates voice persistence" from "the producer never bound a voice" * (server-generated classrooms emit voiceless rosters by design), so without * this memo such classrooms would re-query the mirror on every load forever. * A FAILED mirror read is never memoized — the next load retries the probe. * Session-scoped on purpose: no persistent marker is introduced for a pure * read optimization. */ const fruitlessLegacyProbeStageIds = new Set(); /** Test hook: forget which stages already had a fruitless legacy-mirror probe. */ export function resetLegacyAgentFallbackProbes(): void { fruitlessLegacyProbeStageIds.clear(); } export async function runClassroomLoad({ classroomId, loadToken, isCurrent, loadFromStorage, getCurrentStage, fetchClassroom, applyFallbackScenes, loadRestoredMediaTasks, applyRestoredMediaTasks, discardRestoredMediaTasks, loadLegacyAgentFallbacks, commitMigratedAgentConfigs, applyGeneratedAgents, getSettings, getAgent, restoreAgentSelection: restoreSelection, setError, setLoading, log, }: RunClassroomLoadArgs): Promise { try { await loadFromStorage(classroomId, loadToken); if (!isCurrent()) return; if (!getCurrentStage()) { log.info('No IndexedDB data, trying server-side storage for:', classroomId); // The fetch path converts and commits under the per-stage document lock. // Once it returns, the document owns every allocation; a later // navigation may discard only this in-memory apply, never the durable // assets -- so nothing here rolls allocations back. const classroom = await fetchClassroom(classroomId, isCurrent); if (!isCurrent()) return; if (classroom) { const { stage, scenes } = classroom; const applied = await applyFallbackScenes({ loadToken, stage, scenes }); if (!isCurrent()) return; if (!applied) { log.info('Stage changed during server-side fallback hydration, skipping load:', { requestedStageId: stage.id, latestStageId: getCurrentStage()?.id, }); return; } log.info('Loaded from server-side storage:', classroomId); } } if (!isCurrent()) return; // Metadata-only on the critical path: the default loader defers object-URL // creation for non-priority blobs, so this await is a table read, not a // full media hydration (the rest hydrates in the background after apply). const mediaTasks = await loadRestoredMediaTasks(classroomId); if (!isCurrent()) { discardRestoredMediaTasks(mediaTasks); return; } applyRestoredMediaTasks(mediaTasks); // ── Roster hydration: the stage document is the source of truth ── // The legacy IndexedDB mirror is consulted as a read-only, per-stage // migration probe: it backfills a roster (or its voice fields) written // before the roster was persisted on the document. A successful merge is // committed back onto the stage so the next flush makes it durable, after // which subsequent loads find nothing to merge. A fruitless probe (no // mirror rows, or nothing mergeable — e.g. server-generated rosters whose // producer never bound a voice) is remembered for the session so the // mirror is not re-queried on every load of a classroom that has nothing // to migrate. A FAILED mirror read is neither merged nor remembered — the // next load retries the probe. if (!isCurrent()) return; const stageForRoster = getCurrentStage(); // The absent-vs-empty distinction is load-bearing and deliberately NOT // collapsed here: `undefined` means the document predates roster // persistence (probe the mirror, possibly lifting a whole roster), while // an explicit `[]` is an authoritative empty roster that must never be // resurrected from the stale read-only mirror. const documentConfigs = stageForRoster?.id === classroomId ? stageForRoster.generatedAgentConfigs : undefined; let effectiveConfigs = documentConfigs ?? []; if ( stageForRoster?.id === classroomId && rosterNeedsLegacyFallback(documentConfigs) && !fruitlessLegacyProbeStageIds.has(classroomId) ) { const fallbacks = await loadLegacyAgentFallbacks(classroomId); // The registry is a global singleton: after any await, re-check that this // load is still current before letting the merged roster land anywhere. if (!isCurrent()) return; // `null` = the mirror read FAILED (not "mirror is empty"). Skip both // the merge and the memo so the next load retries the probe once // storage recovers — a transient IndexedDB error must not become a // session-long migration skip. if (fallbacks !== null) { const merged = mergeLegacyAgentFallbacks(documentConfigs ?? [], fallbacks); if (merged.changed) { effectiveConfigs = merged.configs; commitMigratedAgentConfigs(classroomId, merged.configs); } else { // The read SUCCEEDED and confirmed nothing to migrate for this // stage; don't probe again this session. (A successful merge is NOT // memoized: if its flush fails, the next load must retry the merge // until the document carries the voices.) fruitlessLegacyProbeStageIds.add(classroomId); } } } if (!isCurrent()) return; const generatedAgentIds = applyGeneratedAgents(classroomId, effectiveConfigs); if (!isCurrent()) return; const settings = getSettings(); const { selection: next, isUserSet } = restoreSelection({ persisted: { mode: settings.agentMode, selectedAgentIds: settings.selectedAgentIds }, persistedIsUserSet: settings.agentSelectionIsUserSet, generatedAgentIds, stageAgentIds: getCurrentStage()?.agentIds, isPresetAgent: (id) => { const agent = getAgent(id); return !!agent && !agent.isGenerated; }, }); if (!isCurrent()) return; if (next.mode !== settings.agentMode) settings.setAgentMode(next.mode); if (next.selectedAgentIds !== settings.selectedAgentIds) { settings.setSelectedAgentIds(next.selectedAgentIds); } if (isUserSet !== settings.agentSelectionIsUserSet) { settings.setAgentSelectionIsUserSet(isUserSet); } } catch (error) { log.error('Failed to load classroom:', error); if (isCurrent()) { setError(error instanceof Error ? error.message : 'Failed to load classroom'); } } finally { if (isCurrent()) { setLoading(false); } } } export async function fetchClassroomFromApi( classroomId: string, _shouldConvert: () => boolean = () => true, _deps: DocumentMigrationDeps = {}, ): Promise { const res = await fetch(`/api/classroom?id=${encodeURIComponent(classroomId)}`); if (!res.ok) return null; const json = (await res.json()) as { success?: boolean; classroom?: ClassroomPayload; }; if (!json.success || !json.classroom) return null; return json.classroom; } export function applyClassroomStageAndScenes( stage: Stage, scenes: readonly Scene[], options: { persist?: boolean; chats?: ChatSession[]; chatSnapshot?: ChatStorageSnapshot; } = {}, ): void { // Explicit document (re)creation point: deletion only removes client-side // data, so revisiting the classroom URL restores the server copy under the // SAME id. Lift any same-session deleted flag before the store write, or // every subsequent edit of the restored classroom would be silently dropped // until a reload. This is a deliberate restore, not an in-flight flush — // exactly the distinction `deleted-stages.ts` requires. The deletion EPOCH // stays bumped: a pre-delete flush still in flight remains permanently // stale and cannot overwrite the restored document, while the // `saveToStorage` below (and every later edit) captures the current epoch // and persists normally. unmarkStageDeleted(stage.id); const nextScenes = [...scenes]; // A server fallback is a fresh classroom boundary. Never inherit access or // producer state from whichever course previously occupied the singleton // store; these defaults match an ordinary cold load (the stage-meta sidecar // probe corrects them when it answers). const access = resolveStageFallbackAccess(stage.id); useStageStore.setState((state) => ({ stage, scenes: nextScenes, currentSceneId: nextScenes[0]?.id ?? null, chats: options.chats ?? [], chatSnapshot: options.chatSnapshot ?? { sessions: [], restoreMarker: null }, generationComplete: false, isOwner: access.isOwner, readOnly: !access.isOwner, generationEpoch: state.generationEpoch + 1, mode: 'playback', })); if (options.persist !== false) { void useStageStore.getState().saveToStorage(); } } /** * Restored media state split by hydration phase. `tasks` is metadata-complete * and applied before first paint; records in `deferred` have their blob object * URLs materialized afterwards in the background (see * `hydrateDeferredMediaTasks`), so entering a media-heavy classroom never waits * on `URL.createObjectURL` for every restored blob. */ export interface RestoredMediaTasks { readonly stageId: string; readonly tasks: Record; readonly deferred: readonly MediaFileRecord[]; } /** * Media refs owned by the scene the classroom opens on (the persisted cursor, * else the first scene), plus the stage-level whiteboard — it stays open across * standalone classroom switches, so its media can be visible before any scene * is. Their blobs hydrate eagerly during load so the first visible page paints * its media immediately; every other record defers. */ export function collectPriorityMediaRefs( scenes: readonly Scene[], currentSceneId: string | null, stageWhiteboard: readonly Pick[] = [], ): Set { const refs = new Set(); const collect = (slide: Pick) => { for (const slot of slideMediaReferenceSlots(slide)) { const ref = slot.read(); if (ref && !isConcreteMediaAddress(ref)) refs.add(ref); // Task lookup also binds by element id (see resolveMediaTaskForElement), // so a record keyed `stage:` belongs to the opening scene even // when the slot carries a different opaque ref and no placeholderRef. if (slot.element) refs.add(slot.element.id); } }; for (const slide of stageWhiteboard) collect(slide); const scene = scenes.find((candidate) => candidate.id === currentSceneId) ?? scenes[0]; if (!scene) return refs; if (scene.content.type === 'slide') collect(scene.content.canvas); for (const slide of scene.whiteboards ?? []) collect(slide); return refs; } export async function loadRestoredMediaTasksFromDB(stageId: string): Promise { try { const { db } = await import('@/lib/utils/database'); const records = await db.mediaFiles.where('stageId').equals(stageId).toArray(); const state = useStageStore.getState(); const sameStage = state.stage?.id === stageId; const documentElements = sameStage ? collectDocumentMediaElements(state.stage, state.scenes) : []; const priorityRefs = sameStage ? collectPriorityMediaRefs(state.scenes, state.currentSceneId, state.stage?.whiteboard ?? []) : new Set(); // Legacy singleton video recovery assigns a placeholderRef at the end of // the build (see withDocumentLegacyVideoRecovery), so classify against the // recovered binding too — otherwise the opening scene's legacy video is // deferred and stays pending until idle hydration reaches it. The metadata // pass hydrates nothing, it only learns each record's effective ref. const recoveredRefByElementId = new Map( Object.entries(buildRestoredMediaTasks(stageId, records, documentElements, () => false)).map( ([key, task]) => [key, task.placeholderRef] as const, ), ); const deferred: MediaFileRecord[] = []; const tasks = buildRestoredMediaTasks(stageId, records, documentElements, (record) => { const elementId = record.id.includes(':') ? record.id.split(':').slice(1).join(':') : record.id; const recoveredRef = recoveredRefByElementId.get(elementId); const isPriority = priorityRefs.has(elementId) || (!!record.placeholderRef && priorityRefs.has(record.placeholderRef)) || (!!recoveredRef && priorityRefs.has(recoveredRef)); if (!isPriority) deferred.push(record); return isPriority; }); return { stageId, tasks, deferred }; } catch { return { stageId, tasks: {}, deferred: [] }; } } export function buildRestoredMediaTasks( stageId: string, records: readonly MediaFileRecord[], documentElements: readonly PPTElement[] = [], shouldHydrateBlob: (record: MediaFileRecord) => boolean = () => true, ): Record { const restored: Record = {}; for (const rec of records) { const elementId = rec.id.includes(':') ? rec.id.split(':').slice(1).join(':') : rec.id; const params = JSON.parse(rec.params || '{}'); if (rec.error) { restored[elementId] = { elementId, placeholderRef: rec.placeholderRef, type: rec.type, status: 'failed', prompt: rec.prompt, params, error: rec.error, errorCode: rec.errorCode, retryCount: 0, stageId, }; continue; } // A deferred record keeps its done status (so generation resume never // re-runs it) but carries no objectUrl yet: media resolution treats a // known task without bytes as pending and re-renders when background // hydration fills the URL in. const hydrate = shouldHydrateBlob(rec); const objectUrl = hydrate ? URL.createObjectURL(rec.blob.type ? rec.blob : new Blob([rec.blob], { type: rec.mimeType })) : undefined; restored[elementId] = { elementId, placeholderRef: rec.placeholderRef, type: rec.type, status: 'done', prompt: rec.prompt, params, objectUrl, poster: hydrate && rec.poster ? URL.createObjectURL(rec.poster) : undefined, retryCount: 0, stageId, }; } return withDocumentLegacyVideoRecovery(restored, documentElements, stageId); } /** * Monotonic token identifying the latest classroom restore. Applying a new * restore (another classroom, or a reload of the same one) supersedes every * earlier deferred hydration loop, which then stops before materializing more * URLs — both to drop work for an abandoned classroom and to keep bytes read * by an older load from reaching a newer load's tasks. */ let hydrationEpoch = 0; export function applyRestoredMediaTasks( restored: RestoredMediaTasks, isLoadCurrent?: () => boolean, ): void { const epoch = ++hydrationEpoch; if (Object.keys(restored.tasks).length > 0) { useMediaGenerationStore.setState((state) => ({ tasks: { ...state.tasks, ...restored.tasks }, })); } // Blob hydration continues off the load path; each deferred URL lands as an // in-place task update that media resolution picks up as pending → url. if (restored.deferred.length > 0) { // Live only while this restore is both the latest applied one and still // owned by the current classroom load: `isLoadCurrent` is the load token / // effect-cleanup check, so navigation away stops the loop at the next idle // boundary instead of minting URLs for a classroom nobody is watching. const isLive = () => epoch === hydrationEpoch && (isLoadCurrent?.() ?? true); void hydrateDeferredMediaTasks(restored.stageId, restored.deferred, isLive).catch((error) => { moduleLog.warn('Deferred media hydration failed:', error); }); } } export function discardRestoredMediaTasks(restored: RestoredMediaTasks): void { // Only eagerly hydrated tasks own object URLs; deferred records hold raw // blobs and are dropped with nothing to revoke. for (const task of Object.values(restored.tasks)) { if (task.objectUrl) URL.revokeObjectURL(task.objectUrl); if (task.poster) URL.revokeObjectURL(task.poster); } } /** Records hydrated per idle slice — a handful of `createObjectURL` calls. */ const MEDIA_HYDRATION_CHUNK_SIZE = 4; /** Upper bound on one idle slice, so a busy main thread cannot starve hydration. */ const MEDIA_HYDRATION_IDLE_TIMEOUT_MS = 1000; /** Yield to the browser between hydration chunks (idle callback when available). */ function nextIdleSlice(): Promise { return new Promise((resolve) => { if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => resolve(), { timeout: MEDIA_HYDRATION_IDLE_TIMEOUT_MS }); } else { setTimeout(resolve, 0); } }); } /** * Materialize object URLs for restored media records whose blobs were deferred * during the blocking load phase. Runs chunk-by-chunk at idle time. A record * whose task has since been replaced (classroom switch, regeneration, retry) * is skipped and its freshly minted URLs are revoked immediately, so a late * hydration can never leak URLs or overwrite another stage's task. The * `status !== 'done'` check is what catches in-flight replacements: only a * deferred restore is `done` without an `objectUrl`, because regeneration and * retry pass through `pending`/`generating` before they can complete. * * `isLive` gates the whole loop, not individual records: it is re-checked * before each chunk is materialized, so a superseded restore (another * classroom applied, or this stage reloaded — see `hydrationEpoch`) stops * before minting URLs for records nobody will consume. Callers that omit it * (tests) get an always-live loop. */ export async function hydrateDeferredMediaTasks( stageId: string, records: readonly MediaFileRecord[], isLive: () => boolean = () => true, ): Promise { for (let start = 0; start < records.length; start += MEDIA_HYDRATION_CHUNK_SIZE) { await nextIdleSlice(); if (!isLive()) return; const chunk = records.slice(start, start + MEDIA_HYDRATION_CHUNK_SIZE); const hydrated = chunk.map((rec) => { const elementId = rec.id.includes(':') ? rec.id.split(':').slice(1).join(':') : rec.id; const blob = rec.blob.type ? rec.blob : new Blob([rec.blob], { type: rec.mimeType }); return { elementId, objectUrl: URL.createObjectURL(blob), poster: rec.poster ? URL.createObjectURL(rec.poster) : undefined, }; }); useMediaGenerationStore.setState((state) => { let changed = false; const tasks = { ...state.tasks }; for (const entry of hydrated) { const existing = tasks[entry.elementId]; if ( !existing || existing.stageId !== stageId || existing.status !== 'done' || existing.objectUrl ) { URL.revokeObjectURL(entry.objectUrl); if (entry.poster) URL.revokeObjectURL(entry.poster); continue; } let poster = entry.poster; if (poster && existing.poster) { URL.revokeObjectURL(poster); poster = undefined; } tasks[entry.elementId] = { ...existing, objectUrl: entry.objectUrl, ...(poster ? { poster } : {}), }; changed = true; } return changed ? { tasks } : state; }); } } /** * True when the persisted roster MAY still need the legacy mirror consulted. * The absent-vs-empty distinction matters: * * - `undefined` (no roster field on the document) → the document may predate * roster persistence entirely; the mirror may hold the whole roster, so * probe it (the full-lift branch of `mergeLegacyAgentFallbacks`). * - `[]` (an explicitly persisted empty roster) → authoritative; never probe. * No current writer produces `[]` (the roster editor enforces a non-empty * roster, and import/generation only write the field when non-empty), but * any future writer that does must not have its empty roster resurrected * from the stale read-only mirror on every load. * - Non-empty with an agent missing both voice fields → the voice may live * only in the mirror, but can also mean the producer never bound one * (server-generated rosters are voiceless by design). Callers pair this * check with the per-session fruitless-probe memo so the second case does * not re-query the mirror on every load. */ export function rosterNeedsLegacyFallback( configs: readonly GeneratedAgentConfig[] | undefined, ): boolean { if (configs === undefined) return true; if (configs.length === 0) return false; return configs.some((config) => !config.voiceDesign && !config.voiceConfig); } /** * Merge the legacy mirror's roster into the document's configs. Pure. * * - Document configs empty + mirror has agents → lift the full mirror roster * (sorted by priority desc for a stable, teacher-first order). The caller * only routes a roster here when the document carried no roster field at * all — an explicitly persisted `[]` never reaches the merge (see * `rosterNeedsLegacyFallback`). * - Document configs present → only backfill missing voice fields, matched by * agent id. Document fields always win; the mirror never overwrites. * * Idempotent: once the merged result is persisted, a rerun finds nothing to * change and reports `changed: false`. */ export function mergeLegacyAgentFallbacks( configs: readonly GeneratedAgentConfig[], fallbacks: readonly GeneratedAgentConfig[], ): { configs: GeneratedAgentConfig[]; changed: boolean } { if (configs.length === 0) { if (fallbacks.length === 0) return { configs: [], changed: false }; const lifted = [...fallbacks].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); return { configs: lifted, changed: true }; } const byId = new Map(fallbacks.map((fallback) => [fallback.id, fallback])); let changed = false; const merged = configs.map((config) => { if (config.voiceDesign || config.voiceConfig) return config; const fallback = byId.get(config.id); if (!fallback || (!fallback.voiceDesign && !fallback.voiceConfig)) return config; changed = true; return { ...config, ...(fallback.voiceConfig ? { voiceConfig: fallback.voiceConfig } : {}), ...(fallback.voiceDesign ? { voiceDesign: fallback.voiceDesign } : {}), }; }); return { configs: merged, changed }; } /** * Read the legacy roster mirror for a stage as contract-shaped configs. * Read-only: production code only reads this table as a migration source for * pre-single-source classrooms (plus deletion hygiene when a stage is * removed); nothing writes new rows. Returns `null` when the read fails so * the caller can distinguish "empty mirror" (memoizable) from "read failed" * (retry on the next load). */ export async function loadLegacyAgentFallbacksFromDB( stageId: string, ): Promise { try { const { getGeneratedAgentsByStageId } = await import('@/lib/utils/database'); const records = await getGeneratedAgentsByStageId(stageId); return records.map((record) => { // Historical mirror rows spread the whole generated profile, so rows may // carry a voiceConfig that never made it into the declared record type. const voiceConfig = (record as { voiceConfig?: GeneratedAgentConfig['voiceConfig'] }) .voiceConfig; return { id: record.id, name: record.name, role: record.role, persona: record.persona, avatar: record.avatar, color: record.color, priority: record.priority, ...(voiceConfig ? { voiceConfig } : {}), ...(record.voiceDesign ? { voiceDesign: record.voiceDesign } : {}), }; }); } catch { // Signal failure (vs. an empty mirror): the probe memo must not treat a // transient IndexedDB error as "nothing to migrate". return null; } } /** * Commit lazily migrated roster configs onto the in-memory stage and mark the * stage dirty so the merge becomes durable on the next scheduled flush. * Guarded by a stage-id re-check: a classroom switch racing this commit must * not write another stage's roster into the current one. */ export function commitMigratedAgentConfigsToStore( stageId: string, configs: GeneratedAgentConfig[], ): void { const state = useStageStore.getState(); if (state.stage?.id !== stageId) return; useStageStore.setState({ stage: { ...state.stage, generatedAgentConfigs: configs } }); markStagePersistenceDirty([{ kind: 'stage' }]); } export const defaultClassroomLoadDeps = { applyFallbackScenes: (args: ApplyHydratedClassroomFallbackScenesArgs) => applyHydratedClassroomFallbackScenes({ ...args, hydrateChats: hydrateClassroomFallbackChats, }), fetchClassroom: fetchClassroomFromApi, loadRestoredMediaTasks: loadRestoredMediaTasksFromDB, applyRestoredMediaTasks, discardRestoredMediaTasks, loadLegacyAgentFallbacks: loadLegacyAgentFallbacksFromDB, commitMigratedAgentConfigs: commitMigratedAgentConfigsToStore, applyGeneratedAgents: applyGeneratedAgentsToRegistry, restoreAgentSelection, };