feat: add shadow LangGraph orchestrator runtime

This commit is contained in:
john
2026-07-24 21:25:40 +08:00
parent 46ea22b342
commit 24336d0178
29 changed files with 3247 additions and 25 deletions
+1 -1
View File
@@ -252,7 +252,7 @@ export function createAdminApi({
if (!orchestratorConfigService?.getRuntimeState) {
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
}
return res.json(await orchestratorConfigService.getRuntimeState());
return res.json(await orchestratorConfigService.getRuntimeState({ probe: true }));
});
adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => {
+10 -3
View File
@@ -129,8 +129,13 @@ test('admin orchestrator routes expose a versioned plug-in control plane', async
updates.push({ config, context });
return { ...state, config: { ...state.config, ...config }, configVersion: 2 };
},
async getRuntimeState() {
return { ...state, source: 'admin-db' };
async getRuntimeState(options) {
assert.deepEqual(options, { probe: true });
return {
...state,
source: 'admin-db',
serviceHealth: { ok: true, status: 'healthy' },
};
},
},
plazaPosts: null,
@@ -166,7 +171,9 @@ test('admin orchestrator routes expose a versioned plug-in control plane', async
headers: { cookie: 'h5_user_session=token-admin' },
});
assert.equal(runtimeResponse.status, 200);
assert.equal((await runtimeResponse.json()).source, 'admin-db');
const runtimeBody = await runtimeResponse.json();
assert.equal(runtimeBody.source, 'admin-db');
assert.equal(runtimeBody.serviceHealth.status, 'healthy');
} finally {
await server.close();
}
+46 -1
View File
@@ -289,6 +289,7 @@ export function createAgentRunGateway({
conversationMemoryService = null,
syncUserPagesOnSuccess = null,
observePersonalMemoryOnSuccess = null,
observeWorkflowRun = null,
isSessionExternallyBusy = null,
validateRunDeliverables = null,
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
@@ -338,6 +339,39 @@ export function createAgentRunGateway({
);
}
function dispatchShadowObservation(input) {
if (typeof observeWorkflowRun !== 'function' || input.toolMode !== 'code') return;
setImmediate(() => {
void Promise.resolve()
.then(() => observeWorkflowRun(input))
.then(async (result) => {
if (!result?.observed) return;
await appendEvent(input.runId, 'workflow_shadow_completed', {
engine: result.engine ?? 'langgraph',
mode: result.mode ?? 'shadow',
configVersion: result.configVersion ?? null,
status: result.shadowRun?.status ?? null,
phase: result.shadowRun?.phase ?? null,
});
})
.catch(async (error) => {
console.warn(
'[AgentRun] workflow shadow observation failed:',
error instanceof Error ? error.message : error,
);
await appendEvent(input.runId, 'workflow_shadow_failed', {
code: String(error?.code ?? 'WORKFLOW_SHADOW_FAILED').slice(0, 128),
message: String(error instanceof Error ? error.message : error).slice(0, 1000),
}).catch((appendError) => {
console.warn(
'[AgentRun] workflow shadow failure event skipped:',
appendError instanceof Error ? appendError.message : appendError,
);
});
});
});
}
async function appendRunSnapshot(runId) {
if (!isRunStreamReplayEnabled()) return;
const row = await getRunById(runId);
@@ -443,8 +477,19 @@ export function createAgentRunGateway({
taskType: normalizedTaskType,
forceDeepReasoning: Boolean(forceDeepReasoning),
});
const createdRun = projectRun(await getRunById(runId));
dispatchShadowObservation({
runId,
requestId: normalizedRequestId,
userId,
sessionId: sessionId || null,
workflowName: 'code-run-v1',
userMessage: runMessage,
toolMode: normalizedToolMode,
taskType: normalizedTaskType,
});
if (autoDispatch) dispatchRun(runId);
return projectRun(await getRunById(runId));
return createdRun;
}
async function prepareRunDeliverables({ userId, sessionId, runId, runStartedAtMs = null }) {
+92
View File
@@ -374,6 +374,98 @@ test('agent run creation stores code tool metadata in the queued message', async
});
});
test('code run shadow observation is fire-and-forget and records completion separately', async () => {
const pool = createFakePool();
let releaseObservation;
let observedInput = null;
const observation = new Promise((resolve) => {
releaseObservation = resolve;
});
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {},
autoDispatch: false,
observeWorkflowRun: async (input) => {
observedInput = input;
return observation;
},
});
const run = await gateway.createRun('user-shadow', {
requestId: 'req-shadow',
sessionId: 'session-shadow',
userMessage: { role: 'user', content: [{ type: 'text', text: 'refactor this' }] },
toolMode: 'code',
taskType: 'repo_refactor',
});
assert.equal(run.status, 'queued');
await waitFor(() => observedInput != null);
assert.equal(observedInput.runId, run.id);
assert.equal(observedInput.workflowName, 'code-run-v1');
assert.equal(
pool.events.some((event) => event.eventType === 'workflow_shadow_completed'),
false,
);
releaseObservation({
observed: true,
engine: 'langgraph',
mode: 'shadow',
configVersion: 3,
shadowRun: { status: 'succeeded', phase: 'completed' },
});
await waitFor(() => pool.events.some(
(event) => event.eventType === 'workflow_shadow_completed',
));
const event = pool.events.find((item) => item.eventType === 'workflow_shadow_completed');
assert.deepEqual(JSON.parse(event.dataJson), {
engine: 'langgraph',
mode: 'shadow',
configVersion: 3,
status: 'succeeded',
phase: 'completed',
});
});
test('shadow observer failure cannot fail a Native run and chat runs are not observed', async () => {
const pool = createFakePool();
let observations = 0;
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {},
autoDispatch: false,
observeWorkflowRun: async () => {
observations += 1;
const error = new Error('shadow unavailable');
error.code = 'SHADOW_UNAVAILABLE';
throw error;
},
});
const codeRun = await gateway.createRun('user-shadow', {
requestId: 'req-shadow-failure',
userMessage: { role: 'user', content: 'change code' },
toolMode: 'code',
});
const chatRun = await gateway.createRun('user-shadow', {
requestId: 'req-chat-no-shadow',
userMessage: { role: 'user', content: 'hello' },
toolMode: 'chat',
});
assert.equal(codeRun.status, 'queued');
assert.equal(chatRun.status, 'queued');
await waitFor(() => pool.events.some(
(event) => event.eventType === 'workflow_shadow_failed',
));
assert.equal(observations, 1);
const failure = pool.events.find((event) => event.eventType === 'workflow_shadow_failed');
assert.equal(JSON.parse(failure.dataJson).code, 'SHADOW_UNAVAILABLE');
});
test('agent run starts a session and marks submitted reply as succeeded', async () => {
const pool = createFakePool();
const submitted = [];
+3
View File
@@ -0,0 +1,3 @@
ORCHESTRATOR_POSTGRES_PASSWORD=replace-with-a-long-random-password
MEMIND_ORCHESTRATOR_SERVICE_TOKEN=replace-with-a-long-random-service-token
MEMIND_ORCHESTRATOR_PORT=8093
+46
View File
@@ -0,0 +1,46 @@
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: memind_orchestrator
POSTGRES_USER: memind_orchestrator
POSTGRES_PASSWORD: ${ORCHESTRATOR_POSTGRES_PASSWORD:?set ORCHESTRATOR_POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U memind_orchestrator -d memind_orchestrator"]
interval: 5s
timeout: 3s
retries: 20
volumes:
- orchestrator_postgres_data:/var/lib/postgresql/data
networks:
- orchestrator_internal
orchestrator:
build:
context: ../../services/orchestrator
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
MEMIND_ORCHESTRATOR_HOST: 0.0.0.0
MEMIND_ORCHESTRATOR_PORT: 8093
MEMIND_ORCHESTRATOR_CHECKPOINT_MODE: postgres
MEMIND_ORCHESTRATOR_DATABASE_URL: postgresql://memind_orchestrator:${ORCHESTRATOR_POSTGRES_PASSWORD}@postgres:5432/memind_orchestrator
MEMIND_ORCHESTRATOR_DATABASE_SCHEMA: memind_orchestrator
MEMIND_ORCHESTRATOR_SERVICE_TOKEN: ${MEMIND_ORCHESTRATOR_SERVICE_TOKEN:?set MEMIND_ORCHESTRATOR_SERVICE_TOKEN}
ports:
- "127.0.0.1:${MEMIND_ORCHESTRATOR_PORT:-8093}:8093"
networks:
- orchestrator_internal
- orchestrator_edge
networks:
orchestrator_internal:
internal: true
orchestrator_edge:
volumes:
orchestrator_postgres_data:
@@ -54,6 +54,11 @@ Native selection.
The initial workflow allowlist contains only `code-run-v1`. Ordinary chat and
existing Goosed session traffic remain outside the Orchestrator path.
Phase 2 implements only the `off` and `shadow` execution semantics. Although
Canary and Active can be configured and evaluated by the control plane, the
Portal does not hand execution ownership to LangGraph yet. This prevents an
administrative configuration mistake from creating two task executors.
## Protocol
The framework-neutral contracts are:
@@ -61,7 +66,7 @@ The framework-neutral contracts are:
- `orchestrator-run-v1`
- `orchestrator-event-v1`
The eventual internal API is:
The implemented internal API is:
```text
POST /v1/runs
@@ -75,13 +80,38 @@ 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.
The Phase 2 graph has three deterministic nodes:
```text
validate_run -> build_plan -> finalize_run
```
It requires `policy.executionMode=observe-only` and
`policy.sideEffectsAllowed=false`. It projects the Native executor boundary but
cannot call Goosed, Aider, OpenHands, Tool Gateway, or the filesystem.
## Failure isolation
Portal invokes Shadow only after the product run and its `queued` event have
been committed. The observation is scheduled without awaiting it. A timeout or
failure:
1. cannot reject or delay `createRun`;
2. cannot mutate the Native run status;
3. is projected as `workflow_shadow_failed`;
4. remains removable by deleting the observer wiring and changing only the
service URL boundary.
Successful observations are projected as `workflow_shadow_completed`; graph
checkpoints stay in the Orchestrator-owned PostgreSQL database.
## 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.
1. Local native Orchestrator process with an explicit MemorySaver for debugging.
2. Colima Compose with an isolated PostgreSQL checkpoint database.
3. Approved server-side shadow service; no production task claim.
4. User-allowlisted code-run canary with Native rollback.
5. Isolated Aider/OpenHands executor workers.
6. Move the same service contract to Linux or Kubernetes when horizontal scaling
or independent ownership becomes necessary.
+21
View File
@@ -240,6 +240,23 @@ export type OrchestratorConfigState = {
engines: OrchestratorEngineDescriptor[];
};
export type OrchestratorServiceHealth = {
checkedAt: number;
ok: boolean;
status: 'healthy' | 'unhealthy' | 'unconfigured' | 'timeout' | 'unreachable' | 'fetch_unavailable';
latencyMs: number;
httpStatus: number | null;
details: {
service: string | null;
checkpoint: { kind?: string; durable?: boolean } | null;
execution: string | null;
} | null;
};
export type OrchestratorRuntimeState = OrchestratorConfigState & {
serviceHealth: OrchestratorServiceHealth;
};
// ─── Summary ──────────────────────────────────────────────────────────────────
export async function fetchAdminSummary() {
@@ -272,6 +289,10 @@ export async function updateOrchestratorConfig(config: OrchestratorConfig) {
});
}
export async function fetchOrchestratorRuntime() {
return adminFetch<OrchestratorRuntimeState>('/admin-api/orchestrator/runtime');
}
// ─── Users ────────────────────────────────────────────────────────────────────
export async function fetchAdminUsers(params: {
+41 -5
View File
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import {
fetchOrchestratorConfig,
fetchOrchestratorRuntime,
updateOrchestratorConfig,
type OrchestratorConfig,
type OrchestratorConfigState,
type OrchestratorServiceHealth,
} from '../../api/admin';
function listToText(values: string[]) {
@@ -31,6 +33,8 @@ export function OrchestratorPage() {
const [workflowAllowlist, setWorkflowAllowlist] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [checking, setChecking] = useState(false);
const [serviceHealth, setServiceHealth] = useState<OrchestratorServiceHealth | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@@ -54,6 +58,19 @@ export function OrchestratorPage() {
void load();
}, []);
const checkService = async () => {
setChecking(true);
setError(null);
try {
const result = await fetchOrchestratorRuntime();
setServiceHealth(result.serviceHealth);
} catch (err) {
setError(err instanceof Error ? err.message : '服务检查失败');
} finally {
setChecking(false);
}
};
const normalizedDraft = useMemo(() => draft ? {
...draft,
userAllowlist: textToList(userAllowlist),
@@ -75,7 +92,7 @@ export function OrchestratorPage() {
}
if (
normalizedDraft.mode === 'active'
&& !window.confirm('确认启用 Active?工作流白名单内的任务将由 LangGraph Orchestrator 接管。')
&& !window.confirm('确认保存 Active 预配置?Phase 2 不会交出执行权,Native 仍是唯一执行者。')
) {
return;
}
@@ -104,7 +121,7 @@ export function OrchestratorPage() {
: state.runtime.shadowsLangGraph
? 'Shadow 已生效:Native 继续执行,LangGraph 只观察和生成决策。'
: state.runtime.executesLangGraph
? 'LangGraph 路由已生效。'
? 'Canary / Active 路由决策已预配置;Phase 2 尚未交出执行权,Native 仍是唯一执行者。'
: '配置有效。';
return (
@@ -158,8 +175,8 @@ export function OrchestratorPage() {
>
<option value="off">Off Native only</option>
<option value="shadow">Shadow Native LangGraph </option>
<option value="canary">Canary /</option>
<option value="active">Active </option>
<option value="canary">Canary Phase 2 </option>
<option value="active">Active Phase 2 </option>
</select>
</label>
<label>
@@ -195,6 +212,25 @@ export function OrchestratorPage() {
<div className="card grid">
<h3 style={{ margin: 0 }}></h3>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span>
<strong></strong>
{serviceHealth
? `${serviceHealth.ok ? '健康' : '不可用'}${serviceHealth.status}${serviceHealth.latencyMs} ms`
: '尚未检查'}
{serviceHealth?.details?.checkpoint
? ` · checkpoint ${serviceHealth.details.checkpoint.kind ?? 'unknown'}`
: ''}
</span>
<button
type="button"
className="btn secondary"
onClick={() => void checkService()}
disabled={checking}
>
{checking ? '检查中…' : '测试连接'}
</button>
</div>
<label>
<strong>Orchestrator URL</strong>
<input
@@ -244,7 +280,7 @@ export function OrchestratorPage() {
<label>
<strong></strong>
<span style={{ display: 'block', color: '#68716c', fontSize: 12, margin: '4px 0 8px' }}>
Active
Active
</span>
<textarea
rows={8}
+284 -1
View File
@@ -8,6 +8,9 @@
"name": "tkmind-h5",
"version": "0.1.0",
"dependencies": {
"@langchain/core": "^1.2.3",
"@langchain/langgraph": "^1.4.8",
"@langchain/langgraph-checkpoint-postgres": "^1.0.4",
"@node-rs/argon2": "^2.0.2",
"@resvg/resvg-js": "^2.6.2",
"debug": "^4.4.3",
@@ -320,6 +323,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -1432,6 +1441,144 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@langchain/core": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz",
"integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
"@standard-schema/spec": "^1.1.0",
"js-tiktoken": "^1.0.12",
"langsmith": ">=0.5.0 <1.0.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@langchain/langgraph": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.8.tgz",
"integrity": "sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.1.3",
"@langchain/langgraph-sdk": "~1.9.26",
"@langchain/protocol": "^0.0.18",
"@standard-schema/spec": "1.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.1.48",
"zod": "^3.25.32 || ^4.2.0"
}
},
"node_modules/@langchain/langgraph-checkpoint": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.3.tgz",
"integrity": "sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.1.48"
}
},
"node_modules/@langchain/langgraph-checkpoint-postgres": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint-postgres/-/langgraph-checkpoint-postgres-1.0.4.tgz",
"integrity": "sha512-wuCPq7VVqwi0+nQC5GOmheoXGJHAybgXbkA9Nn0J8nYW61WFJ3uqb4wyMbLFhAlya99EaxNNsi3FzdzV2COSzw==",
"license": "MIT",
"dependencies": {
"pg": "^8.12.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.1.44",
"@langchain/langgraph-checkpoint": "^1.0.0"
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.9.28",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.28.tgz",
"integrity": "sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==",
"license": "MIT",
"dependencies": {
"@langchain/protocol": "^0.0.18",
"@types/json-schema": "^7.0.15",
"p-queue": "^9.0.1",
"p-retry": "^7.1.1"
},
"peerDependencies": {
"@langchain/core": "^1.1.48",
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
"svelte": "^4.0.0 || ^5.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
"version": "9.3.3",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz",
"integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4",
"p-timeout": "^7.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
"integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/protocol": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.18.tgz",
"integrity": "sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -2330,6 +2477,12 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2401,6 +2554,12 @@
"@types/node": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
@@ -3902,6 +4061,18 @@
"node": ">=0.10.0"
}
},
"node_modules/is-network-error": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
"integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -3932,6 +4103,15 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/js-tiktoken": {
"version": "1.0.21",
"resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
"integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.5.1"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4016,6 +4196,39 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/langsmith": {
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.8.6.tgz",
"integrity": "sha512-tl7cV79S/SkJNskCo7joRIVCYy+ljVLs4L1hgC2qT01x4CAWX+6IJzmfXGYAJcn1ecpaQr63WRjkLO9jefmOmQ==",
"license": "MIT",
"dependencies": {
"p-queue": "6.6.2"
},
"peerDependencies": {
"@opentelemetry/api": "*",
"@opentelemetry/exporter-trace-otlp-proto": "*",
"@opentelemetry/sdk-trace-base": "*",
"openai": "*",
"ws": ">=7"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@opentelemetry/exporter-trace-otlp-proto": {
"optional": true
},
"@opentelemetry/sdk-trace-base": {
"optional": true
},
"openai": {
"optional": true
},
"ws": {
"optional": true
}
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -4340,6 +4553,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/mysql2": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz",
@@ -4470,6 +4692,15 @@
"wrappy": "1"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -4497,6 +4728,49 @@
"node": ">=8"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -5833,7 +6107,7 @@
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -5948,6 +6222,15 @@
"engines": {
"node": ">= 10"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+6 -2
View File
@@ -23,6 +23,7 @@
"dev:vite": "vite",
"dev:server": "node server.mjs",
"dev:adm": "node admin-server.mjs",
"dev:orchestrator": "node services/orchestrator/server.mjs",
"dev:deep-search": "node deep-search-server.mjs",
"dev:plaza-express": "node plaza-server.mjs",
"dev:ops": "cd ops && npm run dev",
@@ -61,7 +62,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 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": "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 services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/shadow-observer.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",
@@ -95,15 +96,18 @@
"deploy:plaza-105": "bash scripts/deploy-plaza-105.sh"
},
"dependencies": {
"@langchain/core": "^1.2.3",
"@langchain/langgraph": "^1.4.8",
"@langchain/langgraph-checkpoint-postgres": "^1.0.4",
"@node-rs/argon2": "^2.0.2",
"@resvg/resvg-js": "^2.6.2",
"debug": "^4.4.3",
"exceljs": "^4.4.0",
"express": "^4.21.2",
"jszip": "^3.10.1",
"framer-motion": "^12.42.0",
"http-proxy-middleware": "^3.0.7",
"jsonrepair": "^3.14.0",
"jszip": "^3.10.1",
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
+225
View File
@@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@langchain/core':
specifier: ^1.2.3
version: 1.2.3(ws@8.21.0)
'@langchain/langgraph':
specifier: ^1.4.8
version: 1.4.8(@langchain/core@1.2.3(ws@8.21.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)
'@langchain/langgraph-checkpoint-postgres':
specifier: ^1.0.4
version: 1.0.4(@langchain/core@1.2.3(ws@8.21.0))(@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(ws@8.21.0)))
'@node-rs/argon2':
specifier: ^2.0.2
version: 2.0.2
@@ -173,6 +182,9 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
@@ -522,6 +534,51 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@langchain/core@1.2.3':
resolution: {integrity: sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==}
engines: {node: '>=20'}
'@langchain/langgraph-checkpoint-postgres@1.0.4':
resolution: {integrity: sha512-wuCPq7VVqwi0+nQC5GOmheoXGJHAybgXbkA9Nn0J8nYW61WFJ3uqb4wyMbLFhAlya99EaxNNsi3FzdzV2COSzw==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.1.44
'@langchain/langgraph-checkpoint': ^1.0.0
'@langchain/langgraph-checkpoint@1.1.3':
resolution: {integrity: sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.1.48
'@langchain/langgraph-sdk@1.9.28':
resolution: {integrity: sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==}
peerDependencies:
'@langchain/core': ^1.1.48
react: ^18 || ^19
react-dom: ^18 || ^19
svelte: ^4.0.0 || ^5.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
svelte:
optional: true
vue:
optional: true
'@langchain/langgraph@1.4.8':
resolution: {integrity: sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.1.48
zod: ^3.25.32 || ^4.2.0
'@langchain/protocol@0.0.18':
resolution: {integrity: sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -866,6 +923,9 @@ packages:
cpu: [x64]
os: [win32]
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -887,6 +947,9 @@ packages:
'@types/http-proxy@1.17.17':
resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
@@ -1189,6 +1252,9 @@ packages:
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
exceljs@4.4.0:
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
engines: {node: '>=8.3.0'}
@@ -1372,6 +1438,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-network-error@1.3.2:
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
engines: {node: '>=16'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
@@ -1386,6 +1456,9 @@ packages:
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1406,6 +1479,26 @@ packages:
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
langsmith@0.8.6:
resolution: {integrity: sha512-tl7cV79S/SkJNskCo7joRIVCYy+ljVLs4L1hgC2qT01x4CAWX+6IJzmfXGYAJcn1ecpaQr63WRjkLO9jefmOmQ==}
peerDependencies:
'@opentelemetry/api': '*'
'@opentelemetry/exporter-trace-otlp-proto': '*'
'@opentelemetry/sdk-trace-base': '*'
openai: '*'
ws: '>=7'
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@opentelemetry/exporter-trace-otlp-proto':
optional: true
'@opentelemetry/sdk-trace-base':
optional: true
openai:
optional: true
ws:
optional: true
lazystream@1.0.1:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
@@ -1533,6 +1626,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
mysql2@3.22.5:
resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==}
engines: {node: '>= 8.0'}
@@ -1571,6 +1668,10 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@@ -1579,6 +1680,26 @@ packages:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-queue@6.6.2:
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
engines: {node: '>=8'}
p-queue@9.3.3:
resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==}
engines: {node: '>=20'}
p-retry@7.1.1:
resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==}
engines: {node: '>=20'}
p-timeout@3.2.0:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
p-timeout@7.0.1:
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
engines: {node: '>=20'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
@@ -2027,6 +2148,9 @@ packages:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'}
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
'@babel/code-frame@7.29.7':
@@ -2141,6 +2265,8 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@cfworker/json-schema@4.1.1': {}
'@emnapi/core@1.11.1':
dependencies:
'@emnapi/wasi-threads': 1.2.2
@@ -2379,6 +2505,61 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@langchain/core@1.2.3(ws@8.21.0)':
dependencies:
'@cfworker/json-schema': 4.1.1
'@standard-schema/spec': 1.1.0
js-tiktoken: 1.0.21
langsmith: 0.8.6(ws@8.21.0)
mustache: 4.2.0
p-queue: 6.6.2
zod: 4.4.3
transitivePeerDependencies:
- '@opentelemetry/api'
- '@opentelemetry/exporter-trace-otlp-proto'
- '@opentelemetry/sdk-trace-base'
- openai
- ws
'@langchain/langgraph-checkpoint-postgres@1.0.4(@langchain/core@1.2.3(ws@8.21.0))(@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(ws@8.21.0)))':
dependencies:
'@langchain/core': 1.2.3(ws@8.21.0)
'@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(ws@8.21.0))
pg: 8.22.0
transitivePeerDependencies:
- pg-native
'@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(ws@8.21.0))':
dependencies:
'@langchain/core': 1.2.3(ws@8.21.0)
'@langchain/langgraph-sdk@1.9.28(@langchain/core@1.2.3(ws@8.21.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@langchain/core': 1.2.3(ws@8.21.0)
'@langchain/protocol': 0.0.18
'@types/json-schema': 7.0.15
p-queue: 9.3.3
p-retry: 7.1.1
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@langchain/langgraph@1.4.8(@langchain/core@1.2.3(ws@8.21.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)':
dependencies:
'@langchain/core': 1.2.3(ws@8.21.0)
'@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(ws@8.21.0))
'@langchain/langgraph-sdk': 1.9.28(@langchain/core@1.2.3(ws@8.21.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@langchain/protocol': 0.0.18
'@standard-schema/spec': 1.1.0
zod: 4.4.3
transitivePeerDependencies:
- react
- react-dom
- svelte
- vue
'@langchain/protocol@0.0.18': {}
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.11.1
@@ -2601,6 +2782,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.0':
optional: true
'@standard-schema/spec@1.1.0': {}
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
@@ -2633,6 +2816,8 @@ snapshots:
dependencies:
'@types/node': 25.9.3
'@types/json-schema@7.0.15': {}
'@types/node@14.18.63': {}
'@types/node@25.9.3':
@@ -2951,6 +3136,8 @@ snapshots:
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
exceljs@4.4.0:
dependencies:
archiver: 5.3.2
@@ -3181,6 +3368,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-network-error@1.3.2: {}
is-number@7.0.0: {}
is-plain-object@5.0.0: {}
@@ -3189,6 +3378,10 @@ snapshots:
isarray@1.0.0: {}
js-tiktoken@1.0.21:
dependencies:
base64-js: 1.5.1
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
@@ -3204,6 +3397,12 @@ snapshots:
readable-stream: 2.3.8
setimmediate: 1.0.5
langsmith@0.8.6(ws@8.21.0):
dependencies:
p-queue: 6.6.2
optionalDependencies:
ws: 8.21.0
lazystream@1.0.1:
dependencies:
readable-stream: 2.3.8
@@ -3301,6 +3500,8 @@ snapshots:
ms@2.1.3: {}
mustache@4.2.0: {}
mysql2@3.22.5(@types/node@25.9.3):
dependencies:
'@types/node': 25.9.3
@@ -3335,6 +3536,8 @@ snapshots:
dependencies:
wrappy: 1.0.2
p-finally@1.0.0: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@@ -3343,6 +3546,26 @@ snapshots:
dependencies:
p-limit: 2.3.0
p-queue@6.6.2:
dependencies:
eventemitter3: 4.0.7
p-timeout: 3.2.0
p-queue@9.3.3:
dependencies:
eventemitter3: 5.0.4
p-timeout: 7.0.1
p-retry@7.1.1:
dependencies:
is-network-error: 1.3.2
p-timeout@3.2.0:
dependencies:
p-finally: 1.0.0
p-timeout@7.0.1: {}
p-try@2.2.0: {}
pako@1.0.11: {}
@@ -3803,3 +4026,5 @@ snapshots:
archiver-utils: 3.0.4
compress-commons: 4.1.2
readable-stream: 3.6.2
zod@4.4.3: {}
+7
View File
@@ -213,6 +213,8 @@ import { createSessionSnapshotService } from './session-snapshot.mjs';
import { createConversationMemoryService } from './conversation-memory.mjs';
import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs';
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
import { createOrchestratorAdminConfigService } from './services/orchestrator/admin-config.mjs';
import { createWorkflowShadowObserver } from './services/orchestrator/shadow-observer.mjs';
import { createEpisodicMemoryService } from './episodic-memory.mjs';
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs';
@@ -798,6 +800,10 @@ async function bootstrapUserAuth() {
: null,
});
toolGateway = createToolGateway({ llmProviderService });
const workflowShadowObserver = createWorkflowShadowObserver({
configService: createOrchestratorAdminConfigService(pool),
logger: console,
});
agentRunGateway = createAgentRunGateway({
pool,
userAuth,
@@ -809,6 +815,7 @@ async function bootstrapUserAuth() {
chatIntentRouter,
sessionSnapshotService,
conversationMemoryService,
observeWorkflowRun: workflowShadowObserver,
observePersonalMemoryOnSuccess: async ({ userId, sessionId, userMessage }) => {
if (!memoryV2?.observePersonalMemory) return;
await memoryV2.observePersonalMemory({
+16
View File
@@ -0,0 +1,16 @@
FROM node:24-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY *.mjs ./
USER node
EXPOSE 8093
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8093/ready').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "server.mjs"]
+70 -4
View File
@@ -29,7 +29,7 @@ Memind-facing protocol is `orchestrator-run-v1` plus `orchestrator-event-v1`.
## Current stage
Phase 1 establishes:
Phase 1 established:
- framework-neutral run and event contracts;
- a plug-in workflow engine registry;
@@ -38,9 +38,19 @@ Phase 1 establishes:
- 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.
Phase 2 adds:
- a real LangGraph `StateGraph` service on the versioned HTTP boundary;
- a PostgreSQL `PostgresSaver` checkpoint owner;
- an explicitly non-durable MemorySaver option for tests and local debugging;
- a `code-run-v1` observe-only graph with no executor or tool access;
- a Portal fire-and-forget shadow projection;
- live health probing from memindadm;
- a separate Colima/Compose deployment artifact.
The default memindadm mode remains `off`. Canary and Active routing are not
wired to the LangGraph executor in Phase 2. Native Agent Run remains the sole
executor, including in Shadow mode.
## Admin configuration
@@ -55,6 +65,62 @@ Supported modes:
`MEMIND_ORCHESTRATOR_KILL_SWITCH=1` always forces Native selection.
## Local process
PostgreSQL is the default and required checkpoint backend:
```bash
MEMIND_ORCHESTRATOR_DATABASE_URL='postgresql://...' \
MEMIND_ORCHESTRATOR_SERVICE_TOKEN='...' \
pnpm dev:orchestrator
```
Memory mode is intentionally opt-in and non-durable:
```bash
MEMIND_ORCHESTRATOR_CHECKPOINT_MODE=memory pnpm dev:orchestrator
```
The service binds to `127.0.0.1:8093` by default. Configure the same URL and
service token in Portal, then enable `shadow` in `/ops/admin/orchestrator`.
## Colima deployment
Colima is the recommended first container host on macOS because this service and
its PostgreSQL checkpoint database have an isolated Compose lifecycle:
```bash
cd deploy/orchestrator
cp .env.example .env
# Replace both example secrets before starting.
docker compose up -d --build
docker compose ps
curl http://127.0.0.1:8093/ready
```
The Compose project:
- publishes only the Orchestrator port on loopback;
- does not publish PostgreSQL;
- does not mount the Docker socket or a Memind workspace;
- keeps PostgreSQL on a dedicated internal network while the service also joins
a separate edge network for its loopback-published port;
- is not part of the Goosed Compose lifecycle.
This local Compose artifact is not a production release path. Production still
requires the repository release gates and a separately approved deployment.
## Runtime environment
| Variable | Purpose | Default |
|---|---|---|
| `MEMIND_ORCHESTRATOR_HOST` | HTTP bind address | `127.0.0.1` |
| `MEMIND_ORCHESTRATOR_PORT` | HTTP port | `8093` |
| `MEMIND_ORCHESTRATOR_SERVICE_TOKEN` | Bearer token for `/v1/*` | empty |
| `MEMIND_ORCHESTRATOR_CHECKPOINT_MODE` | `postgres` or explicit `memory` | `postgres` |
| `MEMIND_ORCHESTRATOR_DATABASE_URL` | Dedicated checkpoint PostgreSQL URL | required |
| `MEMIND_ORCHESTRATOR_DATABASE_SCHEMA` | Checkpoint schema | `memind_orchestrator` |
## Extraction test
The service is ready to move into a separate repository only when:
+73 -2
View File
@@ -146,6 +146,72 @@ function engineCatalog(config) {
];
}
async function probeServiceHealth(config, {
fetchImpl = globalThis.fetch,
} = {}) {
const checkedAt = Date.now();
if (!config.serviceUrl) {
return {
checkedAt,
ok: false,
status: 'unconfigured',
latencyMs: 0,
httpStatus: null,
details: null,
};
}
if (typeof fetchImpl !== 'function') {
return {
checkedAt,
ok: false,
status: 'fetch_unavailable',
latencyMs: 0,
httpStatus: null,
details: null,
};
}
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
Math.max(500, Number(config.requestTimeoutMs) || 5000),
);
try {
const response = await fetchImpl(`${config.serviceUrl}/ready`, {
headers: { accept: 'application/json' },
signal: controller.signal,
});
const body = await response.json().catch(() => null);
const healthy = response.ok && body?.status === 'ok';
return {
checkedAt,
ok: healthy,
status: healthy ? 'healthy' : 'unhealthy',
latencyMs: Math.max(0, Date.now() - startedAt),
httpStatus: response.status,
details: body && typeof body === 'object' ? {
service: body.service == null ? null : String(body.service).slice(0, 128),
checkpoint: body.checkpoint && typeof body.checkpoint === 'object' ? {
kind: body.checkpoint.kind == null ? null : String(body.checkpoint.kind).slice(0, 64),
durable: Boolean(body.checkpoint.durable),
} : null,
execution: body.execution == null ? null : String(body.execution).slice(0, 64),
} : null,
};
} catch (error) {
return {
checkedAt,
ok: false,
status: error?.name === 'AbortError' ? 'timeout' : 'unreachable',
latencyMs: Math.max(0, Date.now() - startedAt),
httpStatus: null,
details: null,
};
} finally {
clearTimeout(timer);
}
}
async function ensureConfigTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
@@ -177,7 +243,10 @@ async function loadStoredState(pool) {
};
}
export function createOrchestratorAdminConfigService(pool, { env = process.env } = {}) {
export function createOrchestratorAdminConfigService(pool, {
env = process.env,
fetchImpl = globalThis.fetch,
} = {}) {
async function loadEffectiveState() {
const stored = await loadStoredState(pool);
if (stored) return { ...stored, source: 'admin-db' };
@@ -232,12 +301,13 @@ export function createOrchestratorAdminConfigService(pool, { env = process.env }
return this.getAdminConfig();
},
async getRuntimeState() {
async getRuntimeState({ probe = false } = {}) {
const state = await loadEffectiveState();
return {
...state,
runtime: runtimeState(state.config, env),
engines: engineCatalog(state.config),
...(probe ? { serviceHealth: await probeServiceHealth(state.config, { fetchImpl }) } : {}),
};
},
@@ -292,5 +362,6 @@ export const orchestratorAdminConfigInternals = {
CONFIG_SCOPE,
CONFIG_TABLE,
engineCatalog,
probeServiceHealth,
runtimeState,
};
@@ -97,3 +97,34 @@ test('orchestrator emergency kill switch always forces native selection', async
assert.equal(selected.engine, 'native');
assert.equal(selected.reason, 'kill_switch');
});
test('orchestrator runtime probe reports sanitized service health', async () => {
let requestedUrl = null;
const service = createOrchestratorAdminConfigService(createPool(), {
env: {},
fetchImpl: async (url) => {
requestedUrl = url;
return new Response(JSON.stringify({
status: 'ok',
service: 'memind-langgraph-orchestrator',
checkpoint: { kind: 'postgres', durable: true, password: 'must-not-pass-through' },
execution: 'observe-only',
ignoredSecret: 'must-not-pass-through',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
await service.updateAdminConfig({
mode: 'shadow',
serviceUrl: 'http://127.0.0.1:8093',
});
const runtime = await service.getRuntimeState({ probe: true });
assert.equal(requestedUrl, 'http://127.0.0.1:8093/ready');
assert.equal(runtime.serviceHealth.ok, true);
assert.equal(runtime.serviceHealth.details.checkpoint.durable, true);
assert.equal('password' in runtime.serviceHealth.details.checkpoint, false);
assert.equal('ignoredSecret' in runtime.serviceHealth.details, false);
});
+113
View File
@@ -0,0 +1,113 @@
import express from 'express';
function errorStatus(error) {
if (Number.isInteger(error?.status)) return error.status;
if (['WORKFLOW_NOT_SUPPORTED', 'WORKFLOW_OBSERVE_ONLY_REQUIRED'].includes(error?.code)) {
return 422;
}
return 500;
}
function authorize(serviceToken) {
const expected = String(serviceToken ?? '').trim();
return (request, response, next) => {
if (!expected) return next();
if (request.get('authorization') === `Bearer ${expected}`) return next();
return response.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Missing or invalid service token',
},
});
};
}
export function createOrchestratorApp({
runtime,
serviceToken = process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
} = {}) {
if (!runtime) throw new Error('Orchestrator app requires runtime');
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '256kb' }));
app.get('/health', (_request, response) => response.json(runtime.health()));
app.get('/ready', async (_request, response) => {
try {
return response.json(await runtime.ready());
} catch {
return response.status(503).json({
...runtime.health(),
ready: false,
});
}
});
app.use('/v1', authorize(serviceToken));
app.post('/v1/runs', async (request, response, next) => {
try {
response.status(202).json(await runtime.start(request.body));
} catch (error) {
next(error);
}
});
app.get('/v1/runs/:runId', async (request, response, next) => {
try {
const state = await runtime.getState(request.params.runId);
if (!state) return response.status(404).json({ error: { code: 'RUN_NOT_FOUND' } });
return response.json(state);
} catch (error) {
return next(error);
}
});
app.get('/v1/runs/:runId/events', async (request, response, next) => {
try {
const result = await runtime.listEvents(request.params.runId, {
after: request.query.after,
});
if (!result) return response.status(404).json({ error: { code: 'RUN_NOT_FOUND' } });
return response.json(result);
} catch (error) {
return next(error);
}
});
app.post('/v1/runs/:runId/resume', async (request, response, next) => {
try {
const state = await runtime.resume(request.params.runId, request.body);
if (!state) return response.status(404).json({ error: { code: 'RUN_NOT_FOUND' } });
return response.json(state);
} catch (error) {
return next(error);
}
});
app.post('/v1/runs/:runId/cancel', async (request, response, next) => {
try {
const state = await runtime.cancel(request.params.runId, request.body);
if (!state) return response.status(404).json({ error: { code: 'RUN_NOT_FOUND' } });
return response.json(state);
} catch (error) {
return next(error);
}
});
app.use((error, _request, response, _next) => {
response.status(errorStatus(error)).json({
error: {
code: error?.code ?? 'ORCHESTRATOR_ERROR',
message: error instanceof Error ? error.message : 'Orchestrator request failed',
},
});
});
return app;
}
export const orchestratorAppInternals = {
authorize,
errorStatus,
};
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MemorySaver } from '@langchain/langgraph';
import { createOrchestratorApp } from './app.mjs';
import { createLangGraphOrchestratorRuntime } from './runtime.mjs';
async function listen(app) {
const server = await new Promise((resolve) => {
const candidate = app.listen(0, '127.0.0.1', () => resolve(candidate));
});
const address = server.address();
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
}
function spec() {
return {
runId: 'http-shadow-1',
requestId: 'http-request-1',
workflow: { name: 'code-run-v1', version: 1 },
input: { instruction: 'Inspect without executing' },
policy: { executionMode: 'observe-only', sideEffectsAllowed: false },
};
}
test('orchestrator HTTP API exposes health and authenticated run endpoints', async (t) => {
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: new MemorySaver(),
checkpointKind: 'memory',
});
const server = await listen(createOrchestratorApp({
runtime,
serviceToken: 'test-service-token',
}));
t.after(server.close);
const health = await fetch(`${server.baseUrl}/health`);
assert.equal(health.status, 200);
assert.equal((await health.json()).execution, 'observe-only');
const ready = await fetch(`${server.baseUrl}/ready`);
assert.equal(ready.status, 200);
assert.equal((await ready.json()).ready, true);
const unauthorized = await fetch(`${server.baseUrl}/v1/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(spec()),
});
assert.equal(unauthorized.status, 401);
const created = await fetch(`${server.baseUrl}/v1/runs`, {
method: 'POST',
headers: {
authorization: 'Bearer test-service-token',
'content-type': 'application/json',
},
body: JSON.stringify(spec()),
});
assert.equal(created.status, 202);
assert.equal((await created.json()).status, 'succeeded');
const events = await fetch(`${server.baseUrl}/v1/runs/http-shadow-1/events?after=1`, {
headers: { authorization: 'Bearer test-service-token' },
});
const eventBody = await events.json();
assert.equal(events.status, 200);
assert.deepEqual(eventBody.events.map((event) => event.sequence), [2, 3]);
});
test('orchestrator HTTP API reports missing runs and observe-only violations', async (t) => {
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: new MemorySaver(),
});
const server = await listen(createOrchestratorApp({ runtime }));
t.after(server.close);
const missing = await fetch(`${server.baseUrl}/v1/runs/missing`);
assert.equal(missing.status, 404);
const activeSpec = spec();
activeSpec.runId = 'http-active-1';
activeSpec.policy = { executionMode: 'active', sideEffectsAllowed: true };
const rejected = await fetch(`${server.baseUrl}/v1/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(activeSpec),
});
assert.equal(rejected.status, 422);
assert.equal((await rejected.json()).error.code, 'WORKFLOW_OBSERVE_ONLY_REQUIRED');
});
+74
View File
@@ -0,0 +1,74 @@
import { MemorySaver } from '@langchain/langgraph';
import { PostgresSaver } from '@langchain/langgraph-checkpoint-postgres';
const DEFAULT_POSTGRES_SCHEMA = 'memind_orchestrator';
function normalizeMode(value) {
const mode = String(value ?? 'postgres').trim().toLowerCase();
if (mode === 'memory' || mode === 'postgres') return mode;
throw new Error(`Unsupported orchestrator checkpoint mode: ${value}`);
}
function normalizeSchema(value) {
const schema = String(value ?? DEFAULT_POSTGRES_SCHEMA).trim();
if (!/^[a-z_][a-z0-9_]{0,62}$/i.test(schema)) {
throw new Error('Invalid orchestrator PostgreSQL schema');
}
return schema;
}
export async function createOrchestratorCheckpoint({
mode = process.env.MEMIND_ORCHESTRATOR_CHECKPOINT_MODE,
connectionString = process.env.MEMIND_ORCHESTRATOR_DATABASE_URL,
schema = process.env.MEMIND_ORCHESTRATOR_DATABASE_SCHEMA,
} = {}) {
const normalizedMode = normalizeMode(mode);
if (normalizedMode === 'memory') {
return {
kind: 'memory',
durable: false,
checkpointer: new MemorySaver(),
async probe() {
return true;
},
async close() {},
};
}
const normalizedConnectionString = String(connectionString ?? '').trim();
if (!normalizedConnectionString) {
const error = new Error(
'MEMIND_ORCHESTRATOR_DATABASE_URL is required when checkpoint mode is postgres',
);
error.code = 'ORCHESTRATOR_DATABASE_URL_REQUIRED';
throw error;
}
const checkpointer = PostgresSaver.fromConnString(normalizedConnectionString, {
schema: normalizeSchema(schema),
});
await checkpointer.setup();
return {
kind: 'postgres',
durable: true,
checkpointer,
async probe() {
await checkpointer.getTuple({
configurable: {
thread_id: '__orchestrator_readiness__',
checkpoint_ns: '',
},
});
return true;
},
async close() {
await checkpointer.end();
},
};
}
export const orchestratorCheckpointInternals = {
DEFAULT_POSTGRES_SCHEMA,
normalizeMode,
normalizeSchema,
};
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createOrchestratorCheckpoint,
orchestratorCheckpointInternals,
} from './checkpoint.mjs';
test('checkpoint defaults to durable PostgreSQL and fails closed without a URL', async () => {
await assert.rejects(
createOrchestratorCheckpoint({ mode: undefined, connectionString: '' }),
(error) => error.code === 'ORCHESTRATOR_DATABASE_URL_REQUIRED',
);
});
test('memory checkpoint is explicitly non-durable', async () => {
const checkpoint = await createOrchestratorCheckpoint({ mode: 'memory' });
assert.equal(checkpoint.kind, 'memory');
assert.equal(checkpoint.durable, false);
assert.equal(typeof checkpoint.checkpointer.getTuple, 'function');
await checkpoint.close();
});
test('checkpoint schema accepts identifiers and rejects SQL fragments', () => {
assert.equal(
orchestratorCheckpointInternals.normalizeSchema('memind_orchestrator_v2'),
'memind_orchestrator_v2',
);
assert.throws(
() => orchestratorCheckpointInternals.normalizeSchema('public; drop schema public'),
/Invalid orchestrator PostgreSQL schema/,
);
});
@@ -0,0 +1,128 @@
import {
Annotation,
END,
START,
StateGraph,
} from '@langchain/langgraph';
import {
buildRunEvent,
normalizeRunSpec,
} from './contracts.mjs';
export const CODE_RUN_SHADOW_WORKFLOW = 'code-run-v1';
const ShadowState = Annotation.Root({
spec: Annotation(),
status: Annotation({
reducer: (_current, update) => update,
default: () => 'queued',
}),
phase: Annotation({
reducer: (_current, update) => update,
default: () => 'accepted',
}),
plan: Annotation({
reducer: (_current, update) => update,
default: () => null,
}),
result: Annotation({
reducer: (_current, update) => update,
default: () => null,
}),
events: Annotation({
reducer: (current, update) => [...current, ...update],
default: () => [],
}),
});
function eventFor(state, type, data = null) {
return buildRunEvent({
runId: state.spec.runId,
sequence: state.events.length + 1,
type,
data,
});
}
function validateNode(state) {
const spec = normalizeRunSpec(state.spec);
if (spec.workflow.name !== CODE_RUN_SHADOW_WORKFLOW) {
const error = new Error(`Unsupported workflow: ${spec.workflow.name}`);
error.code = 'WORKFLOW_NOT_SUPPORTED';
throw error;
}
if (spec.policy.executionMode !== 'observe-only' || spec.policy.sideEffectsAllowed !== false) {
const error = new Error('Phase 2 LangGraph service accepts observe-only runs');
error.code = 'WORKFLOW_OBSERVE_ONLY_REQUIRED';
throw error;
}
return {
spec,
status: 'running',
phase: 'validated',
events: [eventFor(state, 'workflow_validated', {
workflow: spec.workflow,
executionMode: 'observe-only',
})],
};
}
function planNode(state) {
const taskType = String(state.spec.input?.taskType ?? '').trim() || 'code-change';
const instruction = String(state.spec.input?.instruction ?? '');
const plan = {
taskType,
executorAdapter: 'native-agent-run',
instructionCharacters: instruction.length,
steps: [
'accept_control_plane_run',
'project_executor_boundary',
'record_shadow_result',
],
};
return {
phase: 'planned',
plan,
events: [eventFor(state, 'workflow_planned', {
taskType,
executorAdapter: plan.executorAdapter,
stepCount: plan.steps.length,
})],
};
}
function finalizeNode(state) {
const result = {
observed: true,
executed: false,
executorAdapter: state.plan.executorAdapter,
taskType: state.plan.taskType,
};
return {
status: 'succeeded',
phase: 'completed',
result,
events: [eventFor(state, 'workflow_completed', result)],
};
}
export function createCodeRunShadowGraph({ checkpointer } = {}) {
if (!checkpointer) throw new Error('Code run shadow graph requires a checkpointer');
return new StateGraph(ShadowState)
.addNode('validate_run', validateNode)
.addNode('build_plan', planNode)
.addNode('finalize_run', finalizeNode)
.addEdge(START, 'validate_run')
.addEdge('validate_run', 'build_plan')
.addEdge('build_plan', 'finalize_run')
.addEdge('finalize_run', END)
.compile({ checkpointer });
}
export const codeRunShadowGraphInternals = {
ShadowState,
eventFor,
validateNode,
planNode,
finalizeNode,
};
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@memind/workflow-orchestrator",
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node server.mjs",
"test": "node --test *.test.mjs"
},
"dependencies": {
"@langchain/core": "^1.2.3",
"@langchain/langgraph": "^1.4.8",
"@langchain/langgraph-checkpoint-postgres": "^1.0.4",
"express": "^4.21.2"
}
}
+137
View File
@@ -0,0 +1,137 @@
import { normalizeRunSpec } from './contracts.mjs';
import {
CODE_RUN_SHADOW_WORKFLOW,
createCodeRunShadowGraph,
} from './code-run-shadow-graph.mjs';
function graphConfig(runId) {
return {
configurable: {
thread_id: runId,
checkpoint_ns: '',
},
};
}
function projectSnapshot(runId, snapshot) {
const values = snapshot?.values ?? {};
if (!values.spec) return null;
return {
version: 'orchestrator-state-v1',
runId,
requestId: values.spec.requestId,
workflow: values.spec.workflow,
status: values.status ?? 'unknown',
phase: values.phase ?? null,
plan: values.plan ?? null,
result: values.result ?? null,
createdAt: values.events?.[0]?.timestamp ?? null,
updatedAt: values.events?.at?.(-1)?.timestamp ?? null,
};
}
export function createLangGraphOrchestratorRuntime({
checkpointer,
checkpointKind = 'unknown',
durable = false,
checkpointProbe = null,
} = {}) {
const graph = createCodeRunShadowGraph({ checkpointer });
const probe = typeof checkpointProbe === 'function'
? checkpointProbe
: async () => {
await checkpointer.getTuple(graphConfig('__orchestrator_readiness__'));
return true;
};
async function getSnapshot(runId) {
return graph.getState(graphConfig(runId));
}
async function getState(runId) {
const normalizedRunId = String(runId ?? '').trim();
if (!normalizedRunId) return null;
return projectSnapshot(normalizedRunId, await getSnapshot(normalizedRunId));
}
function health() {
return {
status: 'ok',
service: 'memind-langgraph-orchestrator',
checkpoint: {
kind: checkpointKind,
durable: Boolean(durable),
},
execution: 'observe-only',
};
}
return {
async start(input) {
const spec = normalizeRunSpec(input);
if (spec.workflow.name !== CODE_RUN_SHADOW_WORKFLOW) {
const error = new Error(`Unsupported workflow: ${spec.workflow.name}`);
error.code = 'WORKFLOW_NOT_SUPPORTED';
error.status = 422;
throw error;
}
const existing = await getState(spec.runId);
if (existing) return existing;
await graph.invoke({
spec,
status: 'queued',
phase: 'accepted',
events: [],
}, graphConfig(spec.runId));
return getState(spec.runId);
},
getState,
async listEvents(runId, { after = 0 } = {}) {
const normalizedRunId = String(runId ?? '').trim();
const snapshot = normalizedRunId ? await getSnapshot(normalizedRunId) : null;
if (!snapshot?.values?.spec) return null;
const cursor = Math.max(0, Number(after) || 0);
const events = (snapshot.values.events ?? []).filter((event) => event.sequence > cursor);
return {
runId: normalizedRunId,
events,
nextCursor: events.at(-1)?.sequence ?? cursor,
};
},
async resume(runId) {
const state = await getState(runId);
if (!state) return null;
const error = new Error('This workflow has no interrupt point to resume');
error.code = 'WORKFLOW_NOT_INTERRUPTED';
error.status = 409;
throw error;
},
async cancel(runId) {
const state = await getState(runId);
if (!state) return null;
const error = new Error('Completed shadow observations cannot be cancelled');
error.code = 'WORKFLOW_ALREADY_TERMINAL';
error.status = 409;
throw error;
},
health,
async ready() {
await probe();
return {
...health(),
ready: true,
};
},
};
}
export const orchestratorRuntimeInternals = {
graphConfig,
projectSnapshot,
};
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MemorySaver } from '@langchain/langgraph';
import { createLangGraphOrchestratorRuntime } from './runtime.mjs';
function runSpec(overrides = {}) {
return {
runId: 'run-shadow-1',
requestId: 'request-shadow-1',
workflow: { name: 'code-run-v1', version: 1 },
subject: { userId: 'user-1' },
input: {
instruction: 'Add an idempotency test',
taskType: 'code-change',
},
policy: {
executionMode: 'observe-only',
sideEffectsAllowed: false,
},
...overrides,
};
}
test('LangGraph runtime checkpoints a deterministic observe-only code workflow', async () => {
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: new MemorySaver(),
checkpointKind: 'memory',
durable: false,
});
const state = await runtime.start(runSpec());
assert.equal(state.runId, 'run-shadow-1');
assert.equal(state.status, 'succeeded');
assert.equal(state.phase, 'completed');
assert.equal(state.result.observed, true);
assert.equal(state.result.executed, false);
assert.equal(state.plan.executorAdapter, 'native-agent-run');
const restored = await runtime.getState('run-shadow-1');
assert.deepEqual(restored, state);
const eventPage = await runtime.listEvents('run-shadow-1');
assert.deepEqual(
eventPage.events.map((event) => event.type),
['workflow_validated', 'workflow_planned', 'workflow_completed'],
);
assert.deepEqual(
eventPage.events.map((event) => event.sequence),
[1, 2, 3],
);
assert.equal(eventPage.nextCursor, 3);
assert.equal((await runtime.listEvents('run-shadow-1', { after: 2 })).events.length, 1);
});
test('LangGraph runtime is idempotent by run id', async () => {
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: new MemorySaver(),
});
const first = await runtime.start(runSpec());
const second = await runtime.start(runSpec({
input: { instruction: 'This must not replace the first checkpoint' },
}));
assert.deepEqual(second, first);
assert.equal((await runtime.listEvents('run-shadow-1')).events.length, 3);
});
test('LangGraph runtime rejects execution-enabled and unsupported workflows', async () => {
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: new MemorySaver(),
});
await assert.rejects(
runtime.start(runSpec({
runId: 'run-active',
policy: { executionMode: 'active', sideEffectsAllowed: true },
})),
(error) => error.code === 'WORKFLOW_OBSERVE_ONLY_REQUIRED',
);
await assert.rejects(
runtime.start(runSpec({
runId: 'run-unsupported',
workflow: { name: 'research-v1', version: 1 },
})),
(error) => error.code === 'WORKFLOW_NOT_SUPPORTED' && error.status === 422,
);
});
+65
View File
@@ -0,0 +1,65 @@
import { pathToFileURL } from 'node:url';
import { createOrchestratorApp } from './app.mjs';
import { createOrchestratorCheckpoint } from './checkpoint.mjs';
import { createLangGraphOrchestratorRuntime } from './runtime.mjs';
function positivePort(value, fallback = 8093) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return fallback;
return parsed;
}
export async function startOrchestratorServer({
env = process.env,
logger = console,
} = {}) {
const checkpoint = await createOrchestratorCheckpoint({
mode: env.MEMIND_ORCHESTRATOR_CHECKPOINT_MODE,
connectionString: env.MEMIND_ORCHESTRATOR_DATABASE_URL,
schema: env.MEMIND_ORCHESTRATOR_DATABASE_SCHEMA,
});
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: checkpoint.checkpointer,
checkpointKind: checkpoint.kind,
durable: checkpoint.durable,
checkpointProbe: checkpoint.probe,
});
const app = createOrchestratorApp({
runtime,
serviceToken: env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
});
const host = String(env.MEMIND_ORCHESTRATOR_HOST ?? '127.0.0.1').trim() || '127.0.0.1';
const port = positivePort(env.MEMIND_ORCHESTRATOR_PORT, 8093);
const server = await new Promise((resolve, reject) => {
const listening = app.listen(port, host, () => resolve(listening));
listening.once('error', reject);
});
logger.log(`[orchestrator] listening on http://${host}:${port} (${checkpoint.kind})`);
async function close() {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await checkpoint.close();
}
return { app, server, runtime, checkpoint, close };
}
const isEntrypoint = process.argv[1]
&& import.meta.url === pathToFileURL(process.argv[1]).href;
if (isEntrypoint) {
const running = await startOrchestratorServer();
const shutdown = async (signal) => {
console.log(`[orchestrator] received ${signal}, shutting down`);
await running.close();
process.exit(0);
};
process.once('SIGINT', () => void shutdown('SIGINT'));
process.once('SIGTERM', () => void shutdown('SIGTERM'));
}
export const orchestratorServerInternals = {
positivePort,
};
+111
View File
@@ -0,0 +1,111 @@
import {
WORKFLOW_ENGINE,
normalizeRunSpec,
} from './contracts.mjs';
import { createRemoteWorkflowEngine } from './engine-registry.mjs';
const MAX_INSTRUCTION_CHARACTERS = 16_000;
function extractMessageText(message) {
if (typeof message === 'string') return message;
if (!message || typeof message !== 'object') return '';
if (typeof message.content === 'string') return message.content;
if (!Array.isArray(message.content)) return '';
return message.content
.filter((part) => part?.type === 'text')
.map((part) => String(part.text ?? ''))
.join('\n');
}
function safeError(error) {
return {
code: String(error?.code ?? 'WORKFLOW_SHADOW_FAILED').slice(0, 128),
message: String(error instanceof Error ? error.message : error).slice(0, 1000),
};
}
export function createWorkflowShadowObserver({
configService,
serviceToken = process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
fetchImpl = globalThis.fetch,
logger = console,
} = {}) {
if (!configService?.selectEngine || !configService?.getRuntimeState) {
throw new Error('Workflow shadow observer requires orchestrator config service');
}
return async function observeWorkflowRun({
runId,
requestId,
userId,
sessionId = null,
workflowName = 'code-run-v1',
userMessage,
taskType = null,
} = {}) {
const selection = await configService.selectEngine({
runId,
requestId,
userId,
workflowName,
});
if (selection.shadowEngine !== WORKFLOW_ENGINE.LANGGRAPH) {
return {
observed: false,
reason: selection.reason,
mode: selection.mode,
};
}
const state = await configService.getRuntimeState();
const engine = createRemoteWorkflowEngine({
id: WORKFLOW_ENGINE.LANGGRAPH,
baseUrl: state.config.serviceUrl,
serviceToken,
timeoutMs: state.config.requestTimeoutMs,
fetchImpl,
});
const instruction = extractMessageText(userMessage).slice(0, MAX_INSTRUCTION_CHARACTERS);
const spec = normalizeRunSpec({
runId,
requestId,
workflow: { name: workflowName, version: 1 },
subject: { userId },
input: {
instruction,
taskType,
toolMode: 'code',
sessionRef: sessionId
? { kind: 'goose-session', id: String(sessionId) }
: null,
},
policy: {
executionMode: 'observe-only',
sideEffectsAllowed: false,
},
metadata: {
source: 'memind-agent-run',
configVersion: selection.configVersion,
},
});
try {
const result = await engine.start(spec);
return {
observed: true,
engine: WORKFLOW_ENGINE.LANGGRAPH,
mode: selection.mode,
configVersion: selection.configVersion,
shadowRun: result,
};
} catch (error) {
logger.warn('[orchestrator-shadow] observation failed:', safeError(error));
throw error;
}
};
}
export const workflowShadowObserverInternals = {
MAX_INSTRUCTION_CHARACTERS,
extractMessageText,
safeError,
};
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createWorkflowShadowObserver } from './shadow-observer.mjs';
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
test('shadow observer skips without creating a remote client when mode does not select shadow', async () => {
let fetchCalls = 0;
const observer = createWorkflowShadowObserver({
configService: {
async selectEngine() {
return { shadowEngine: null, reason: 'mode_off', mode: 'off' };
},
async getRuntimeState() {
throw new Error('must not load runtime config');
},
},
fetchImpl: async () => {
fetchCalls += 1;
return jsonResponse({});
},
});
const result = await observer({
runId: 'run-1',
requestId: 'request-1',
userId: 'user-1',
});
assert.deepEqual(result, {
observed: false,
reason: 'mode_off',
mode: 'off',
});
assert.equal(fetchCalls, 0);
});
test('shadow observer sends a bounded observe-only RunSpec to LangGraph service', async () => {
let capturedUrl = null;
let capturedInit = null;
const observer = createWorkflowShadowObserver({
configService: {
async selectEngine() {
return {
shadowEngine: 'langgraph',
reason: 'shadow',
mode: 'shadow',
configVersion: 7,
};
},
async getRuntimeState() {
return {
config: {
serviceUrl: 'http://orchestrator.internal:8093',
requestTimeoutMs: 1200,
},
};
},
},
serviceToken: 'internal-token',
fetchImpl: async (url, init) => {
capturedUrl = url;
capturedInit = init;
return jsonResponse({ runId: 'run-2', status: 'succeeded' }, 202);
},
});
const result = await observer({
runId: 'run-2',
requestId: 'request-2',
userId: 'user-2',
sessionId: 'session-2',
taskType: 'code-change',
userMessage: {
content: [{ type: 'text', text: 'Implement the service boundary' }],
},
});
assert.equal(result.observed, true);
assert.equal(capturedUrl, 'http://orchestrator.internal:8093/v1/runs');
assert.equal(capturedInit.headers.authorization, 'Bearer internal-token');
const body = JSON.parse(capturedInit.body);
assert.equal(body.version, 'orchestrator-run-v1');
assert.equal(body.policy.executionMode, 'observe-only');
assert.equal(body.policy.sideEffectsAllowed, false);
assert.deepEqual(body.input.sessionRef, { kind: 'goose-session', id: 'session-2' });
assert.equal(body.metadata.configVersion, 7);
});