#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import { runMemoryV2AppCanaryCli } from './check-memory-v2-app-canary.mjs'; import { runMemoryV2ContractCli } from './check-memory-v2-contracts.mjs'; import { runMemoryV2HealthCli } from './check-memory-v2-health.mjs'; import { runMemoryV2SessionFlowCli } from './check-memory-v2-session-flow.mjs'; import { runMemoryV2ExternalSmokeCli } from './smoke-memory-v2-external.mjs'; import { runMemoryPgvectorSmokeCli } from './smoke-memory-v2-pgvector.mjs'; import { runMemoryQdrantSmokeCli } from './smoke-memory-v2-qdrant.mjs'; const DEFAULT_BASE_URL = 'http://127.0.0.1:8081'; function loadEnvFile(filePath, env = process.env) { if (!filePath || !fs.existsSync(filePath)) return; for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const idx = trimmed.indexOf('='); if (idx < 0) continue; const key = trimmed.slice(0, idx).trim(); const value = trimmed.slice(idx + 1).trim(); if (!env[key]) env[key] = value; } } function normalizeBaseUrl(value) { return String(value ?? '').trim().replace(/\/+$/, ''); } function flag(value) { const normalized = String(value ?? '').trim().toLowerCase(); return ['1', 'true', 'yes', 'on'].includes(normalized); } function writableBuffer() { let value = ''; return { write(chunk) { value += String(chunk); }, value() { return value; }, }; } function parseJsonReport(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } } function check(name, ok, details = {}) { return { name, ok: Boolean(ok), ...details }; } function usage() { return `Usage: node scripts/check-memory-v2-stack.mjs [options] Options: --base-url Portal base URL. Default: ${DEFAULT_BASE_URL}. --env-file Env file to load before checks. Default: MEMIND_ENV_FILE or .env. --no-env-file Do not load an env file. --skip-app-canary Skip /api/runtime/status app canary. --skip-session-flow Skip real user session flow verification. --skip-backend-smokes Skip pgvector / external backend smoke probes. --expect-backend Require memory.backend to match this value when app canary runs. --expect-selected-backend Require memory.selectedBackend to match this value when app canary runs. --timeout-ms Timeout passed to live checks. Default: 90000. -h, --help Show this help. `; } export function parseMemoryV2StackArgs(argv = [], env = process.env) { const options = { baseUrl: normalizeBaseUrl(env.MEMORY_V2_STACK_BASE_URL || DEFAULT_BASE_URL), envFile: env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'), runAppCanary: true, runSessionFlow: true, runBackendSmokes: true, expectedBackend: env.MEMORY_BACKEND || null, expectedSelectedBackend: env.MEMORY_BACKEND || null, timeoutMs: Number(env.MEMORY_V2_STACK_TIMEOUT_MS || 90000), help: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--base-url') { options.baseUrl = normalizeBaseUrl(argv[++i]); } else if (arg === '--env-file') { options.envFile = argv[++i] ?? ''; } else if (arg === '--no-env-file') { options.envFile = ''; } else if (arg === '--skip-app-canary') { options.runAppCanary = false; } else if (arg === '--skip-session-flow') { options.runSessionFlow = false; } else if (arg === '--skip-backend-smokes') { options.runBackendSmokes = false; } else if (arg === '--expect-backend') { options.expectedBackend = argv[++i] ?? ''; } else if (arg === '--expect-selected-backend') { options.expectedSelectedBackend = argv[++i] ?? ''; } else if (arg === '--timeout-ms') { options.timeoutMs = Number(argv[++i]); } else if (arg === '-h' || arg === '--help') { options.help = true; } else { throw new Error(`Unknown argument: ${arg}`); } } if (!options.help && !options.baseUrl && (options.runAppCanary || options.runSessionFlow)) { throw new Error('--base-url is required for live checks'); } if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { throw new Error('--timeout-ms must be a positive number'); } return options; } function detectBackendPlans(env) { return [ { name: 'pgvector', configured: flag(env.MEMORY_VECTOR_ENABLED) && Boolean(String(env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim()) && Boolean(String(env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? '').trim()), reason: 'requires MEMORY_VECTOR_ENABLED, MEMORY_PGVECTOR_DATABASE_URL, MEMORY_PGVECTOR_EMBEDDING_MODULE', }, { name: 'qdrant', configured: flag(env.MEMORY_QDRANT_ENABLED) && Boolean(String(env.MEMORY_QDRANT_URL ?? '').trim()) && Boolean(String(env.MEMORY_QDRANT_EMBEDDING_MODULE ?? '').trim()), reason: 'requires MEMORY_QDRANT_ENABLED, MEMORY_QDRANT_URL, MEMORY_QDRANT_EMBEDDING_MODULE', }, { name: 'weaviate', configured: flag(env.MEMORY_WEAVIATE_ENABLED) && Boolean(String(env.MEMORY_WEAVIATE_URL ?? '').trim()) && Boolean(String(env.MEMORY_WEAVIATE_EMBEDDING_MODULE ?? '').trim()), reason: 'requires MEMORY_WEAVIATE_ENABLED, MEMORY_WEAVIATE_URL, MEMORY_WEAVIATE_EMBEDDING_MODULE', }, { name: 'mem0', configured: flag(env.MEMORY_MEM0_ENABLED) && Boolean(String(env.MEMORY_MEM0_API_KEY ?? '').trim()), reason: 'requires MEMORY_MEM0_ENABLED and MEMORY_MEM0_API_KEY', }, { name: 'letta', configured: flag(env.MEMORY_LETTA_ENABLED) && Boolean(String(env.MEMORY_LETTA_API_KEY ?? '').trim()) && Boolean(String(env.MEMORY_LETTA_AGENT_ID ?? '').trim()), reason: 'requires MEMORY_LETTA_ENABLED, MEMORY_LETTA_API_KEY, MEMORY_LETTA_AGENT_ID', }, { name: 'neo4j', configured: flag(env.MEMORY_NEO4J_ENABLED) && Boolean(String(env.MEMORY_NEO4J_HTTP_URL ?? env.MEMORY_NEO4J_URI ?? '').trim()) && Boolean(String(env.MEMORY_NEO4J_USER ?? '').trim()) && Boolean(String(env.MEMORY_NEO4J_PASSWORD ?? '').trim()), reason: 'requires MEMORY_NEO4J_ENABLED, MEMORY_NEO4J_HTTP_URL or MEMORY_NEO4J_URI, MEMORY_NEO4J_USER, MEMORY_NEO4J_PASSWORD', }, { name: 'redis-streams', configured: flag(env.MEMORY_REDIS_STREAMS_ENABLED) && Boolean(String(env.MEMORY_REDIS_STREAMS_URL ?? '').trim()), reason: 'requires MEMORY_REDIS_STREAMS_ENABLED and MEMORY_REDIS_STREAMS_URL', }, { name: 'langgraph', configured: flag(env.MEMORY_LANGGRAPH_ENABLED) && Boolean(String(env.MEMORY_LANGGRAPH_URL ?? '').trim()), reason: 'requires MEMORY_LANGGRAPH_ENABLED and MEMORY_LANGGRAPH_URL', }, ]; } async function runCliAndParse(executor) { const stdout = writableBuffer(); const code = await executor(stdout); const report = parseJsonReport(stdout.value()); return { code, report }; } export async function runMemoryV2StackCli({ argv = process.argv.slice(2), env = process.env, stdout = process.stdout, stderr = process.stderr, runners = {}, } = {}) { const options = parseMemoryV2StackArgs(argv, env); if (options.help) { stdout.write(usage()); return 0; } if (options.envFile) loadEnvFile(options.envFile, env); const checks = []; const sections = {}; const logProgress = (message) => { stderr.write(`[memory-v2-stack] ${message}\n`); }; logProgress('running contracts'); const contractResult = await runCliAndParse((buffer) => (runners.runContractCli ?? runMemoryV2ContractCli)({ argv: [], stdout: buffer }), ); sections.contracts = contractResult.report; checks.push(check('contracts', contractResult.code === 0, { backendCount: contractResult.report?.summary?.backendCount ?? null, })); logProgress(`contracts ${contractResult.code === 0 ? 'ok' : 'failed'}`); const healthArgs = ['--require-enabled']; if (options.expectedBackend) healthArgs.push('--expect-backend', options.expectedBackend); if (!options.envFile) healthArgs.push('--no-env-file'); else healthArgs.push('--env-file', options.envFile); logProgress('running health'); const healthResult = await runCliAndParse((buffer) => (runners.runHealthCli ?? runMemoryV2HealthCli)({ argv: healthArgs, env, stdout: buffer }), ); sections.health = healthResult.report; checks.push(check('health', healthResult.code === 0, { backend: healthResult.report?.summary?.backend ?? null, selectedBackend: healthResult.report?.summary?.selectedBackend ?? null, })); logProgress(`health ${healthResult.code === 0 ? 'ok' : 'failed'}`); if (options.runAppCanary) { const appArgs = ['--base-url', options.baseUrl, '--require-enabled', '--require-target-healthy', '--timeout-ms', String(options.timeoutMs)]; if (options.expectedBackend) appArgs.push('--expect-backend', options.expectedBackend); if (options.expectedSelectedBackend) { appArgs.push('--expect-selected-backend', options.expectedSelectedBackend); } logProgress('running app canary'); const appResult = await runCliAndParse((buffer) => (runners.runAppCanaryCli ?? runMemoryV2AppCanaryCli)({ argv: appArgs, env, stdout: buffer }), ); sections.appCanary = appResult.report; checks.push(check('app_canary', appResult.code === 0, { baseUrl: options.baseUrl, })); logProgress(`app canary ${appResult.code === 0 ? 'ok' : 'failed'}`); } if (options.runSessionFlow) { const sessionArgs = ['--base-url', options.baseUrl, '--timeout-ms', String(options.timeoutMs)]; if (options.expectedBackend) sessionArgs.push('--expect-backend', options.expectedBackend); if (options.expectedSelectedBackend) { sessionArgs.push('--expect-selected-backend', options.expectedSelectedBackend); } logProgress('running session flow'); const sessionResult = await runCliAndParse((buffer) => (runners.runSessionFlowCli ?? runMemoryV2SessionFlowCli)({ argv: sessionArgs, env, stdout: buffer }), ); sections.sessionFlow = sessionResult.report; checks.push(check('session_flow', sessionResult.code === 0, { sessionId: sessionResult.report?.sessionId ?? null, })); logProgress(`session flow ${sessionResult.code === 0 ? 'ok' : 'failed'}`); } const backendPlans = detectBackendPlans(env); const backendReports = []; if (options.runBackendSmokes) { for (const plan of backendPlans) { if (!plan.configured) { backendReports.push({ backend: plan.name, skipped: true, reason: 'not_configured', expectedConfig: plan.reason, }); checks.push(check(`backend_${plan.name}`, true, { skipped: true })); continue; } if (plan.name === 'pgvector') { logProgress('running backend smoke: pgvector'); const runner = runners.runPgvectorSmokeCli ?? runMemoryPgvectorSmokeCli; const report = await runner( [], env, { stdout: writableBuffer() }, ).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })); backendReports.push({ backend: plan.name, report }); checks.push(check(`backend_${plan.name}`, report?.ok === true, { error: report?.error ?? null, })); logProgress(`backend smoke pgvector ${report?.ok === true ? 'ok' : 'failed'}`); continue; } if (plan.name === 'qdrant') { logProgress('running backend smoke: qdrant'); const rawRunner = runners.runQdrantSmokeCli ?? runMemoryQdrantSmokeCli; const runtimeRunner = runners.runExternalSmokeCli ?? runMemoryV2ExternalSmokeCli; const rawReport = await rawRunner({ env, stdout: writableBuffer(), }).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })); const runtimeReport = await runtimeRunner({ argv: ['--backend', 'qdrant'], env, stdout: writableBuffer(), }).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })); backendReports.push({ backend: plan.name, rawReport, runtimeReport }); checks.push(check(`backend_${plan.name}`, rawReport?.ok === true && runtimeReport?.ok === true, { rawError: rawReport?.error ?? null, runtimeError: runtimeReport?.error ?? null, })); logProgress(`backend smoke qdrant ${(rawReport?.ok === true && runtimeReport?.ok === true) ? 'ok' : 'failed'}`); continue; } logProgress(`running backend smoke: ${plan.name}`); const runtimeRunner = runners.runExternalSmokeCli ?? runMemoryV2ExternalSmokeCli; const runtimeReport = await runtimeRunner({ argv: ['--backend', plan.name], env, stdout: writableBuffer(), }).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })); backendReports.push({ backend: plan.name, runtimeReport }); checks.push(check(`backend_${plan.name}`, runtimeReport?.ok === true, { error: runtimeReport?.error ?? null, })); logProgress(`backend smoke ${plan.name} ${runtimeReport?.ok === true ? 'ok' : 'failed'}`); } } sections.backends = backendReports; const report = { ok: checks.every((item) => item.ok), checkedAt: new Date().toISOString(), summary: { baseUrl: options.baseUrl, expectedBackend: options.expectedBackend ?? null, expectedSelectedBackend: options.expectedSelectedBackend ?? null, configuredBackends: backendPlans.filter((item) => item.configured).map((item) => item.name), skippedBackends: backendReports.filter((item) => item.skipped).map((item) => item.backend), }, checks, sections, }; stdout.write(`${JSON.stringify(report, null, 2)}\n`); return report.ok ? 0 : 1; } if (import.meta.url === `file://${process.argv[1]}`) { runMemoryV2StackCli().then((code) => { process.exitCode = code; }).catch((err) => { console.error(err instanceof Error ? err.message : err); process.exitCode = 1; }); }