feat: productionize learning engine and classroom
This commit is contained in:
104
OpenMAIC/lib/makelore-runtime/ops-engine-control.ts
Normal file
104
OpenMAIC/lib/makelore-runtime/ops-engine-control.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { getServerDeploymentRole } from '@/lib/server/ops-access';
|
||||
import { configuredLearningRuntimeToken } from '@/lib/makelore-runtime/auth';
|
||||
import type { CourseRecord } from '@/lib/course-framework/types';
|
||||
import type { LargeCourseControlAction } from '@/lib/makelore-runtime/large-course-control';
|
||||
|
||||
export const LEARNING_ENGINE_BASE_URL_ENV = 'LEARNING_ENGINE_BASE_URL';
|
||||
export const COURSE_PUBLISH_SERVER_BASE_URL_ENV = 'COURSE_PUBLISH_SERVER_BASE_URL';
|
||||
|
||||
function validatedRuntimeBaseUrl(raw: string, appendRuntimePath: boolean): string | null {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (
|
||||
(url.protocol !== 'http:' && url.protocol !== 'https:')
|
||||
|| url.username
|
||||
|| url.password
|
||||
|| url.search
|
||||
|| url.hash
|
||||
) return null;
|
||||
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
||||
if (appendRuntimePath) {
|
||||
if (pathname !== '/') return null;
|
||||
url.pathname = '/api/runtime/v1';
|
||||
} else if (pathname !== '/api/runtime/v1') {
|
||||
return null;
|
||||
}
|
||||
return url.href.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** Resolve one environment-owned Engine destination; request data never selects it. */
|
||||
export function configuredLearningEngineRuntimeBaseUrl(): string | null {
|
||||
const runtimeBase = process.env[LEARNING_ENGINE_BASE_URL_ENV]?.trim();
|
||||
if (runtimeBase) return validatedRuntimeBaseUrl(runtimeBase, false);
|
||||
const publishBase = process.env[COURSE_PUBLISH_SERVER_BASE_URL_ENV]?.trim();
|
||||
if (publishBase) return validatedRuntimeBaseUrl(publishBase, true);
|
||||
return null;
|
||||
}
|
||||
|
||||
function unavailable(message: string): Response {
|
||||
return Response.json({ error: 'engine_control_unavailable', message }, { status: 503 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy an Ops runner mutation to Engine. Only non-production `all` mode may
|
||||
* return null and use the legacy in-process development runner.
|
||||
*/
|
||||
export async function proxyLargeCourseControlToEngine(options: {
|
||||
request: Request;
|
||||
course: CourseRecord;
|
||||
action: LargeCourseControlAction;
|
||||
moduleIndex?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<Response | null> {
|
||||
const runtimeBase = configuredLearningEngineRuntimeBaseUrl();
|
||||
if (!runtimeBase) {
|
||||
if (process.env.NODE_ENV !== 'production' && getServerDeploymentRole() === 'all') return null;
|
||||
return unavailable(
|
||||
`${LEARNING_ENGINE_BASE_URL_ENV} or ${COURSE_PUBLISH_SERVER_BASE_URL_ENV} is not configured`,
|
||||
);
|
||||
}
|
||||
const owner = options.course.ownerPrincipalId?.trim();
|
||||
if (!owner) {
|
||||
return Response.json(
|
||||
{ error: 'course_owner_missing', message: 'Course has no Works owner binding' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const token = configuredLearningRuntimeToken();
|
||||
if (!token) return unavailable('LEARNING_ENGINE_TOKEN is not configured');
|
||||
|
||||
try {
|
||||
const response = await (options.fetchImpl ?? fetch)(
|
||||
`${runtimeBase}/courses/jobs/${encodeURIComponent(options.course.id)}/control`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Owner-User-Id': owner,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: options.action,
|
||||
...(options.moduleIndex === undefined ? {} : { moduleIndex: options.moduleIndex }),
|
||||
}),
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength > 1024 * 1024) return unavailable('Engine control response is too large');
|
||||
return new Response(bytes, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') ?? 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return unavailable('Learning Engine control endpoint is unavailable');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user