feat: add pluggable workflow orchestrator controls
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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'],
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user