Files
openmaic/OpenMAIC/lib/server/classroom-job-store.ts
2026-08-16 14:58:47 +08:00

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;
}