368 lines
12 KiB
JavaScript
368 lines
12 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 waitForAgentRunCompletion(fetchImpl, baseUrl, cookie, timeoutMs, createRun) {
|
|
const { runId, sessionId } = await createRun();
|
|
const deadline = Date.now() + timeoutMs;
|
|
const seen = [];
|
|
|
|
const response = await fetchImpl(`${baseUrl}/api/agent/runs/${encodeURIComponent(runId)}/events`, {
|
|
headers: {
|
|
Accept: 'text/event-stream',
|
|
Cookie: cookie,
|
|
},
|
|
});
|
|
|
|
if (response.ok && response.body) {
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
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 || trimmed.startsWith(':')) continue;
|
|
seen.push(trimmed);
|
|
if (trimmed.includes('"status":"failed"')) {
|
|
await reader.cancel().catch(() => {});
|
|
return { runId, sessionId, seen, terminalStatus: 'failed' };
|
|
}
|
|
if (trimmed.includes('"status":"succeeded"')) {
|
|
await reader.cancel().catch(() => {});
|
|
return { runId, sessionId, seen, terminalStatus: 'succeeded' };
|
|
}
|
|
}
|
|
}
|
|
await reader.cancel().catch(() => {});
|
|
}
|
|
|
|
while (Date.now() < deadline) {
|
|
const run = await requestJson(fetchImpl, `${baseUrl}/api/agent/runs/${encodeURIComponent(runId)}`, {
|
|
headers: { Cookie: cookie },
|
|
timeoutMs: Math.min(10000, timeoutMs),
|
|
});
|
|
const status = run.json?.run?.status ?? null;
|
|
const resolvedSessionId = run.json?.run?.agent_session_id ?? sessionId;
|
|
if (status === 'succeeded' || status === 'failed') {
|
|
return { runId, sessionId: resolvedSessionId, seen, terminalStatus: status };
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
}
|
|
|
|
throw new Error(`agent run timeout after ${timeoutMs}ms`);
|
|
}
|
|
|
|
export { waitForAgentRunCompletion };
|
|
|
|
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, sessionId: activeSessionId, seen, terminalStatus } = await waitForAgentRunCompletion(
|
|
fetchImpl,
|
|
options.baseUrl,
|
|
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 {
|
|
runId: created.json.run.id,
|
|
sessionId: created.json?.run?.agent_session_id ?? sessionId,
|
|
};
|
|
},
|
|
);
|
|
checks.push(makeCheck('agent_run_terminal', terminalStatus === 'succeeded', {
|
|
terminalStatus,
|
|
eventCount: seen.length,
|
|
}));
|
|
|
|
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(activeSessionId)}`, {
|
|
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: activeSessionId },
|
|
timeoutMs: options.timeoutMs,
|
|
});
|
|
checks.push(makeCheck('remember_recent_ok', remember.ok && remember.json?.ok === true, {
|
|
status: remember.status,
|
|
}));
|
|
checks.push(makeCheck('remember_recent_extracted', remember.ok && Number(remember.json?.analyzed ?? 0) > 0, {
|
|
status: remember.status,
|
|
analyzed: remember.json?.analyzed ?? 0,
|
|
memories: remember.json?.memories ?? 0,
|
|
}));
|
|
|
|
const sync = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/sync`, {
|
|
method: 'POST',
|
|
headers: { Cookie: cookie },
|
|
body: { sessionId: activeSessionId },
|
|
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: activeSessionId,
|
|
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),
|
|
terminalStatus,
|
|
},
|
|
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;
|
|
});
|
|
}
|