Files
memind/services/orchestrator/engine-registry.mjs
T
2026-07-24 23:31:29 +08:00

144 lines
4.7 KiB
JavaScript

import {
WORKFLOW_ENGINE,
normalizeWorkflowEngineId,
} from './contracts.mjs';
const REQUIRED_ENGINE_METHODS = Object.freeze([
'start',
'resume',
'cancel',
'getState',
'streamEvents',
]);
function validateEngine(engine) {
if (!engine || typeof engine !== 'object') {
throw new Error('Workflow engine must be an object');
}
const id = normalizeWorkflowEngineId(engine.id, '');
if (!id) throw new Error('Workflow engine requires a valid id');
const missing = REQUIRED_ENGINE_METHODS.filter((method) => typeof engine[method] !== 'function');
if (missing.length) {
throw new Error(`Workflow engine ${id} missing methods: ${missing.join(', ')}`);
}
return id;
}
export function createWorkflowEngineRegistry(initialEngines = []) {
const engines = new Map();
function register(engine) {
const id = validateEngine(engine);
if (engines.has(id)) throw new Error(`Workflow engine already registered: ${id}`);
engines.set(id, engine);
return engine;
}
for (const engine of initialEngines) register(engine);
return {
register,
get(id) {
return engines.get(normalizeWorkflowEngineId(id, '')) ?? null;
},
has(id) {
return engines.has(normalizeWorkflowEngineId(id, ''));
},
list() {
return [...engines.values()].map((engine) => ({
id: engine.id,
label: engine.label ?? engine.id,
kind: engine.kind ?? 'plugin',
capabilities: Array.isArray(engine.capabilities) ? [...engine.capabilities] : [],
}));
},
};
}
function createHttpError(response, action) {
const error = new Error(`Workflow engine ${action} failed with HTTP ${response?.status ?? 'unknown'}`);
error.code = 'WORKFLOW_ENGINE_HTTP_ERROR';
error.status = response?.status ?? null;
return error;
}
function joinUrl(baseUrl, pathname) {
return `${String(baseUrl).replace(/\/$/, '')}/${String(pathname).replace(/^\//, '')}`;
}
export function createRemoteWorkflowEngine({
id = WORKFLOW_ENGINE.LANGGRAPH,
label = 'LangGraph',
baseUrl,
serviceToken = null,
timeoutMs = 5000,
fetchImpl = globalThis.fetch,
} = {}) {
const normalizedId = normalizeWorkflowEngineId(id, '');
const normalizedBaseUrl = String(baseUrl ?? '').trim().replace(/\/$/, '');
if (!normalizedId) throw new Error('Remote workflow engine requires a valid id');
if (!normalizedBaseUrl) throw new Error(`Remote workflow engine ${normalizedId} requires baseUrl`);
if (typeof fetchImpl !== 'function') throw new Error('Remote workflow engine requires fetch');
async function request(pathname, init = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(100, Number(timeoutMs) || 5000));
try {
const response = await fetchImpl(joinUrl(normalizedBaseUrl, pathname), {
...init,
headers: {
accept: 'application/json',
...(init.body ? { 'content-type': 'application/json' } : {}),
...(serviceToken ? { authorization: `Bearer ${serviceToken}` } : {}),
...(init.headers ?? {}),
},
signal: controller.signal,
});
if (!response?.ok) throw createHttpError(response, pathname);
return response.json();
} finally {
clearTimeout(timer);
}
}
return {
id: normalizedId,
label,
kind: 'remote',
capabilities: ['durable-execution', 'interrupt', 'streaming', 'executor-observability'],
async start(spec) {
return request('/v1/runs', { method: 'POST', body: JSON.stringify(spec) });
},
async resume(runId, input) {
return request(`/v1/runs/${encodeURIComponent(runId)}/resume`, {
method: 'POST',
body: JSON.stringify(input ?? {}),
});
},
async cancel(runId, input = {}) {
return request(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
method: 'POST',
body: JSON.stringify(input),
});
},
async getState(runId) {
return request(`/v1/runs/${encodeURIComponent(runId)}`);
},
async *streamEvents(runId, cursor = null) {
const query = cursor == null ? '' : `?after=${encodeURIComponent(cursor)}`;
const result = await request(`/v1/runs/${encodeURIComponent(runId)}/events${query}`);
for (const event of Array.isArray(result?.events) ? result.events : []) yield event;
},
async getExecutorJob(jobId) {
return request(`/v1/executor-jobs/${encodeURIComponent(jobId)}`);
},
async *streamExecutorJobEvents(jobId, cursor = null) {
const query = cursor == null ? '' : `?after=${encodeURIComponent(cursor)}`;
const result = await request(
`/v1/executor-jobs/${encodeURIComponent(jobId)}/events${query}`,
);
for (const event of Array.isArray(result?.events) ? result.events : []) yield event;
},
};
}