import { promises as fs } from 'fs'; import path from 'path'; import type { ClassroomGenerationProgress, ClassroomGenerationStep, GenerateClassroomInput, GenerateClassroomResult, } from '@/lib/server/classroom-generation'; import { CLASSROOM_JOBS_DIR, ensureClassroomJobsDir, writeJsonFileAtomic, } from '@/lib/server/classroom-storage'; export type ClassroomGenerationJobStatus = | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; export interface ClassroomGenerationJob { id: string; /** * Permanent account owner. Optional only for legacy/operations records and * deployments still running in shadow identity mode. */ ownerPrincipalId?: string; /** Temporary device guest subject, cleared by explicit account migration. */ guestPrincipalId?: string; /** First time an account or guest subject was bound to the resource. */ ownershipBoundAt?: string; /** Set only when a legacy/guest resource is explicitly migrated to account ownership. */ ownershipMigratedAt?: string; status: ClassroomGenerationJobStatus; step: ClassroomGenerationStep | 'queued' | 'failed' | 'cancelled'; progress: number; message: string; createdAt: string; updatedAt: string; startedAt?: string; completedAt?: string; inputSummary: { requirementPreview: string; hasPdf: boolean; pdfTextLength: number; pdfImageCount: number; }; /** * Full generation input persisted for resume ("继续"). Deliberately omitted * from list/detail API responses — the browser never needs it back. */ input: GenerateClassroomInput; scenesGenerated: number; totalScenes?: number; result?: { classroomId: string; url: string; scenesCount: number; }; error?: string; } export interface ClassroomJobOwnershipBinding { ownerPrincipalId?: string; guestPrincipalId?: string; } export class ClassroomJobOwnershipMutationError extends Error { constructor(message: string) { super(message); this.name = 'ClassroomJobOwnershipMutationError'; } } const IMMUTABLE_OWNERSHIP_FIELDS = [ 'ownerPrincipalId', 'guestPrincipalId', 'ownershipBoundAt', 'ownershipMigratedAt', ] as const; export type ClassroomGenerationJobPatch = Partial< Omit >; function normalizePrincipalId(value: string | undefined, label: string): string | undefined { if (value === undefined) return undefined; const normalized = value.trim(); if (!normalized || normalized.length > 256 || /[\u0000-\u001f\u007f]/.test(normalized)) { throw new ClassroomJobOwnershipMutationError(`Invalid ${label}`); } return normalized; } function normalizeOwnershipBinding( binding: ClassroomJobOwnershipBinding | undefined, ): ClassroomJobOwnershipBinding { const ownerPrincipalId = normalizePrincipalId(binding?.ownerPrincipalId, 'owner principal id'); const guestPrincipalId = normalizePrincipalId(binding?.guestPrincipalId, 'guest principal id'); if (ownerPrincipalId && guestPrincipalId) { throw new ClassroomJobOwnershipMutationError( 'A classroom generation job cannot have both an account owner and a guest owner', ); } return { ...(ownerPrincipalId ? { ownerPrincipalId } : {}), ...(guestPrincipalId ? { guestPrincipalId } : {}), }; } function jobFilePath(jobId: string) { return path.join(CLASSROOM_JOBS_DIR, `${jobId}.json`); } function buildInputSummary(input: GenerateClassroomInput): ClassroomGenerationJob['inputSummary'] { return { requirementPreview: input.requirement.length > 200 ? `${input.requirement.slice(0, 197)}...` : input.requirement, hasPdf: !!input.pdfContent, pdfTextLength: input.pdfContent?.text.length || 0, pdfImageCount: input.pdfContent?.images.length || 0, }; } /** Simple per-job mutex to serialize read-modify-write on the same job file. */ const jobLocks = new Map>(); async function withJobLock(jobId: string, fn: () => Promise): Promise { const prev = jobLocks.get(jobId) ?? Promise.resolve(); let resolve: () => void; const next = new Promise((r) => { resolve = r; }); jobLocks.set(jobId, next); try { await prev; return await fn(); } finally { resolve!(); if (jobLocks.get(jobId) === next) jobLocks.delete(jobId); } } /** Max age (ms) before a "running" job without an active runner is considered stale. */ const STALE_JOB_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes function markStaleIfNeeded(job: ClassroomGenerationJob): ClassroomGenerationJob { if (job.status !== 'running') return job; const updatedAt = new Date(job.updatedAt).getTime(); if (Date.now() - updatedAt > STALE_JOB_TIMEOUT_MS) { return { ...job, status: 'failed', step: 'failed', message: 'Job appears stale (no progress update for 30 minutes)', error: 'Stale job: process may have restarted during generation', completedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } return job; } export function isValidClassroomJobId(jobId: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(jobId); } export async function createClassroomGenerationJob( jobId: string, input: GenerateClassroomInput, ownership?: ClassroomJobOwnershipBinding, ): Promise { const now = new Date().toISOString(); const normalizedOwnership = normalizeOwnershipBinding(ownership); const hasOwnership = !!normalizedOwnership.ownerPrincipalId || !!normalizedOwnership.guestPrincipalId; const job: ClassroomGenerationJob = { id: jobId, ...normalizedOwnership, ...(hasOwnership ? { ownershipBoundAt: now } : {}), status: 'queued', step: 'queued', progress: 0, message: 'Classroom generation job queued', createdAt: now, updatedAt: now, inputSummary: buildInputSummary(input), input, scenesGenerated: 0, }; await ensureClassroomJobsDir(); await writeJsonFileAtomic(jobFilePath(jobId), job); return job; } export async function readClassroomGenerationJob( jobId: string, ): Promise { try { const content = await fs.readFile(jobFilePath(jobId), 'utf-8'); const job = JSON.parse(content) as ClassroomGenerationJob; return markStaleIfNeeded(job); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null; } throw error; } } export async function updateClassroomGenerationJob( jobId: string, patch: ClassroomGenerationJobPatch, ): Promise { const attemptedOwnershipMutation = IMMUTABLE_OWNERSHIP_FIELDS.find((field) => Object.prototype.hasOwnProperty.call(patch, field), ); if (attemptedOwnershipMutation) { throw new ClassroomJobOwnershipMutationError( `Classroom job ownership field is immutable: ${attemptedOwnershipMutation}`, ); } return withJobLock(jobId, async () => { const existing = await readClassroomGenerationJob(jobId); if (!existing) { throw new Error(`Classroom generation job not found: ${jobId}`); } const updated: ClassroomGenerationJob = { ...existing, ...patch, updatedAt: new Date().toISOString(), }; await writeJsonFileAtomic(jobFilePath(jobId), updated); return updated; }); } export interface MigrateClassroomJobOwnershipInput { ownerPrincipalId: string; /** Required when the resource is currently bound to a guest device. */ expectedGuestPrincipalId?: string; /** Explicit administrative opt-in for records created before ownership existed. */ allowLegacyUnowned?: boolean; } /** * Compare-and-set migration from guest/legacy ownership to a permanent account. * Existing account ownership is immutable; retrying the same target is idempotent. */ export async function migrateClassroomGenerationJobOwnership( jobId: string, input: MigrateClassroomJobOwnershipInput, ): Promise { const ownerPrincipalId = normalizePrincipalId(input.ownerPrincipalId, 'owner principal id'); if (!ownerPrincipalId) { throw new ClassroomJobOwnershipMutationError('Owner principal id is required'); } const expectedGuestPrincipalId = normalizePrincipalId( input.expectedGuestPrincipalId, 'expected guest principal id', ); return withJobLock(jobId, async () => { const existing = await readClassroomGenerationJob(jobId); if (!existing) { throw new Error(`Classroom generation job not found: ${jobId}`); } const hasOwner = Object.prototype.hasOwnProperty.call(existing, 'ownerPrincipalId'); const hasGuest = Object.prototype.hasOwnProperty.call(existing, 'guestPrincipalId'); if (hasOwner && hasGuest) { throw new ClassroomJobOwnershipMutationError( 'Classroom generation job has conflicting ownership subjects', ); } if (hasOwner) { const existingOwner = normalizePrincipalId( existing.ownerPrincipalId, 'stored owner principal id', ); if (existingOwner === ownerPrincipalId) return existing; throw new ClassroomJobOwnershipMutationError( 'Classroom generation job already belongs to a different account', ); } if (hasGuest) { const existingGuest = normalizePrincipalId( existing.guestPrincipalId, 'stored guest principal id', ); if (!expectedGuestPrincipalId || expectedGuestPrincipalId !== existingGuest) { throw new ClassroomJobOwnershipMutationError( 'Guest ownership changed or was not proven for this migration', ); } } else if (!input.allowLegacyUnowned) { throw new ClassroomJobOwnershipMutationError( 'Legacy ownerless jobs require an explicit administrative migration', ); } const now = new Date().toISOString(); const { guestPrincipalId: _guestPrincipalId, ...withoutGuest } = existing; const updated: ClassroomGenerationJob = { ...withoutGuest, ownerPrincipalId, ownershipBoundAt: existing.ownershipBoundAt ?? now, ownershipMigratedAt: now, updatedAt: now, }; await writeJsonFileAtomic(jobFilePath(jobId), updated); return updated; }); } export async function markClassroomGenerationJobRunning( jobId: string, ): Promise { return withJobLock(jobId, async () => { const existing = await readClassroomGenerationJob(jobId); if (!existing) { throw new Error(`Classroom generation job not found: ${jobId}`); } const updated: ClassroomGenerationJob = { ...existing, status: 'running', startedAt: existing.startedAt || new Date().toISOString(), message: 'Classroom generation started', updatedAt: new Date().toISOString(), }; await writeJsonFileAtomic(jobFilePath(jobId), updated); return updated; }); } export async function updateClassroomGenerationJobProgress( jobId: string, progress: ClassroomGenerationProgress, ): Promise { return updateClassroomGenerationJob(jobId, { status: 'running', step: progress.step, progress: progress.progress, message: progress.message, scenesGenerated: progress.scenesGenerated, totalScenes: progress.totalScenes, }); } export async function markClassroomGenerationJobSucceeded( jobId: string, result: GenerateClassroomResult, ): Promise { return updateClassroomGenerationJob(jobId, { status: 'succeeded', step: 'completed', progress: 100, message: 'Classroom generation completed', completedAt: new Date().toISOString(), scenesGenerated: result.scenesCount, result: { classroomId: result.id, url: result.url, scenesCount: result.scenesCount, }, }); } export async function markClassroomGenerationJobFailed( jobId: string, error: string, ): Promise { return updateClassroomGenerationJob(jobId, { status: 'failed', step: 'failed', message: 'Classroom generation failed', completedAt: new Date().toISOString(), error, }); } export async function markClassroomGenerationJobCancelled( jobId: string, message = 'Classroom generation cancelled', ): Promise { return updateClassroomGenerationJob(jobId, { status: 'cancelled', step: 'cancelled', message, completedAt: new Date().toISOString(), }); } /** List the most recent jobs, newest first. */ export async function listClassroomGenerationJobs(limit = 20): Promise { const dirEntries = await fs.readdir(CLASSROOM_JOBS_DIR).catch(() => [] as string[]); const files = dirEntries.filter((name) => name.endsWith('.json')); const jobs: ClassroomGenerationJob[] = []; for (const file of files) { try { const content = await fs.readFile(path.join(CLASSROOM_JOBS_DIR, file), 'utf-8'); jobs.push(markStaleIfNeeded(JSON.parse(content) as ClassroomGenerationJob)); } catch { // corrupt job files must not take the listing down continue; } } return jobs.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)).slice(0, Math.max(1, limit)); } /** Delete a job record (cancel semantics are the caller's job). */ export async function deleteClassroomGenerationJob(jobId: string): Promise { await fs.rm(jobFilePath(jobId), { force: true }); return true; }