feat: add dry-run workflow execution boundary

This commit is contained in:
john
2026-07-24 22:30:41 +08:00
parent 646178944d
commit c9ad841543
12 changed files with 553 additions and 17 deletions
+143
View File
@@ -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,
};