memorix 1.9.4 → 1.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -1
- package/README.md +12 -6
- package/README.zh-CN.md +12 -6
- package/dist/cli/index.js +2533 -585
- package/dist/index.d.ts +15 -8
- package/dist/index.js +1201 -284
- package/dist/maintenance-runner.js +387 -216
- package/dist/media-audio-runner.js +300 -129
- package/dist/media-video-runner.js +300 -129
- package/dist/memcode-runtime/CHANGELOG.md +75 -1
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +1882 -998
- package/dist/types.d.ts +1 -1
- package/dist/vector-backfill-runner.js +300 -129
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
- package/docs/API_REFERENCE.md +39 -1
- package/docs/CONFIGURATION.md +58 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/DOCKER.md +23 -3
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/KNOWN_ISSUES_AND_ROADMAP.md +61 -1
- package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
- package/docs/PERFORMANCE.md +46 -2
- package/docs/README.md +2 -1
- package/docs/SETUP.md +56 -1
- package/package.json +3 -2
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/capability-map.ts +1 -0
- package/src/cli/command-guide.ts +15 -3
- package/src/cli/commands/agent-integrations.ts +57 -0
- package/src/cli/commands/background.ts +11 -2
- package/src/cli/commands/context.ts +2 -0
- package/src/cli/commands/continuity.ts +155 -0
- package/src/cli/commands/init.ts +5 -2
- package/src/cli/commands/serve-http.ts +435 -84
- package/src/cli/commands/setup.ts +77 -10
- package/src/cli/commands/sync-store.ts +47 -12
- package/src/cli/commands/sync.ts +26 -0
- package/src/cli/index.ts +3 -1
- package/src/cli/tui/ConfigureView.tsx +2 -0
- package/src/codegraph/auto-context.ts +17 -0
- package/src/config/legacy-loader.ts +2 -2
- package/src/config/resolved-config.ts +8 -5
- package/src/config/yaml-loader.ts +20 -10
- package/src/dashboard/server.ts +10 -12
- package/src/embedding/api-provider.ts +4 -2
- package/src/embedding/fastembed-provider.ts +47 -14
- package/src/embedding/transformers-provider.ts +29 -7
- package/src/evaluation/memory-quality-gate.ts +90 -0
- package/src/hooks/official-skills.ts +2 -0
- package/src/integrations/registry.ts +1 -1
- package/src/knowledge/context-assembly.ts +1 -0
- package/src/knowledge/context-receipt.ts +15 -0
- package/src/knowledge/task-continuity.ts +413 -0
- package/src/knowledge/workset.ts +77 -0
- package/src/llm/provider.ts +2 -1
- package/src/memory/graph.ts +10 -8
- package/src/memory/session.ts +1 -1
- package/src/orchestrate/coordinator.ts +60 -0
- package/src/orchestrate/effect-ledger.ts +160 -0
- package/src/orchestrate/pipeline-trace.ts +40 -6
- package/src/runtime/maintenance-jobs.ts +12 -6
- package/src/sdk.ts +24 -0
- package/src/server/mcp-binding-store.ts +14 -3
- package/src/server/modern-mcp-bridge.ts +153 -27
- package/src/server/tool-profile.ts +3 -2
- package/src/server.ts +131 -7
- package/src/store/file-lock.ts +28 -3
- package/src/store/graph-store.ts +178 -46
- package/src/store/persistence.ts +14 -9
- package/src/store/sqlite-db.ts +150 -7
- package/src/store/sqlite-store.ts +32 -27
- package/src/sync/engine.ts +9 -7
- package/src/sync/namespace.ts +23 -0
- package/src/sync/policy.ts +19 -4
- package/src/sync/store-port.ts +43 -13
- package/src/sync/types.ts +2 -0
- package/src/team/team-store.ts +62 -27
- package/src/types.ts +1 -1
- package/src/workspace/mcp-adapters/grok.ts +32 -0
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
import { defineCommand } from 'citty';
|
|
23
23
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
24
24
|
import { getHeapStatistics } from 'node:v8';
|
|
25
|
+
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
26
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
25
27
|
import type { ObservationStore } from '../../store/obs-store.js';
|
|
26
28
|
import { resolveToolProfile } from '../../server/tool-profile.js';
|
|
27
29
|
import { scopeKnowledgeGraphToProject } from '../../memory/graph-scope.js';
|
|
@@ -39,6 +41,13 @@ import { mcpFileUriToPath } from '../mcp-root-path.js';
|
|
|
39
41
|
*/
|
|
40
42
|
export const DEFAULT_SESSION_TIMEOUT_MS = 12 * 60 * 60 * 1000;
|
|
41
43
|
export const EXPIRED_SESSION_TTL_MS = 10 * 60 * 1000;
|
|
44
|
+
export const DEFAULT_HTTP_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
45
|
+
export const MAX_HTTP_BODY_LIMIT_BYTES = 64 * 1024 * 1024;
|
|
46
|
+
export const MIN_HTTP_BODY_LIMIT_BYTES = 64 * 1024;
|
|
47
|
+
export const MAX_PROJECT_CACHE_ENTRIES = 256;
|
|
48
|
+
export const MAX_STORE_CACHE_ENTRIES = 32;
|
|
49
|
+
export const DEFAULT_MAX_HTTP_SESSIONS = 64;
|
|
50
|
+
export const MAX_HTTP_SESSIONS_LIMIT = 512;
|
|
42
51
|
/** Pinned SDK protocol accepted internally while the HTTP boundary speaks the current stateless contract. */
|
|
43
52
|
export const MCP_SDK_COMPAT_PROTOCOL_VERSION = '2025-11-25';
|
|
44
53
|
|
|
@@ -56,6 +65,109 @@ export function parseSessionTimeoutMs(raw: string | undefined): number {
|
|
|
56
65
|
return Math.floor(parsed);
|
|
57
66
|
}
|
|
58
67
|
|
|
68
|
+
export function parseHttpBodyLimit(raw: string | undefined): number {
|
|
69
|
+
const value = raw?.trim();
|
|
70
|
+
if (!value) return DEFAULT_HTTP_BODY_LIMIT_BYTES;
|
|
71
|
+
const parsed = Number(value);
|
|
72
|
+
if (!Number.isSafeInteger(parsed) || parsed < MIN_HTTP_BODY_LIMIT_BYTES) {
|
|
73
|
+
return DEFAULT_HTTP_BODY_LIMIT_BYTES;
|
|
74
|
+
}
|
|
75
|
+
return Math.min(parsed, MAX_HTTP_BODY_LIMIT_BYTES);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseMaxHttpSessions(raw: string | undefined): number {
|
|
79
|
+
const value = raw?.trim();
|
|
80
|
+
if (!value) return DEFAULT_MAX_HTTP_SESSIONS;
|
|
81
|
+
const parsed = Number(value);
|
|
82
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) return DEFAULT_MAX_HTTP_SESSIONS;
|
|
83
|
+
return Math.min(parsed, MAX_HTTP_SESSIONS_LIMIT);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isLoopbackHost(host: string): boolean {
|
|
87
|
+
const normalized = host.trim().toLowerCase();
|
|
88
|
+
return normalized === 'localhost'
|
|
89
|
+
|| normalized === '127.0.0.1'
|
|
90
|
+
|| normalized === '::1'
|
|
91
|
+
|| normalized === '[::1]';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function validateHttpBindSecurity(
|
|
95
|
+
host: string,
|
|
96
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
97
|
+
): { requiresAuth: boolean; explicitlyUnauthenticated: boolean } {
|
|
98
|
+
const hasToken = Boolean(env.MEMORIX_HTTP_AUTH_TOKEN?.trim());
|
|
99
|
+
const explicitlyUnauthenticated = env.MEMORIX_HTTP_ALLOW_UNAUTHENTICATED_BIND === '1';
|
|
100
|
+
if (!isLoopbackHost(host) && !hasToken && !explicitlyUnauthenticated) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`[memorix] Refusing unauthenticated non-loopback HTTP bind on ${host}. `
|
|
103
|
+
+ 'Set MEMORIX_HTTP_AUTH_TOKEN or explicitly set MEMORIX_HTTP_ALLOW_UNAUTHENTICATED_BIND=1.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return { requiresAuth: hasToken, explicitlyUnauthenticated };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isAuthorizedHttpRequest(req: IncomingMessage, token: string): boolean {
|
|
110
|
+
const header = req.headers.authorization;
|
|
111
|
+
if (!header) return false;
|
|
112
|
+
const match = /^Bearer\s+(.+)$/iu.exec(header);
|
|
113
|
+
if (!match) return false;
|
|
114
|
+
const provided = Buffer.from(match[1], 'utf8');
|
|
115
|
+
const expected = Buffer.from(token, 'utf8');
|
|
116
|
+
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class HttpPayloadTooLargeError extends Error {
|
|
120
|
+
readonly statusCode = 413;
|
|
121
|
+
|
|
122
|
+
constructor(limitBytes: number) {
|
|
123
|
+
super(`Request body exceeds the ${limitBytes} byte limit.`);
|
|
124
|
+
this.name = 'HttpPayloadTooLargeError';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function parseJsonBody(req: IncomingMessage, limitBytes: number): Promise<unknown> {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const contentLength = Number(req.headers['content-length']);
|
|
131
|
+
if (Number.isSafeInteger(contentLength) && contentLength > limitBytes) {
|
|
132
|
+
req.resume();
|
|
133
|
+
reject(new HttpPayloadTooLargeError(limitBytes));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const chunks: Buffer[] = [];
|
|
138
|
+
let size = 0;
|
|
139
|
+
let settled = false;
|
|
140
|
+
const fail = (error: unknown) => {
|
|
141
|
+
if (settled) return;
|
|
142
|
+
settled = true;
|
|
143
|
+
reject(error);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
req.on('data', (chunk: Buffer) => {
|
|
147
|
+
if (settled) return;
|
|
148
|
+
size += chunk.length;
|
|
149
|
+
if (size > limitBytes) {
|
|
150
|
+
req.removeAllListeners('data');
|
|
151
|
+
req.resume();
|
|
152
|
+
fail(new HttpPayloadTooLargeError(limitBytes));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
chunks.push(chunk);
|
|
156
|
+
});
|
|
157
|
+
req.on('end', () => {
|
|
158
|
+
if (settled) return;
|
|
159
|
+
settled = true;
|
|
160
|
+
try {
|
|
161
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
162
|
+
resolve(body ? JSON.parse(body) : undefined);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
reject(error);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
req.on('error', fail);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
59
171
|
export interface ServeHttpConfiguredDefaults {
|
|
60
172
|
port?: number;
|
|
61
173
|
transport?: 'stdio' | 'http';
|
|
@@ -132,7 +244,8 @@ export default defineCommand({
|
|
|
132
244
|
// node:fs from being evaluated before their mock factories initialize.
|
|
133
245
|
const { McpBindingStore } = await import('../../server/mcp-binding-store.js');
|
|
134
246
|
const { MemoryFeedbackStore } = await import('../../memory/feedback.js');
|
|
135
|
-
const {
|
|
247
|
+
const { createModernMcpRuntime, ModernMcpRuntimePool, withBusinessRuntimeQueue } = await import('../../server/modern-mcp-bridge.js');
|
|
248
|
+
const { acquireDatabase, evictIdleDatabases, getDatabaseStats } = await import('../../store/sqlite-db.js');
|
|
136
249
|
const { createMcpHandler } = await import('@modelcontextprotocol/server');
|
|
137
250
|
const { toNodeHandler } = await import('@modelcontextprotocol/node');
|
|
138
251
|
|
|
@@ -146,6 +259,14 @@ export default defineCommand({
|
|
|
146
259
|
if (resolved.transportNotice) console.error(resolved.transportNotice);
|
|
147
260
|
const port = resolved.port;
|
|
148
261
|
const host = args.host || '127.0.0.1';
|
|
262
|
+
const bindSecurity = validateHttpBindSecurity(host);
|
|
263
|
+
const httpAuthToken = process.env.MEMORIX_HTTP_AUTH_TOKEN?.trim() || '';
|
|
264
|
+
if (bindSecurity.explicitlyUnauthenticated && !isLoopbackHost(host)) {
|
|
265
|
+
console.error(`[memorix] WARNING: HTTP control plane is unauthenticated on ${host}; keep it behind a private network or authenticated proxy.`);
|
|
266
|
+
}
|
|
267
|
+
if (bindSecurity.requiresAuth) {
|
|
268
|
+
console.error('[memorix] HTTP control plane authentication is enabled (Bearer token).');
|
|
269
|
+
}
|
|
149
270
|
const toolProfile = resolveToolProfile({ explicit: args.mode, envValue: process.env.MEMORIX_MODE, fallback: 'team' });
|
|
150
271
|
|
|
151
272
|
// Priority: explicit --cwd arg > MEMORIX_PROJECT_ROOT env > process.cwd().
|
|
@@ -176,31 +297,80 @@ export default defineCommand({
|
|
|
176
297
|
console.error(`[memorix] HTTP transport starting on ${host}:${port}`);
|
|
177
298
|
console.error(`[memorix] Project root: ${projectRoot}`);
|
|
178
299
|
const sessionTimeoutMs = parseSessionTimeoutMs(process.env.MEMORIX_SESSION_TIMEOUT_MS);
|
|
300
|
+
const httpBodyLimitBytes = parseHttpBodyLimit(process.env.MEMORIX_HTTP_MAX_BODY_BYTES);
|
|
301
|
+
const maxHttpSessions = parseMaxHttpSessions(process.env.MEMORIX_MAX_HTTP_SESSIONS);
|
|
302
|
+
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
303
|
+
eventLoopDelay.enable();
|
|
179
304
|
console.error(
|
|
180
305
|
sessionTimeoutMs === 0
|
|
181
306
|
? '[memorix] HTTP session idle timeout: disabled'
|
|
182
307
|
: `[memorix] HTTP session idle timeout: ${Math.round(sessionTimeoutMs / 60000)}min (${sessionTimeoutMs}ms)`,
|
|
183
308
|
);
|
|
309
|
+
console.error(`[memorix] HTTP session limit: ${maxHttpSessions}`);
|
|
184
310
|
|
|
185
311
|
// Per-project TeamStore cache (Option B) — each dataDir gets its own TeamStore instance.
|
|
186
312
|
// This ensures true isolation between projects in HTTP control-plane mode.
|
|
187
313
|
const teamStoreCache = new Map<string, Awaited<ReturnType<typeof initTeamStore>>>();
|
|
314
|
+
const teamStoreLeases = new Map<string, { release: () => void }>();
|
|
188
315
|
const { initTeamStore } = await import('../../team/team-store.js');
|
|
189
316
|
|
|
317
|
+
function cacheSet<K, V>(
|
|
318
|
+
cache: Map<K, V>,
|
|
319
|
+
key: K,
|
|
320
|
+
value: V,
|
|
321
|
+
limit: number,
|
|
322
|
+
onEvict?: (evictedKey: K, evictedValue: V) => void,
|
|
323
|
+
): void {
|
|
324
|
+
cache.delete(key);
|
|
325
|
+
cache.set(key, value);
|
|
326
|
+
while (cache.size > limit) {
|
|
327
|
+
const oldest = cache.keys().next().value as K | undefined;
|
|
328
|
+
if (oldest === undefined) break;
|
|
329
|
+
const oldValue = cache.get(oldest);
|
|
330
|
+
cache.delete(oldest);
|
|
331
|
+
if (oldValue !== undefined) onEvict?.(oldest, oldValue);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
190
335
|
async function getTeamStore(dataDir: string) {
|
|
191
336
|
const existing = teamStoreCache.get(dataDir);
|
|
192
|
-
if (existing)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
337
|
+
if (existing) {
|
|
338
|
+
cacheSet(teamStoreCache, dataDir, existing, MAX_STORE_CACHE_ENTRIES);
|
|
339
|
+
return existing;
|
|
340
|
+
}
|
|
341
|
+
const lease = acquireDatabase(dataDir);
|
|
342
|
+
try {
|
|
343
|
+
const store = await initTeamStore(dataDir);
|
|
344
|
+
teamStoreLeases.set(dataDir, lease);
|
|
345
|
+
cacheSet(teamStoreCache, dataDir, store, MAX_STORE_CACHE_ENTRIES, (evictedDataDir) => {
|
|
346
|
+
teamStoreLeases.get(evictedDataDir)?.release();
|
|
347
|
+
teamStoreLeases.delete(evictedDataDir);
|
|
348
|
+
evictIdleDatabases(MAX_STORE_CACHE_ENTRIES);
|
|
349
|
+
});
|
|
350
|
+
return store;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
lease.release();
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
196
355
|
}
|
|
197
356
|
|
|
198
357
|
// TeamStore is resolved per MCP session/project. Sharing one mutable
|
|
199
358
|
// singleton here made project A's coordination state visible to project B
|
|
200
359
|
// after a roots switch. The cache still reuses the same project's SQLite
|
|
201
360
|
// handle without crossing project data directories.
|
|
361
|
+
const controlPlaneBaseDir = persistMod.getBaseDataDir();
|
|
362
|
+
const controlPlaneBaseLease = acquireDatabase(controlPlaneBaseDir);
|
|
202
363
|
const statelessBindingStore = new McpBindingStore();
|
|
203
|
-
await statelessBindingStore.init(
|
|
364
|
+
await statelessBindingStore.init(controlPlaneBaseDir);
|
|
365
|
+
const modernRuntimePool = new ModernMcpRuntimePool(MAX_STORE_CACHE_ENTRIES);
|
|
366
|
+
|
|
367
|
+
async function resolveModernDataDir(root: string, persisted?: { dataDir?: string }): Promise<string> {
|
|
368
|
+
if (persisted?.dataDir) return persisted.dataDir;
|
|
369
|
+
const detected = detectorMod.detectProject(root);
|
|
370
|
+
return detected
|
|
371
|
+
? persistMod.getProjectDataDir(detected.id)
|
|
372
|
+
: controlPlaneBaseDir;
|
|
373
|
+
}
|
|
204
374
|
|
|
205
375
|
// Modern MCP (2026-07-28) is stateless at the protocol layer. The official
|
|
206
376
|
// v2 handler owns discovery, per-request envelopes, result metadata,
|
|
@@ -211,32 +381,64 @@ export default defineCommand({
|
|
|
211
381
|
const persisted = handleId ? statelessBindingStore.touch(handleId) : undefined;
|
|
212
382
|
const requestedRoot = requestInfo?.headers.get('mcp-project-root') ?? undefined;
|
|
213
383
|
const root = persisted?.projectRoot ?? requestedRoot ?? projectRoot;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
384
|
+
const dataDir = await resolveModernDataDir(root, persisted);
|
|
385
|
+
const runtimeKey = JSON.stringify([dataDir, root]);
|
|
386
|
+
// The SDK closes the per-request bridge after the response. The pool
|
|
387
|
+
// decrements the runtime reference there; the business runtime remains
|
|
388
|
+
// available for the next request for this project.
|
|
389
|
+
return modernRuntimePool.createBridge(runtimeKey, () => withBusinessRuntimeQueue(async () => {
|
|
390
|
+
const lease = acquireDatabase(dataDir);
|
|
391
|
+
try {
|
|
392
|
+
const runtime = await createModernMcpRuntime({
|
|
393
|
+
projectRoot: root,
|
|
394
|
+
allowUntrackedFallback: false,
|
|
395
|
+
deferProjectInitUntilBound: true,
|
|
396
|
+
deferProjectRuntimeInit: true,
|
|
397
|
+
dashboardMode: 'control-plane',
|
|
398
|
+
dashboardPort: port,
|
|
399
|
+
toolProfile,
|
|
400
|
+
});
|
|
401
|
+
const close = runtime.close.bind(runtime);
|
|
402
|
+
return {
|
|
403
|
+
...runtime,
|
|
404
|
+
close: async () => {
|
|
405
|
+
try {
|
|
406
|
+
await close();
|
|
407
|
+
} finally {
|
|
408
|
+
lease.release();
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
} catch (error) {
|
|
413
|
+
lease.release();
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
}));
|
|
223
417
|
}, {
|
|
224
418
|
// Legacy requests are routed to the existing sessionful implementation
|
|
225
419
|
// below; this handler is strict modern-only by construction.
|
|
226
420
|
legacy: 'reject',
|
|
227
421
|
responseMode: 'auto',
|
|
422
|
+
onerror: error => {
|
|
423
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
424
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
425
|
+
console.error(`[memorix] modern MCP request failed: ${message}${stack ? `\n${stack}` : ''}`);
|
|
426
|
+
},
|
|
228
427
|
});
|
|
229
428
|
const modernNodeHandler = toNodeHandler(modernMcpHandler);
|
|
230
429
|
|
|
231
430
|
type SessionState = {
|
|
232
431
|
transport: InstanceType<typeof StreamableHTTPServerTransport>;
|
|
233
432
|
server: Awaited<ReturnType<typeof createMemorixServer>>['server'];
|
|
433
|
+
activateProjectRuntime: Awaited<ReturnType<typeof createMemorixServer>>['activateProjectRuntime'];
|
|
234
434
|
switchProject: Awaited<ReturnType<typeof createMemorixServer>>['switchProject'];
|
|
235
435
|
binding: import('../../server/request-context.js').ProjectBindingController;
|
|
436
|
+
leases: Array<{ release: () => void }>;
|
|
236
437
|
};
|
|
237
438
|
|
|
238
439
|
// Session map: sessionId → transport + per-session server state
|
|
239
440
|
const sessions = new Map<string, SessionState>();
|
|
441
|
+
let pendingSessionInitializations = 0;
|
|
240
442
|
|
|
241
443
|
// Session activity tracking (for GC timeout)
|
|
242
444
|
const sessionLastActivity = new Map<string, number>();
|
|
@@ -283,25 +485,6 @@ export default defineCommand({
|
|
|
283
485
|
res.end(message);
|
|
284
486
|
}
|
|
285
487
|
|
|
286
|
-
/**
|
|
287
|
-
* Parse JSON body from IncomingMessage
|
|
288
|
-
*/
|
|
289
|
-
function parseBody(req: IncomingMessage): Promise<unknown> {
|
|
290
|
-
return new Promise((resolve, reject) => {
|
|
291
|
-
const chunks: Buffer[] = [];
|
|
292
|
-
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
293
|
-
req.on('end', () => {
|
|
294
|
-
try {
|
|
295
|
-
const body = Buffer.concat(chunks).toString('utf-8');
|
|
296
|
-
resolve(body ? JSON.parse(body) : undefined);
|
|
297
|
-
} catch (err) {
|
|
298
|
-
reject(err);
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
req.on('error', reject);
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
488
|
/**
|
|
306
489
|
* Patch ServerResponse for MCP compatibility with strict HTTP clients (rmcp/reqwest).
|
|
307
490
|
*
|
|
@@ -442,7 +625,7 @@ export default defineCommand({
|
|
|
442
625
|
// No Access-Control-Allow-Origin at all for disallowed origins —
|
|
443
626
|
// browser will block the response (fail-closed).
|
|
444
627
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
445
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Mcp-Project-Handle, Mcp-Project-Root, Mcp-Stateless, Last-Event-Id');
|
|
628
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Mcp-Session-Id, Mcp-Project-Handle, Mcp-Project-Root, Mcp-Stateless, Last-Event-Id');
|
|
446
629
|
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, Mcp-Project-Handle, Mcp-Protocol-Version, Mcp-Method, Mcp-Name');
|
|
447
630
|
res.setHeader('Mcp-Protocol-Version', MCP_SDK_COMPAT_PROTOCOL_VERSION);
|
|
448
631
|
res.setHeader('Mcp-Method', req.method || 'UNKNOWN');
|
|
@@ -454,7 +637,7 @@ export default defineCommand({
|
|
|
454
637
|
*/
|
|
455
638
|
async function handlePost(req: IncomingMessage, res: ServerResponse) {
|
|
456
639
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
457
|
-
const body = await
|
|
640
|
+
const body = await parseJsonBody(req, httpBodyLimitBytes);
|
|
458
641
|
|
|
459
642
|
// Keep the pre-2026 startup probe compatible. Older MCP clients sent a
|
|
460
643
|
// claim-less server/discover before initialize and expect a plain JSON
|
|
@@ -525,7 +708,11 @@ export default defineCommand({
|
|
|
525
708
|
// Existing session — route to its transport
|
|
526
709
|
sessionLastActivity.set(sessionId, Date.now());
|
|
527
710
|
patchMcpResponse(res);
|
|
528
|
-
|
|
711
|
+
const session = sessions.get(sessionId)!;
|
|
712
|
+
await withBusinessRuntimeQueue(async () => {
|
|
713
|
+
await session.activateProjectRuntime();
|
|
714
|
+
await session.transport.handleRequest(req, res, body);
|
|
715
|
+
});
|
|
529
716
|
return;
|
|
530
717
|
}
|
|
531
718
|
|
|
@@ -535,12 +722,34 @@ export default defineCommand({
|
|
|
535
722
|
}
|
|
536
723
|
|
|
537
724
|
if (!sessionId && isInitializeRequest(body)) {
|
|
725
|
+
if (sessions.size + pendingSessionInitializations >= maxHttpSessions) {
|
|
726
|
+
res.writeHead(429, {
|
|
727
|
+
'Content-Type': 'application/json',
|
|
728
|
+
'Retry-After': '5',
|
|
729
|
+
});
|
|
730
|
+
res.end(JSON.stringify({
|
|
731
|
+
jsonrpc: '2.0',
|
|
732
|
+
error: { code: -32029, message: `HTTP session limit reached (${maxHttpSessions}). Retry after an existing session closes.` },
|
|
733
|
+
id: (body as { id?: string | number | null }).id ?? null,
|
|
734
|
+
}));
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
pendingSessionInitializations++;
|
|
738
|
+
let pendingReservationReleased = false;
|
|
739
|
+
const releasePendingReservation = () => {
|
|
740
|
+
if (pendingReservationReleased) return;
|
|
741
|
+
pendingReservationReleased = true;
|
|
742
|
+
pendingSessionInitializations = Math.max(0, pendingSessionInitializations - 1);
|
|
743
|
+
};
|
|
744
|
+
|
|
538
745
|
// New session — create transport + server
|
|
539
746
|
let createdState: SessionState | null = null;
|
|
747
|
+
const sessionLeases: Array<{ release: () => void }> = [acquireDatabase(defaultDataDir)];
|
|
540
748
|
const transport = new StreamableHTTPServerTransport({
|
|
541
749
|
sessionIdGenerator: () => randomUUID(),
|
|
542
750
|
enableJsonResponse: true,
|
|
543
751
|
onsessioninitialized: (sid: string) => {
|
|
752
|
+
releasePendingReservation();
|
|
544
753
|
if (createdState) sessions.set(sid, createdState);
|
|
545
754
|
sessionLastActivity.set(sid, Date.now());
|
|
546
755
|
// Defer log — only emit if session survives past SESSION_LOG_DELAY_MS
|
|
@@ -553,6 +762,7 @@ export default defineCommand({
|
|
|
553
762
|
});
|
|
554
763
|
|
|
555
764
|
transport.onclose = () => {
|
|
765
|
+
releasePendingReservation();
|
|
556
766
|
const sid = transport.sessionId;
|
|
557
767
|
if (sid) {
|
|
558
768
|
// If the deferred init log hasn't fired yet, this was a short-lived probe
|
|
@@ -569,30 +779,53 @@ export default defineCommand({
|
|
|
569
779
|
sessionLastActivity.delete(sid);
|
|
570
780
|
}
|
|
571
781
|
handleTransportClose();
|
|
782
|
+
for (const lease of sessionLeases) lease.release();
|
|
783
|
+
evictIdleDatabases(MAX_STORE_CACHE_ENTRIES);
|
|
572
784
|
};
|
|
573
785
|
|
|
574
786
|
// Legacy Mcp-Session-Id only routes requests. Project ownership lives
|
|
575
787
|
// in this transport-neutral binding object, ready for stateless MCP.
|
|
576
788
|
const binding = createProjectBindingController(projectRoot);
|
|
577
789
|
// Create a fresh MCP server for this session (with shared team state)
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
790
|
+
let server: Awaited<ReturnType<typeof createMemorixServer>>['server'];
|
|
791
|
+
let activateProjectRuntime: Awaited<ReturnType<typeof createMemorixServer>>['activateProjectRuntime'];
|
|
792
|
+
let handleTransportClose: Awaited<ReturnType<typeof createMemorixServer>>['handleTransportClose'];
|
|
793
|
+
let getProjectDataDirForSession: Awaited<ReturnType<typeof createMemorixServer>>['getProjectDataDir'];
|
|
794
|
+
let switchProject: Awaited<ReturnType<typeof createMemorixServer>>['switchProject'];
|
|
795
|
+
try {
|
|
796
|
+
({ server, activateProjectRuntime, switchProject, handleTransportClose, getProjectDataDir: getProjectDataDirForSession } = await createMemorixServer(
|
|
797
|
+
projectRoot,
|
|
798
|
+
undefined,
|
|
799
|
+
undefined,
|
|
800
|
+
{
|
|
801
|
+
allowUntrackedFallback: false,
|
|
802
|
+
deferProjectInitUntilBound: true,
|
|
803
|
+
// Same as stdio: initialize/tools/list must not hydrate 40k+
|
|
804
|
+
// observations on the HTTP thread. /health and the LaunchAgent
|
|
805
|
+
// watchdog die if createMemorixServer blocks here.
|
|
806
|
+
deferProjectRuntimeInit: true,
|
|
807
|
+
dashboardMode: 'control-plane',
|
|
808
|
+
dashboardPort: port,
|
|
809
|
+
toolProfile,
|
|
810
|
+
projectBinding: binding,
|
|
811
|
+
},
|
|
812
|
+
));
|
|
813
|
+
} catch (error) {
|
|
814
|
+
releasePendingReservation();
|
|
815
|
+
for (const lease of sessionLeases) lease.release();
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
818
|
+
const switchProjectWithLease = async (newCwd: string, source?: import('../../server/request-context.js').ProjectBindingSource): Promise<boolean> => {
|
|
819
|
+
const before = getProjectDataDirForSession();
|
|
820
|
+
const changed = await switchProject(newCwd, source);
|
|
821
|
+
const after = getProjectDataDirForSession();
|
|
822
|
+
if (changed && after !== before) {
|
|
823
|
+
sessionLeases[0].release();
|
|
824
|
+
sessionLeases[0] = acquireDatabase(after);
|
|
825
|
+
}
|
|
826
|
+
return changed;
|
|
827
|
+
};
|
|
828
|
+
createdState = { transport, server, activateProjectRuntime, switchProject: switchProjectWithLease, binding, leases: sessionLeases };
|
|
596
829
|
await server.connect(transport);
|
|
597
830
|
|
|
598
831
|
const tryRootsSwitch = async () => {
|
|
@@ -639,7 +872,16 @@ export default defineCommand({
|
|
|
639
872
|
} catch { /* optional */ }
|
|
640
873
|
|
|
641
874
|
patchMcpResponse(res);
|
|
642
|
-
|
|
875
|
+
try {
|
|
876
|
+
await withBusinessRuntimeQueue(async () => {
|
|
877
|
+
await activateProjectRuntime();
|
|
878
|
+
await transport.handleRequest(req, res, body);
|
|
879
|
+
});
|
|
880
|
+
} finally {
|
|
881
|
+
// A malformed probe can finish without creating a session; do not
|
|
882
|
+
// let it consume an admission slot forever.
|
|
883
|
+
if (!transport.sessionId) releasePendingReservation();
|
|
884
|
+
}
|
|
643
885
|
// Do NOT proactively call listRoots() after session establishment —
|
|
644
886
|
// this violates MCP SEP-2260 (server-initiated request must be associated
|
|
645
887
|
// with a client request). Codex and other strict clients treat standalone
|
|
@@ -675,6 +917,8 @@ export default defineCommand({
|
|
|
675
917
|
return;
|
|
676
918
|
}
|
|
677
919
|
|
|
920
|
+
// GET may keep an SSE stream open; it does not enter the business tool
|
|
921
|
+
// layer and must not occupy the singleton runtime gate.
|
|
678
922
|
await sessions.get(sessionId)!.transport.handleRequest(req, res);
|
|
679
923
|
}
|
|
680
924
|
|
|
@@ -693,7 +937,7 @@ export default defineCommand({
|
|
|
693
937
|
return;
|
|
694
938
|
}
|
|
695
939
|
|
|
696
|
-
await sessions.get(sessionId)!.transport.handleRequest(req, res);
|
|
940
|
+
await withBusinessRuntimeQueue(() => sessions.get(sessionId)!.transport.handleRequest(req, res));
|
|
697
941
|
}
|
|
698
942
|
|
|
699
943
|
// Create HTTP server
|
|
@@ -725,8 +969,9 @@ export default defineCommand({
|
|
|
725
969
|
|
|
726
970
|
// Cache resolved project data dirs to avoid repeated fs lookups
|
|
727
971
|
const projectDataDirCache = new Map<string, string>();
|
|
728
|
-
projectDataDirCache
|
|
972
|
+
cacheSet(projectDataDirCache, defaultProject.id, defaultDataDir, MAX_PROJECT_CACHE_ENTRIES);
|
|
729
973
|
const dashboardObservationStores = new Map<string, ObservationStore>();
|
|
974
|
+
const dashboardStoreLeases = new Map<string, { release: () => void }>();
|
|
730
975
|
|
|
731
976
|
/** Resolve ?project= query param to { projectId, projectName, dataDir } */
|
|
732
977
|
async function resolveRequestProject(url: URL): Promise<{
|
|
@@ -742,7 +987,7 @@ export default defineCommand({
|
|
|
742
987
|
let dataDir = projectDataDirCache.get(requestedId);
|
|
743
988
|
if (!dataDir) {
|
|
744
989
|
dataDir = await getProjectDataDir(requestedId);
|
|
745
|
-
projectDataDirCache
|
|
990
|
+
cacheSet(projectDataDirCache, requestedId, dataDir, MAX_PROJECT_CACHE_ENTRIES);
|
|
746
991
|
}
|
|
747
992
|
const name = requestedId.split('/').pop() || requestedId;
|
|
748
993
|
return { projectId: requestedId, projectName: name, dataDir };
|
|
@@ -750,11 +995,26 @@ export default defineCommand({
|
|
|
750
995
|
|
|
751
996
|
async function getDashboardObservationStore(dataDir: string): Promise<ObservationStore> {
|
|
752
997
|
const cached = dashboardObservationStores.get(dataDir);
|
|
753
|
-
if (cached)
|
|
998
|
+
if (cached) {
|
|
999
|
+
cacheSet(dashboardObservationStores, dataDir, cached, MAX_STORE_CACHE_ENTRIES);
|
|
1000
|
+
return cached;
|
|
1001
|
+
}
|
|
754
1002
|
const { createObservationStore } = await import('../../store/obs-store.js');
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
1003
|
+
const lease = acquireDatabase(dataDir);
|
|
1004
|
+
try {
|
|
1005
|
+
const store = await createObservationStore(dataDir);
|
|
1006
|
+
dashboardStoreLeases.set(dataDir, lease);
|
|
1007
|
+
cacheSet(dashboardObservationStores, dataDir, store, MAX_STORE_CACHE_ENTRIES, (evictedDataDir, evictedStore) => {
|
|
1008
|
+
try { evictedStore.close(); } catch { /* best-effort */ }
|
|
1009
|
+
dashboardStoreLeases.get(evictedDataDir)?.release();
|
|
1010
|
+
dashboardStoreLeases.delete(evictedDataDir);
|
|
1011
|
+
evictIdleDatabases(MAX_STORE_CACHE_ENTRIES);
|
|
1012
|
+
});
|
|
1013
|
+
return store;
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
lease.release();
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
758
1018
|
}
|
|
759
1019
|
|
|
760
1020
|
async function loadDashboardObservations(dataDir: string) {
|
|
@@ -861,7 +1121,7 @@ export default defineCommand({
|
|
|
861
1121
|
sendJson({ error: 'Maintenance actions require POST.' }, 405);
|
|
862
1122
|
return;
|
|
863
1123
|
}
|
|
864
|
-
const bodyValue = await
|
|
1124
|
+
const bodyValue = await parseJsonBody(req, Math.min(httpBodyLimitBytes, 1024 * 1024));
|
|
865
1125
|
const body = bodyValue && typeof bodyValue === 'object' && !Array.isArray(bodyValue)
|
|
866
1126
|
? bodyValue as Record<string, unknown>
|
|
867
1127
|
: {};
|
|
@@ -1023,9 +1283,9 @@ export default defineCommand({
|
|
|
1023
1283
|
|
|
1024
1284
|
if (apiPath === '/stats') {
|
|
1025
1285
|
const { projectId: statsProjectId, dataDir: statsDataDir } = await resolveRequestProject(url);
|
|
1026
|
-
const { initGraphStore
|
|
1027
|
-
await initGraphStore(statsDataDir);
|
|
1028
|
-
const graph = { entities:
|
|
1286
|
+
const { initGraphStore } = await import('../../store/graph-store.js');
|
|
1287
|
+
const gStore = await initGraphStore(statsDataDir, statsProjectId);
|
|
1288
|
+
const graph = { entities: gStore.loadEntities(), relations: gStore.loadRelations() };
|
|
1029
1289
|
|
|
1030
1290
|
const observations = await loadDashboardProjectObservations(statsDataDir, statsProjectId, 'active');
|
|
1031
1291
|
const feedback = await loadProjectFeedback(statsDataDir, statsProjectId);
|
|
@@ -1210,9 +1470,9 @@ export default defineCommand({
|
|
|
1210
1470
|
|
|
1211
1471
|
if (apiPath === '/graph') {
|
|
1212
1472
|
const { projectId: graphProjectId, dataDir: graphDataDir } = await resolveRequestProject(url);
|
|
1213
|
-
const { initGraphStore
|
|
1214
|
-
await initGraphStore(graphDataDir);
|
|
1215
|
-
const fullGraph = { entities:
|
|
1473
|
+
const { initGraphStore } = await import('../../store/graph-store.js');
|
|
1474
|
+
const gStore = await initGraphStore(graphDataDir, graphProjectId);
|
|
1475
|
+
const fullGraph = { entities: gStore.loadEntities(), relations: gStore.loadRelations() };
|
|
1216
1476
|
|
|
1217
1477
|
const allObs = await loadDashboardProjectObservations(graphDataDir, graphProjectId, 'active');
|
|
1218
1478
|
const scoped = scopeKnowledgeGraphToProject(fullGraph, allObs);
|
|
@@ -1298,15 +1558,15 @@ export default defineCommand({
|
|
|
1298
1558
|
const { projectId: kgProjectId, dataDir: kgDataDir } = await resolveRequestProject(url);
|
|
1299
1559
|
const { generateKnowledgeGraph } = await import('../../wiki/knowledge-graph.js');
|
|
1300
1560
|
const { initMiniSkillStore, getMiniSkillStore } = await import('../../store/mini-skill-store.js');
|
|
1301
|
-
const { initGraphStore
|
|
1561
|
+
const { initGraphStore } = await import('../../store/graph-store.js');
|
|
1302
1562
|
|
|
1303
1563
|
await initMiniSkillStore(kgDataDir);
|
|
1304
|
-
await initGraphStore(kgDataDir);
|
|
1564
|
+
const gStore = await initGraphStore(kgDataDir, kgProjectId);
|
|
1305
1565
|
|
|
1306
1566
|
const allObs = await loadDashboardProjectObservations(kgDataDir, kgProjectId, 'active');
|
|
1307
1567
|
const skills = await getMiniSkillStore().loadByProject(kgProjectId);
|
|
1308
1568
|
|
|
1309
|
-
const fullGraph = { entities:
|
|
1569
|
+
const fullGraph = { entities: gStore.loadEntities(), relations: gStore.loadRelations() };
|
|
1310
1570
|
const scoped = scopeKnowledgeGraphToProject(fullGraph, allObs);
|
|
1311
1571
|
|
|
1312
1572
|
const graph = generateKnowledgeGraph({
|
|
@@ -1634,9 +1894,8 @@ export default defineCommand({
|
|
|
1634
1894
|
await store.remove(obsId);
|
|
1635
1895
|
// Sync: clean up graph entity references
|
|
1636
1896
|
try {
|
|
1637
|
-
const { initGraphStore
|
|
1638
|
-
await initGraphStore(delDataDir);
|
|
1639
|
-
const gStore = getGraphStore();
|
|
1897
|
+
const { initGraphStore } = await import('../../store/graph-store.js');
|
|
1898
|
+
const gStore = await initGraphStore(delDataDir, delProjectId);
|
|
1640
1899
|
const prefix = `[#${obsId}] `;
|
|
1641
1900
|
const deletions: { entityName: string; observations: string[] }[] = [];
|
|
1642
1901
|
for (const entity of gStore.loadEntities()) {
|
|
@@ -1653,9 +1912,9 @@ export default defineCommand({
|
|
|
1653
1912
|
// GET /api/export — export observations as JSON
|
|
1654
1913
|
if (apiPath === '/export') {
|
|
1655
1914
|
const { projectId: expProjectId, projectName: expProjectName, dataDir: expDataDir } = await resolveRequestProject(url);
|
|
1656
|
-
const { initGraphStore
|
|
1657
|
-
await initGraphStore(expDataDir);
|
|
1658
|
-
const fullGraph = { entities:
|
|
1915
|
+
const { initGraphStore } = await import('../../store/graph-store.js');
|
|
1916
|
+
const gStore = await initGraphStore(expDataDir, expProjectId);
|
|
1917
|
+
const fullGraph = { entities: gStore.loadEntities(), relations: gStore.loadRelations() };
|
|
1659
1918
|
const observations = await loadDashboardProjectObservations(expDataDir, expProjectId, 'active');
|
|
1660
1919
|
const expStore = await getDashboardObservationStore(expDataDir);
|
|
1661
1920
|
const nextId = await expStore.loadIdCounter();
|
|
@@ -1694,7 +1953,8 @@ export default defineCommand({
|
|
|
1694
1953
|
}, 503);
|
|
1695
1954
|
return;
|
|
1696
1955
|
}
|
|
1697
|
-
|
|
1956
|
+
const status = err instanceof HttpPayloadTooLargeError ? err.statusCode : 500;
|
|
1957
|
+
sendJson({ error: err instanceof Error ? err.message : 'Unknown error' }, status);
|
|
1698
1958
|
}
|
|
1699
1959
|
}
|
|
1700
1960
|
|
|
@@ -1711,6 +1971,15 @@ export default defineCommand({
|
|
|
1711
1971
|
|
|
1712
1972
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
1713
1973
|
|
|
1974
|
+
if (bindSecurity.requiresAuth && url.pathname !== '/health' && !isAuthorizedHttpRequest(req, httpAuthToken)) {
|
|
1975
|
+
res.writeHead(401, {
|
|
1976
|
+
'Content-Type': 'application/json',
|
|
1977
|
+
'WWW-Authenticate': 'Bearer',
|
|
1978
|
+
});
|
|
1979
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1714
1983
|
// Lightweight health check — responds immediately, no heavy init required.
|
|
1715
1984
|
// This is the readiness signal for background start / status.
|
|
1716
1985
|
if (url.pathname === '/health') {
|
|
@@ -1736,6 +2005,18 @@ export default defineCommand({
|
|
|
1736
2005
|
arrayBuffers: memory.arrayBuffers,
|
|
1737
2006
|
heapLimit: heap.heap_size_limit,
|
|
1738
2007
|
},
|
|
2008
|
+
sqlite: getDatabaseStats(),
|
|
2009
|
+
sessions: {
|
|
2010
|
+
active: sessions.size,
|
|
2011
|
+
pending: pendingSessionInitializations,
|
|
2012
|
+
limit: maxHttpSessions,
|
|
2013
|
+
},
|
|
2014
|
+
modernRuntime: modernRuntimePool.stats(),
|
|
2015
|
+
eventLoop: {
|
|
2016
|
+
meanMs: Number.isFinite(eventLoopDelay.mean) ? eventLoopDelay.mean / 1e6 : null,
|
|
2017
|
+
p95Ms: eventLoopDelay.percentile(95) / 1e6,
|
|
2018
|
+
maxMs: eventLoopDelay.max / 1e6,
|
|
2019
|
+
},
|
|
1739
2020
|
embedding: getEmbeddingRuntimeHealth(),
|
|
1740
2021
|
}));
|
|
1741
2022
|
return;
|
|
@@ -1792,10 +2073,15 @@ export default defineCommand({
|
|
|
1792
2073
|
} catch (err) {
|
|
1793
2074
|
console.error('[memorix] HTTP handler error:', err);
|
|
1794
2075
|
if (!res.headersSent) {
|
|
1795
|
-
|
|
2076
|
+
const status = err instanceof HttpPayloadTooLargeError ? err.statusCode : 500;
|
|
2077
|
+
const message = err instanceof Error ? err.message : 'Internal server error';
|
|
2078
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1796
2079
|
res.end(JSON.stringify({
|
|
1797
2080
|
jsonrpc: '2.0',
|
|
1798
|
-
error: {
|
|
2081
|
+
error: {
|
|
2082
|
+
code: status === 413 ? -32013 : -32603,
|
|
2083
|
+
message: status === 413 ? message : 'Internal server error',
|
|
2084
|
+
},
|
|
1799
2085
|
id: null,
|
|
1800
2086
|
}));
|
|
1801
2087
|
}
|
|
@@ -1819,7 +2105,13 @@ export default defineCommand({
|
|
|
1819
2105
|
httpServer.keepAliveTimeout = 60_000;
|
|
1820
2106
|
httpServer.headersTimeout = 65_000;
|
|
1821
2107
|
|
|
1822
|
-
|
|
2108
|
+
const listenPromise = new Promise<void>((resolve, reject) => {
|
|
2109
|
+
const onError = (error: Error) => {
|
|
2110
|
+
if (typeof (httpServer as any).off === 'function') httpServer.off('listening', onListening);
|
|
2111
|
+
reject(error);
|
|
2112
|
+
};
|
|
2113
|
+
const onListening = () => {
|
|
2114
|
+
if (typeof (httpServer as any).off === 'function') httpServer.off('error', onError);
|
|
1823
2115
|
httpReadyAt = new Date().toISOString();
|
|
1824
2116
|
// Write readiness file — background start polls this for out-of-band readiness detection
|
|
1825
2117
|
try {
|
|
@@ -1841,7 +2133,36 @@ export default defineCommand({
|
|
|
1841
2133
|
// Fire-and-forget: background update check after server is fully started.
|
|
1842
2134
|
// Default is notify-only. All output goes to stderr only. Failures never affect the server.
|
|
1843
2135
|
import('../update-checker.js').then(m => m.checkForUpdates()).catch(() => {});
|
|
2136
|
+
resolve();
|
|
2137
|
+
};
|
|
2138
|
+
if (typeof (httpServer as any).once === 'function') {
|
|
2139
|
+
httpServer.once('error', onError);
|
|
2140
|
+
httpServer.once('listening', onListening);
|
|
2141
|
+
httpServer.listen(port, host);
|
|
2142
|
+
} else {
|
|
2143
|
+
// Lightweight test doubles may only implement listen(callback).
|
|
2144
|
+
httpServer.listen(port, host, onListening);
|
|
2145
|
+
}
|
|
1844
2146
|
});
|
|
2147
|
+
try {
|
|
2148
|
+
await listenPromise;
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
console.error(`[memorix] HTTP server failed to listen on ${host}:${port}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2151
|
+
for (const store of dashboardObservationStores.values()) {
|
|
2152
|
+
try { store.close(); } catch { /* best-effort */ }
|
|
2153
|
+
}
|
|
2154
|
+
dashboardObservationStores.clear();
|
|
2155
|
+
for (const lease of dashboardStoreLeases.values()) lease.release();
|
|
2156
|
+
dashboardStoreLeases.clear();
|
|
2157
|
+
for (const lease of teamStoreLeases.values()) lease.release();
|
|
2158
|
+
teamStoreLeases.clear();
|
|
2159
|
+
controlPlaneBaseLease.release();
|
|
2160
|
+
await modernRuntimePool.close();
|
|
2161
|
+
eventLoopDelay.disable();
|
|
2162
|
+
const { closeAllDatabases } = await import('../../store/sqlite-db.js');
|
|
2163
|
+
closeAllDatabases();
|
|
2164
|
+
throw error;
|
|
2165
|
+
}
|
|
1845
2166
|
|
|
1846
2167
|
// Session timeout GC — close sessions idle past the configured threshold.
|
|
1847
2168
|
// MEMORIX_SESSION_TIMEOUT_MS lets operators work around HTTP clients that do
|
|
@@ -1851,6 +2172,9 @@ export default defineCommand({
|
|
|
1851
2172
|
maybeProbeSummary(); // Flush suppressed probe count periodically
|
|
1852
2173
|
const now = Date.now();
|
|
1853
2174
|
forgetExpiredSessions(now);
|
|
2175
|
+
statelessBindingStore.cleanupExpired(now);
|
|
2176
|
+
evictIdleDatabases(MAX_STORE_CACHE_ENTRIES);
|
|
2177
|
+
void modernRuntimePool.evictIdle();
|
|
1854
2178
|
if (SESSION_TIMEOUT_MS === 0) return;
|
|
1855
2179
|
for (const [sid, state] of sessions) {
|
|
1856
2180
|
const lastActive = sessionLastActivity.get(sid) ?? 0;
|
|
@@ -1866,7 +2190,10 @@ export default defineCommand({
|
|
|
1866
2190
|
gcInterval.unref(); // Don't prevent process exit
|
|
1867
2191
|
|
|
1868
2192
|
// Graceful shutdown
|
|
2193
|
+
let shutdownStarted = false;
|
|
1869
2194
|
const shutdown = async () => {
|
|
2195
|
+
if (shutdownStarted) return;
|
|
2196
|
+
shutdownStarted = true;
|
|
1870
2197
|
if (suppressedProbeCount > 0) {
|
|
1871
2198
|
console.error(`[memorix] ${suppressedProbeCount} probe connection(s) suppressed during this session`);
|
|
1872
2199
|
}
|
|
@@ -1883,6 +2210,10 @@ export default defineCommand({
|
|
|
1883
2210
|
try { store.close(); } catch { /* best-effort */ }
|
|
1884
2211
|
}
|
|
1885
2212
|
dashboardObservationStores.clear();
|
|
2213
|
+
for (const lease of dashboardStoreLeases.values()) lease.release();
|
|
2214
|
+
dashboardStoreLeases.clear();
|
|
2215
|
+
for (const lease of teamStoreLeases.values()) lease.release();
|
|
2216
|
+
teamStoreLeases.clear();
|
|
1886
2217
|
for (const [sid, state] of sessions) {
|
|
1887
2218
|
try {
|
|
1888
2219
|
await state.transport.close();
|
|
@@ -1891,7 +2222,20 @@ export default defineCommand({
|
|
|
1891
2222
|
console.error(`[memorix] Error closing session ${sid}:`, err);
|
|
1892
2223
|
}
|
|
1893
2224
|
}
|
|
1894
|
-
|
|
2225
|
+
await modernRuntimePool.close();
|
|
2226
|
+
clearInterval(gcInterval);
|
|
2227
|
+
clearInterval(heartbeatInterval);
|
|
2228
|
+
eventLoopDelay.disable();
|
|
2229
|
+
await new Promise<void>((resolve) => {
|
|
2230
|
+
if (!httpServer.listening) {
|
|
2231
|
+
resolve();
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
httpServer.close(() => resolve());
|
|
2235
|
+
});
|
|
2236
|
+
const { closeAllDatabases } = await import('../../store/sqlite-db.js');
|
|
2237
|
+
controlPlaneBaseLease.release();
|
|
2238
|
+
closeAllDatabases();
|
|
1895
2239
|
process.exit(0);
|
|
1896
2240
|
};
|
|
1897
2241
|
|
|
@@ -1943,6 +2287,13 @@ export default defineCommand({
|
|
|
1943
2287
|
/** @internal */
|
|
1944
2288
|
export const _testing = {
|
|
1945
2289
|
DEFAULT_SESSION_TIMEOUT_MS,
|
|
2290
|
+
DEFAULT_HTTP_BODY_LIMIT_BYTES,
|
|
2291
|
+
MAX_HTTP_BODY_LIMIT_BYTES,
|
|
2292
|
+
MIN_HTTP_BODY_LIMIT_BYTES,
|
|
1946
2293
|
parseSessionTimeoutMs,
|
|
2294
|
+
parseMaxHttpSessions,
|
|
2295
|
+
parseHttpBodyLimit,
|
|
2296
|
+
isLoopbackHost,
|
|
2297
|
+
validateHttpBindSecurity,
|
|
1947
2298
|
parseTcpPortOrReport,
|
|
1948
2299
|
};
|