Merge Memory V2 runtime facade
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
#!/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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user