feat(orchestrator): enforce Page Data validation gate

This commit is contained in:
john
2026-07-25 14:27:07 +08:00
parent 417aa9c78b
commit 721db19a92
10 changed files with 432 additions and 43 deletions
+97 -15
View File
@@ -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]);
+175
View File
@@ -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:
+7
View File
@@ -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;
+13
View File
@@ -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}
+25
View File
@@ -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);
+19
View File
@@ -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 () => {