Files
openmaic/OpenMAIC/lib/makelore-runtime/ops-engine-control.ts

105 lines
3.8 KiB
TypeScript

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