@mnexium/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -0
- package/README.md +37 -0
- package/docs/API.md +299 -0
- package/docs/BEHAVIOR.md +224 -0
- package/docs/OPERATIONS.md +116 -0
- package/docs/SETUP.md +239 -0
- package/package.json +22 -0
- package/scripts/e2e.lib.mjs +604 -0
- package/scripts/e2e.routes.mjs +32 -0
- package/scripts/e2e.sh +76 -0
- package/scripts/e2e.webapp.client.js +408 -0
- package/scripts/e2e.webapp.mjs +1065 -0
- package/sql/postgres/schema.sql +275 -0
- package/src/adapters/postgres/PostgresCoreStore.ts +1017 -0
- package/src/ai/memoryExtractionService.ts +265 -0
- package/src/ai/recallService.ts +442 -0
- package/src/ai/types.ts +11 -0
- package/src/contracts/storage.ts +137 -0
- package/src/contracts/types.ts +138 -0
- package/src/dev.ts +144 -0
- package/src/index.ts +15 -0
- package/src/providers/cerebras.ts +101 -0
- package/src/providers/openaiChat.ts +116 -0
- package/src/providers/openaiEmbedding.ts +52 -0
- package/src/server/createCoreServer.ts +1154 -0
- package/src/server/memoryEventBus.ts +57 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
function assert(condition, message) {
|
|
5
|
+
if (!condition) throw new Error(message);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function toErrorMessage(err) {
|
|
9
|
+
if (!err) return "unknown_error";
|
|
10
|
+
if (typeof err === "string") return err;
|
|
11
|
+
if (err instanceof AggregateError && Array.isArray(err.errors) && err.errors.length > 0) {
|
|
12
|
+
return `AggregateError: ${err.errors.map((e) => toErrorMessage(e)).join(" | ")}`;
|
|
13
|
+
}
|
|
14
|
+
if (err && typeof err === "object" && Array.isArray(err.errors) && err.errors.length > 0) {
|
|
15
|
+
return `AggregateError: ${err.errors.map((e) => toErrorMessage(e)).join(" | ")}`;
|
|
16
|
+
}
|
|
17
|
+
if (err && typeof err === "object" && err.cause) {
|
|
18
|
+
return `${String(err.message || err)} (cause: ${toErrorMessage(err.cause)})`;
|
|
19
|
+
}
|
|
20
|
+
return String(err.message || err);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function defaultHeaders(projectId, extra = {}) {
|
|
24
|
+
return {
|
|
25
|
+
"content-type": "application/json",
|
|
26
|
+
"x-project-id": projectId,
|
|
27
|
+
...extra,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createRequest(baseUrl, projectId) {
|
|
32
|
+
const normalizedBaseUrl = String(baseUrl || "").trim().replace(/\/$/, "");
|
|
33
|
+
if (!normalizedBaseUrl) throw new Error("baseUrl is required");
|
|
34
|
+
|
|
35
|
+
return async function request(method, path, opts = {}) {
|
|
36
|
+
const response = await fetch(`${normalizedBaseUrl}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: opts.headers || defaultHeaders(projectId),
|
|
39
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
40
|
+
signal: opts.signal,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const text = await response.text();
|
|
44
|
+
let json = null;
|
|
45
|
+
if (text) {
|
|
46
|
+
try {
|
|
47
|
+
json = JSON.parse(text);
|
|
48
|
+
} catch {
|
|
49
|
+
json = { raw: text };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opts.expectedStatus && response.status !== opts.expectedStatus) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${method} ${path} expected ${opts.expectedStatus}, got ${response.status}: ${JSON.stringify(json)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { status: response.status, json };
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function waitForHealth(baseUrl, timeoutMs = 20_000) {
|
|
64
|
+
const startedAt = Date.now();
|
|
65
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/health`);
|
|
68
|
+
if (res.ok) return true;
|
|
69
|
+
} catch {
|
|
70
|
+
// Continue retry loop.
|
|
71
|
+
}
|
|
72
|
+
await delay(500);
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Server at ${baseUrl} did not become healthy in time`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function openSSEAndCaptureMemoryCreated({
|
|
78
|
+
baseUrl,
|
|
79
|
+
projectId,
|
|
80
|
+
subjectId,
|
|
81
|
+
onConnected,
|
|
82
|
+
timeoutMs = 12_000,
|
|
83
|
+
}) {
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/events/memories?subject_id=${encodeURIComponent(subjectId)}`;
|
|
86
|
+
const response = await fetch(url, {
|
|
87
|
+
method: "GET",
|
|
88
|
+
headers: {
|
|
89
|
+
"x-project-id": projectId,
|
|
90
|
+
accept: "text/event-stream",
|
|
91
|
+
},
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
assert(response.ok, `SSE subscribe failed: ${response.status}`);
|
|
96
|
+
assert(response.body, "SSE response has no body");
|
|
97
|
+
|
|
98
|
+
const reader = response.body.getReader();
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
let buffer = "";
|
|
101
|
+
let connectedSeen = false;
|
|
102
|
+
let resolved = false;
|
|
103
|
+
|
|
104
|
+
const parseEventBlock = (block) => {
|
|
105
|
+
const lines = block.split("\n");
|
|
106
|
+
let event = "message";
|
|
107
|
+
let data = "";
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
110
|
+
if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
111
|
+
}
|
|
112
|
+
let parsed = null;
|
|
113
|
+
if (data) {
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(data);
|
|
116
|
+
} catch {
|
|
117
|
+
parsed = { raw: data };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { event, data: parsed };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const timeout = setTimeout(() => {
|
|
124
|
+
if (!resolved) controller.abort(new Error("Timed out waiting for SSE memory.created"));
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
while (true) {
|
|
129
|
+
const { value, done } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
buffer += decoder.decode(value, { stream: true });
|
|
132
|
+
|
|
133
|
+
const blocks = buffer.split("\n\n");
|
|
134
|
+
buffer = blocks.pop() || "";
|
|
135
|
+
|
|
136
|
+
for (const block of blocks) {
|
|
137
|
+
const evt = parseEventBlock(block);
|
|
138
|
+
if (evt.event === "connected" && !connectedSeen) {
|
|
139
|
+
connectedSeen = true;
|
|
140
|
+
if (typeof onConnected === "function") {
|
|
141
|
+
await onConnected();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (evt.event === "memory.created") {
|
|
145
|
+
resolved = true;
|
|
146
|
+
assert(connectedSeen, "SSE memory.created arrived before connected event");
|
|
147
|
+
return evt.data;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
controller.abort();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error("SSE ended before memory.created event was observed");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function seedSuperseded({ dbPool, projectId, subjectId, memoryId }) {
|
|
160
|
+
await dbPool.query(
|
|
161
|
+
`
|
|
162
|
+
UPDATE memories
|
|
163
|
+
SET status = 'superseded', superseded_by = $4
|
|
164
|
+
WHERE project_id = $1 AND subject_id = $2 AND id = $3
|
|
165
|
+
`,
|
|
166
|
+
[projectId, subjectId, memoryId, `mem_sup_${randomUUID()}`],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function seedRecallEvent({ dbPool, projectId, subjectId, memoryId, chatId, score = 77 }) {
|
|
171
|
+
await dbPool.query(
|
|
172
|
+
`
|
|
173
|
+
INSERT INTO memory_recall_events (
|
|
174
|
+
event_id, project_id, subject_id, memory_id, memory_text,
|
|
175
|
+
chat_id, message_index, chat_logged, similarity_score,
|
|
176
|
+
request_type, model, metadata
|
|
177
|
+
) VALUES (
|
|
178
|
+
$1, $2, $3, $4, $5,
|
|
179
|
+
$6, $7, TRUE, $8,
|
|
180
|
+
'chat', 'e2e-test-model', '{}'::jsonb
|
|
181
|
+
)
|
|
182
|
+
`,
|
|
183
|
+
[`evt_${randomUUID()}`, projectId, subjectId, memoryId, "test recall", chatId, 1, score],
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function runCoreRouteSuite({
|
|
188
|
+
baseUrl,
|
|
189
|
+
projectId,
|
|
190
|
+
subjectId,
|
|
191
|
+
dbPool,
|
|
192
|
+
onLog,
|
|
193
|
+
}) {
|
|
194
|
+
const logs = [];
|
|
195
|
+
const steps = [];
|
|
196
|
+
const artifacts = {};
|
|
197
|
+
const startedAt = Date.now();
|
|
198
|
+
|
|
199
|
+
const log = (message) => {
|
|
200
|
+
const entry = `[${new Date().toISOString()}] ${message}`;
|
|
201
|
+
logs.push(entry);
|
|
202
|
+
if (typeof onLog === "function") onLog(entry);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const request = createRequest(baseUrl, projectId);
|
|
206
|
+
|
|
207
|
+
async function step(name, fn) {
|
|
208
|
+
const stepStart = Date.now();
|
|
209
|
+
log(`STEP START: ${name}`);
|
|
210
|
+
try {
|
|
211
|
+
const value = await fn();
|
|
212
|
+
const finished = {
|
|
213
|
+
name,
|
|
214
|
+
status: "passed",
|
|
215
|
+
duration_ms: Date.now() - stepStart,
|
|
216
|
+
};
|
|
217
|
+
steps.push(finished);
|
|
218
|
+
log(`STEP PASS: ${name} (${finished.duration_ms}ms)`);
|
|
219
|
+
return value;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
const failed = {
|
|
222
|
+
name,
|
|
223
|
+
status: "failed",
|
|
224
|
+
duration_ms: Date.now() - stepStart,
|
|
225
|
+
error: toErrorMessage(err),
|
|
226
|
+
};
|
|
227
|
+
steps.push(failed);
|
|
228
|
+
log(`STEP FAIL: ${name} (${failed.duration_ms}ms) - ${failed.error}`);
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await step("wait_for_health", async () => {
|
|
235
|
+
await waitForHealth(baseUrl);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await step("health_endpoint", async () => {
|
|
239
|
+
const res = await request("GET", "/health", {
|
|
240
|
+
expectedStatus: 200,
|
|
241
|
+
headers: { accept: "application/json" },
|
|
242
|
+
});
|
|
243
|
+
assert(res.json?.ok === true, "health response missing ok=true");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const memoryText = "My favorite color is yellow and I live in Austin";
|
|
247
|
+
let currentMemoryText = memoryText;
|
|
248
|
+
let memoryId = "";
|
|
249
|
+
|
|
250
|
+
await step("sse_and_create_memory", async () => {
|
|
251
|
+
const createdViaSSE = await openSSEAndCaptureMemoryCreated({
|
|
252
|
+
baseUrl,
|
|
253
|
+
projectId,
|
|
254
|
+
subjectId,
|
|
255
|
+
onConnected: async () => {
|
|
256
|
+
const createRes = await request("POST", "/api/v1/memories", {
|
|
257
|
+
expectedStatus: 201,
|
|
258
|
+
body: { subject_id: subjectId, text: memoryText, kind: "fact" },
|
|
259
|
+
});
|
|
260
|
+
assert(String(createRes.json?.id || "").startsWith("mem_"), "create memory did not return memory id");
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
memoryId = String(createdViaSSE?.id || "");
|
|
264
|
+
assert(memoryId.startsWith("mem_"), "SSE memory.created did not include memory id");
|
|
265
|
+
artifacts.memory_id = memoryId;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await step("list_memories", async () => {
|
|
269
|
+
const res = await request(
|
|
270
|
+
"GET",
|
|
271
|
+
`/api/v1/memories?subject_id=${encodeURIComponent(subjectId)}&limit=10&offset=0`,
|
|
272
|
+
{ expectedStatus: 200 },
|
|
273
|
+
);
|
|
274
|
+
assert(Array.isArray(res.json?.data), "list memories data is not an array");
|
|
275
|
+
assert(res.json.data.some((m) => m.id === memoryId), "created memory missing from list");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await step("get_memory_by_id", async () => {
|
|
279
|
+
const res = await request("GET", `/api/v1/memories/${encodeURIComponent(memoryId)}`, { expectedStatus: 200 });
|
|
280
|
+
assert(res.json?.data?.id === memoryId, "get memory by id mismatch");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await step("patch_memory", async () => {
|
|
284
|
+
const updatedText = "My favorite color is yellow (updated)";
|
|
285
|
+
const patch = await request("PATCH", `/api/v1/memories/${encodeURIComponent(memoryId)}`, {
|
|
286
|
+
expectedStatus: 200,
|
|
287
|
+
body: { text: updatedText, tags: ["e2e", "updated"] },
|
|
288
|
+
});
|
|
289
|
+
assert(patch.json?.updated === true, "patch memory failed");
|
|
290
|
+
const check = await request("GET", `/api/v1/memories/${encodeURIComponent(memoryId)}`, { expectedStatus: 200 });
|
|
291
|
+
assert(check.json?.data?.text === updatedText, "memory text was not updated");
|
|
292
|
+
currentMemoryText = updatedText;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await step("search_memories", async () => {
|
|
296
|
+
const res = await request(
|
|
297
|
+
"GET",
|
|
298
|
+
`/api/v1/memories/search?subject_id=${encodeURIComponent(subjectId)}&q=${encodeURIComponent("favorite color")}&limit=10`,
|
|
299
|
+
{ expectedStatus: 200 },
|
|
300
|
+
);
|
|
301
|
+
assert(Array.isArray(res.json?.data), "search memories data is not an array");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await step("create_memory_duplicate_skip", async () => {
|
|
305
|
+
const duplicate = await request("POST", "/api/v1/memories", {
|
|
306
|
+
expectedStatus: 200,
|
|
307
|
+
body: { subject_id: subjectId, text: currentMemoryText, kind: "fact" },
|
|
308
|
+
});
|
|
309
|
+
assert(duplicate.json?.created === false, "duplicate memory should not be created");
|
|
310
|
+
assert(duplicate.json?.reason === "duplicate", "duplicate response reason mismatch");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
let extractedMemoryId = "";
|
|
314
|
+
|
|
315
|
+
await step("extract_memories_non_learn", async () => {
|
|
316
|
+
const res = await request("POST", "/api/v1/memories/extract", {
|
|
317
|
+
expectedStatus: 200,
|
|
318
|
+
body: { subject_id: subjectId, text: "I work at Acme", learn: false },
|
|
319
|
+
});
|
|
320
|
+
assert(res.json?.learned === false, "extract learn=false response mismatch");
|
|
321
|
+
assert(Array.isArray(res.json?.memories), "extract memories should be array");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await step("extract_memories_learn", async () => {
|
|
325
|
+
const res = await request("POST", "/api/v1/memories/extract", {
|
|
326
|
+
expectedStatus: 200,
|
|
327
|
+
body: { subject_id: subjectId, text: "My name is Marius", learn: true, force: true },
|
|
328
|
+
});
|
|
329
|
+
assert(res.json?.learned === true, "extract learn=true response mismatch");
|
|
330
|
+
assert(Array.isArray(res.json?.memories), "extract learn=true memories should be array");
|
|
331
|
+
if (res.json.memories.length > 0) {
|
|
332
|
+
extractedMemoryId = String(res.json.memories[0].memory_id || "");
|
|
333
|
+
artifacts.extracted_memory_id = extractedMemoryId;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
let claimA = "";
|
|
338
|
+
let claimB = "";
|
|
339
|
+
|
|
340
|
+
await step("create_claims_and_memory_claims", async () => {
|
|
341
|
+
const c1 = await request("POST", "/api/v1/claims", {
|
|
342
|
+
expectedStatus: 201,
|
|
343
|
+
body: {
|
|
344
|
+
subject_id: subjectId,
|
|
345
|
+
predicate: "favorite_color",
|
|
346
|
+
object_value: "yellow",
|
|
347
|
+
source_memory_id: memoryId,
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
claimA = String(c1.json?.claim_id || "");
|
|
351
|
+
assert(claimA.startsWith("clm_"), "first claim id invalid");
|
|
352
|
+
|
|
353
|
+
const memoryClaims = await request("GET", `/api/v1/memories/${encodeURIComponent(memoryId)}/claims`, {
|
|
354
|
+
expectedStatus: 200,
|
|
355
|
+
});
|
|
356
|
+
assert(Array.isArray(memoryClaims.json?.data), "memory claims data should be array");
|
|
357
|
+
|
|
358
|
+
const c2 = await request("POST", "/api/v1/claims", {
|
|
359
|
+
expectedStatus: 201,
|
|
360
|
+
body: {
|
|
361
|
+
subject_id: subjectId,
|
|
362
|
+
predicate: "favorite_color",
|
|
363
|
+
object_value: "blue",
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
claimB = String(c2.json?.claim_id || "");
|
|
367
|
+
assert(claimB.startsWith("clm_"), "second claim id invalid");
|
|
368
|
+
|
|
369
|
+
artifacts.claim_a = claimA;
|
|
370
|
+
artifacts.claim_b = claimB;
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await step("get_claim_by_id", async () => {
|
|
374
|
+
const res = await request("GET", `/api/v1/claims/${encodeURIComponent(claimB)}`, { expectedStatus: 200 });
|
|
375
|
+
assert(res.json?.claim?.claim_id === claimB, "get claim mismatch");
|
|
376
|
+
assert(Array.isArray(res.json?.assertions), "claim assertions should be array");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await step("claims_subject_endpoints", async () => {
|
|
380
|
+
const truth = await request("GET", `/api/v1/claims/subject/${encodeURIComponent(subjectId)}/truth`, {
|
|
381
|
+
expectedStatus: 200,
|
|
382
|
+
});
|
|
383
|
+
assert(Array.isArray(truth.json?.slots), "truth slots should be array");
|
|
384
|
+
|
|
385
|
+
const slot = await request(
|
|
386
|
+
"GET",
|
|
387
|
+
`/api/v1/claims/subject/${encodeURIComponent(subjectId)}/slot/${encodeURIComponent("favorite_color")}`,
|
|
388
|
+
{ expectedStatus: 200 },
|
|
389
|
+
);
|
|
390
|
+
assert(slot.json?.slot === "favorite_color", "slot endpoint returned wrong slot");
|
|
391
|
+
|
|
392
|
+
const slots = await request("GET", `/api/v1/claims/subject/${encodeURIComponent(subjectId)}/slots?limit=20`, {
|
|
393
|
+
expectedStatus: 200,
|
|
394
|
+
});
|
|
395
|
+
assert(slots.json?.slots && typeof slots.json.slots === "object", "slots group response missing");
|
|
396
|
+
|
|
397
|
+
const graph = await request("GET", `/api/v1/claims/subject/${encodeURIComponent(subjectId)}/graph?limit=20`, {
|
|
398
|
+
expectedStatus: 200,
|
|
399
|
+
});
|
|
400
|
+
assert(Array.isArray(graph.json?.claims), "graph claims should be array");
|
|
401
|
+
|
|
402
|
+
const history = await request("GET", `/api/v1/claims/subject/${encodeURIComponent(subjectId)}/history?limit=20`, {
|
|
403
|
+
expectedStatus: 200,
|
|
404
|
+
});
|
|
405
|
+
assert(history.json && typeof history.json.by_slot === "object", "history by_slot missing");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await step("retract_claim", async () => {
|
|
409
|
+
const retract = await request("POST", `/api/v1/claims/${encodeURIComponent(claimB)}/retract`, {
|
|
410
|
+
expectedStatus: 200,
|
|
411
|
+
body: { reason: "e2e_retract" },
|
|
412
|
+
});
|
|
413
|
+
assert(retract.json?.success === true, "retract claim failed");
|
|
414
|
+
assert(retract.json?.restored_previous === true, "expected previous claim to be restored");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await step("superseded_and_restore_memory", async () => {
|
|
418
|
+
await seedSuperseded({ dbPool, projectId, subjectId, memoryId });
|
|
419
|
+
|
|
420
|
+
const superseded = await request(
|
|
421
|
+
"GET",
|
|
422
|
+
`/api/v1/memories/superseded?subject_id=${encodeURIComponent(subjectId)}&limit=10&offset=0`,
|
|
423
|
+
{ expectedStatus: 200 },
|
|
424
|
+
);
|
|
425
|
+
assert(Array.isArray(superseded.json?.data), "superseded data should be array");
|
|
426
|
+
assert(superseded.json.data.some((m) => m.id === memoryId), "superseded list missing memory");
|
|
427
|
+
|
|
428
|
+
const restore = await request("POST", `/api/v1/memories/${encodeURIComponent(memoryId)}/restore`, {
|
|
429
|
+
expectedStatus: 200,
|
|
430
|
+
body: {},
|
|
431
|
+
});
|
|
432
|
+
assert(restore.json?.restored === true, "restore memory failed");
|
|
433
|
+
|
|
434
|
+
const alreadyActive = await request("POST", `/api/v1/memories/${encodeURIComponent(memoryId)}/restore`, {
|
|
435
|
+
expectedStatus: 200,
|
|
436
|
+
body: {},
|
|
437
|
+
});
|
|
438
|
+
assert(alreadyActive.json?.restored === false, "restore on active memory should return restored=false");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await step("memory_recalls_variants", async () => {
|
|
442
|
+
const recallMemoryId = extractedMemoryId || memoryId;
|
|
443
|
+
const chatId = `chat_${randomUUID()}`;
|
|
444
|
+
await seedRecallEvent({ dbPool, projectId, subjectId, memoryId: recallMemoryId, chatId, score: 88 });
|
|
445
|
+
|
|
446
|
+
const byChat = await request(
|
|
447
|
+
"GET",
|
|
448
|
+
`/api/v1/memories/recalls?chat_id=${encodeURIComponent(chatId)}`,
|
|
449
|
+
{ expectedStatus: 200 },
|
|
450
|
+
);
|
|
451
|
+
assert(Array.isArray(byChat.json?.data), "recalls by chat should return array");
|
|
452
|
+
assert(byChat.json.data.length >= 1, "recalls by chat should have seeded event");
|
|
453
|
+
|
|
454
|
+
const byMemory = await request(
|
|
455
|
+
"GET",
|
|
456
|
+
`/api/v1/memories/recalls?memory_id=${encodeURIComponent(recallMemoryId)}&limit=10`,
|
|
457
|
+
{ expectedStatus: 200 },
|
|
458
|
+
);
|
|
459
|
+
assert(Array.isArray(byMemory.json?.data), "recalls by memory should return array");
|
|
460
|
+
|
|
461
|
+
const stats = await request(
|
|
462
|
+
"GET",
|
|
463
|
+
`/api/v1/memories/recalls?memory_id=${encodeURIComponent(recallMemoryId)}&stats=true`,
|
|
464
|
+
{ expectedStatus: 200 },
|
|
465
|
+
);
|
|
466
|
+
assert(Number(stats.json?.stats?.total_recalls || 0) >= 1, "recall stats should be >= 1");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
await step("delete_memory", async () => {
|
|
470
|
+
const del = await request("DELETE", `/api/v1/memories/${encodeURIComponent(memoryId)}`, {
|
|
471
|
+
expectedStatus: 200,
|
|
472
|
+
});
|
|
473
|
+
assert(del.json?.deleted === true, "delete memory failed");
|
|
474
|
+
|
|
475
|
+
const afterDelete = await request("GET", `/api/v1/memories/${encodeURIComponent(memoryId)}`, {
|
|
476
|
+
expectedStatus: 404,
|
|
477
|
+
});
|
|
478
|
+
assert(afterDelete.json?.error === "memory_deleted", "deleted memory should return memory_deleted");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const result = {
|
|
482
|
+
ok: true,
|
|
483
|
+
duration_ms: Date.now() - startedAt,
|
|
484
|
+
steps,
|
|
485
|
+
artifacts,
|
|
486
|
+
logs,
|
|
487
|
+
};
|
|
488
|
+
log(`SUITE PASS in ${result.duration_ms}ms`);
|
|
489
|
+
return result;
|
|
490
|
+
} catch (err) {
|
|
491
|
+
const result = {
|
|
492
|
+
ok: false,
|
|
493
|
+
duration_ms: Date.now() - startedAt,
|
|
494
|
+
steps,
|
|
495
|
+
artifacts,
|
|
496
|
+
logs,
|
|
497
|
+
error: toErrorMessage(err),
|
|
498
|
+
};
|
|
499
|
+
log(`SUITE FAIL in ${result.duration_ms}ms: ${result.error}`);
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export async function getSystemStatus({ baseUrl, dbPool, projectId, subjectId }) {
|
|
505
|
+
const effectiveProjectId = String(projectId || "default-project").trim() || "default-project";
|
|
506
|
+
const effectiveSubjectId = String(subjectId || "status_probe_subject").trim() || "status_probe_subject";
|
|
507
|
+
const status = {
|
|
508
|
+
timestamp: new Date().toISOString(),
|
|
509
|
+
core: {
|
|
510
|
+
reachable: false,
|
|
511
|
+
status_code: null,
|
|
512
|
+
ok: false,
|
|
513
|
+
payload: null,
|
|
514
|
+
error: null,
|
|
515
|
+
db_route_probe: {
|
|
516
|
+
ok: false,
|
|
517
|
+
status_code: null,
|
|
518
|
+
error: null,
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
postgres: {
|
|
522
|
+
connected: false,
|
|
523
|
+
error: null,
|
|
524
|
+
version: null,
|
|
525
|
+
expected_tables: {},
|
|
526
|
+
all_public_tables: [],
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const healthRes = await fetch(`${String(baseUrl || "").replace(/\/$/, "")}/health`);
|
|
532
|
+
status.core.reachable = true;
|
|
533
|
+
status.core.status_code = healthRes.status;
|
|
534
|
+
const text = await healthRes.text();
|
|
535
|
+
try {
|
|
536
|
+
status.core.payload = text ? JSON.parse(text) : null;
|
|
537
|
+
} catch {
|
|
538
|
+
status.core.payload = { raw: text };
|
|
539
|
+
}
|
|
540
|
+
status.core.ok = healthRes.ok && status.core.payload?.ok === true;
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const probeRes = await fetch(
|
|
544
|
+
`${String(baseUrl || "").replace(/\/$/, "")}/api/v1/memories?subject_id=${encodeURIComponent(effectiveSubjectId)}&limit=1`,
|
|
545
|
+
{
|
|
546
|
+
method: "GET",
|
|
547
|
+
headers: {
|
|
548
|
+
"x-project-id": effectiveProjectId,
|
|
549
|
+
accept: "application/json",
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
status.core.db_route_probe.status_code = probeRes.status;
|
|
554
|
+
const probeText = await probeRes.text();
|
|
555
|
+
let probeJson = null;
|
|
556
|
+
try {
|
|
557
|
+
probeJson = probeText ? JSON.parse(probeText) : null;
|
|
558
|
+
} catch {
|
|
559
|
+
probeJson = { raw: probeText };
|
|
560
|
+
}
|
|
561
|
+
status.core.db_route_probe.ok = probeRes.ok;
|
|
562
|
+
if (!probeRes.ok) {
|
|
563
|
+
status.core.db_route_probe.error = probeJson?.message || probeJson?.error || `HTTP ${probeRes.status}`;
|
|
564
|
+
}
|
|
565
|
+
} catch (err) {
|
|
566
|
+
status.core.db_route_probe.error = toErrorMessage(err);
|
|
567
|
+
}
|
|
568
|
+
} catch (err) {
|
|
569
|
+
status.core.error = toErrorMessage(err);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const versionRes = await dbPool.query("select version() as version, now() as now");
|
|
574
|
+
status.postgres.connected = true;
|
|
575
|
+
status.postgres.version = versionRes.rows?.[0]?.version || null;
|
|
576
|
+
|
|
577
|
+
const expected = [
|
|
578
|
+
"memories",
|
|
579
|
+
"claims",
|
|
580
|
+
"claim_assertions",
|
|
581
|
+
"claim_edges",
|
|
582
|
+
"slot_state",
|
|
583
|
+
"memory_recall_events",
|
|
584
|
+
];
|
|
585
|
+
|
|
586
|
+
const tableRes = await dbPool.query(
|
|
587
|
+
`
|
|
588
|
+
SELECT table_name
|
|
589
|
+
FROM information_schema.tables
|
|
590
|
+
WHERE table_schema = 'public'
|
|
591
|
+
ORDER BY table_name ASC
|
|
592
|
+
`,
|
|
593
|
+
);
|
|
594
|
+
const names = tableRes.rows.map((r) => r.table_name);
|
|
595
|
+
status.postgres.all_public_tables = names;
|
|
596
|
+
for (const table of expected) {
|
|
597
|
+
status.postgres.expected_tables[table] = names.includes(table);
|
|
598
|
+
}
|
|
599
|
+
} catch (err) {
|
|
600
|
+
status.postgres.error = toErrorMessage(err);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return status;
|
|
604
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { runCoreRouteSuite } from "./e2e.lib.mjs";
|
|
4
|
+
|
|
5
|
+
const baseUrl = process.env.CORE_E2E_BASE_URL || "http://127.0.0.1:18080";
|
|
6
|
+
const projectId = process.env.CORE_E2E_PROJECT_ID || "default-project";
|
|
7
|
+
const subjectId = process.env.CORE_E2E_SUBJECT_ID || "user_e2e";
|
|
8
|
+
|
|
9
|
+
const dbPool = new Pool({
|
|
10
|
+
host: process.env.POSTGRES_HOST || "127.0.0.1",
|
|
11
|
+
port: Number(process.env.POSTGRES_PORT || 5432),
|
|
12
|
+
user: process.env.POSTGRES_USER || "mnexium",
|
|
13
|
+
password: process.env.POSTGRES_PASSWORD || "mnexium_dev_password",
|
|
14
|
+
database: process.env.POSTGRES_DB || "mnexium_core",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const result = await runCoreRouteSuite({
|
|
18
|
+
baseUrl,
|
|
19
|
+
projectId,
|
|
20
|
+
subjectId,
|
|
21
|
+
dbPool,
|
|
22
|
+
onLog: (line) => console.log(`[e2e] ${line}`),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (result.ok) {
|
|
26
|
+
console.log("[e2e] all routes passed");
|
|
27
|
+
} else {
|
|
28
|
+
console.error("[e2e] FAILED", result.error || "unknown error");
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await dbPool.end().catch(() => undefined);
|