377 lines
14 KiB
JavaScript
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;
|
|
});
|
|
}
|