feat(orchestrator): enforce Page Data validation gate
This commit is contained in:
+97
-15
@@ -539,6 +539,10 @@ export function createAgentRunGateway({
|
||||
process.env.MEMIND_ORCHESTRATOR_SHADOW_MAX_QUEUE,
|
||||
100,
|
||||
),
|
||||
enforcePageDataWorkflowValidation = envFlag(
|
||||
process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED,
|
||||
false,
|
||||
),
|
||||
workerIdentity = null,
|
||||
}) {
|
||||
const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {});
|
||||
@@ -721,7 +725,7 @@ export function createAgentRunGateway({
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchPageDataValidationObservation({
|
||||
function buildPageDataValidationObservationInput({
|
||||
row,
|
||||
runId,
|
||||
checks,
|
||||
@@ -729,8 +733,7 @@ export function createAgentRunGateway({
|
||||
}) {
|
||||
const userMessage = parseDbJsonColumn(row?.user_message_json, {}) ?? {};
|
||||
const runOptions = getRunOptionsFromMessage(userMessage);
|
||||
if (!isPageDataWorkflowRequested(userMessage, runOptions.taskType)) return false;
|
||||
return validationDispatcher.dispatch({
|
||||
return {
|
||||
runId,
|
||||
requestId: row?.request_id ?? null,
|
||||
userId: row?.user_id ?? null,
|
||||
@@ -747,7 +750,74 @@ export function createAgentRunGateway({
|
||||
source: 'portal-agent-run',
|
||||
observedAt: nowMs(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchPageDataValidationObservation(input) {
|
||||
return validationDispatcher.dispatch(input);
|
||||
}
|
||||
|
||||
async function enforcePageDataValidationGate(input) {
|
||||
const startedAt = nowMs();
|
||||
let result = null;
|
||||
let gateError = null;
|
||||
try {
|
||||
if (typeof observeWorkflowValidation !== 'function') {
|
||||
gateError = new Error('Page Data Orchestrator validation observer is unavailable');
|
||||
gateError.code = 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE';
|
||||
throw gateError;
|
||||
}
|
||||
result = await observeWorkflowValidation(input);
|
||||
const verdict = result?.validation?.verdict ?? null;
|
||||
if (!result?.observed) {
|
||||
gateError = new Error(
|
||||
`Page Data Orchestrator validation was not observed: ${result?.reason ?? 'not_selected'}`,
|
||||
);
|
||||
gateError.code = 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE';
|
||||
throw gateError;
|
||||
}
|
||||
if (verdict !== 'passed') {
|
||||
gateError = new Error(
|
||||
`Page Data Orchestrator validation did not pass: ${verdict ?? 'unknown'}`,
|
||||
);
|
||||
gateError.code = verdict === 'failed'
|
||||
? 'WORKFLOW_VALIDATION_GATE_REJECTED'
|
||||
: 'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE';
|
||||
throw gateError;
|
||||
}
|
||||
await appendEvent(input.runId, 'workflow_validation_gate_passed', {
|
||||
engine: result.engine ?? 'langgraph',
|
||||
mode: result.mode ?? 'shadow',
|
||||
configVersion: result.configVersion ?? null,
|
||||
kind: result.validation?.kind ?? 'page-data-delivery',
|
||||
verdict,
|
||||
latencyMs: Math.max(0, nowMs() - startedAt),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const resolved = gateError ?? error;
|
||||
const code = String(
|
||||
gateError?.code ?? 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE',
|
||||
).slice(0, 128);
|
||||
const failure = resolved instanceof Error
|
||||
? resolved
|
||||
: new Error(String(resolved ?? 'Page Data Orchestrator validation failed'));
|
||||
await appendEvent(input.runId, 'workflow_validation_gate_failed', {
|
||||
code,
|
||||
upstreamCode: gateError
|
||||
? null
|
||||
: String(error?.code ?? 'WORKFLOW_VALIDATION_OBSERVER_FAILED').slice(0, 128),
|
||||
message: String(
|
||||
failure.message,
|
||||
).slice(0, 1000),
|
||||
verdict: result?.validation?.verdict ?? null,
|
||||
latencyMs: Math.max(0, nowMs() - startedAt),
|
||||
}).catch(() => {});
|
||||
failure.code = code;
|
||||
failure.retryable = false;
|
||||
failure.validationObservationAttempted = true;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendRunSnapshot(runId) {
|
||||
@@ -1662,14 +1732,8 @@ export function createAgentRunGateway({
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const marked = await markRun(runId, 'succeeded', {
|
||||
agent_session_id: sessionId,
|
||||
completed_at: nowMs(),
|
||||
error_message: null,
|
||||
}, { expectedStatus: 'running' });
|
||||
if (!marked) return false;
|
||||
if (pageDataIntent) {
|
||||
dispatchPageDataValidationObservation({
|
||||
const validationInput = pageDataIntent
|
||||
? buildPageDataValidationObservationInput({
|
||||
row,
|
||||
runId,
|
||||
checks: [
|
||||
@@ -1695,7 +1759,19 @@ export function createAgentRunGateway({
|
||||
pageCount: deliverables?.pageCount ?? 0,
|
||||
publicationCount: deliverables?.publicationCount ?? 0,
|
||||
},
|
||||
});
|
||||
})
|
||||
: null;
|
||||
if (validationInput && enforcePageDataWorkflowValidation) {
|
||||
await enforcePageDataValidationGate(validationInput);
|
||||
}
|
||||
const marked = await markRun(runId, 'succeeded', {
|
||||
agent_session_id: sessionId,
|
||||
completed_at: nowMs(),
|
||||
error_message: null,
|
||||
}, { expectedStatus: 'running' });
|
||||
if (!marked) return false;
|
||||
if (validationInput && !enforcePageDataWorkflowValidation) {
|
||||
dispatchPageDataValidationObservation(validationInput);
|
||||
}
|
||||
if (typeof observePersonalMemoryOnSuccess === 'function') {
|
||||
await observePersonalMemoryOnSuccess({
|
||||
@@ -1779,8 +1855,12 @@ export function createAgentRunGateway({
|
||||
}
|
||||
: {}),
|
||||
}, { expectedStatus: 'running' });
|
||||
if (!retryable) {
|
||||
dispatchPageDataValidationObservation({
|
||||
if (!retryable && err?.validationObservationAttempted !== true) {
|
||||
const userMessage = parseDbJsonColumn(row?.user_message_json, {}) ?? {};
|
||||
const runOptions = getRunOptionsFromMessage(userMessage);
|
||||
if (isPageDataWorkflowRequested(userMessage, runOptions.taskType)) {
|
||||
dispatchPageDataValidationObservation(
|
||||
buildPageDataValidationObservationInput({
|
||||
row,
|
||||
runId,
|
||||
checks: [{
|
||||
@@ -1788,7 +1868,9 @@ export function createAgentRunGateway({
|
||||
status: 'failed',
|
||||
codes: [String(err?.code ?? 'AGENT_RUN_FAILED')],
|
||||
}],
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (retryable && autoDispatch) {
|
||||
setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]);
|
||||
|
||||
@@ -609,6 +609,181 @@ test('Page Data validation observation failure stays isolated from the Native re
|
||||
assert.equal(failure.code, 'VALIDATION_OBSERVER_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('enforced Page Data validation waits for Orchestrator passed before Native success', async () => {
|
||||
const pool = createFakePool();
|
||||
const validationStatuses = [];
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth: {},
|
||||
tkmindProxy: {
|
||||
async startSessionForUser() {
|
||||
return { id: 'session-page-data-gate-pass' };
|
||||
},
|
||||
async submitSessionReplyForUser() {},
|
||||
},
|
||||
observeWorkflowRun: async () => ({ observed: true }),
|
||||
observeWorkflowValidation: async (input) => {
|
||||
validationStatuses.push(pool.runs.get(input.runId)?.status);
|
||||
assert.deepEqual(
|
||||
input.observation.checks
|
||||
.filter((check) => [
|
||||
'agent_run_completion',
|
||||
'page_data_binding',
|
||||
'page_data_storage_policy',
|
||||
].includes(check.id))
|
||||
.map((check) => [check.id, check.status]),
|
||||
[
|
||||
['agent_run_completion', 'passed'],
|
||||
['page_data_binding', 'passed'],
|
||||
['page_data_storage_policy', 'passed'],
|
||||
],
|
||||
);
|
||||
return {
|
||||
observed: true,
|
||||
engine: 'langgraph',
|
||||
mode: 'shadow',
|
||||
configVersion: 12,
|
||||
validation: {
|
||||
kind: 'page-data-delivery',
|
||||
verdict: 'passed',
|
||||
},
|
||||
};
|
||||
},
|
||||
syncUserPagesOnSuccess: async () => ({
|
||||
pageDataBind: { errors: [] },
|
||||
pageDataRelativePaths: [],
|
||||
}),
|
||||
validateRunDeliverables: async () => ({ errors: [] }),
|
||||
enforcePageDataWorkflowValidation: true,
|
||||
retryDelaysMs: [],
|
||||
});
|
||||
|
||||
const run = await gateway.createRun('user-page-data-gate-pass', {
|
||||
requestId: 'req-page-data-gate-pass',
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '解释 Page Data API' }],
|
||||
metadata: {
|
||||
displayText: '解释 Page Data API',
|
||||
memindRun: { pgRequired: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
|
||||
assert.deepEqual(validationStatuses, ['running']);
|
||||
assert.ok(pool.events.some(
|
||||
(event) => event.runId === run.id
|
||||
&& event.eventType === 'workflow_validation_gate_passed',
|
||||
));
|
||||
assert.equal(pool.events.some(
|
||||
(event) => event.runId === run.id
|
||||
&& event.eventType === 'workflow_validation_observation_completed',
|
||||
), false);
|
||||
});
|
||||
|
||||
test('enforced Page Data validation fails closed on an inconclusive verdict', async () => {
|
||||
const pool = createFakePool();
|
||||
let validationCalls = 0;
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth: {},
|
||||
tkmindProxy: {
|
||||
async startSessionForUser() {
|
||||
return { id: 'session-page-data-gate-inconclusive' };
|
||||
},
|
||||
async submitSessionReplyForUser() {},
|
||||
},
|
||||
observeWorkflowRun: async () => ({ observed: true }),
|
||||
observeWorkflowValidation: async () => {
|
||||
validationCalls += 1;
|
||||
return {
|
||||
observed: true,
|
||||
engine: 'langgraph',
|
||||
mode: 'shadow',
|
||||
validation: {
|
||||
kind: 'page-data-delivery',
|
||||
verdict: 'inconclusive',
|
||||
},
|
||||
};
|
||||
},
|
||||
enforcePageDataWorkflowValidation: true,
|
||||
retryDelaysMs: [],
|
||||
});
|
||||
|
||||
const run = await gateway.createRun('user-page-data-gate-inconclusive', {
|
||||
requestId: 'req-page-data-gate-inconclusive',
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '解释 Page Data API' }],
|
||||
metadata: {
|
||||
displayText: '解释 Page Data API',
|
||||
memindRun: { pgRequired: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => pool.runs.get(run.id)?.status === 'failed');
|
||||
assert.equal(validationCalls, 1);
|
||||
assert.match(pool.runs.get(run.id).error_message, /did not pass: inconclusive/);
|
||||
const failed = pool.events.find(
|
||||
(event) => event.runId === run.id
|
||||
&& event.eventType === 'workflow_validation_gate_failed',
|
||||
);
|
||||
assert.ok(failed);
|
||||
assert.equal(
|
||||
JSON.parse(failed.dataJson).code,
|
||||
'WORKFLOW_VALIDATION_GATE_INCONCLUSIVE',
|
||||
);
|
||||
});
|
||||
|
||||
test('enforced Page Data validation fails closed without retrying when Orchestrator is unavailable', async () => {
|
||||
const pool = createFakePool();
|
||||
let validationCalls = 0;
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth: {},
|
||||
tkmindProxy: {
|
||||
async startSessionForUser() {
|
||||
return { id: 'session-page-data-gate-unavailable' };
|
||||
},
|
||||
async submitSessionReplyForUser() {},
|
||||
},
|
||||
observeWorkflowRun: async () => ({ observed: true }),
|
||||
observeWorkflowValidation: async () => {
|
||||
validationCalls += 1;
|
||||
throw Object.assign(new Error('orchestrator unavailable'), {
|
||||
code: 'ORCHESTRATOR_UNAVAILABLE',
|
||||
});
|
||||
},
|
||||
enforcePageDataWorkflowValidation: true,
|
||||
retryDelaysMs: [1, 1, 1],
|
||||
});
|
||||
|
||||
const run = await gateway.createRun('user-page-data-gate-unavailable', {
|
||||
requestId: 'req-page-data-gate-unavailable',
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '解释 Page Data API' }],
|
||||
metadata: {
|
||||
displayText: '解释 Page Data API',
|
||||
memindRun: { pgRequired: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => pool.runs.get(run.id)?.status === 'failed');
|
||||
assert.equal(validationCalls, 1);
|
||||
assert.equal(pool.runs.get(run.id).attempts, 1);
|
||||
const failed = pool.events.find(
|
||||
(event) => event.runId === run.id
|
||||
&& event.eventType === 'workflow_validation_gate_failed',
|
||||
);
|
||||
const failure = JSON.parse(failed.dataJson);
|
||||
assert.equal(failure.code, 'WORKFLOW_VALIDATION_GATE_UNAVAILABLE');
|
||||
assert.equal(failure.upstreamCode, 'ORCHESTRATOR_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('agent run starts a session and marks submitted reply as succeeded', async () => {
|
||||
const pool = createFakePool();
|
||||
const submitted = [];
|
||||
|
||||
@@ -61,6 +61,15 @@ configuration from the Agent Run path, schedule background observations, or
|
||||
send data to the Orchestrator. Changing memindadm mode alone therefore cannot
|
||||
put existing Native traffic on the Shadow path.
|
||||
|
||||
Page Data validation enforcement has a second independent Portal gate:
|
||||
`MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED=1`. It is off by
|
||||
default and becomes effective only while Shadow wiring is effective. When
|
||||
enabled, a Page Data run remains `running` after the Native delivery guards
|
||||
finish until the Orchestrator checkpoints a `passed` validation observation.
|
||||
`failed`, `inconclusive`, not-selected, timeout, and unavailable outcomes fail
|
||||
closed and prevent the run from being marked successful. The Orchestrator still
|
||||
does not publish artifacts or receive user content in this mode.
|
||||
|
||||
memindadm projects the requested and effective wiring states separately. Canary
|
||||
readiness requires `shadow_wiring_enabled=true`, so a saved Shadow mode with a
|
||||
closed Portal gate is visible and cannot be mistaken for a collecting Shadow
|
||||
@@ -187,7 +196,9 @@ workspace paths, dataset contents, credentials, and database identifiers must
|
||||
not cross this boundary. The Orchestrator derives and checkpoints a
|
||||
`passed`/`failed`/`inconclusive` verdict and emits
|
||||
`workflow_validation_observed`. In the initial observation phase this verdict
|
||||
does not mutate the Native run result or publication state.
|
||||
does not mutate the Native run result or publication state. With the independent
|
||||
Page Data validation gate enabled, Portal waits for `passed` before committing
|
||||
the Native success transition; the default remains observation-only.
|
||||
|
||||
The graph keeps three deterministic control-plane nodes:
|
||||
|
||||
|
||||
@@ -228,6 +228,13 @@ export type OrchestratorRuntime = {
|
||||
reason: string | null;
|
||||
environmentGate: boolean;
|
||||
};
|
||||
pageDataValidationGate: {
|
||||
requested: boolean;
|
||||
enabled: boolean;
|
||||
failClosed: boolean;
|
||||
reason: string | null;
|
||||
environmentGate: boolean;
|
||||
};
|
||||
executionHandoff: {
|
||||
implemented: boolean;
|
||||
requested: boolean;
|
||||
|
||||
@@ -182,6 +182,19 @@ export function OrchestratorPage() {
|
||||
{shadowWiringLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Page Data 准入</strong>
|
||||
<p style={{
|
||||
marginBottom: 0,
|
||||
color: state.runtime.pageDataValidationGate.enabled ? '#2f6f57' : '#8a5a16',
|
||||
}}>
|
||||
{state.runtime.pageDataValidationGate.enabled
|
||||
? '强制通过'
|
||||
: state.runtime.pageDataValidationGate.requested
|
||||
? '等待 Shadow wiring'
|
||||
: '仅观察'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className={state.runtime.reason === 'kill_switch' ? 'alert' : 'warn'} style={{ marginBottom: 0 }}>
|
||||
{runtimeMessage}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { createLlmProviderService } from '../llm-providers.mjs';
|
||||
import { createTkmindProxy } from '../tkmind-proxy.mjs';
|
||||
import { createToolGateway } from '../tool-gateway.mjs';
|
||||
import { createUserAuth } from '../user-auth.mjs';
|
||||
import { createOrchestratorAdminConfigService } from '../services/orchestrator/admin-config.mjs';
|
||||
import { createWorkflowShadowObserver } from '../services/orchestrator/shadow-observer.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -32,6 +34,12 @@ function parseTargets() {
|
||||
return [process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'];
|
||||
}
|
||||
|
||||
function isEnabledFlag(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
String(value ?? '').trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
once: false,
|
||||
@@ -87,6 +95,15 @@ if (args.help) {
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
const workflowShadowEnabled = isEnabledFlag(
|
||||
process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED,
|
||||
);
|
||||
const workflowShadowObserver = workflowShadowEnabled
|
||||
? createWorkflowShadowObserver({
|
||||
configService: createOrchestratorAdminConfigService(pool),
|
||||
serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
|
||||
})
|
||||
: null;
|
||||
const baseUserAuth = createUserAuth(pool, {
|
||||
usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'),
|
||||
h5Root: root,
|
||||
@@ -124,6 +141,14 @@ const gateway = createAgentRunGateway({
|
||||
autoDispatch: false,
|
||||
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
|
||||
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
|
||||
observeWorkflowRun: workflowShadowObserver,
|
||||
observeWorkflowValidation:
|
||||
workflowShadowObserver?.observeValidation ?? null,
|
||||
enforcePageDataWorkflowValidation:
|
||||
workflowShadowEnabled
|
||||
&& isEnabledFlag(
|
||||
process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED,
|
||||
),
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
|
||||
@@ -331,6 +331,11 @@ export function bootstrapPortalGatewayServices({
|
||||
observeWorkflowRun: workflowShadowObserver,
|
||||
observeWorkflowValidation:
|
||||
workflowShadowObserver?.observeValidation ?? null,
|
||||
enforcePageDataWorkflowValidation:
|
||||
workflowShadowEnabled
|
||||
&& isEnabledFlag(
|
||||
env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED,
|
||||
),
|
||||
observePersonalMemoryOnSuccess: async ({
|
||||
userId,
|
||||
sessionId,
|
||||
|
||||
@@ -168,6 +168,7 @@ test('preserves Proxy, Tool, and Agent gateway wiring', () => {
|
||||
assert.equal(captured.agentOptions.runTimeoutMs, 9000);
|
||||
assert.equal(captured.agentOptions.observeWorkflowRun, null);
|
||||
assert.equal(captured.agentOptions.observeWorkflowValidation, null);
|
||||
assert.equal(captured.agentOptions.enforcePageDataWorkflowValidation, false);
|
||||
assert.equal(captured.agentOptions.maxConcurrentShadowObservations, 2);
|
||||
assert.equal(captured.agentOptions.maxQueuedShadowObservations, 100);
|
||||
});
|
||||
@@ -184,6 +185,7 @@ test('wires Shadow observation only behind the explicit environment gate', () =>
|
||||
MEMIND_ORCHESTRATOR_SERVICE_TOKEN: 'shadow-token',
|
||||
MEMIND_ORCHESTRATOR_SHADOW_MAX_CONCURRENCY: '4',
|
||||
MEMIND_ORCHESTRATOR_SHADOW_MAX_QUEUE: '25',
|
||||
MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1',
|
||||
},
|
||||
createOrchestratorAdminConfigServiceFn(receivedPool) {
|
||||
assert.equal(receivedPool, setup.pool);
|
||||
@@ -201,10 +203,25 @@ test('wires Shadow observation only behind the explicit environment gate', () =>
|
||||
assert.equal(receivedObserverOptions.serviceToken, 'shadow-token');
|
||||
assert.equal(agentOptions.observeWorkflowRun, observer);
|
||||
assert.equal(agentOptions.observeWorkflowValidation, validationObserver);
|
||||
assert.equal(agentOptions.enforcePageDataWorkflowValidation, true);
|
||||
assert.equal(agentOptions.maxConcurrentShadowObservations, 4);
|
||||
assert.equal(agentOptions.maxQueuedShadowObservations, 25);
|
||||
});
|
||||
|
||||
test('keeps the Page Data validation gate disabled without Shadow wiring', () => {
|
||||
const setup = createSetup({
|
||||
env: {
|
||||
MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1',
|
||||
},
|
||||
});
|
||||
|
||||
bootstrapPortalGatewayServices(setup.options);
|
||||
|
||||
const { agentOptions } = setup.getCaptured();
|
||||
assert.equal(agentOptions.observeWorkflowValidation, null);
|
||||
assert.equal(agentOptions.enforcePageDataWorkflowValidation, false);
|
||||
});
|
||||
|
||||
test('preserves local asset reads and optional asset absence', async () => {
|
||||
const setup = createSetup();
|
||||
bootstrapPortalGatewayServices(setup.options);
|
||||
|
||||
@@ -151,6 +151,10 @@ function runtimeState(config, env = process.env) {
|
||||
env.MEMIND_ORCHESTRATOR_EXECUTION_HANDOFF_ENABLED,
|
||||
false,
|
||||
);
|
||||
const environmentPageDataValidationGate = normalizeBoolean(
|
||||
env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED,
|
||||
false,
|
||||
);
|
||||
const configured = config.primaryEngine !== WORKFLOW_ENGINE.LANGGRAPH || Boolean(config.serviceUrl);
|
||||
let reason = null;
|
||||
if (killSwitch) reason = 'kill_switch';
|
||||
@@ -175,6 +179,8 @@ function runtimeState(config, env = process.env) {
|
||||
const shadowObservationEnabled = !reason
|
||||
&& environmentShadowObservationGate
|
||||
&& shadowObservationRequested;
|
||||
const pageDataValidationGateEnabled = environmentPageDataValidationGate
|
||||
&& shadowObservationEnabled;
|
||||
return {
|
||||
killSwitch,
|
||||
configured,
|
||||
@@ -203,6 +209,19 @@ function runtimeState(config, env = process.env) {
|
||||
: 'environment_shadow_observation_gate_disabled',
|
||||
environmentGate: environmentShadowObservationGate,
|
||||
},
|
||||
pageDataValidationGate: {
|
||||
requested: environmentPageDataValidationGate,
|
||||
enabled: pageDataValidationGateEnabled,
|
||||
failClosed: true,
|
||||
reason: pageDataValidationGateEnabled
|
||||
? null
|
||||
: !environmentPageDataValidationGate
|
||||
? 'environment_page_data_validation_gate_disabled'
|
||||
: !shadowObservationEnabled
|
||||
? 'shadow_observation_not_enabled'
|
||||
: 'page_data_validation_gate_disabled',
|
||||
environmentGate: environmentPageDataValidationGate,
|
||||
},
|
||||
executionHandoff: {
|
||||
implemented: EXECUTION_HANDOFF_IMPLEMENTED,
|
||||
requested: executionHandoffRequested,
|
||||
|
||||
@@ -46,6 +46,13 @@ test('orchestrator config defaults to a disabled native-safe runtime', async ()
|
||||
reason: 'shadow_observation_not_requested',
|
||||
environmentGate: false,
|
||||
});
|
||||
assert.deepEqual(state.runtime.pageDataValidationGate, {
|
||||
requested: false,
|
||||
enabled: false,
|
||||
failClosed: true,
|
||||
reason: 'environment_page_data_validation_gate_disabled',
|
||||
environmentGate: false,
|
||||
});
|
||||
assert.equal(state.engines.find((engine) => engine.id === 'native').configured, true);
|
||||
assert.equal(state.engines.find((engine) => engine.id === 'langgraph').configured, false);
|
||||
assert.deepEqual(
|
||||
@@ -275,6 +282,34 @@ test('Shadow runtime reports the independent Portal observation wiring gate', as
|
||||
reason: null,
|
||||
environmentGate: true,
|
||||
});
|
||||
assert.deepEqual(runtime.runtime.pageDataValidationGate, {
|
||||
requested: false,
|
||||
enabled: false,
|
||||
failClosed: true,
|
||||
reason: 'environment_page_data_validation_gate_disabled',
|
||||
environmentGate: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('Page Data validation gate is effective only with Shadow wiring and its own environment gate', async () => {
|
||||
const service = createOrchestratorAdminConfigService(createPool(), {
|
||||
env: {
|
||||
MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED: '1',
|
||||
MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1',
|
||||
},
|
||||
});
|
||||
await service.updateAdminConfig({
|
||||
mode: 'shadow',
|
||||
serviceUrl: 'http://127.0.0.1:8093',
|
||||
});
|
||||
const runtime = await service.getRuntimeState();
|
||||
assert.deepEqual(runtime.runtime.pageDataValidationGate, {
|
||||
requested: true,
|
||||
enabled: true,
|
||||
failClosed: true,
|
||||
reason: null,
|
||||
environmentGate: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('orchestrator emergency kill switch always forces native selection', async () => {
|
||||
|
||||
Reference in New Issue
Block a user