Files
memind/scripts/check-memory-v2-stack.mjs
T
2026-07-03 09:46:03 +08:00

377 lines
14 KiB
JavaScript

#!/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 <url> Portal base URL. Default: ${DEFAULT_BASE_URL}.
--env-file <path> 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 <name> Require memory.backend to match this value when app canary runs.
--expect-selected-backend <name> Require memory.selectedBackend to match this value when app canary runs.
--timeout-ms <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;
});
}