Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions app/api/persistence/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,13 +300,27 @@ export async function handlePersistenceRequest(
);
}

const response =
access === 'not-found'
? jsonError(404, 'DOCUMENT_NOT_FOUND', '@openmaic/storage: document not found')
: await runNodeHandler(
await createPersistenceHandler(connectionString, ownerId, access, deps.poolFactory),
request,
);
let response: Response;
if (typeof access === 'object' && access.outcome === 'gone') {
response = Response.json(
{
error: {
code: 'DOCUMENT_GONE',
message: '@openmaic/storage: document is gone',
deleted_at: access.deletedAt.toISOString(),
},
deleted_at: access.deletedAt.toISOString(),
},
{ status: 410 },
);
} else if (access === 'not-found') {
response = jsonError(404, 'DOCUMENT_NOT_FOUND', '@openmaic/storage: document not found');
} else {
response = await runNodeHandler(
await createPersistenceHandler(connectionString, ownerId, access, deps.poolFactory),
request,
);
}
for (const [name, value] of responseHeaders.entries()) response.headers.append(name, value);
return response;
} catch (error) {
Expand Down
14 changes: 11 additions & 3 deletions app/api/stage-meta/[stageId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import { resolveStageAccess } from '@/lib/server/stage-access';
import { readStageAccessIncludingDeleted } from '@/lib/server/stage-access';
import { withRequestOwnerId } from '@/lib/server/agent-runtime/with-owner';

// Per-viewer and mutable on every publish/unpublish/delete: this response must
Expand All @@ -41,13 +41,21 @@ export async function GET(req: NextRequest, { params }: Params) {
return withRequestOwnerId(req, async (ownerId, responseHeaders) => {
const { stageId } = await params;
try {
const access = await resolveStageAccess(stageId);
const access = await readStageAccessIncludingDeleted(stageId);

// Absent or tombstoned — indistinguishable, deliberately.
// Absent — never existed.
if (!access) {
return NextResponse.json({ error: 'not_found' }, { status: 404, headers: responseHeaders });
}

// Tombstoned — existed and was deleted.
if (access.deletedAt !== null) {
return NextResponse.json(
{ error: 'gone', deleted_at: access.deletedAt.toISOString() },
{ status: 410, headers: responseHeaders },
);
}

// Identity comparison, and nothing else: this boolean is the client's
// ONLY owner signal, so a `true` here must mean every write through the
// owner-bound store will be accepted (the store re-checks the owner
Expand Down
249 changes: 2 additions & 247 deletions app/classroom/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,256 +1,11 @@
'use client';

import { Stage } from '@/components/stage';
import { ThemeProvider } from '@/lib/hooks/use-theme';
import { useStageStore } from '@/lib/store';
import { useSettingsStore } from '@/lib/store/settings';
import { claimStageSceneLoadToken, isCurrentStageSceneLoadToken } from '@/lib/store/stage';
import { loadImageMapping } from '@/lib/utils/image-storage';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useSceneGenerator } from '@/lib/hooks/use-scene-generator';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
import { createLogger } from '@/lib/logger';
import { MediaStageProvider } from '@/lib/contexts/media-stage-context';
import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import { fetchStageMeta } from '@/lib/classroom/stage-meta-client';
import { noteStageOwnership } from '@/lib/classroom/stage-ownership-signal';
import {
applyClassroomStageAndScenes,
defaultClassroomLoadDeps,
runClassroomLoad,
} from '@/lib/classroom/load-classroom';

const log = createLogger('Classroom');
import { ClassroomSurface } from '@/components/classroom/ClassroomSurface';

export default function ClassroomDetailPage() {
const params = useParams();
const classroomId = params?.id as string;

const { loadFromStorage } = useStageStore();

const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const generationStartedRef = useRef(false);

const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({
onComplete: () => {
log.info('[Classroom] All scenes generated');
},
});

const loadClassroom = useCallback(
async (isEffectCurrent: () => boolean = () => true) => {
const loadToken = claimStageSceneLoadToken();
const isCurrent = () => isEffectCurrent() && isCurrentStageSceneLoadToken(loadToken);

await runClassroomLoad({
classroomId,
loadToken,
isCurrent,
loadFromStorage,
getCurrentStage: () => useStageStore.getState().stage,
fetchClassroom: defaultClassroomLoadDeps.fetchClassroom,
applyFallbackScenes: (args) =>
defaultClassroomLoadDeps.applyFallbackScenes({
...args,
isCurrent,
applyStageAndScenes: applyClassroomStageAndScenes,
}),
loadRestoredMediaTasks: defaultClassroomLoadDeps.loadRestoredMediaTasks,
applyRestoredMediaTasks: (restored) =>
defaultClassroomLoadDeps.applyRestoredMediaTasks(restored, isCurrent),
discardRestoredMediaTasks: defaultClassroomLoadDeps.discardRestoredMediaTasks,
loadLegacyAgentFallbacks: defaultClassroomLoadDeps.loadLegacyAgentFallbacks,
commitMigratedAgentConfigs: defaultClassroomLoadDeps.commitMigratedAgentConfigs,
applyGeneratedAgents: defaultClassroomLoadDeps.applyGeneratedAgents,
getSettings: () => useSettingsStore.getState(),
getAgent: (id) => useAgentRegistry.getState().getAgent(id),
restoreAgentSelection: defaultClassroomLoadDeps.restoreAgentSelection,
setError,
setLoading,
log,
});

// The stage-meta sidecar resolves the viewer-facing ownership facts the
// document seam does not carry — `isOwner` decides read-only vs editable
// (see `stage-meta-client.ts`). Run it strictly AFTER the load applied
// its defaults so its answer wins, and fire it without blocking the
// render that already happened.
if (isEffectCurrent()) {
void fetchStageMeta(classroomId)
.then((result) => {
if (!isEffectCurrent()) return;
if (result.outcome === 'found') {
noteStageOwnership(classroomId, true, {
isOwner: result.meta.isOwner,
});
useStageStore.getState().setViewerAccess({
isOwner: result.meta.isOwner,
});
} else if (result.outcome === 'unavailable') {
// A silent sidecar is not "this is a stranger's course": record
// the outage so nothing treats `isOwner === false` as a visitor
// conclusion. The edit gate stays on the upstream defaults.
noteStageOwnership(classroomId, false, null);
} else {
// 'absent' — no sidecar row for this id. This classroom also
// serves local-only courses, so the upstream editable default
// stays; the server's owner-scoped writes remain the authority.
noteStageOwnership(classroomId, true, null);
}
})
.catch(() => noteStageOwnership(classroomId, false, null));
}
},
[classroomId, loadFromStorage],
);

useEffect(() => {
// Reset loading state on course switch to unmount Stage during transition,
// preventing stale data from syncing back to the new course
/* eslint-disable react-hooks/set-state-in-effect -- Course switch must hide stale Stage before async load */
setLoading(true);
setError(null);
/* eslint-enable react-hooks/set-state-in-effect */
generationStartedRef.current = false;

// Clear previous classroom's media tasks to prevent cross-classroom contamination.
// Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages,
// so stale tasks from a previous classroom would shadow the new one's.
const mediaStore = useMediaGenerationStore.getState();
mediaStore.revokeObjectUrls();
useMediaGenerationStore.setState({ tasks: {} });

// Clear whiteboard history to prevent snapshots from a previous course leaking in.
useWhiteboardHistoryStore.getState().clearHistory();

let cancelled = false;
loadClassroom(() => !cancelled);

// Cancel ongoing generation when classroomId changes or component unmounts
return () => {
cancelled = true;
stop();
};
}, [classroomId, loadClassroom, stop]);

// Auto-resume generation for pending outlines
useEffect(() => {
if (loading || error || generationStartedRef.current) return;

const state = useStageStore.getState();
const { outlines, scenes, stage, generationComplete } = state;

// Check if there are pending outlines. A finished deck is frozen for
// editing: deleting a slide leaves its outline orphaned, but that must not
// be treated as an interrupted generation and regenerated. Only resume
// when generation has not completed.
const completedOrders = new Set(scenes.map((s) => s.order));
const hasPending = !generationComplete && outlines.some((o) => !completedOrders.has(o.order));

if (hasPending && stage) {
generationStartedRef.current = true;

// Load generation params from sessionStorage (stored by generation-preview before navigating)
const genParamsStr = sessionStorage.getItem('generationParams');
const params = genParamsStr ? JSON.parse(genParamsStr) : {};

// Reconstruct imageMapping for the resumed generation. A server-backed
// deployment stored allocated asset ids on the session's pdfImages (RFC
// #1153 part 2 B): the extracted images are pool assets, so generation
// is fed by id and the routes resolve the bytes server-side. Per source
// (N4) the mapping may MIX allocated asset ids and IndexedDB data URLs —
// a source whose cache write failed materialized its own images — so the
// resume mapping merges both, instead of choosing one transport for the
// whole set and silently dropping the other half.
const pdfImages = (params.pdfImages || []) as Array<
{ id: string; assetId?: string; storageId?: string } & Record<string, unknown>
>;
const finishResume = (imageMapping: Record<string, string>) =>
generateRemaining({
pdfImages: params.pdfImages,
imageMapping,
stageInfo: {
name: stage.name || '',
description: stage.description,
style: stage.style,
},
agents: params.agents,
userProfile: params.userProfile,
languageDirective: params.languageDirective || stage.languageDirective,
});

const imageMapping: Record<string, string> = {};
for (const img of pdfImages) {
if (img.assetId) imageMapping[img.id] = img.assetId;
}
const storageIds = pdfImages
.filter((img) => !img.assetId && img.storageId)
.map((img) => img.storageId as string);
void (async () => {
if (storageIds.length > 0) {
Object.assign(imageMapping, await loadImageMapping(storageIds));
}
finishResume(imageMapping);
})();
} else if (outlines.length > 0 && stage) {
// All scenes are generated, but some media may not have finished.
// Resume media generation for any tasks not yet in IndexedDB.
// generateMediaForOutlines skips already-completed tasks automatically.
generationStartedRef.current = true;
// The deck reached the classroom already fully materialized (e.g. a
// single-slide course, or a deck whose last slide finished in
// generation-preview), so generateRemaining's completion path never
// ran. Record completion now so a later edit/delete is not treated as
// an interrupted generation. No-op if already complete or not all
// outlines have scenes.
useStageStore.getState().markGenerationCompleteIfDone();
// Resume media only for outlines that still have a scene. On a finished
// deck the user may have deleted a slide, leaving an orphaned outline;
// generating its media would waste API calls on a slide that is gone.
const materializedOrders = new Set(scenes.map((s) => s.order));
const materializedOutlines = outlines.filter((o) => materializedOrders.has(o.order));
generateMediaForOutlines(materializedOutlines, stage.id).catch((err) => {
log.warn('[Classroom] Media generation resume error:', err);
});
}
}, [loading, error, generateRemaining]);

return (
<ThemeProvider>
<MediaStageProvider value={classroomId}>
<div className="h-screen flex flex-col overflow-hidden">
{loading ? (
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center text-muted-foreground">
<p>Loading classroom...</p>
</div>
</div>
) : error ? (
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<p className="text-destructive mb-4">Error: {error}</p>
<button
onClick={() => {
setError(null);
setLoading(true);
loadClassroom();
}}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Retry
</button>
</div>
</div>
) : (
<Stage onRetryOutline={retrySingleOutline} />
)}
</div>
</MediaStageProvider>
</ThemeProvider>
);
return <ClassroomSurface classroomId={classroomId} variant="page" />;
}
Loading
Loading