feat: add dry-run workflow execution boundary
This commit is contained in:
@@ -346,6 +346,26 @@ export function createAgentRunGateway({
|
||||
void Promise.resolve()
|
||||
.then(() => observeWorkflowRun(input))
|
||||
.then(async (result) => {
|
||||
if (result?.executionPlan?.dryRun) {
|
||||
await appendEvent(input.runId, 'workflow_execution_planned', {
|
||||
version: result.executionPlan.version ?? 'workflow-execution-decision-v1',
|
||||
mode: result.mode ?? null,
|
||||
candidateEngine: result.executionPlan.candidateEngine ?? null,
|
||||
effectiveEngine: result.executionPlan.effectiveEngine ?? 'native',
|
||||
fallbackEngine: result.executionPlan.fallbackEngine ?? 'native',
|
||||
reason: result.executionPlan.reason ?? 'execution_gate_disabled',
|
||||
candidateReason: result.executionPlan.candidateReason ?? null,
|
||||
configVersion: result.executionPlan.configVersion ?? null,
|
||||
bucket: result.executionPlan.bucket ?? null,
|
||||
dryRun: true,
|
||||
handoffAllowed: false,
|
||||
}).catch((error) => {
|
||||
console.warn(
|
||||
'[AgentRun] workflow execution plan event skipped:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!result?.observed) return;
|
||||
await appendEvent(input.runId, 'workflow_shadow_completed', {
|
||||
engine: result.engine ?? 'langgraph',
|
||||
|
||||
@@ -468,6 +468,63 @@ test('shadow observer failure cannot fail a Native run and chat runs are not obs
|
||||
assert.equal(JSON.parse(failure.dataJson).code, 'SHADOW_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('canary dry-run records the candidate decision without changing Native run state', async () => {
|
||||
const pool = createFakePool();
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth: {},
|
||||
tkmindProxy: {},
|
||||
autoDispatch: false,
|
||||
observeWorkflowRun: async () => ({
|
||||
observed: false,
|
||||
mode: 'canary',
|
||||
executionPlan: {
|
||||
version: 'workflow-execution-decision-v1',
|
||||
candidateEngine: 'langgraph',
|
||||
effectiveEngine: 'native',
|
||||
fallbackEngine: 'native',
|
||||
reason: 'execution_gate_disabled',
|
||||
candidateReason: 'percentage',
|
||||
configVersion: 9,
|
||||
bucket: 3,
|
||||
dryRun: true,
|
||||
handoffAllowed: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const run = await gateway.createRun('user-canary', {
|
||||
requestId: 'req-canary-dry-run',
|
||||
sessionId: 'session-canary',
|
||||
userMessage: { role: 'user', content: 'refactor this module' },
|
||||
toolMode: 'code',
|
||||
taskType: 'repo_refactor',
|
||||
});
|
||||
|
||||
await waitFor(() => pool.events.some(
|
||||
(event) => event.eventType === 'workflow_execution_planned',
|
||||
));
|
||||
assert.equal(run.status, 'queued');
|
||||
assert.equal(
|
||||
pool.events.some((event) => event.eventType === 'workflow_shadow_completed'),
|
||||
false,
|
||||
);
|
||||
const event = pool.events.find((item) => item.eventType === 'workflow_execution_planned');
|
||||
assert.deepEqual(JSON.parse(event.dataJson), {
|
||||
version: 'workflow-execution-decision-v1',
|
||||
mode: 'canary',
|
||||
candidateEngine: 'langgraph',
|
||||
effectiveEngine: 'native',
|
||||
fallbackEngine: 'native',
|
||||
reason: 'execution_gate_disabled',
|
||||
candidateReason: 'percentage',
|
||||
configVersion: 9,
|
||||
bucket: 3,
|
||||
dryRun: true,
|
||||
handoffAllowed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('agent run starts a session and marks submitted reply as succeeded', async () => {
|
||||
const pool = createFakePool();
|
||||
const submitted = [];
|
||||
|
||||
@@ -59,12 +59,23 @@ Canary and Active can be configured and evaluated by the control plane, the
|
||||
Portal does not hand execution ownership to LangGraph yet. This prevents an
|
||||
administrative configuration mistake from creating two task executors.
|
||||
|
||||
Phase 3 makes that restriction explicit in the contract. Routing returns both
|
||||
`candidateEngine` and `engine`: Canary or Active may nominate LangGraph, while
|
||||
`engine` remains Native. The decision is projected as
|
||||
`workflow_execution_planned` with `dryRun=true` and `handoffAllowed=false`.
|
||||
The execution adapter normalizes authorization, idempotency, timeout,
|
||||
cancellation, and fallback controls, but its non-Native dispatch path is
|
||||
hard-disabled in code. Changing memindadm configuration or setting an
|
||||
environment override cannot transfer execution ownership.
|
||||
|
||||
## Protocol
|
||||
|
||||
The framework-neutral contracts are:
|
||||
|
||||
- `orchestrator-run-v1`
|
||||
- `orchestrator-event-v1`
|
||||
- `workflow-execution-request-v1`
|
||||
- `workflow-execution-decision-v1`
|
||||
|
||||
The implemented internal API is:
|
||||
|
||||
|
||||
@@ -219,7 +219,14 @@ export type OrchestratorRuntime = {
|
||||
effective: boolean;
|
||||
reason: string | null;
|
||||
executesLangGraph: boolean;
|
||||
plansLangGraph: boolean;
|
||||
shadowsLangGraph: boolean;
|
||||
executionHandoff: {
|
||||
implemented: boolean;
|
||||
requested: boolean;
|
||||
enabled: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrchestratorEngineDescriptor = {
|
||||
|
||||
@@ -25,6 +25,7 @@ const RUNTIME_REASON: Record<string, string> = {
|
||||
mode_off: '当前为关闭模式,所有任务继续由 Native Agent Run 执行。',
|
||||
service_url_missing: '尚未配置 Orchestrator 服务地址,LangGraph 不会接管任务。',
|
||||
kill_switch: '环境级紧急熔断已开启,强制回退 Native。',
|
||||
execution_gate_disabled: 'Canary / Active 仅生成 Dry-run 候选决策;执行交接闸门仍被硬锁定,Native 是唯一执行者。',
|
||||
};
|
||||
|
||||
export function OrchestratorPage() {
|
||||
@@ -93,7 +94,7 @@ export function OrchestratorPage() {
|
||||
}
|
||||
if (
|
||||
normalizedDraft.mode === 'active'
|
||||
&& !window.confirm('确认保存 Active 预配置?Phase 2 不会交出执行权,Native 仍是唯一执行者。')
|
||||
&& !window.confirm('确认保存 Active 预配置?Phase 3 仅生成 Dry-run 决策,Native 仍是唯一执行者。')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +123,7 @@ export function OrchestratorPage() {
|
||||
: state.runtime.shadowsLangGraph
|
||||
? 'Shadow 已生效:Native 继续执行,LangGraph 只观察和生成决策。'
|
||||
: state.runtime.executesLangGraph
|
||||
? 'Canary / Active 路由决策已预配置;Phase 2 尚未交出执行权,Native 仍是唯一执行者。'
|
||||
? '执行交接已开启。'
|
||||
: '配置有效。';
|
||||
|
||||
return (
|
||||
@@ -155,6 +156,12 @@ export function OrchestratorPage() {
|
||||
<strong>更新时间</strong>
|
||||
<p style={{ marginBottom: 0 }}>{formatTime(state.updatedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>执行交接</strong>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{state.runtime.executionHandoff.enabled ? '已开启' : 'Dry-run 锁定'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className={state.runtime.reason === 'kill_switch' ? 'alert' : 'warn'} style={{ marginBottom: 0 }}>
|
||||
{runtimeMessage}
|
||||
@@ -178,8 +185,8 @@ export function OrchestratorPage() {
|
||||
>
|
||||
<option value="off">Off — Native only</option>
|
||||
<option value="shadow">Shadow — Native 执行,LangGraph 观察</option>
|
||||
<option value="canary">Canary — 预配置,Phase 2 不接管</option>
|
||||
<option value="active">Active — 预配置,Phase 2 不接管</option>
|
||||
<option value="canary">Canary — Phase 3 Dry-run</option>
|
||||
<option value="active">Active — Phase 3 Dry-run</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
@@ -72,9 +72,22 @@ window and never changes the runtime mode. The first gate requires:
|
||||
Passing the gate means only `manual_canary_review`; it does not authorize or
|
||||
activate Canary routing.
|
||||
|
||||
Phase 3 adds the execution handoff boundary without enabling it:
|
||||
|
||||
- `workflow-execution-request-v1` normalizes authorization, timeout,
|
||||
cancellation, fallback, and idempotency controls;
|
||||
- `workflow-execution-decision-v1` separates the candidate engine from the
|
||||
effective executor;
|
||||
- Canary and Active selections emit auditable `workflow_execution_planned`
|
||||
events;
|
||||
- the effective engine remains Native and non-Native dispatch throws
|
||||
`WORKFLOW_EXECUTION_HANDOFF_DISABLED`;
|
||||
- the implementation capability is hard-disabled in code, so memindadm
|
||||
configuration cannot accidentally unlock execution.
|
||||
|
||||
The default memindadm mode remains `off`. Canary and Active routing are not
|
||||
wired to the LangGraph executor in Phase 2. Native Agent Run remains the sole
|
||||
executor, including in Shadow mode.
|
||||
wired to the LangGraph executor in Phase 3. Native Agent Run remains the sole
|
||||
executor in every mode.
|
||||
|
||||
## Admin configuration
|
||||
|
||||
@@ -84,8 +97,10 @@ Supported modes:
|
||||
|
||||
- `off`: Native Agent Run only.
|
||||
- `shadow`: Native executes; the configured engine may observe.
|
||||
- `canary`: user allowlist and deterministic rollout percentage.
|
||||
- `active`: the primary engine handles workflow-allowlisted tasks.
|
||||
- `canary`: user allowlist and deterministic rollout percentage produce
|
||||
Dry-run candidate decisions.
|
||||
- `active`: workflow-allowlisted tasks produce Dry-run primary-engine
|
||||
decisions.
|
||||
|
||||
`MEMIND_ORCHESTRATOR_KILL_SWITCH=1` always forces Native selection.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
const CONFIG_TABLE = 'h5_orchestrator_admin_config';
|
||||
const CONFIG_SCOPE = 'global';
|
||||
const EXECUTION_HANDOFF_IMPLEMENTED = false;
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value == null || value === '') return fallback;
|
||||
@@ -113,17 +114,42 @@ function runtimeState(config, env = process.env) {
|
||||
if (killSwitch) reason = 'kill_switch';
|
||||
else if (config.mode === ORCHESTRATOR_MODE.OFF) reason = 'mode_off';
|
||||
else if (!configured) reason = 'service_url_missing';
|
||||
const executionModeSelected = [ORCHESTRATOR_MODE.CANARY, ORCHESTRATOR_MODE.ACTIVE]
|
||||
.includes(config.mode);
|
||||
const executionHandoffRequested = executionModeSelected
|
||||
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH;
|
||||
const executionHandoffEnabled = !reason
|
||||
&& EXECUTION_HANDOFF_IMPLEMENTED
|
||||
&& executionHandoffRequested
|
||||
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH;
|
||||
return {
|
||||
killSwitch,
|
||||
configured,
|
||||
effective: !reason,
|
||||
reason,
|
||||
executesLangGraph: !reason
|
||||
&& [ORCHESTRATOR_MODE.CANARY, ORCHESTRATOR_MODE.ACTIVE].includes(config.mode)
|
||||
reason: reason ?? (
|
||||
executionModeSelected
|
||||
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH
|
||||
&& !executionHandoffEnabled
|
||||
? 'execution_gate_disabled'
|
||||
: null
|
||||
),
|
||||
executesLangGraph: executionHandoffEnabled,
|
||||
plansLangGraph: !reason
|
||||
&& executionModeSelected
|
||||
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH,
|
||||
shadowsLangGraph: !reason
|
||||
&& config.mode === ORCHESTRATOR_MODE.SHADOW
|
||||
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH,
|
||||
executionHandoff: {
|
||||
implemented: EXECUTION_HANDOFF_IMPLEMENTED,
|
||||
requested: executionHandoffRequested,
|
||||
enabled: executionHandoffEnabled,
|
||||
reason: executionHandoffEnabled
|
||||
? null
|
||||
: EXECUTION_HANDOFF_IMPLEMENTED
|
||||
? 'execution_handoff_not_requested'
|
||||
: 'phase3_dry_run_only',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,12 +350,15 @@ export function createOrchestratorAdminConfigService(pool, {
|
||||
const workflowMatched = config.workflowAllowlist.includes(normalizedWorkflow);
|
||||
const base = {
|
||||
engine: WORKFLOW_ENGINE.NATIVE,
|
||||
candidateEngine: WORKFLOW_ENGINE.NATIVE,
|
||||
shadowEngine: null,
|
||||
fallbackEngine: config.fallbackToNative ? config.fallbackEngine : null,
|
||||
mode: config.mode,
|
||||
workflowMatched,
|
||||
configVersion: state.configVersion,
|
||||
reason: runtime.reason,
|
||||
candidateReason: null,
|
||||
dryRun: false,
|
||||
};
|
||||
if (!runtime.effective || !workflowMatched) {
|
||||
return { ...base, reason: runtime.reason ?? 'workflow_not_enabled' };
|
||||
@@ -342,16 +371,39 @@ export function createOrchestratorAdminConfigService(pool, {
|
||||
};
|
||||
}
|
||||
if (config.mode === ORCHESTRATOR_MODE.ACTIVE) {
|
||||
return { ...base, engine: config.primaryEngine, reason: 'active' };
|
||||
const selected = {
|
||||
...base,
|
||||
candidateEngine: config.primaryEngine,
|
||||
candidateReason: 'active',
|
||||
};
|
||||
if (config.primaryEngine === WORKFLOW_ENGINE.NATIVE) {
|
||||
return { ...selected, reason: 'active' };
|
||||
}
|
||||
return runtime.executionHandoff.enabled
|
||||
? { ...selected, engine: config.primaryEngine, reason: 'active' }
|
||||
: { ...selected, reason: 'execution_gate_disabled', dryRun: true };
|
||||
}
|
||||
if (config.mode === ORCHESTRATOR_MODE.CANARY) {
|
||||
const explicitlyAllowed = Boolean(userId && config.userAllowlist.includes(String(userId)));
|
||||
const rolloutKey = runId || requestId || `${userId ?? ''}:${normalizedWorkflow}`;
|
||||
const bucket = stableRolloutBucket(rolloutKey);
|
||||
const percentAllowed = bucket < config.rolloutPercent;
|
||||
return explicitlyAllowed || percentAllowed
|
||||
? { ...base, engine: config.primaryEngine, reason: explicitlyAllowed ? 'user_allowlist' : 'percentage', bucket }
|
||||
: { ...base, reason: 'canary_not_selected', bucket };
|
||||
if (!explicitlyAllowed && !percentAllowed) {
|
||||
return { ...base, reason: 'canary_not_selected', bucket };
|
||||
}
|
||||
const candidateReason = explicitlyAllowed ? 'user_allowlist' : 'percentage';
|
||||
const selected = {
|
||||
...base,
|
||||
candidateEngine: config.primaryEngine,
|
||||
candidateReason,
|
||||
bucket,
|
||||
};
|
||||
if (config.primaryEngine === WORKFLOW_ENGINE.NATIVE) {
|
||||
return { ...selected, reason: candidateReason };
|
||||
}
|
||||
return runtime.executionHandoff.enabled
|
||||
? { ...selected, engine: config.primaryEngine, reason: candidateReason }
|
||||
: { ...selected, reason: 'execution_gate_disabled', dryRun: true };
|
||||
}
|
||||
return base;
|
||||
},
|
||||
@@ -362,6 +414,7 @@ export const orchestratorAdminConfigInternals = {
|
||||
CONFIG_SCOPE,
|
||||
CONFIG_TABLE,
|
||||
engineCatalog,
|
||||
EXECUTION_HANDOFF_IMPLEMENTED,
|
||||
probeServiceHealth,
|
||||
runtimeState,
|
||||
};
|
||||
|
||||
@@ -69,8 +69,11 @@ test('orchestrator config persists versioned admin updates and selects canary us
|
||||
userId: 'user-1',
|
||||
workflowName: 'code-run-v1',
|
||||
});
|
||||
assert.equal(selected.engine, 'langgraph');
|
||||
assert.equal(selected.reason, 'user_allowlist');
|
||||
assert.equal(selected.engine, 'native');
|
||||
assert.equal(selected.candidateEngine, 'langgraph');
|
||||
assert.equal(selected.reason, 'execution_gate_disabled');
|
||||
assert.equal(selected.candidateReason, 'user_allowlist');
|
||||
assert.equal(selected.dryRun, true);
|
||||
|
||||
const native = await service.selectEngine({
|
||||
runId: 'run-2',
|
||||
@@ -78,9 +81,54 @@ test('orchestrator config persists versioned admin updates and selects canary us
|
||||
workflowName: 'code-run-v1',
|
||||
});
|
||||
assert.equal(native.engine, 'native');
|
||||
assert.equal(native.candidateEngine, 'native');
|
||||
assert.equal(native.reason, 'canary_not_selected');
|
||||
});
|
||||
|
||||
test('orchestrator execution handoff remains hard-disabled when Active is requested', async () => {
|
||||
const service = createOrchestratorAdminConfigService(createPool(), { env: {} });
|
||||
await service.updateAdminConfig({
|
||||
mode: 'active',
|
||||
serviceUrl: 'http://127.0.0.1:8093',
|
||||
});
|
||||
|
||||
const runtime = await service.getRuntimeState();
|
||||
assert.equal(runtime.runtime.plansLangGraph, true);
|
||||
assert.equal(runtime.runtime.executesLangGraph, false);
|
||||
assert.deepEqual(runtime.runtime.executionHandoff, {
|
||||
implemented: false,
|
||||
requested: true,
|
||||
enabled: false,
|
||||
reason: 'phase3_dry_run_only',
|
||||
});
|
||||
|
||||
const selected = await service.selectEngine({
|
||||
runId: 'run-1',
|
||||
userId: 'user-1',
|
||||
workflowName: 'code-run-v1',
|
||||
});
|
||||
assert.equal(selected.engine, 'native');
|
||||
assert.equal(selected.candidateEngine, 'langgraph');
|
||||
assert.equal(selected.dryRun, true);
|
||||
});
|
||||
|
||||
test('orchestrator keeps a Native primary as a non-dry-run Native selection', async () => {
|
||||
const service = createOrchestratorAdminConfigService(createPool(), { env: {} });
|
||||
await service.updateAdminConfig({
|
||||
mode: 'active',
|
||||
primaryEngine: 'native',
|
||||
});
|
||||
|
||||
const selected = await service.selectEngine({
|
||||
runId: 'run-native',
|
||||
workflowName: 'code-run-v1',
|
||||
});
|
||||
assert.equal(selected.engine, 'native');
|
||||
assert.equal(selected.candidateEngine, 'native');
|
||||
assert.equal(selected.reason, 'active');
|
||||
assert.equal(selected.dryRun, false);
|
||||
});
|
||||
|
||||
test('orchestrator emergency kill switch always forces native selection', async () => {
|
||||
const service = createOrchestratorAdminConfigService(createPool(), {
|
||||
env: { MEMIND_ORCHESTRATOR_KILL_SWITCH: '1' },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createWorkflowEngineRegistry } from './engine-registry.mjs';
|
||||
import {
|
||||
createWorkflowExecutionAdapter,
|
||||
normalizeWorkflowExecutionRequest,
|
||||
} from './execution-adapter.mjs';
|
||||
|
||||
function engine(id) {
|
||||
return {
|
||||
id,
|
||||
start() {
|
||||
throw new Error('must not dispatch during Phase 3 dry-run');
|
||||
},
|
||||
resume() {},
|
||||
cancel() {},
|
||||
getState() {},
|
||||
async *streamEvents() {},
|
||||
};
|
||||
}
|
||||
|
||||
function executionRequest(overrides = {}) {
|
||||
return {
|
||||
spec: {
|
||||
runId: 'run-1',
|
||||
requestId: 'request-1',
|
||||
workflow: { name: 'code-run-v1', version: 1 },
|
||||
subject: { userId: 'user-1' },
|
||||
},
|
||||
idempotencyKey: 'agent-run:run-1:attempt-1',
|
||||
authorization: { executionAllowed: true, actorId: 'user-1' },
|
||||
controls: { timeoutMs: 10_000 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('workflow execution request normalizes authorization, limits and idempotency', () => {
|
||||
const request = normalizeWorkflowExecutionRequest(executionRequest({
|
||||
idempotencyKey: ` ${'x'.repeat(250)} `,
|
||||
controls: { timeoutMs: 120_000, fallbackAllowed: false },
|
||||
}));
|
||||
assert.equal(request.version, 'workflow-execution-request-v1');
|
||||
assert.equal(request.idempotencyKey.length, 200);
|
||||
assert.equal(request.authorization.executionAllowed, true);
|
||||
assert.equal(request.controls.timeoutMs, 60_000);
|
||||
assert.equal(request.controls.fallbackAllowed, false);
|
||||
});
|
||||
|
||||
test('workflow execution adapter records a LangGraph candidate but keeps Native effective', async () => {
|
||||
const registry = createWorkflowEngineRegistry([engine('native'), engine('langgraph')]);
|
||||
const adapter = createWorkflowExecutionAdapter({
|
||||
engineRegistry: registry,
|
||||
configService: {
|
||||
async selectEngine() {
|
||||
return {
|
||||
engine: 'native',
|
||||
candidateEngine: 'langgraph',
|
||||
fallbackEngine: 'native',
|
||||
mode: 'canary',
|
||||
reason: 'execution_gate_disabled',
|
||||
candidateReason: 'user_allowlist',
|
||||
configVersion: 4,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const decision = await adapter.plan(executionRequest());
|
||||
assert.equal(decision.version, 'workflow-execution-decision-v1');
|
||||
assert.equal(decision.candidateEngine, 'langgraph');
|
||||
assert.equal(decision.effectiveEngine, 'native');
|
||||
assert.equal(decision.dryRun, true);
|
||||
assert.equal(decision.handoffAllowed, false);
|
||||
assert.equal(decision.candidateReason, 'user_allowlist');
|
||||
assert.deepEqual(decision.gates, {
|
||||
implementation: false,
|
||||
routingSelected: true,
|
||||
engineRegistered: true,
|
||||
idempotencyKeyPresent: true,
|
||||
executionAuthorized: true,
|
||||
killSwitchOpen: true,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.start(executionRequest()),
|
||||
(error) => error.code === 'WORKFLOW_EXECUTION_HANDOFF_DISABLED'
|
||||
&& error.decision.effectiveEngine === 'native',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow execution adapter leaves native dispatch under Portal ownership', async () => {
|
||||
const registry = createWorkflowEngineRegistry([engine('native')]);
|
||||
const adapter = createWorkflowExecutionAdapter({
|
||||
engineRegistry: registry,
|
||||
configService: {
|
||||
async selectEngine() {
|
||||
return {
|
||||
engine: 'native',
|
||||
candidateEngine: 'native',
|
||||
fallbackEngine: 'native',
|
||||
mode: 'off',
|
||||
reason: 'mode_off',
|
||||
configVersion: 1,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await adapter.start(executionRequest());
|
||||
assert.equal(result.dispatched, false);
|
||||
assert.equal(result.reason, 'native_execution_owned_by_portal');
|
||||
assert.equal(result.decision.effectiveEngine, 'native');
|
||||
});
|
||||
@@ -54,6 +54,18 @@ export function createWorkflowShadowObserver({
|
||||
observed: false,
|
||||
reason: selection.reason,
|
||||
mode: selection.mode,
|
||||
executionPlan: selection.dryRun ? {
|
||||
version: 'workflow-execution-decision-v1',
|
||||
candidateEngine: selection.candidateEngine ?? WORKFLOW_ENGINE.NATIVE,
|
||||
effectiveEngine: selection.engine ?? WORKFLOW_ENGINE.NATIVE,
|
||||
fallbackEngine: selection.fallbackEngine ?? WORKFLOW_ENGINE.NATIVE,
|
||||
reason: selection.reason,
|
||||
candidateReason: selection.candidateReason ?? null,
|
||||
configVersion: selection.configVersion ?? null,
|
||||
bucket: selection.bucket ?? null,
|
||||
dryRun: true,
|
||||
handoffAllowed: false,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,56 @@ test('shadow observer skips without creating a remote client when mode does not
|
||||
observed: false,
|
||||
reason: 'mode_off',
|
||||
mode: 'off',
|
||||
executionPlan: null,
|
||||
});
|
||||
assert.equal(fetchCalls, 0);
|
||||
});
|
||||
|
||||
test('shadow boundary returns an auditable dry-run plan without calling LangGraph', async () => {
|
||||
let fetchCalls = 0;
|
||||
const observer = createWorkflowShadowObserver({
|
||||
configService: {
|
||||
async selectEngine() {
|
||||
return {
|
||||
engine: 'native',
|
||||
candidateEngine: 'langgraph',
|
||||
shadowEngine: null,
|
||||
fallbackEngine: 'native',
|
||||
reason: 'execution_gate_disabled',
|
||||
candidateReason: 'user_allowlist',
|
||||
mode: 'canary',
|
||||
configVersion: 8,
|
||||
bucket: 42,
|
||||
dryRun: true,
|
||||
};
|
||||
},
|
||||
async getRuntimeState() {
|
||||
throw new Error('must not load runtime config');
|
||||
},
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
fetchCalls += 1;
|
||||
return jsonResponse({});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await observer({
|
||||
runId: 'run-dry-1',
|
||||
requestId: 'request-dry-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
assert.equal(result.observed, false);
|
||||
assert.deepEqual(result.executionPlan, {
|
||||
version: 'workflow-execution-decision-v1',
|
||||
candidateEngine: 'langgraph',
|
||||
effectiveEngine: 'native',
|
||||
fallbackEngine: 'native',
|
||||
reason: 'execution_gate_disabled',
|
||||
candidateReason: 'user_allowlist',
|
||||
configVersion: 8,
|
||||
bucket: 42,
|
||||
dryRun: true,
|
||||
handoffAllowed: false,
|
||||
});
|
||||
assert.equal(fetchCalls, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user