172 lines
5.9 KiB
JavaScript
172 lines
5.9 KiB
JavaScript
import {
|
|
WORKFLOW_ENGINE,
|
|
normalizeRunSpec,
|
|
} from './contracts.mjs';
|
|
|
|
const EXECUTION_REQUEST_VERSION = 'workflow-execution-request-v1';
|
|
const EXECUTION_DECISION_VERSION = 'workflow-execution-decision-v1';
|
|
const MAX_IDEMPOTENCY_KEY_CHARACTERS = 200;
|
|
|
|
function clampTimeoutMs(value, fallback = 30_000) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number)) return fallback;
|
|
return Math.min(60_000, Math.max(500, Math.floor(number)));
|
|
}
|
|
|
|
function normalizeIdempotencyKey(value) {
|
|
return String(value ?? '').trim().slice(0, MAX_IDEMPOTENCY_KEY_CHARACTERS);
|
|
}
|
|
|
|
export function normalizeWorkflowExecutionRequest(input = {}) {
|
|
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
const spec = normalizeRunSpec(source.spec ?? source.runSpec ?? source);
|
|
return {
|
|
version: EXECUTION_REQUEST_VERSION,
|
|
spec,
|
|
idempotencyKey: normalizeIdempotencyKey(source.idempotencyKey),
|
|
authorization: {
|
|
executionAllowed: source.authorization?.executionAllowed === true,
|
|
actorId: String(source.authorization?.actorId ?? '').trim() || null,
|
|
},
|
|
controls: {
|
|
timeoutMs: clampTimeoutMs(source.controls?.timeoutMs),
|
|
fallbackAllowed: source.controls?.fallbackAllowed !== false,
|
|
cancellationAllowed: source.controls?.cancellationAllowed !== false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function disabledHandoffError(decision) {
|
|
const error = new Error(
|
|
`Workflow execution handoff is disabled; ${decision.effectiveEngine} remains the executor`,
|
|
);
|
|
error.code = 'WORKFLOW_EXECUTION_HANDOFF_DISABLED';
|
|
error.status = 409;
|
|
error.decision = decision;
|
|
return error;
|
|
}
|
|
|
|
/**
|
|
* Framework-neutral control-plane adapter. Routing, authorization and the
|
|
* environment/admin gates are evaluated before a remote engine can be invoked.
|
|
*/
|
|
export function createWorkflowExecutionAdapter({
|
|
configService,
|
|
engineRegistry,
|
|
} = {}) {
|
|
if (!configService?.selectEngine) {
|
|
throw new Error('Workflow execution adapter requires orchestrator config service');
|
|
}
|
|
if (!engineRegistry?.has) {
|
|
throw new Error('Workflow execution adapter requires workflow engine registry');
|
|
}
|
|
|
|
async function plan(input) {
|
|
const request = normalizeWorkflowExecutionRequest(input);
|
|
const selection = await configService.selectEngine({
|
|
runId: request.spec.runId,
|
|
requestId: request.spec.requestId,
|
|
userId: request.spec.subject.userId,
|
|
workflowName: request.spec.workflow.name,
|
|
});
|
|
const candidateEngine = selection.candidateEngine ?? selection.engine ?? WORKFLOW_ENGINE.NATIVE;
|
|
const nonNativeCandidate = candidateEngine !== WORKFLOW_ENGINE.NATIVE;
|
|
const selectedEngine = selection.engine ?? WORKFLOW_ENGINE.NATIVE;
|
|
const nonNativeSelected = selectedEngine !== WORKFLOW_ENGINE.NATIVE;
|
|
const gates = {
|
|
implementation: true,
|
|
routingSelected: nonNativeSelected,
|
|
engineRegistered: engineRegistry.has(selectedEngine),
|
|
idempotencyKeyPresent: Boolean(request.idempotencyKey),
|
|
executionAuthorized: request.authorization.executionAllowed,
|
|
killSwitchOpen: selection.reason !== 'kill_switch',
|
|
};
|
|
const handoffAllowed = nonNativeSelected && Object.values(gates).every(Boolean);
|
|
return {
|
|
version: EXECUTION_DECISION_VERSION,
|
|
runId: request.spec.runId,
|
|
requestId: request.spec.requestId,
|
|
mode: selection.mode,
|
|
candidateEngine,
|
|
effectiveEngine: handoffAllowed ? selectedEngine : WORKFLOW_ENGINE.NATIVE,
|
|
fallbackEngine: selection.fallbackEngine ?? WORKFLOW_ENGINE.NATIVE,
|
|
dryRun: nonNativeCandidate && !handoffAllowed,
|
|
handoffAllowed,
|
|
reason: handoffAllowed
|
|
? selection.reason ?? 'execution_handoff_selected'
|
|
: nonNativeCandidate
|
|
? selection.reason ?? 'execution_gate_disabled'
|
|
: selection.reason ?? 'native_selected',
|
|
candidateReason: selection.candidateReason ?? null,
|
|
configVersion: selection.configVersion,
|
|
gates,
|
|
controls: request.controls,
|
|
};
|
|
}
|
|
|
|
return {
|
|
plan,
|
|
|
|
async start(input) {
|
|
const request = normalizeWorkflowExecutionRequest(input);
|
|
const decision = await plan(input);
|
|
if (!decision.handoffAllowed) {
|
|
if (decision.candidateEngine !== WORKFLOW_ENGINE.NATIVE) {
|
|
throw disabledHandoffError(decision);
|
|
}
|
|
return {
|
|
dispatched: false,
|
|
reason: 'native_execution_owned_by_portal',
|
|
decision,
|
|
};
|
|
}
|
|
const engine = engineRegistry.get(decision.effectiveEngine);
|
|
try {
|
|
const state = await engine.start(request.spec);
|
|
return {
|
|
dispatched: true,
|
|
engine: decision.effectiveEngine,
|
|
state,
|
|
decision,
|
|
};
|
|
} catch (error) {
|
|
error.code = error.code ?? 'WORKFLOW_EXECUTION_HANDOFF_FAILED';
|
|
error.decision = decision;
|
|
error.fallbackRequested = request.controls.fallbackAllowed;
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async resume({ engineId = WORKFLOW_ENGINE.LANGGRAPH, runId, input = null } = {}) {
|
|
const engine = engineRegistry.get(engineId);
|
|
if (!engine || engineId === WORKFLOW_ENGINE.NATIVE) {
|
|
throw disabledHandoffError({
|
|
effectiveEngine: WORKFLOW_ENGINE.NATIVE,
|
|
reason: 'execution_gate_disabled',
|
|
});
|
|
}
|
|
return engine.resume(runId, input);
|
|
},
|
|
|
|
async cancel({ engineId = WORKFLOW_ENGINE.LANGGRAPH, runId, reason = null } = {}) {
|
|
const engine = engineRegistry.get(engineId);
|
|
if (!engine || engineId === WORKFLOW_ENGINE.NATIVE) {
|
|
throw disabledHandoffError({
|
|
effectiveEngine: WORKFLOW_ENGINE.NATIVE,
|
|
reason: 'execution_gate_disabled',
|
|
});
|
|
}
|
|
return engine.cancel(runId, reason);
|
|
},
|
|
};
|
|
}
|
|
|
|
export const workflowExecutionAdapterInternals = {
|
|
EXECUTION_DECISION_VERSION,
|
|
EXECUTION_REQUEST_VERSION,
|
|
MAX_IDEMPOTENCY_KEY_CHARACTERS,
|
|
clampTimeoutMs,
|
|
disabledHandoffError,
|
|
normalizeIdempotencyKey,
|
|
};
|