feat: add pluggable workflow orchestrator controls

This commit is contained in:
john
2026-07-24 20:59:01 +08:00
parent 3fd1db8c2f
commit 46ea22b342
16 changed files with 1327 additions and 2 deletions
+4
View File
@@ -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,
+30
View File
@@ -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 配置服务未启用' });
+82
View File
@@ -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 = {
+1
View File
@@ -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,
@@ -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.
+2
View File
@@ -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() {
<Route path="billing" element={<BillingPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="policy" element={<SystemPolicyPage />} />
<Route path="orchestrator" element={<OrchestratorPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
+55
View File
@@ -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<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 ────────────────────────────────────────────────────────────────────
export async function fetchAdminUsers(params: {
+2 -1
View File
@@ -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() {
<div className="layout">
<header>
<h1></h1>
<p style={{ color: '#68716c' }}></p>
<p style={{ color: '#68716c' }}></p>
</header>
<nav className="nav">
<NavLink
+279
View File
@@ -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' }}>
IDCanary
</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
View File
@@ -61,7 +61,7 @@
"test:scenario": "node scripts/run-scenario-test.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": "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: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",
+66
View File
@@ -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`.
+296
View File
@@ -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');
});
+104
View File
@@ -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;
}
+83
View File
@@ -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' }]);
});
+133
View File
@@ -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;
},
};
}