feat: add pluggable workflow orchestrator controls
This commit is contained in:
@@ -19,6 +19,7 @@ import { createLlmProviderService } from './llm-providers.mjs';
|
|||||||
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
|
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
|
||||||
import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs';
|
import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs';
|
||||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||||
|
import { createOrchestratorAdminConfigService } from './services/orchestrator/admin-config.mjs';
|
||||||
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
|
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
|
||||||
import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs';
|
import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs';
|
||||||
import { createMindSearchConfigService } from './mindsearch-config.mjs';
|
import { createMindSearchConfigService } from './mindsearch-config.mjs';
|
||||||
@@ -109,6 +110,8 @@ export async function createAdminServices(env = {}) {
|
|||||||
});
|
});
|
||||||
await imageMakeAdminConfigService.ensureSchema();
|
await imageMakeAdminConfigService.ensureSchema();
|
||||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||||
|
const orchestratorConfigService = createOrchestratorAdminConfigService(pool);
|
||||||
|
await orchestratorConfigService.ensureSchema();
|
||||||
const mindSearchConfigService = createMindSearchConfigService(pool);
|
const mindSearchConfigService = createMindSearchConfigService(pool);
|
||||||
await mindSearchConfigService.ensureSchema();
|
await mindSearchConfigService.ensureSchema();
|
||||||
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root });
|
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root });
|
||||||
@@ -155,6 +158,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
assetGatewayConfigService,
|
assetGatewayConfigService,
|
||||||
imageMakeAdminConfigService,
|
imageMakeAdminConfigService,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
|
orchestratorConfigService,
|
||||||
mindSearchConfigService,
|
mindSearchConfigService,
|
||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
systemDisclosurePolicyService,
|
systemDisclosurePolicyService,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function plazaRouteError(res, req, error) {
|
|||||||
* @param {object} deps.userAuth
|
* @param {object} deps.userAuth
|
||||||
* @param {object|null} deps.llmProviderService
|
* @param {object|null} deps.llmProviderService
|
||||||
* @param {object|null} deps.memoryV2ConfigService
|
* @param {object|null} deps.memoryV2ConfigService
|
||||||
|
* @param {object|null} deps.orchestratorConfigService
|
||||||
* @param {object|null} deps.skillRuntimeConfigService
|
* @param {object|null} deps.skillRuntimeConfigService
|
||||||
* @param {object|null} deps.systemDisclosurePolicyService
|
* @param {object|null} deps.systemDisclosurePolicyService
|
||||||
* @param {object|null} deps.adminSystemTestService
|
* @param {object|null} deps.adminSystemTestService
|
||||||
@@ -47,6 +48,7 @@ export function createAdminApi({
|
|||||||
assetGatewayConfigService,
|
assetGatewayConfigService,
|
||||||
imageMakeAdminConfigService,
|
imageMakeAdminConfigService,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
|
orchestratorConfigService,
|
||||||
mindSearchConfigService,
|
mindSearchConfigService,
|
||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
systemDisclosurePolicyService,
|
systemDisclosurePolicyService,
|
||||||
@@ -225,6 +227,34 @@ export function createAdminApi({
|
|||||||
return res.json(result);
|
return res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminApi.get('/orchestrator/config', requireAdmin, async (_req, res) => {
|
||||||
|
if (!orchestratorConfigService?.getAdminConfig) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await orchestratorConfigService.getAdminConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOrchestratorConfig = async (req, res) => {
|
||||||
|
if (!orchestratorConfigService?.updateAdminConfig) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||||
|
}
|
||||||
|
const result = await orchestratorConfigService.updateAdminConfig(
|
||||||
|
req.body?.config ?? req.body ?? {},
|
||||||
|
{ updatedBy: req.currentUser.id },
|
||||||
|
);
|
||||||
|
return res.json(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
adminApi.put('/orchestrator/config', requireAdmin, updateOrchestratorConfig);
|
||||||
|
adminApi.patch('/orchestrator/config', requireAdmin, updateOrchestratorConfig);
|
||||||
|
|
||||||
|
adminApi.get('/orchestrator/runtime', requireAdmin, async (_req, res) => {
|
||||||
|
if (!orchestratorConfigService?.getRuntimeState) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await orchestratorConfigService.getRuntimeState());
|
||||||
|
});
|
||||||
|
|
||||||
adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => {
|
adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => {
|
||||||
if (!skillRuntimeConfigService?.getAdminConfig) {
|
if (!skillRuntimeConfigService?.getAdminConfig) {
|
||||||
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||||
|
|||||||
@@ -90,6 +90,88 @@ test('admin memory-v2 config routes expose config and runtime state', async () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admin orchestrator routes expose a versioned plug-in control plane', async () => {
|
||||||
|
const updates = [];
|
||||||
|
const state = {
|
||||||
|
config: {
|
||||||
|
mode: 'off',
|
||||||
|
primaryEngine: 'langgraph',
|
||||||
|
fallbackEngine: 'native',
|
||||||
|
serviceUrl: '',
|
||||||
|
requestTimeoutMs: 5000,
|
||||||
|
rolloutPercent: 0,
|
||||||
|
userAllowlist: [],
|
||||||
|
workflowAllowlist: ['code-run-v1'],
|
||||||
|
fallbackToNative: true,
|
||||||
|
requireHealthy: true,
|
||||||
|
},
|
||||||
|
configVersion: 1,
|
||||||
|
runtime: { effective: false, reason: 'mode_off' },
|
||||||
|
engines: [{ id: 'native', configured: true }, { id: 'langgraph', configured: false }],
|
||||||
|
};
|
||||||
|
const router = createAdminApi({
|
||||||
|
jsonBody: express.json(),
|
||||||
|
getToken() {
|
||||||
|
return 'token-admin';
|
||||||
|
},
|
||||||
|
userAuth: {
|
||||||
|
async getMe() {
|
||||||
|
return { id: 'admin-1', role: 'admin' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
llmProviderService: null,
|
||||||
|
memoryV2ConfigService: null,
|
||||||
|
orchestratorConfigService: {
|
||||||
|
async getAdminConfig() {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
async updateAdminConfig(config, context) {
|
||||||
|
updates.push({ config, context });
|
||||||
|
return { ...state, config: { ...state.config, ...config }, configVersion: 2 };
|
||||||
|
},
|
||||||
|
async getRuntimeState() {
|
||||||
|
return { ...state, source: 'admin-db' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plazaPosts: null,
|
||||||
|
plazaOps: null,
|
||||||
|
wechatAdmin: null,
|
||||||
|
subscriptionService: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = await startTestServer(router);
|
||||||
|
try {
|
||||||
|
const configResponse = await fetch(`${server.baseUrl}/admin-api/orchestrator/config`, {
|
||||||
|
headers: { cookie: 'h5_user_session=token-admin' },
|
||||||
|
});
|
||||||
|
assert.equal(configResponse.status, 200);
|
||||||
|
assert.equal((await configResponse.json()).config.mode, 'off');
|
||||||
|
|
||||||
|
const updateResponse = await fetch(`${server.baseUrl}/admin-api/orchestrator/config`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
cookie: 'h5_user_session=token-admin',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ config: { mode: 'shadow', serviceUrl: 'http://127.0.0.1:8093' } }),
|
||||||
|
});
|
||||||
|
assert.equal(updateResponse.status, 200);
|
||||||
|
assert.equal((await updateResponse.json()).configVersion, 2);
|
||||||
|
assert.deepEqual(updates, [{
|
||||||
|
config: { mode: 'shadow', serviceUrl: 'http://127.0.0.1:8093' },
|
||||||
|
context: { updatedBy: 'admin-1' },
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const runtimeResponse = await fetch(`${server.baseUrl}/admin-api/orchestrator/runtime`, {
|
||||||
|
headers: { cookie: 'h5_user_session=token-admin' },
|
||||||
|
});
|
||||||
|
assert.equal(runtimeResponse.status, 200);
|
||||||
|
assert.equal((await runtimeResponse.json()).source, 'admin-db');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('admin system disclosure policy routes version config through the injected control-plane service', async () => {
|
test('admin system disclosure policy routes version config through the injected control-plane service', async () => {
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const CONSOLES = {
|
|||||||
assetGatewayConfigService: services.assetGatewayConfigService,
|
assetGatewayConfigService: services.assetGatewayConfigService,
|
||||||
imageMakeAdminConfigService: services.imageMakeAdminConfigService,
|
imageMakeAdminConfigService: services.imageMakeAdminConfigService,
|
||||||
memoryV2ConfigService: services.memoryV2ConfigService,
|
memoryV2ConfigService: services.memoryV2ConfigService,
|
||||||
|
orchestratorConfigService: services.orchestratorConfigService,
|
||||||
mindSearchConfigService: services.mindSearchConfigService,
|
mindSearchConfigService: services.mindSearchConfigService,
|
||||||
systemDisclosurePolicyService: services.systemDisclosurePolicyService,
|
systemDisclosurePolicyService: services.systemDisclosurePolicyService,
|
||||||
adminSystemTestService: services.adminSystemTestService,
|
adminSystemTestService: services.adminSystemTestService,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Memind Workflow Orchestrator Boundary
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The Orchestrator starts in the Memind repository but is designed as an
|
||||||
|
independently deployable bounded context. Source colocation does not authorize
|
||||||
|
in-process execution inside Portal.
|
||||||
|
|
||||||
|
The stable boundary is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Memind Control Plane
|
||||||
|
-> versioned Orchestrator API / events
|
||||||
|
Workflow Orchestrator
|
||||||
|
-> versioned Executor Gateway
|
||||||
|
Executor adapters
|
||||||
|
-> Goosed / Aider / OpenHands
|
||||||
|
```
|
||||||
|
|
||||||
|
LangGraph remains an internal workflow engine implementation. Memind must not
|
||||||
|
depend on LangGraph graph, checkpoint, command, or interrupt types.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| Data | Owner |
|
||||||
|
|---|---|
|
||||||
|
| User, authorization, billing, product run | Memind |
|
||||||
|
| Workflow node state, checkpoint, interrupt | Orchestrator |
|
||||||
|
| Goosed session | Goosed plus Session Broker mapping |
|
||||||
|
| Executor job | Executor Gateway / executor adapter |
|
||||||
|
| Workspace and deliverable | Workspace / Artifact / MindSpace services |
|
||||||
|
| User-visible progress and audit | Memind run-event projection |
|
||||||
|
|
||||||
|
The Orchestrator must not read or write Memind user, billing, capability, or
|
||||||
|
provider-key tables. A shared PostgreSQL instance is acceptable during the
|
||||||
|
single-host phase, but the Orchestrator must use its own database and database
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
## Runtime modes
|
||||||
|
|
||||||
|
memindadm owns the versioned routing configuration:
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `off` | Native Agent Run only |
|
||||||
|
| `shadow` | Native executes; LangGraph observes and compares decisions |
|
||||||
|
| `canary` | Explicit users and deterministic percentage may use LangGraph |
|
||||||
|
| `active` | Workflow-allowlisted tasks use the primary engine |
|
||||||
|
|
||||||
|
All modes support an explicit Native fallback. The environment kill switch
|
||||||
|
`MEMIND_ORCHESTRATOR_KILL_SWITCH=1` overrides admin configuration and forces
|
||||||
|
Native selection.
|
||||||
|
|
||||||
|
The initial workflow allowlist contains only `code-run-v1`. Ordinary chat and
|
||||||
|
existing Goosed session traffic remain outside the Orchestrator path.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
The framework-neutral contracts are:
|
||||||
|
|
||||||
|
- `orchestrator-run-v1`
|
||||||
|
- `orchestrator-event-v1`
|
||||||
|
|
||||||
|
The eventual internal API is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /v1/runs
|
||||||
|
GET /v1/runs/:id
|
||||||
|
POST /v1/runs/:id/resume
|
||||||
|
POST /v1/runs/:id/cancel
|
||||||
|
GET /v1/runs/:id/events?after=<cursor>
|
||||||
|
```
|
||||||
|
|
||||||
|
External state changes must use an idempotency key derived from run, graph
|
||||||
|
version, node, and attempt. Graph state stores resource references and decisions,
|
||||||
|
not API keys, large logs, binary artifacts, or absolute production paths.
|
||||||
|
|
||||||
|
## Deployment evolution
|
||||||
|
|
||||||
|
1. Local native Orchestrator process with a development checkpoint database.
|
||||||
|
2. 103 shadow LaunchAgent; no production task claim.
|
||||||
|
3. User-allowlisted code-run canary with Native rollback.
|
||||||
|
4. Isolated Aider/OpenHands executor workers.
|
||||||
|
5. Containerized Orchestrator in its own Compose project.
|
||||||
|
6. Move the same service contract to Linux or Kubernetes when horizontal scaling
|
||||||
|
or independent ownership becomes necessary.
|
||||||
|
|
||||||
|
The Orchestrator must not share the Goosed Compose lifecycle or mount the Docker
|
||||||
|
socket. It calls MindSpace through its service API and calls Goosed through the
|
||||||
|
existing proxy boundary.
|
||||||
@@ -13,6 +13,7 @@ import { UsersPage } from './pages/admin/UsersPage';
|
|||||||
import { BillingPage } from './pages/admin/BillingPage';
|
import { BillingPage } from './pages/admin/BillingPage';
|
||||||
import { WechatPage } from './pages/admin/WechatPage';
|
import { WechatPage } from './pages/admin/WechatPage';
|
||||||
import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
|
import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
|
||||||
|
import { OrchestratorPage } from './pages/admin/OrchestratorPage';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -46,6 +47,7 @@ export function App() {
|
|||||||
<Route path="billing" element={<BillingPage />} />
|
<Route path="billing" element={<BillingPage />} />
|
||||||
<Route path="wechat" element={<WechatPage />} />
|
<Route path="wechat" element={<WechatPage />} />
|
||||||
<Route path="policy" element={<SystemPolicyPage />} />
|
<Route path="policy" element={<SystemPolicyPage />} />
|
||||||
|
<Route path="orchestrator" element={<OrchestratorPage />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,48 @@ export type SystemDisclosurePolicyState = {
|
|||||||
lastRefreshError?: string | null;
|
lastRefreshError?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrchestratorMode = 'off' | 'shadow' | 'canary' | 'active';
|
||||||
|
|
||||||
|
export type OrchestratorConfig = {
|
||||||
|
mode: OrchestratorMode;
|
||||||
|
primaryEngine: string;
|
||||||
|
fallbackEngine: string;
|
||||||
|
serviceUrl: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
rolloutPercent: number;
|
||||||
|
userAllowlist: string[];
|
||||||
|
workflowAllowlist: string[];
|
||||||
|
fallbackToNative: boolean;
|
||||||
|
requireHealthy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorRuntime = {
|
||||||
|
killSwitch: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
effective: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
executesLangGraph: boolean;
|
||||||
|
shadowsLangGraph: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorEngineDescriptor = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
configured: boolean;
|
||||||
|
capabilities: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorConfigState = {
|
||||||
|
config: OrchestratorConfig;
|
||||||
|
configVersion: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
updatedAt: number | null;
|
||||||
|
source: string;
|
||||||
|
runtime: OrchestratorRuntime;
|
||||||
|
engines: OrchestratorEngineDescriptor[];
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function fetchAdminSummary() {
|
export async function fetchAdminSummary() {
|
||||||
@@ -217,6 +259,19 @@ export async function updateSystemDisclosurePolicy(config: SystemDisclosurePolic
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Workflow Orchestrator ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchOrchestratorConfig() {
|
||||||
|
return adminFetch<OrchestratorConfigState>('/admin-api/orchestrator/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrchestratorConfig(config: OrchestratorConfig) {
|
||||||
|
return adminFetch<OrchestratorConfigState>('/admin-api/orchestrator/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ config }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function fetchAdminUsers(params: {
|
export async function fetchAdminUsers(params: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const links = [
|
|||||||
{ to: '/admin/billing', label: '账单记录' },
|
{ to: '/admin/billing', label: '账单记录' },
|
||||||
{ to: '/admin/wechat', label: '服务号管理' },
|
{ to: '/admin/wechat', label: '服务号管理' },
|
||||||
{ to: '/admin/policy', label: '策略中心' },
|
{ to: '/admin/policy', label: '策略中心' },
|
||||||
|
{ to: '/admin/orchestrator', label: '任务编排' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
@@ -13,7 +14,7 @@ export function AdminLayout() {
|
|||||||
<div className="layout">
|
<div className="layout">
|
||||||
<header>
|
<header>
|
||||||
<h1>超级管理后台</h1>
|
<h1>超级管理后台</h1>
|
||||||
<p style={{ color: '#68716c' }}>用户、计费与服务号</p>
|
<p style={{ color: '#68716c' }}>用户、计费、策略与任务编排</p>
|
||||||
</header>
|
</header>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchOrchestratorConfig,
|
||||||
|
updateOrchestratorConfig,
|
||||||
|
type OrchestratorConfig,
|
||||||
|
type OrchestratorConfigState,
|
||||||
|
} from '../../api/admin';
|
||||||
|
|
||||||
|
function listToText(values: string[]) {
|
||||||
|
return values.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToList(value: string) {
|
||||||
|
return [...new Set(value.split(/\r?\n|,/).map((item) => item.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: number | null) {
|
||||||
|
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未保存';
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUNTIME_REASON: Record<string, string> = {
|
||||||
|
mode_off: '当前为关闭模式,所有任务继续由 Native Agent Run 执行。',
|
||||||
|
service_url_missing: '尚未配置 Orchestrator 服务地址,LangGraph 不会接管任务。',
|
||||||
|
kill_switch: '环境级紧急熔断已开启,强制回退 Native。',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrchestratorPage() {
|
||||||
|
const [state, setState] = useState<OrchestratorConfigState | null>(null);
|
||||||
|
const [draft, setDraft] = useState<OrchestratorConfig | null>(null);
|
||||||
|
const [userAllowlist, setUserAllowlist] = useState('');
|
||||||
|
const [workflowAllowlist, setWorkflowAllowlist] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await fetchOrchestratorConfig();
|
||||||
|
setState(result);
|
||||||
|
setDraft(result.config);
|
||||||
|
setUserAllowlist(listToText(result.config.userAllowlist));
|
||||||
|
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizedDraft = useMemo(() => draft ? {
|
||||||
|
...draft,
|
||||||
|
userAllowlist: textToList(userAllowlist),
|
||||||
|
workflowAllowlist: textToList(workflowAllowlist),
|
||||||
|
} : null, [draft, userAllowlist, workflowAllowlist]);
|
||||||
|
|
||||||
|
const dirty = Boolean(
|
||||||
|
state && normalizedDraft && JSON.stringify(normalizedDraft) !== JSON.stringify(state.config),
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!normalizedDraft) return;
|
||||||
|
if (
|
||||||
|
['canary', 'active'].includes(normalizedDraft.mode)
|
||||||
|
&& !normalizedDraft.serviceUrl
|
||||||
|
) {
|
||||||
|
setError('Canary / Active 模式必须先配置 Orchestrator 服务地址。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalizedDraft.mode === 'active'
|
||||||
|
&& !window.confirm('确认启用 Active?工作流白名单内的任务将由 LangGraph Orchestrator 接管。')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const result = await updateOrchestratorConfig(normalizedDraft);
|
||||||
|
setState(result);
|
||||||
|
setDraft(result.config);
|
||||||
|
setUserAllowlist(listToText(result.config.userAllowlist));
|
||||||
|
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
|
||||||
|
setNotice(`Orchestrator 配置 v${result.configVersion} 已保存。`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p>加载中…</p>;
|
||||||
|
if (!state || !draft) return <p className="alert">{error ?? 'Orchestrator 配置不可用'}</p>;
|
||||||
|
|
||||||
|
const runtimeMessage = state.runtime.reason
|
||||||
|
? (RUNTIME_REASON[state.runtime.reason] ?? state.runtime.reason)
|
||||||
|
: state.runtime.shadowsLangGraph
|
||||||
|
? 'Shadow 已生效:Native 继续执行,LangGraph 只观察和生成决策。'
|
||||||
|
: state.runtime.executesLangGraph
|
||||||
|
? 'LangGraph 路由已生效。'
|
||||||
|
: '配置有效。';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid">
|
||||||
|
<div className="card">
|
||||||
|
<h2 style={{ marginTop: 0 }}>Workflow Orchestrator</h2>
|
||||||
|
<p style={{ color: '#68716c', marginBottom: 0 }}>
|
||||||
|
LangGraph 通过通用 WorkflowEngine 协议接入。关闭和 Shadow 模式不会改变现有 Native Agent Run 执行路径。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="alert">{error}</p> : null}
|
||||||
|
{notice ? <p style={{ color: '#2f6f57', fontSize: 13 }}>{notice}</p> : null}
|
||||||
|
|
||||||
|
<div className="card grid">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<strong>配置版本</strong>
|
||||||
|
<p style={{ marginBottom: 0 }}>v{state.configVersion}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>运行状态</strong>
|
||||||
|
<p style={{ marginBottom: 0 }}>{state.runtime.effective ? '有效' : '安全回退'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>配置来源</strong>
|
||||||
|
<p style={{ marginBottom: 0 }}>{state.source}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>更新时间</strong>
|
||||||
|
<p style={{ marginBottom: 0 }}>{formatTime(state.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={state.runtime.reason === 'kill_switch' ? 'alert' : 'warn'} style={{ marginBottom: 0 }}>
|
||||||
|
{runtimeMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card grid">
|
||||||
|
<h3 style={{ margin: 0 }}>路由模式</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||||
|
<label>
|
||||||
|
<strong>运行模式</strong>
|
||||||
|
<select
|
||||||
|
value={draft.mode}
|
||||||
|
onChange={(event) => setDraft({
|
||||||
|
...draft,
|
||||||
|
mode: event.target.value as OrchestratorConfig['mode'],
|
||||||
|
})}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
<option value="off">Off — Native only</option>
|
||||||
|
<option value="shadow">Shadow — Native 执行,LangGraph 观察</option>
|
||||||
|
<option value="canary">Canary — 白名单/比例灰度</option>
|
||||||
|
<option value="active">Active — 白名单工作流正式接管</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<strong>主执行引擎</strong>
|
||||||
|
<select
|
||||||
|
value={draft.primaryEngine}
|
||||||
|
onChange={(event) => setDraft({ ...draft, primaryEngine: event.target.value })}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
{state.engines.map((engine) => (
|
||||||
|
<option key={engine.id} value={engine.id}>
|
||||||
|
{engine.label}{engine.configured ? '' : '(未配置)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<strong>灰度百分比</strong>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={draft.rolloutPercent}
|
||||||
|
onChange={(event) => setDraft({
|
||||||
|
...draft,
|
||||||
|
rolloutPercent: Number(event.target.value),
|
||||||
|
})}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card grid">
|
||||||
|
<h3 style={{ margin: 0 }}>服务连接</h3>
|
||||||
|
<label>
|
||||||
|
<strong>Orchestrator URL</strong>
|
||||||
|
<input
|
||||||
|
value={draft.serviceUrl}
|
||||||
|
placeholder="http://127.0.0.1:8093"
|
||||||
|
onChange={(event) => setDraft({ ...draft, serviceUrl: event.target.value })}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<strong>请求超时(毫秒)</strong>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={500}
|
||||||
|
max={60000}
|
||||||
|
value={draft.requestTimeoutMs}
|
||||||
|
onChange={(event) => setDraft({
|
||||||
|
...draft,
|
||||||
|
requestTimeoutMs: Number(event.target.value),
|
||||||
|
})}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.requireHealthy}
|
||||||
|
onChange={(event) => setDraft({ ...draft, requireHealthy: event.target.checked })}
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
/>
|
||||||
|
接管任务前要求 Orchestrator 健康
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.fallbackToNative}
|
||||||
|
onChange={(event) => setDraft({ ...draft, fallbackToNative: event.target.checked })}
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
/>
|
||||||
|
Orchestrator 不可用时回退 Native
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card grid">
|
||||||
|
<h3 style={{ margin: 0 }}>灰度范围</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
<label>
|
||||||
|
<strong>工作流白名单</strong>
|
||||||
|
<span style={{ display: 'block', color: '#68716c', fontSize: 12, margin: '4px 0 8px' }}>
|
||||||
|
每行一个。Active 也只接管这里列出的工作流。
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={workflowAllowlist}
|
||||||
|
onChange={(event) => setWorkflowAllowlist(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<strong>用户白名单</strong>
|
||||||
|
<span style={{ display: 'block', color: '#68716c', fontSize: 12, margin: '4px 0 8px' }}>
|
||||||
|
每行一个用户 ID;Canary 下优先于百分比。
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={userAllowlist}
|
||||||
|
onChange={(event) => setUserAllowlist(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button type="button" className="btn secondary" onClick={() => void load()} disabled={saving}>
|
||||||
|
放弃修改
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={() => void save()} disabled={saving || !dirty}>
|
||||||
|
{saving ? '保存中…' : '保存配置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -61,7 +61,7 @@
|
|||||||
"test:scenario": "node scripts/run-scenario-test.mjs",
|
"test:scenario": "node scripts/run-scenario-test.mjs",
|
||||||
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
|
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
|
||||||
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
|
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
|
||||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
||||||
"test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs",
|
"test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs",
|
||||||
"test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs",
|
"test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs",
|
||||||
"test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs",
|
"test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Memind Workflow Orchestrator
|
||||||
|
|
||||||
|
This directory is the extraction boundary for Memind's durable workflow runtime.
|
||||||
|
It is intentionally source-colocated with Memind while remaining framework-neutral
|
||||||
|
at its public edge.
|
||||||
|
|
||||||
|
## Dependency rule
|
||||||
|
|
||||||
|
Allowed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Memind -> orchestrator contracts / client
|
||||||
|
Orchestrator -> contracts / executor adapters
|
||||||
|
Executor adapters -> versioned internal APIs
|
||||||
|
```
|
||||||
|
|
||||||
|
Forbidden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Orchestrator -> server.mjs
|
||||||
|
Orchestrator -> user-auth.mjs
|
||||||
|
Orchestrator -> Memind business tables
|
||||||
|
Orchestrator -> production filesystem paths
|
||||||
|
Memind -> LangGraph StateGraph / Command / checkpoint types
|
||||||
|
```
|
||||||
|
|
||||||
|
LangGraph is an implementation of the generic workflow engine contract. The
|
||||||
|
Memind-facing protocol is `orchestrator-run-v1` plus `orchestrator-event-v1`.
|
||||||
|
|
||||||
|
## Current stage
|
||||||
|
|
||||||
|
Phase 1 establishes:
|
||||||
|
|
||||||
|
- framework-neutral run and event contracts;
|
||||||
|
- a plug-in workflow engine registry;
|
||||||
|
- a remote workflow engine client;
|
||||||
|
- versioned admin configuration;
|
||||||
|
- deterministic Off / Shadow / Canary / Active routing decisions;
|
||||||
|
- an environment-level emergency kill switch.
|
||||||
|
|
||||||
|
The default mode is `off`. No production task is routed to LangGraph in this
|
||||||
|
phase. Runtime dispatch wiring and a separately deployed Orchestrator worker are
|
||||||
|
subsequent phases.
|
||||||
|
|
||||||
|
## Admin configuration
|
||||||
|
|
||||||
|
The memindadm page is `/ops/admin/orchestrator`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
`MEMIND_ORCHESTRATOR_KILL_SWITCH=1` always forces Native selection.
|
||||||
|
|
||||||
|
## Extraction test
|
||||||
|
|
||||||
|
The service is ready to move into a separate repository only when:
|
||||||
|
|
||||||
|
1. it can run against a mock Memind control plane;
|
||||||
|
2. it owns its checkpoint database and credentials;
|
||||||
|
3. task inputs use resource references rather than host paths;
|
||||||
|
4. executor calls use versioned adapters;
|
||||||
|
5. moving the service requires only changing `ORCHESTRATOR_URL`.
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_ORCHESTRATED_WORKFLOWS,
|
||||||
|
ORCHESTRATOR_MODE,
|
||||||
|
WORKFLOW_ENGINE,
|
||||||
|
normalizeStringList,
|
||||||
|
normalizeWorkflowEngineId,
|
||||||
|
normalizeWorkflowName,
|
||||||
|
stableRolloutBucket,
|
||||||
|
} from './contracts.mjs';
|
||||||
|
|
||||||
|
const CONFIG_TABLE = 'h5_orchestrator_admin_config';
|
||||||
|
const CONFIG_SCOPE = 'global';
|
||||||
|
|
||||||
|
function normalizeBoolean(value, fallback = false) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInteger(value, fallback, min, max) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(number)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMode(value, fallback = ORCHESTRATOR_MODE.OFF) {
|
||||||
|
const normalized = String(value ?? '').trim().toLowerCase();
|
||||||
|
return Object.values(ORCHESTRATOR_MODE).includes(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServiceUrl(value) {
|
||||||
|
const raw = String(value ?? '').trim().replace(/\/$/, '');
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) return '';
|
||||||
|
url.username = '';
|
||||||
|
url.password = '';
|
||||||
|
url.hash = '';
|
||||||
|
return url.toString().replace(/\/$/, '').slice(0, 2048);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonLike(value, fallback) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(value));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
if (typeof structuredClone === 'function') return structuredClone(value);
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultOrchestratorConfig() {
|
||||||
|
return {
|
||||||
|
mode: ORCHESTRATOR_MODE.OFF,
|
||||||
|
primaryEngine: WORKFLOW_ENGINE.LANGGRAPH,
|
||||||
|
fallbackEngine: WORKFLOW_ENGINE.NATIVE,
|
||||||
|
serviceUrl: '',
|
||||||
|
requestTimeoutMs: 5000,
|
||||||
|
rolloutPercent: 0,
|
||||||
|
userAllowlist: [],
|
||||||
|
workflowAllowlist: [...DEFAULT_ORCHESTRATED_WORKFLOWS],
|
||||||
|
fallbackToNative: true,
|
||||||
|
requireHealthy: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOrchestratorConfig(input = {}, fallback = defaultOrchestratorConfig()) {
|
||||||
|
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
||||||
|
const base = fallback && typeof fallback === 'object' ? fallback : defaultOrchestratorConfig();
|
||||||
|
const workflowAllowlist = normalizeStringList(
|
||||||
|
source.workflowAllowlist ?? base.workflowAllowlist,
|
||||||
|
{ normalize: (item) => normalizeWorkflowName(item, ''), itemLimit: 128 },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
mode: normalizeMode(source.mode, base.mode),
|
||||||
|
primaryEngine: normalizeWorkflowEngineId(source.primaryEngine, base.primaryEngine),
|
||||||
|
fallbackEngine: normalizeWorkflowEngineId(source.fallbackEngine, base.fallbackEngine),
|
||||||
|
serviceUrl: normalizeServiceUrl(source.serviceUrl ?? base.serviceUrl),
|
||||||
|
requestTimeoutMs: clampInteger(
|
||||||
|
source.requestTimeoutMs,
|
||||||
|
base.requestTimeoutMs,
|
||||||
|
500,
|
||||||
|
60_000,
|
||||||
|
),
|
||||||
|
rolloutPercent: clampInteger(source.rolloutPercent, base.rolloutPercent, 0, 100),
|
||||||
|
userAllowlist: normalizeStringList(source.userAllowlist ?? base.userAllowlist, {
|
||||||
|
itemLimit: 128,
|
||||||
|
}),
|
||||||
|
workflowAllowlist: workflowAllowlist.length
|
||||||
|
? workflowAllowlist
|
||||||
|
: [...DEFAULT_ORCHESTRATED_WORKFLOWS],
|
||||||
|
fallbackToNative: normalizeBoolean(source.fallbackToNative, base.fallbackToNative),
|
||||||
|
requireHealthy: normalizeBoolean(source.requireHealthy, base.requireHealthy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeState(config, env = process.env) {
|
||||||
|
const killSwitch = normalizeBoolean(env.MEMIND_ORCHESTRATOR_KILL_SWITCH, false);
|
||||||
|
const configured = config.primaryEngine !== WORKFLOW_ENGINE.LANGGRAPH || Boolean(config.serviceUrl);
|
||||||
|
let reason = null;
|
||||||
|
if (killSwitch) reason = 'kill_switch';
|
||||||
|
else if (config.mode === ORCHESTRATOR_MODE.OFF) reason = 'mode_off';
|
||||||
|
else if (!configured) reason = 'service_url_missing';
|
||||||
|
return {
|
||||||
|
killSwitch,
|
||||||
|
configured,
|
||||||
|
effective: !reason,
|
||||||
|
reason,
|
||||||
|
executesLangGraph: !reason
|
||||||
|
&& [ORCHESTRATOR_MODE.CANARY, ORCHESTRATOR_MODE.ACTIVE].includes(config.mode)
|
||||||
|
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH,
|
||||||
|
shadowsLangGraph: !reason
|
||||||
|
&& config.mode === ORCHESTRATOR_MODE.SHADOW
|
||||||
|
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineCatalog(config) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: WORKFLOW_ENGINE.NATIVE,
|
||||||
|
label: 'Native Agent Run',
|
||||||
|
kind: 'built-in',
|
||||||
|
configured: true,
|
||||||
|
capabilities: ['existing-runtime', 'tool-gateway'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: WORKFLOW_ENGINE.LANGGRAPH,
|
||||||
|
label: 'LangGraph Orchestrator',
|
||||||
|
kind: 'remote',
|
||||||
|
configured: Boolean(config.serviceUrl),
|
||||||
|
capabilities: ['durable-execution', 'interrupt', 'streaming'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureConfigTable(pool) {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
|
||||||
|
config_scope VARCHAR(32) PRIMARY KEY,
|
||||||
|
config_json JSON NOT NULL,
|
||||||
|
config_version INT NOT NULL DEFAULT 1,
|
||||||
|
updated_by CHAR(36) NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStoredState(pool) {
|
||||||
|
await ensureConfigTable(pool);
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT config_json, config_version, updated_by, updated_at
|
||||||
|
FROM ${CONFIG_TABLE}
|
||||||
|
WHERE config_scope = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[CONFIG_SCOPE],
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
config: normalizeOrchestratorConfig(parseJsonLike(row.config_json, {})),
|
||||||
|
configVersion: Math.max(1, Number(row.config_version ?? 1) || 1),
|
||||||
|
updatedBy: row.updated_by ?? null,
|
||||||
|
updatedAt: Number(row.updated_at ?? 0) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrchestratorAdminConfigService(pool, { env = process.env } = {}) {
|
||||||
|
async function loadEffectiveState() {
|
||||||
|
const stored = await loadStoredState(pool);
|
||||||
|
if (stored) return { ...stored, source: 'admin-db' };
|
||||||
|
const config = normalizeOrchestratorConfig({
|
||||||
|
mode: env.MEMIND_ORCHESTRATOR_MODE,
|
||||||
|
serviceUrl: env.MEMIND_ORCHESTRATOR_URL,
|
||||||
|
});
|
||||||
|
const fromEnv = Boolean(env.MEMIND_ORCHESTRATOR_MODE || env.MEMIND_ORCHESTRATOR_URL);
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
configVersion: 1,
|
||||||
|
updatedBy: null,
|
||||||
|
updatedAt: null,
|
||||||
|
source: fromEnv ? 'env' : 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensureSchema() {
|
||||||
|
return ensureConfigTable(pool);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAdminConfig() {
|
||||||
|
const state = await loadEffectiveState();
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
runtime: runtimeState(state.config, env),
|
||||||
|
engines: engineCatalog(state.config),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
|
||||||
|
const stored = await loadStoredState(pool);
|
||||||
|
const current = stored?.config ?? defaultOrchestratorConfig();
|
||||||
|
const nextConfig = normalizeOrchestratorConfig(
|
||||||
|
{ ...current, ...(patch?.config ?? patch) },
|
||||||
|
current,
|
||||||
|
);
|
||||||
|
const configVersion = (stored?.configVersion ?? 0) + 1;
|
||||||
|
const now = Date.now();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO ${CONFIG_TABLE}
|
||||||
|
(config_scope, config_json, config_version, updated_by, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
config_json = VALUES(config_json),
|
||||||
|
config_version = VALUES(config_version),
|
||||||
|
updated_by = VALUES(updated_by),
|
||||||
|
updated_at = VALUES(updated_at)`,
|
||||||
|
[CONFIG_SCOPE, JSON.stringify(nextConfig), configVersion, updatedBy, now],
|
||||||
|
);
|
||||||
|
return this.getAdminConfig();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRuntimeState() {
|
||||||
|
const state = await loadEffectiveState();
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
runtime: runtimeState(state.config, env),
|
||||||
|
engines: engineCatalog(state.config),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectEngine({
|
||||||
|
runId,
|
||||||
|
requestId,
|
||||||
|
userId = null,
|
||||||
|
workflowName,
|
||||||
|
} = {}) {
|
||||||
|
const state = await loadEffectiveState();
|
||||||
|
const config = state.config;
|
||||||
|
const runtime = runtimeState(config, env);
|
||||||
|
const normalizedWorkflow = normalizeWorkflowName(workflowName, '');
|
||||||
|
const workflowMatched = config.workflowAllowlist.includes(normalizedWorkflow);
|
||||||
|
const base = {
|
||||||
|
engine: WORKFLOW_ENGINE.NATIVE,
|
||||||
|
shadowEngine: null,
|
||||||
|
fallbackEngine: config.fallbackToNative ? config.fallbackEngine : null,
|
||||||
|
mode: config.mode,
|
||||||
|
workflowMatched,
|
||||||
|
configVersion: state.configVersion,
|
||||||
|
reason: runtime.reason,
|
||||||
|
};
|
||||||
|
if (!runtime.effective || !workflowMatched) {
|
||||||
|
return { ...base, reason: runtime.reason ?? 'workflow_not_enabled' };
|
||||||
|
}
|
||||||
|
if (config.mode === ORCHESTRATOR_MODE.SHADOW) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
shadowEngine: config.primaryEngine,
|
||||||
|
reason: 'shadow',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (config.mode === ORCHESTRATOR_MODE.ACTIVE) {
|
||||||
|
return { ...base, engine: config.primaryEngine, reason: 'active' };
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orchestratorAdminConfigInternals = {
|
||||||
|
CONFIG_SCOPE,
|
||||||
|
CONFIG_TABLE,
|
||||||
|
engineCatalog,
|
||||||
|
runtimeState,
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createOrchestratorAdminConfigService,
|
||||||
|
defaultOrchestratorConfig,
|
||||||
|
normalizeOrchestratorConfig,
|
||||||
|
} from './admin-config.mjs';
|
||||||
|
|
||||||
|
function createPool() {
|
||||||
|
let row = null;
|
||||||
|
return {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
if (sql.includes('CREATE TABLE')) return [[], []];
|
||||||
|
if (sql.includes('SELECT config_json')) return [[...(row ? [row] : [])], []];
|
||||||
|
if (sql.includes('INSERT INTO')) {
|
||||||
|
row = {
|
||||||
|
config_json: params[1],
|
||||||
|
config_version: params[2],
|
||||||
|
updated_by: params[3],
|
||||||
|
updated_at: params[4],
|
||||||
|
};
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected SQL: ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('orchestrator config defaults to a disabled native-safe runtime', async () => {
|
||||||
|
const service = createOrchestratorAdminConfigService(createPool(), { env: {} });
|
||||||
|
const state = await service.getAdminConfig();
|
||||||
|
assert.equal(state.config.mode, 'off');
|
||||||
|
assert.equal(state.config.primaryEngine, 'langgraph');
|
||||||
|
assert.equal(state.runtime.effective, false);
|
||||||
|
assert.equal(state.runtime.reason, 'mode_off');
|
||||||
|
assert.equal(state.engines.find((engine) => engine.id === 'native').configured, true);
|
||||||
|
assert.equal(state.engines.find((engine) => engine.id === 'langgraph').configured, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orchestrator config normalizes unsafe values and keeps a workflow allowlist', () => {
|
||||||
|
const normalized = normalizeOrchestratorConfig({
|
||||||
|
mode: 'active',
|
||||||
|
serviceUrl: 'file:///tmp/graph',
|
||||||
|
requestTimeoutMs: 1,
|
||||||
|
rolloutPercent: 500,
|
||||||
|
workflowAllowlist: ['code-run-v1', 'INVALID WORKFLOW', 'code-run-v1'],
|
||||||
|
});
|
||||||
|
assert.equal(normalized.serviceUrl, '');
|
||||||
|
assert.equal(normalized.requestTimeoutMs, 500);
|
||||||
|
assert.equal(normalized.rolloutPercent, 100);
|
||||||
|
assert.deepEqual(normalized.workflowAllowlist, ['code-run-v1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orchestrator config persists versioned admin updates and selects canary users', async () => {
|
||||||
|
const service = createOrchestratorAdminConfigService(createPool(), { env: {} });
|
||||||
|
const updated = await service.updateAdminConfig({
|
||||||
|
...defaultOrchestratorConfig(),
|
||||||
|
mode: 'canary',
|
||||||
|
serviceUrl: 'http://127.0.0.1:8093',
|
||||||
|
rolloutPercent: 0,
|
||||||
|
userAllowlist: ['user-1'],
|
||||||
|
}, { updatedBy: 'admin-1' });
|
||||||
|
assert.equal(updated.configVersion, 1);
|
||||||
|
assert.equal(updated.updatedBy, 'admin-1');
|
||||||
|
assert.equal(updated.runtime.effective, true);
|
||||||
|
|
||||||
|
const selected = await service.selectEngine({
|
||||||
|
runId: 'run-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
workflowName: 'code-run-v1',
|
||||||
|
});
|
||||||
|
assert.equal(selected.engine, 'langgraph');
|
||||||
|
assert.equal(selected.reason, 'user_allowlist');
|
||||||
|
|
||||||
|
const native = await service.selectEngine({
|
||||||
|
runId: 'run-2',
|
||||||
|
userId: 'user-2',
|
||||||
|
workflowName: 'code-run-v1',
|
||||||
|
});
|
||||||
|
assert.equal(native.engine, 'native');
|
||||||
|
assert.equal(native.reason, 'canary_not_selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orchestrator emergency kill switch always forces native selection', async () => {
|
||||||
|
const service = createOrchestratorAdminConfigService(createPool(), {
|
||||||
|
env: { MEMIND_ORCHESTRATOR_KILL_SWITCH: '1' },
|
||||||
|
});
|
||||||
|
await service.updateAdminConfig({
|
||||||
|
mode: 'active',
|
||||||
|
serviceUrl: 'http://127.0.0.1:8093',
|
||||||
|
});
|
||||||
|
const selected = await service.selectEngine({
|
||||||
|
runId: 'run-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
workflowName: 'code-run-v1',
|
||||||
|
});
|
||||||
|
assert.equal(selected.engine, 'native');
|
||||||
|
assert.equal(selected.reason, 'kill_switch');
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export const ORCHESTRATOR_MODE = Object.freeze({
|
||||||
|
OFF: 'off',
|
||||||
|
SHADOW: 'shadow',
|
||||||
|
CANARY: 'canary',
|
||||||
|
ACTIVE: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WORKFLOW_ENGINE = Object.freeze({
|
||||||
|
NATIVE: 'native',
|
||||||
|
LANGGRAPH: 'langgraph',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DEFAULT_ORCHESTRATED_WORKFLOWS = Object.freeze([
|
||||||
|
'code-run-v1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ENGINE_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
||||||
|
const WORKFLOW_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,127}$/;
|
||||||
|
|
||||||
|
function normalizeIdentifier(value, pattern, fallback) {
|
||||||
|
const normalized = String(value ?? '').trim().toLowerCase();
|
||||||
|
return pattern.test(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkflowEngineId(value, fallback = WORKFLOW_ENGINE.NATIVE) {
|
||||||
|
return normalizeIdentifier(value, ENGINE_ID_PATTERN, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkflowName(value, fallback = '') {
|
||||||
|
return normalizeIdentifier(value, WORKFLOW_NAME_PATTERN, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStringList(value, {
|
||||||
|
limit = 200,
|
||||||
|
itemLimit = 128,
|
||||||
|
normalize = (item) => String(item ?? '').trim(),
|
||||||
|
} = {}) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return [...new Set(value
|
||||||
|
.map((item) => normalize(item).slice(0, itemLimit))
|
||||||
|
.filter(Boolean))]
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRunSpec(input = {}) {
|
||||||
|
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
||||||
|
const requestId = String(source.requestId ?? '').trim();
|
||||||
|
const runId = String(source.runId ?? crypto.randomUUID()).trim();
|
||||||
|
const workflowName = normalizeWorkflowName(
|
||||||
|
source.workflow?.name ?? source.workflowName,
|
||||||
|
);
|
||||||
|
if (!requestId) throw new Error('RunSpec requires requestId');
|
||||||
|
if (!runId) throw new Error('RunSpec requires runId');
|
||||||
|
if (!workflowName) throw new Error('RunSpec requires workflow.name');
|
||||||
|
return {
|
||||||
|
version: 'orchestrator-run-v1',
|
||||||
|
runId,
|
||||||
|
requestId,
|
||||||
|
workflow: {
|
||||||
|
name: workflowName,
|
||||||
|
version: Math.max(1, Number(source.workflow?.version ?? 1) || 1),
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
tenantId: String(source.subject?.tenantId ?? '').trim() || null,
|
||||||
|
userId: String(source.subject?.userId ?? '').trim() || null,
|
||||||
|
},
|
||||||
|
input: source.input && typeof source.input === 'object' ? structuredClone(source.input) : {},
|
||||||
|
policy: source.policy && typeof source.policy === 'object' ? structuredClone(source.policy) : {},
|
||||||
|
limits: source.limits && typeof source.limits === 'object' ? structuredClone(source.limits) : {},
|
||||||
|
metadata: source.metadata && typeof source.metadata === 'object'
|
||||||
|
? structuredClone(source.metadata)
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRunEvent({
|
||||||
|
eventId = crypto.randomUUID(),
|
||||||
|
runId,
|
||||||
|
sequence,
|
||||||
|
type,
|
||||||
|
timestamp = Date.now(),
|
||||||
|
data = null,
|
||||||
|
} = {}) {
|
||||||
|
const normalizedRunId = String(runId ?? '').trim();
|
||||||
|
const normalizedType = String(type ?? '').trim();
|
||||||
|
if (!normalizedRunId) throw new Error('RunEvent requires runId');
|
||||||
|
if (!normalizedType) throw new Error('RunEvent requires type');
|
||||||
|
return {
|
||||||
|
version: 'orchestrator-event-v1',
|
||||||
|
eventId: String(eventId),
|
||||||
|
runId: normalizedRunId,
|
||||||
|
sequence: Math.max(0, Number(sequence ?? 0) || 0),
|
||||||
|
type: normalizedType,
|
||||||
|
timestamp: Number(timestamp) || Date.now(),
|
||||||
|
data: data == null ? null : structuredClone(data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stableRolloutBucket(value) {
|
||||||
|
const hash = crypto.createHash('sha256').update(String(value ?? '')).digest();
|
||||||
|
return hash.readUInt32BE(0) % 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildRunEvent,
|
||||||
|
normalizeRunSpec,
|
||||||
|
stableRolloutBucket,
|
||||||
|
} from './contracts.mjs';
|
||||||
|
import {
|
||||||
|
createRemoteWorkflowEngine,
|
||||||
|
createWorkflowEngineRegistry,
|
||||||
|
} from './engine-registry.mjs';
|
||||||
|
|
||||||
|
test('orchestrator contracts normalize run specs without exposing framework types', () => {
|
||||||
|
const spec = normalizeRunSpec({
|
||||||
|
runId: 'run-1',
|
||||||
|
requestId: 'request-1',
|
||||||
|
workflow: { name: 'code-run-v1', version: 2 },
|
||||||
|
subject: { userId: 'user-1' },
|
||||||
|
input: { instruction: 'fix tests' },
|
||||||
|
});
|
||||||
|
assert.equal(spec.version, 'orchestrator-run-v1');
|
||||||
|
assert.equal(spec.workflow.name, 'code-run-v1');
|
||||||
|
assert.equal(spec.workflow.version, 2);
|
||||||
|
assert.equal(spec.subject.userId, 'user-1');
|
||||||
|
|
||||||
|
const event = buildRunEvent({
|
||||||
|
runId: spec.runId,
|
||||||
|
sequence: 1,
|
||||||
|
type: 'workflow.started',
|
||||||
|
});
|
||||||
|
assert.equal(event.version, 'orchestrator-event-v1');
|
||||||
|
assert.equal(event.type, 'workflow.started');
|
||||||
|
assert.equal(stableRolloutBucket('run-1'), stableRolloutBucket('run-1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workflow engine registry enforces the framework-neutral contract', () => {
|
||||||
|
const engine = {
|
||||||
|
id: 'native',
|
||||||
|
start() {},
|
||||||
|
resume() {},
|
||||||
|
cancel() {},
|
||||||
|
getState() {},
|
||||||
|
async *streamEvents() {},
|
||||||
|
};
|
||||||
|
const registry = createWorkflowEngineRegistry([engine]);
|
||||||
|
assert.equal(registry.has('native'), true);
|
||||||
|
assert.equal(registry.get('native'), engine);
|
||||||
|
assert.throws(
|
||||||
|
() => registry.register({ id: 'broken' }),
|
||||||
|
/missing methods/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote workflow engine speaks only the versioned orchestrator HTTP contract', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const engine = createRemoteWorkflowEngine({
|
||||||
|
baseUrl: 'http://orchestrator.internal',
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return url.endsWith('/events') ? { events: [{ type: 'run.succeeded' }] } : { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await engine.start({ runId: 'run-1' });
|
||||||
|
await engine.resume('run-1', { approvalId: 'approval-1', decision: 'approve' });
|
||||||
|
await engine.cancel('run-1');
|
||||||
|
await engine.getState('run-1');
|
||||||
|
const events = [];
|
||||||
|
for await (const event of engine.streamEvents('run-1')) events.push(event);
|
||||||
|
|
||||||
|
assert.deepEqual(calls.map((call) => [call.init?.method ?? 'GET', call.url]), [
|
||||||
|
['POST', 'http://orchestrator.internal/v1/runs'],
|
||||||
|
['POST', 'http://orchestrator.internal/v1/runs/run-1/resume'],
|
||||||
|
['POST', 'http://orchestrator.internal/v1/runs/run-1/cancel'],
|
||||||
|
['GET', 'http://orchestrator.internal/v1/runs/run-1'],
|
||||||
|
['GET', 'http://orchestrator.internal/v1/runs/run-1/events'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(events, [{ type: 'run.succeeded' }]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
WORKFLOW_ENGINE,
|
||||||
|
normalizeWorkflowEngineId,
|
||||||
|
} from './contracts.mjs';
|
||||||
|
|
||||||
|
const REQUIRED_ENGINE_METHODS = Object.freeze([
|
||||||
|
'start',
|
||||||
|
'resume',
|
||||||
|
'cancel',
|
||||||
|
'getState',
|
||||||
|
'streamEvents',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function validateEngine(engine) {
|
||||||
|
if (!engine || typeof engine !== 'object') {
|
||||||
|
throw new Error('Workflow engine must be an object');
|
||||||
|
}
|
||||||
|
const id = normalizeWorkflowEngineId(engine.id, '');
|
||||||
|
if (!id) throw new Error('Workflow engine requires a valid id');
|
||||||
|
const missing = REQUIRED_ENGINE_METHODS.filter((method) => typeof engine[method] !== 'function');
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`Workflow engine ${id} missing methods: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkflowEngineRegistry(initialEngines = []) {
|
||||||
|
const engines = new Map();
|
||||||
|
|
||||||
|
function register(engine) {
|
||||||
|
const id = validateEngine(engine);
|
||||||
|
if (engines.has(id)) throw new Error(`Workflow engine already registered: ${id}`);
|
||||||
|
engines.set(id, engine);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const engine of initialEngines) register(engine);
|
||||||
|
|
||||||
|
return {
|
||||||
|
register,
|
||||||
|
get(id) {
|
||||||
|
return engines.get(normalizeWorkflowEngineId(id, '')) ?? null;
|
||||||
|
},
|
||||||
|
has(id) {
|
||||||
|
return engines.has(normalizeWorkflowEngineId(id, ''));
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return [...engines.values()].map((engine) => ({
|
||||||
|
id: engine.id,
|
||||||
|
label: engine.label ?? engine.id,
|
||||||
|
kind: engine.kind ?? 'plugin',
|
||||||
|
capabilities: Array.isArray(engine.capabilities) ? [...engine.capabilities] : [],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpError(response, action) {
|
||||||
|
const error = new Error(`Workflow engine ${action} failed with HTTP ${response?.status ?? 'unknown'}`);
|
||||||
|
error.code = 'WORKFLOW_ENGINE_HTTP_ERROR';
|
||||||
|
error.status = response?.status ?? null;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(baseUrl, pathname) {
|
||||||
|
return `${String(baseUrl).replace(/\/$/, '')}/${String(pathname).replace(/^\//, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRemoteWorkflowEngine({
|
||||||
|
id = WORKFLOW_ENGINE.LANGGRAPH,
|
||||||
|
label = 'LangGraph',
|
||||||
|
baseUrl,
|
||||||
|
serviceToken = null,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
fetchImpl = globalThis.fetch,
|
||||||
|
} = {}) {
|
||||||
|
const normalizedId = normalizeWorkflowEngineId(id, '');
|
||||||
|
const normalizedBaseUrl = String(baseUrl ?? '').trim().replace(/\/$/, '');
|
||||||
|
if (!normalizedId) throw new Error('Remote workflow engine requires a valid id');
|
||||||
|
if (!normalizedBaseUrl) throw new Error(`Remote workflow engine ${normalizedId} requires baseUrl`);
|
||||||
|
if (typeof fetchImpl !== 'function') throw new Error('Remote workflow engine requires fetch');
|
||||||
|
|
||||||
|
async function request(pathname, init = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), Math.max(100, Number(timeoutMs) || 5000));
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(joinUrl(normalizedBaseUrl, pathname), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
...(init.body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(serviceToken ? { authorization: `Bearer ${serviceToken}` } : {}),
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response?.ok) throw createHttpError(response, pathname);
|
||||||
|
return response.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizedId,
|
||||||
|
label,
|
||||||
|
kind: 'remote',
|
||||||
|
capabilities: ['durable-execution', 'interrupt', 'streaming'],
|
||||||
|
async start(spec) {
|
||||||
|
return request('/v1/runs', { method: 'POST', body: JSON.stringify(spec) });
|
||||||
|
},
|
||||||
|
async resume(runId, input) {
|
||||||
|
return request(`/v1/runs/${encodeURIComponent(runId)}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input ?? {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async cancel(runId, input = {}) {
|
||||||
|
return request(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async getState(runId) {
|
||||||
|
return request(`/v1/runs/${encodeURIComponent(runId)}`);
|
||||||
|
},
|
||||||
|
async *streamEvents(runId, cursor = null) {
|
||||||
|
const query = cursor == null ? '' : `?after=${encodeURIComponent(cursor)}`;
|
||||||
|
const result = await request(`/v1/runs/${encodeURIComponent(runId)}/events${query}`);
|
||||||
|
for (const event of Array.isArray(result?.events) ? result.events : []) yield event;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user