425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
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<ClassroomGenerationJob, (typeof IMMUTABLE_OWNERSHIP_FIELDS)[number]>
|
|
>;
|
|
|
|
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<string, Promise<void>>();
|
|
|
|
async function withJobLock<T>(jobId: string, fn: () => Promise<T>): Promise<T> {
|
|
const prev = jobLocks.get(jobId) ?? Promise.resolve();
|
|
let resolve: () => void;
|
|
const next = new Promise<void>((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<ClassroomGenerationJob> {
|
|
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<ClassroomGenerationJob | null> {
|
|
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<ClassroomGenerationJob> {
|
|
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<ClassroomGenerationJob> {
|
|
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<ClassroomGenerationJob> {
|
|
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<ClassroomGenerationJob> {
|
|
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<ClassroomGenerationJob> {
|
|
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<ClassroomGenerationJob> {
|
|
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<ClassroomGenerationJob> {
|
|
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<ClassroomGenerationJob[]> {
|
|
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<boolean> {
|
|
await fs.rm(jobFilePath(jobId), { force: true });
|
|
return true;
|
|
}
|