feat: add dry-run workflow execution boundary
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 boundary adapter.
|
||||
*
|
||||
* It turns control-plane routing into a framework-neutral, auditable decision.
|
||||
* It intentionally has no remote dispatch path yet: Native remains owned by the
|
||||
* existing Portal Agent Run and non-Native candidates are dry-run only.
|
||||
*/
|
||||
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 gates = {
|
||||
implementation: false,
|
||||
routingSelected: nonNativeCandidate,
|
||||
engineRegistered: engineRegistry.has(candidateEngine),
|
||||
idempotencyKeyPresent: Boolean(request.idempotencyKey),
|
||||
executionAuthorized: request.authorization.executionAllowed,
|
||||
killSwitchOpen: selection.reason !== 'kill_switch',
|
||||
};
|
||||
return {
|
||||
version: EXECUTION_DECISION_VERSION,
|
||||
runId: request.spec.runId,
|
||||
requestId: request.spec.requestId,
|
||||
mode: selection.mode,
|
||||
candidateEngine,
|
||||
effectiveEngine: WORKFLOW_ENGINE.NATIVE,
|
||||
fallbackEngine: selection.fallbackEngine ?? WORKFLOW_ENGINE.NATIVE,
|
||||
dryRun: nonNativeCandidate,
|
||||
handoffAllowed: false,
|
||||
reason: nonNativeCandidate
|
||||
? 'execution_gate_disabled'
|
||||
: selection.reason ?? 'native_selected',
|
||||
candidateReason: selection.candidateReason ?? null,
|
||||
configVersion: selection.configVersion,
|
||||
gates,
|
||||
controls: request.controls,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan,
|
||||
|
||||
async start(input) {
|
||||
const decision = await plan(input);
|
||||
if (decision.candidateEngine === WORKFLOW_ENGINE.NATIVE) {
|
||||
return {
|
||||
dispatched: false,
|
||||
reason: 'native_execution_owned_by_portal',
|
||||
decision,
|
||||
};
|
||||
}
|
||||
throw disabledHandoffError(decision);
|
||||
},
|
||||
|
||||
async resume() {
|
||||
throw disabledHandoffError({
|
||||
effectiveEngine: WORKFLOW_ENGINE.NATIVE,
|
||||
reason: 'execution_gate_disabled',
|
||||
});
|
||||
},
|
||||
|
||||
async cancel() {
|
||||
throw disabledHandoffError({
|
||||
effectiveEngine: WORKFLOW_ENGINE.NATIVE,
|
||||
reason: 'execution_gate_disabled',
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const workflowExecutionAdapterInternals = {
|
||||
EXECUTION_DECISION_VERSION,
|
||||
EXECUTION_REQUEST_VERSION,
|
||||
MAX_IDEMPOTENCY_KEY_CHARACTERS,
|
||||
clampTimeoutMs,
|
||||
disabledHandoffError,
|
||||
normalizeIdempotencyKey,
|
||||
};
|
||||
Reference in New Issue
Block a user