Files
openmaic/OpenMAIC/lib/server/classroom-job-runner.ts

127 lines
4.3 KiB
TypeScript

import { createLogger } from '@/lib/logger';
import { generateClassroom, type GenerateClassroomInput } from '@/lib/server/classroom-generation';
import {
markClassroomGenerationJobCancelled,
markClassroomGenerationJobFailed,
markClassroomGenerationJobRunning,
markClassroomGenerationJobSucceeded,
readClassroomGenerationJob,
updateClassroomGenerationJobProgress,
updateClassroomGenerationJob,
} from '@/lib/server/classroom-job-store';
const log = createLogger('ClassroomJob');
const runningJobs = new Map<string, Promise<void>>();
const runningControllers = new Map<string, AbortController>();
function isAbortError(error: unknown): boolean {
return (
(error instanceof DOMException && error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
);
}
/**
* Request cancellation of a running job. Cooperative: the generation stops at
* the next scene boundary (see `generateClassroom`'s signal checks) and the
* job is then marked `cancelled`. No-op for jobs that are not running.
*/
export function cancelClassroomGenerationJob(jobId: string): boolean {
const controller = runningControllers.get(jobId);
if (!controller) return false;
controller.abort();
return true;
}
/**
* Resume a cancelled/failed job from its persisted input: reset it to
* `queued` and re-run. The generation restarts from the beginning — server-side
* generation commits atomically, so there is no partial state to continue from.
*/
export async function resumeClassroomGenerationJob(
jobId: string,
baseUrl: string,
): Promise<boolean> {
const job = await readClassroomGenerationJob(jobId);
if (!job) return false;
if (job.status !== 'failed' && job.status !== 'cancelled') return false;
if (!job.input) return false;
await updateClassroomGenerationJob(jobId, {
status: 'queued',
step: 'queued',
progress: 0,
message: 'Classroom generation job queued (resumed)',
completedAt: undefined,
error: undefined,
result: undefined,
});
runClassroomGenerationJob(jobId, job.input, baseUrl);
return true;
}
export function runClassroomGenerationJob(
jobId: string,
input: GenerateClassroomInput,
baseUrl: string,
): Promise<void> {
const existing = runningJobs.get(jobId);
if (existing) {
return existing;
}
const controller = new AbortController();
runningControllers.set(jobId, controller);
const jobPromise = (async () => {
try {
const runningJob = await markClassroomGenerationJobRunning(jobId);
const result = await generateClassroom(input, {
baseUrl,
signal: controller.signal,
ownership: {
...(runningJob.ownerPrincipalId ? { ownerPrincipalId: runningJob.ownerPrincipalId } : {}),
...(runningJob.guestPrincipalId ? { guestPrincipalId: runningJob.guestPrincipalId } : {}),
...(runningJob.ownershipBoundAt ? { ownershipBoundAt: runningJob.ownershipBoundAt } : {}),
...(runningJob.ownershipMigratedAt
? { ownershipMigratedAt: runningJob.ownershipMigratedAt }
: {}),
},
onProgress: async (progress) => {
// Do not keep reporting progress after a cancellation request —
// the generate loop may still be finishing its current scene.
if (controller.signal.aborted) return;
await updateClassroomGenerationJobProgress(jobId, progress);
},
});
await markClassroomGenerationJobSucceeded(jobId, result);
} catch (error) {
if (isAbortError(error)) {
log.info(`Classroom generation job ${jobId} cancelled`);
try {
await markClassroomGenerationJobCancelled(jobId);
} catch (markCancelledError) {
log.error(`Failed to persist cancelled status for job ${jobId}:`, markCancelledError);
}
return;
}
const message = error instanceof Error ? error.message : String(error);
log.error(`Classroom generation job ${jobId} failed:`, error);
try {
await markClassroomGenerationJobFailed(jobId, message);
} catch (markFailedError) {
log.error(`Failed to persist failed status for job ${jobId}:`, markFailedError);
}
} finally {
runningJobs.delete(jobId);
runningControllers.delete(jobId);
}
})();
runningJobs.set(jobId, jobPromise);
return jobPromise;
}