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

345 lines
11 KiB
JavaScript

#!/usr/bin/env node
import crypto from 'node:crypto';
function normalizeBaseUrl(value) {
return String(value ?? '').trim().replace(/\/+$/, '');
}
function requireValue(name, value) {
if (!value) throw new Error(`${name} requires a value`);
return value;
}
export function parseMemoryV2SessionFlowArgs(argv = [], env = process.env) {
const options = {
baseUrl: normalizeBaseUrl(env.MEMORY_V2_SESSION_FLOW_BASE_URL || 'http://127.0.0.1:8081'),
prompt: env.MEMORY_V2_SESSION_FLOW_PROMPT || 'Please confirm Memory V2 session flow is working.',
expectedBackend: null,
expectedSelectedBackend: null,
timeoutMs: Number(env.MEMORY_V2_SESSION_FLOW_TIMEOUT_MS || 45000),
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--base-url') {
options.baseUrl = normalizeBaseUrl(requireValue(arg, argv[++i]));
} else if (arg === '--prompt') {
options.prompt = requireValue(arg, argv[++i]);
} else if (arg === '--expect-backend') {
options.expectedBackend = requireValue(arg, argv[++i]);
} else if (arg === '--expect-selected-backend') {
options.expectedSelectedBackend = requireValue(arg, argv[++i]);
} else if (arg === '--timeout-ms') {
options.timeoutMs = Number(requireValue(arg, argv[++i]));
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.baseUrl) throw new Error('--base-url is required');
if (!options.prompt) throw new Error('--prompt is required');
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error('--timeout-ms must be a positive number');
}
return options;
}
function usage() {
return `Usage:
node scripts/check-memory-v2-session-flow.mjs [options]
Options:
--base-url <url> Portal base URL. Defaults to MEMORY_V2_SESSION_FLOW_BASE_URL or http://127.0.0.1:8081.
--prompt <text> User prompt sent through the live session.
--expect-backend <name> Fail unless runtime memory.backend equals this value.
--expect-selected-backend <name> Fail unless runtime memory.selectedBackend equals this value.
--timeout-ms <ms> Request timeout. Defaults to 45000.
`;
}
function makeCheck(name, ok, details = {}) {
return { name, ok: Boolean(ok), ...details };
}
async function parseResponseBody(response) {
const contentType = response.headers?.get?.('content-type') ?? '';
const text = await response.text();
if (contentType.includes('application/json')) {
try {
return { text, json: text ? JSON.parse(text) : null };
} catch {
return { text, json: null };
}
}
try {
return { text, json: text ? JSON.parse(text) : null };
} catch {
return { text, json: null };
}
}
async function requestJson(fetchImpl, url, { method = 'GET', headers = {}, body, timeoutMs = 45000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const payload = await parseResponseBody(response);
return { ok: response.ok, status: response.status, ...payload, headers: response.headers };
} finally {
clearTimeout(timer);
}
}
function buildTempUser() {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
return {
username: `mv2_${suffix}`,
password: 'MemoryV2-Session-2026',
email: `mv2-${suffix}@example.test`,
displayName: `mv2_${suffix}`,
};
}
async function waitForSessionFinish(fetchImpl, baseUrl, sessionId, cookie, timeoutMs, runTrigger) {
const response = await fetchImpl(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
headers: {
Accept: 'text/event-stream',
Cookie: cookie,
},
});
if (!response.ok || !response.body) {
const payload = await parseResponseBody(response);
throw new Error(`session events failed: ${response.status} ${payload.text}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const seen = [];
const runId = await runTrigger();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed) continue;
seen.push(trimmed);
if (trimmed.includes('type":"Error"')) {
throw new Error(`session stream error: ${trimmed}`);
}
if (trimmed.includes('type":"Finish"')) {
await reader.cancel().catch(() => {});
return { runId, seen };
}
}
}
await reader.cancel().catch(() => {});
throw new Error(`session stream timeout after ${timeoutMs}ms`);
}
export async function runMemoryV2SessionFlowCli({
argv = process.argv.slice(2),
env = process.env,
stdout = process.stdout,
fetchImpl = globalThis.fetch,
} = {}) {
const options = parseMemoryV2SessionFlowArgs(argv, env);
if (options.help) {
stdout.write(usage());
return 0;
}
if (typeof fetchImpl !== 'function') {
throw new Error('fetch is not available in this Node.js runtime');
}
const checks = [];
const user = buildTempUser();
const register = await requestJson(fetchImpl, `${options.baseUrl}/auth/register`, {
method: 'POST',
body: user,
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('register_http_ok', register.ok, { status: register.status }));
if (!register.ok) {
const report = { ok: false, baseUrl: options.baseUrl, checks, error: register.json ?? register.text };
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return 1;
}
const login = await requestJson(fetchImpl, `${options.baseUrl}/auth/login`, {
method: 'POST',
body: { username: user.username, password: user.password },
timeoutMs: options.timeoutMs,
});
const cookie = login.headers?.get?.('set-cookie')?.split(';', 1)[0] ?? null;
checks.push(makeCheck('login_http_ok', login.ok && Boolean(cookie), {
status: login.status,
hasCookie: Boolean(cookie),
}));
if (!login.ok || !cookie) {
const report = { ok: false, baseUrl: options.baseUrl, checks, error: login.json ?? login.text };
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return 1;
}
const started = await requestJson(fetchImpl, `${options.baseUrl}/api/agent/start`, {
method: 'POST',
body: {},
headers: { Cookie: cookie },
timeoutMs: options.timeoutMs,
});
const sessionId = started.json?.id ?? null;
checks.push(makeCheck('agent_start_ok', started.ok && Boolean(sessionId), {
status: started.status,
sessionId,
}));
if (!started.ok || !sessionId) {
const report = { ok: false, baseUrl: options.baseUrl, checks, error: started.json ?? started.text };
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return 1;
}
const requestId = crypto.randomUUID();
const { runId, seen } = await waitForSessionFinish(
fetchImpl,
options.baseUrl,
sessionId,
cookie,
options.timeoutMs,
async () => {
const created = await requestJson(fetchImpl, `${options.baseUrl}/api/agent/runs`, {
method: 'POST',
headers: { Cookie: cookie },
body: {
session_id: sessionId,
request_id: requestId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text: options.prompt }],
metadata: { userVisible: true, agentVisible: true },
},
},
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('agent_run_created', created.ok && created.status === 202, {
status: created.status,
runId: created.json?.run?.id ?? null,
}));
if (!created.ok || created.status !== 202 || !created.json?.run?.id) {
throw new Error(`agent run failed: ${created.status} ${created.text}`);
}
return created.json.run.id;
},
);
checks.push(makeCheck('session_finish_seen', seen.some((chunk) => chunk.includes('type":"Finish"')), {
eventCount: seen.length,
}));
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
headers: { Cookie: cookie },
timeoutMs: options.timeoutMs,
});
const userVisibleMessages = Array.isArray(detail.json?.conversation)
? detail.json.conversation.filter((message) => message?.metadata?.userVisible)
: [];
const assistantMessages = userVisibleMessages.filter((message) => message?.role === 'assistant');
checks.push(makeCheck('session_detail_assistant_reply', detail.ok && assistantMessages.length > 0, {
status: detail.status,
userVisibleCount: userVisibleMessages.length,
assistantCount: assistantMessages.length,
}));
const remember = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/remember-recent`, {
method: 'POST',
headers: { Cookie: cookie },
body: { sessionId },
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('remember_recent_ok', remember.ok && remember.json?.ok === true, {
status: remember.status,
}));
const sync = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/sync`, {
method: 'POST',
headers: { Cookie: cookie },
body: { sessionId },
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('sync_ok', sync.ok && sync.json?.ok === true, {
status: sync.status,
}));
const runtime = await requestJson(fetchImpl, `${options.baseUrl}/api/runtime/status`, {
timeoutMs: options.timeoutMs,
});
const memory = runtime.json?.memory ?? null;
if (options.expectedBackend) {
checks.push(makeCheck('memory_backend', memory?.backend === options.expectedBackend, {
expected: options.expectedBackend,
actual: memory?.backend ?? null,
}));
}
if (options.expectedSelectedBackend) {
checks.push(makeCheck('memory_selected_backend', memory?.selectedBackend === options.expectedSelectedBackend, {
expected: options.expectedSelectedBackend,
actual: memory?.selectedBackend ?? null,
}));
}
const report = {
ok: checks.every((item) => item.ok),
baseUrl: options.baseUrl,
user: user.username,
sessionId,
runId,
summary: {
assistantPreview: assistantMessages.at(-1)?.content?.[0]?.text?.slice?.(0, 200) ?? null,
remember: remember.json,
sync: sync.json,
runtimeMemory: memory
? {
enabled: memory.enabled,
backend: memory.backend,
selectedBackend: memory.selectedBackend,
failOpen: memory.failOpen,
vectorEnabled: memory.vectorEnabled,
}
: null,
sessionEventsTail: seen.slice(-4),
},
checks,
};
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report.ok ? 0 : 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
runMemoryV2SessionFlowCli().then((code) => {
process.exitCode = code;
}).catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}