diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 9a1575b..01fc55f 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -19,6 +19,7 @@ import { createLlmProviderService } from './llm-providers.mjs'; import { createAssetGatewayConfigService } from './asset-gateway.mjs'; import { createImageMakeAdminConfigService } from './image-make-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 { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs'; import { createMindSearchConfigService } from './mindsearch-config.mjs'; @@ -109,6 +110,8 @@ export async function createAdminServices(env = {}) { }); await imageMakeAdminConfigService.ensureSchema(); const memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + const orchestratorConfigService = createOrchestratorAdminConfigService(pool); + await orchestratorConfigService.ensureSchema(); const mindSearchConfigService = createMindSearchConfigService(pool); await mindSearchConfigService.ensureSchema(); const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root }); @@ -155,6 +158,7 @@ export async function createAdminServices(env = {}) { assetGatewayConfigService, imageMakeAdminConfigService, memoryV2ConfigService, + orchestratorConfigService, mindSearchConfigService, skillRuntimeConfigService, systemDisclosurePolicyService, diff --git a/admin-routes.mjs b/admin-routes.mjs index debe42d..bcb03dc 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -31,6 +31,7 @@ function plazaRouteError(res, req, error) { * @param {object} deps.userAuth * @param {object|null} deps.llmProviderService * @param {object|null} deps.memoryV2ConfigService + * @param {object|null} deps.orchestratorConfigService * @param {object|null} deps.skillRuntimeConfigService * @param {object|null} deps.systemDisclosurePolicyService * @param {object|null} deps.adminSystemTestService @@ -47,6 +48,7 @@ export function createAdminApi({ assetGatewayConfigService, imageMakeAdminConfigService, memoryV2ConfigService, + orchestratorConfigService, mindSearchConfigService, skillRuntimeConfigService, systemDisclosurePolicyService, @@ -225,6 +227,34 @@ export function createAdminApi({ 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) => { if (!skillRuntimeConfigService?.getAdminConfig) { return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs index 18c0b90..cdb2615 100644 --- a/admin-routes.test.mjs +++ b/admin-routes.test.mjs @@ -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 () => { const updates = []; const config = { diff --git a/admin-server.mjs b/admin-server.mjs index fc0edb2..8b623a8 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -79,6 +79,7 @@ const CONSOLES = { assetGatewayConfigService: services.assetGatewayConfigService, imageMakeAdminConfigService: services.imageMakeAdminConfigService, memoryV2ConfigService: services.memoryV2ConfigService, + orchestratorConfigService: services.orchestratorConfigService, mindSearchConfigService: services.mindSearchConfigService, systemDisclosurePolicyService: services.systemDisclosurePolicyService, adminSystemTestService: services.adminSystemTestService, diff --git a/docs/architecture/memind-orchestrator-boundary.md b/docs/architecture/memind-orchestrator-boundary.md new file mode 100644 index 0000000..22ba45a --- /dev/null +++ b/docs/architecture/memind-orchestrator-boundary.md @@ -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= +``` + +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. diff --git a/ops/src/App.tsx b/ops/src/App.tsx index 43138e3..895556d 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -13,6 +13,7 @@ import { UsersPage } from './pages/admin/UsersPage'; import { BillingPage } from './pages/admin/BillingPage'; import { WechatPage } from './pages/admin/WechatPage'; import { SystemPolicyPage } from './pages/admin/SystemPolicyPage'; +import { OrchestratorPage } from './pages/admin/OrchestratorPage'; export function App() { return ( @@ -46,6 +47,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index 68c7c16..428386b 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -198,6 +198,48 @@ export type SystemDisclosurePolicyState = { 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 ────────────────────────────────────────────────────────────────── export async function fetchAdminSummary() { @@ -217,6 +259,19 @@ export async function updateSystemDisclosurePolicy(config: SystemDisclosurePolic }); } +// ─── Workflow Orchestrator ─────────────────────────────────────────────────── + +export async function fetchOrchestratorConfig() { + return adminFetch('/admin-api/orchestrator/config'); +} + +export async function updateOrchestratorConfig(config: OrchestratorConfig) { + return adminFetch('/admin-api/orchestrator/config', { + method: 'PUT', + body: JSON.stringify({ config }), + }); +} + // ─── Users ──────────────────────────────────────────────────────────────────── export async function fetchAdminUsers(params: { diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 31fe7d4..87d2a78 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -6,6 +6,7 @@ const links = [ { to: '/admin/billing', label: '账单记录' }, { to: '/admin/wechat', label: '服务号管理' }, { to: '/admin/policy', label: '策略中心' }, + { to: '/admin/orchestrator', label: '任务编排' }, ]; export function AdminLayout() { @@ -13,7 +14,7 @@ export function AdminLayout() {

超级管理后台

-

用户、计费与服务号

+

用户、计费、策略与任务编排