Il gateway irraggiungibile costava ~30s x4 tentativi (fino a ~2 minuti) per ogni
chiamata: ora si distinguono i due casi e le chiamate successive sono immediate.
- due timeout separati: `connectTimeoutMs` (default 2500, connect+headers) e
`timeoutMs` (default 30000, budget per il body). Nessuna risposta entro il
primo = "gateway non raggiungibile"; body lento = "elaborazione lunga"
- circuit breaker persistente in ~/.local/share/pi-qmem/breaker.json
(env QMEM_BREAKER_FILE): fallimento definitivo (connessione rifiutata/DNS/
connect timeout) → nessun retry e apertura immediata per `breakerBaseMs`
(default 120000 = 2 min) con escalation fino a `breakerMaxMs` (10 min);
5xx/body lento sono ambigui → retry con Retry-After e apertura dopo
`breakerTripAfter` (default 2). Un successo lo richiude; cambiando `url` lo
stato riparte chiuso (endpoint-aware)
- con breaker aperto gatewayRequest ritorna in ~0 ms senza rete
(`gateway_unreachable`, `breaker_open`, `retry_in_ms`): i tool passano subito
al fallback locale e l'outbox accoda
- fix di due bug scoperti durante i test:
* `res.json().catch(() => ({}))` trasformava un body non completato in
"successo con dati vuoti" → l'agente vedeva "nessun risultato" invece del
fallback locale. Ora è `timeout_body` (fallimento, ambiguo)
* `submitOrQueue` passava un AbortSignal esterno, che con la nuova semantica
sarebbe stato letto come annullamento utente (eccezione invece di coda)
- messaggi dei tool con lo stato del breaker e come forzare un tentativo;
`details.breaker` per l'osservabilità
- comandi: `/qmem:local breaker [reset]` e `qmem-sqlite breaker [--reset]`;
lo stato compare in `/qmem:local status` e nella CLI
- budget interni per enrich/pull/flush (niente AbortSignal esterni)
Misure: connessione rifiutata → 4-8 ms (prima: 4 x 30 s); front che risponde
503 dopo ~40 s → 3,5 s alla prima chiamata, poi 0 ms di rete a breaker aperto;
server che accetta e non risponde → 708 ms (connect timeout); body lento →
1,2 s senza aprire il breaker; persistenza verificata fra processi distinti.
Test: scripts/test-local.mjs 38/38 (nuova fase dedicata al breaker).
372 lines
24 KiB
JavaScript
372 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Test dell'indice locale qmem (SQLite/FTS5) e del fallback offline.
|
|
*
|
|
* Isola tutto in una HOME temporanea:
|
|
* - sessioni pi sintetiche (store / correct / search / get)
|
|
* - config con gateway "black hole" (127.0.0.1:9 → connessione rifiutata)
|
|
* - stub HTTP locale per testare enrich e pull
|
|
*
|
|
* Uso: node scripts/test-local.mjs (esce != 0 se un controllo fallisce)
|
|
*/
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import * as crypto from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO = path.dirname(HERE);
|
|
const PI_BASE =
|
|
process.env.PI_BASE ??
|
|
"/home/enne2/.local/share/pi-node/node-v22.23.2-linux-x64/lib/node_modules/@earendil-works/pi-coding-agent";
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "qmem-test-"));
|
|
const HOME = path.join(TMP, "home");
|
|
const SESS_DIR = path.join(HOME, ".pi", "agent", "sessions", "--tmp--");
|
|
const DB = path.join(HOME, "qmem.sqlite");
|
|
const CONFIG = path.join(HOME, ".config", "pi-qmem", "config.json");
|
|
const ID_A = "11111111-1111-4111-8111-111111111111"; // store, attivo
|
|
const ID_B = "22222222-2222-4222-8222-222222222222"; // store poi superseduto
|
|
const ID_C = "33333333-3333-4333-8333-333333333333"; // correzione (nuovo)
|
|
const ID_D = "44444444-4444-4444-8444-444444444444"; // visto in qmem_search (senza project_id)
|
|
const ID_E = "55555555-5555-4555-8555-555555555555"; // visto in qmem_get
|
|
const EXPORT_ID = "66666666-6666-4666-8666-666666666666"; // dal pull export
|
|
const TOMBSTONE_ID = "77777777-7777-4777-8777-777777777777"; // tombstone dal pull export
|
|
|
|
fs.mkdirSync(SESS_DIR, { recursive: true });
|
|
fs.mkdirSync(path.dirname(CONFIG), { recursive: true });
|
|
|
|
// ---------------------------------------------------------------- sessioni sintetiche
|
|
const entry = (obj) => JSON.stringify(obj);
|
|
const call = (id, name, args, ts) => entry({ type: "message", id, timestamp: ts, message: { role: "assistant", content: [{ type: "toolCall", id: `c-${id}`, name, arguments: args }] } });
|
|
const result = (id, name, text, details, ts) => entry({ type: "message", id, timestamp: ts, message: { role: "toolResult", toolCallId: `c-${id}`, toolName: name, content: [{ type: "text", text }], details } });
|
|
|
|
const T = "2026-09-01T10:00:0";
|
|
const lines = [
|
|
entry({ type: "session", version: 3, id: "synthetic", timestamp: `${T}0.000Z`, cwd: "/tmp" }),
|
|
// 1) store attivo
|
|
call("m1", "qmem_store", { text: "Il fallback locale usa SQLite FTS5 con tokenizer unicode61 per la ricerca testuale offline.", kind: "decision", project_id: "test-project", scope: "agent", agent_id: "tester" }, `${T}1.000Z`),
|
|
result("m1", "qmem_store", `Memoria salvata: ${ID_A} (decision, scope agent)`, { memory_id: ID_A, created_at: `${T}1.000Z` }, `${T}1.100Z`),
|
|
// 2) store che verrà corretto
|
|
call("m2", "qmem_store", { text: "Il gateway remoto è sempre raggiungibile via VPN.", kind: "fact", project_id: "test-project", scope: "agent" }, `${T}2.000Z`),
|
|
result("m2", "qmem_store", `Memoria salvata: ${ID_B} (fact, scope agent)`, { memory_id: ID_B, created_at: `${T}2.000Z` }, `${T}2.100Z`),
|
|
// 3) correzione: il gateway remoto NON è sempre raggiungibile
|
|
call("m3", "qmem_correct", { memory_id: ID_B, text: "Il gateway remoto NON è sempre raggiungibile: serve un fallback locale per la ricerca.", reason: "verificato outage" }, `${T}3.000Z`),
|
|
result("m3", "qmem_correct", `Correzione applicata: nuovo record ${ID_C} supersede ${ID_B}.`, { new_id: ID_C, superseded_id: ID_B, reparented: 0 }, `${T}3.100Z`),
|
|
// 4) risultato di ricerca (record creato altrove, senza project_id nel rendering)
|
|
call("m4", "qmem_search", { query: "backup qdrant snapshot" }, `${T}4.000Z`),
|
|
result("m4", "qmem_search", `1. [fact/org score=0.71] Backup giornaliero: snapshot Qdrant + rsync in /home/enne2/archive/backups\n (id: ${ID_D}, agente: pi, creato: 2026-08-16T06:00:00Z)`, { hits: 1 }, `${T}4.100Z`),
|
|
// 5) qmem_get
|
|
call("m5", "qmem_get", { memory_id: ID_E }, `${T}5.000Z`),
|
|
result("m5", "qmem_get", `memory_id: ${ID_E}\n[episode/agent conf=medium] | project: infra-security | agente: shared, creato: 2026-08-14T09:00:00Z\n\nRollback del firewall: ripristinare la regola precedente e verificare con nmap.`, { memory_id: ID_E, kind: "episode", scope: "agent", project_id: "infra-security" }, `${T}5.100Z`),
|
|
];
|
|
fs.writeFileSync(path.join(SESS_DIR, "2026-09-01T10-00-00-000Z_test.jsonl"), lines.join("\n") + "\n");
|
|
|
|
// ---------------------------------------------------------------- config black-hole
|
|
fs.writeFileSync(CONFIG, JSON.stringify({ url: "http://127.0.0.1:9", apiKey: "test-key", timeoutMs: 1500, connectTimeoutMs: 700, breakerBaseMs: 20000, breakerMaxMs: 60000, localDbPath: DB }, null, 2), { mode: 0o600 });
|
|
|
|
const env = { ...process.env, HOME, QMEM_SQLITE: DB, QMEM_SESSIONS_DIR: path.join(HOME, ".pi", "agent", "sessions") };
|
|
// isolamento anche per il processo di test: l'estensione caricata in-process
|
|
// deve leggere la config/DB di test (non quelli reali dell'utente)
|
|
process.env.HOME = HOME;
|
|
process.env.QMEM_SQLITE = DB;
|
|
process.env.QMEM_SESSIONS_DIR = path.join(HOME, ".pi", "agent", "sessions");
|
|
const results = [];
|
|
const check = (name, ok, info = "") => {
|
|
results.push({ name, ok, info });
|
|
console.log(`${ok ? "✅" : "❌"} ${name}${info ? ` — ${info}` : ""}`);
|
|
};
|
|
|
|
// ---------------------------------------------------------------- 1) CLI import
|
|
const runCli = (args) => spawnSync("node", [path.join(HERE, "qmem-sqlite.mjs"), ...args], { env, encoding: "utf8" });
|
|
// async: spawnSync bloccherebbe l'event loop e lo stub in-process non risponderebbe
|
|
const runCliAsync = (args) =>
|
|
new Promise((resolve) => {
|
|
const child = spawn("node", [path.join(HERE, "qmem-sqlite.mjs"), ...args], { env });
|
|
let out = "";
|
|
let err = "";
|
|
child.stdout.on("data", (d) => (out += d));
|
|
child.stderr.on("data", (d) => (err += d));
|
|
child.on("close", (code) => {
|
|
if (process.env.DEBUG_CLI && (!out.trim() || code !== 0)) {
|
|
console.log(` [cli ${args.join(" ")}] code=${code} stdout=${out.trim().slice(0, 200)} stderr=${err.trim().slice(0, 200)}`);
|
|
}
|
|
resolve(out);
|
|
});
|
|
});
|
|
const imp = runCli(["import", "--json"]);
|
|
let impJson = null;
|
|
try {
|
|
impJson = JSON.parse(imp.stdout);
|
|
} catch {
|
|
/* fallback su output testuale */
|
|
}
|
|
check("CLI import dai sessioni sintetiche", imp.status === 0 && impJson?.stats?.records >= 5, `record=${impJson?.stats?.records} store=${impJson?.stats?.store} correct=${impJson?.stats?.correct} get=${impJson?.stats?.get} search=${impJson?.stats?.searchHits}`);
|
|
|
|
// ---------------------------------------------------------------- 2) CLI find
|
|
const findOut = runCli(["find", "sqlite", "--json"]);
|
|
const hits = JSON.parse(findOut.stdout || "[]");
|
|
check("ricerca lessicale trova il record attivo", hits.some((h) => h.memory_id === ID_A), `${hits.length} hit`);
|
|
const findAll = JSON.parse(runCli(["find", "raggiungibile", "--all", "--json"]).stdout || "[]");
|
|
check("--all include i superseduti", findAll.some((h) => h.memory_id === ID_B || h.memory_id === ID_C), `${findAll.length} hit`);
|
|
const findActive = JSON.parse(runCli(["find", "raggiungibile", "--json"]).stdout || "[]");
|
|
check("default esclude i superseduti", !findActive.some((h) => h.memory_id === ID_B), `${findActive.length} hit`);
|
|
const projectFilter = JSON.parse(runCli(["find", "sqlite", "--project", "test-project", "--json"]).stdout || "[]");
|
|
check("filtro --project funziona", projectFilter.length === 1 && projectFilter[0].memory_id === ID_A);
|
|
const orMode = JSON.parse(runCli(["find", "firewall sqlite", "--json"]).stdout || "[]"); // termini non co-occorrenti → AND=0 → OR
|
|
check("ripiego OR su match parziale", orMode.length > 0 && orMode[0].match_mode === "or", `${orMode.length} hit`);
|
|
|
|
// ---------------------------------------------------------------- 3) estensione: fallback offline
|
|
const { createJiti } = await import(`${PI_BASE}/node_modules/jiti/lib/jiti.mjs`);
|
|
const jiti = createJiti(import.meta.url, {
|
|
interopDefault: true,
|
|
alias: {
|
|
"@earendil-works/pi-coding-agent": PI_BASE,
|
|
"@earendil-works/pi-tui": path.join(PI_BASE, "node_modules/@earendil-works/pi-tui"),
|
|
"@earendil-works/pi-ai": path.join(PI_BASE, "node_modules/@earendil-works/pi-ai"),
|
|
"@earendil-works/pi-agent-core": path.join(PI_BASE, "node_modules/@earendil-works/pi-agent-core"),
|
|
typebox: path.join(PI_BASE, "node_modules/typebox/build/index.mjs"),
|
|
},
|
|
});
|
|
const mod = await jiti.import(path.join(REPO, "extensions/index.ts"));
|
|
const tools = new Map();
|
|
const commands = new Map();
|
|
(mod.default ?? mod)({
|
|
on() {},
|
|
registerTool: (t) => tools.set(t.name, t),
|
|
registerCommand: (n, d) => commands.set(n, d),
|
|
registerShortcut() {},
|
|
registerFlag() {},
|
|
appendEntry() {},
|
|
});
|
|
check("estensione caricata (6 tool + comandi)", tools.size >= 6 && commands.has("qmem:local"), `tool=${[...tools.keys()].join(",")} cmd=${[...commands.keys()].join(",")}`);
|
|
|
|
const notices = [];
|
|
const ctx = {
|
|
mode: "print",
|
|
hasUI: false,
|
|
cwd: REPO,
|
|
ui: { notify: (m) => notices.push(m), setStatus() {}, select: async () => null, input: async () => null, confirm: async () => false, custom: () => ({}) },
|
|
sessionManager: { getSessionId: () => "test", getEntries: () => [], getSessionFile: () => undefined },
|
|
};
|
|
const search = await tools.get("qmem_search").execute("t1", { query: "sqlite fts5" }, undefined, undefined, ctx);
|
|
check("qmem_search → fallback locale con gateway giù", search.details?.fallback === "local_sqlite" && search.details?.hits > 0, `hits=${search.details?.hits} status=${search.details?.gateway_status}`);
|
|
check("risultato locale etichettato come testuale", /INDICE LOCALE/i.test(search.content[0].text) && /non neurale/i.test(search.content[0].text) && /sqlite/i.test(search.content[0].text));
|
|
check("il messaggio indica il circuit breaker e come forzare un tentativo", /circuit breaker aperto/i.test(search.content[0].text) && /breaker reset/i.test(search.content[0].text), `breaker=${JSON.stringify(search.details?.breaker)}`);
|
|
const getRes = await tools.get("qmem_get").execute("t2", { memory_id: ID_E }, undefined, undefined, ctx);
|
|
if (process.env.DEBUG_GET) console.log("GET RESULT:", JSON.stringify(getRes, null, 1).slice(0, 900));
|
|
check("qmem_get → fallback locale per UUID", getRes.details?.fallback === "local_sqlite" && /nmap/.test(getRes.content[0].text));
|
|
const storeRes = await tools.get("qmem_store").execute("t3", { text: "Record creato offline durante un outage del gateway.", project_id: "test-project", kind: "fact" }, undefined, undefined, ctx);
|
|
const queuedLocalId = storeRes.details?.local_id;
|
|
check("qmem_store offline → accoda nell'outbox", storeRes.details?.queued === true && /ACCODATO/i.test(storeRes.content[0].text), `local_id=${String(queuedLocalId).slice(0, 8)} coda=${storeRes.details?.queue_size}`);
|
|
|
|
// ---------------------------------------------------------------- 4) arricchimento dal gateway (stub)
|
|
const posted = [];
|
|
const server = createServer((req, res) => {
|
|
if (req.method === "POST" && req.url === "/v1/memories") {
|
|
let raw = "";
|
|
req.on("data", (c) => (raw += c));
|
|
req.on("end", () => {
|
|
let body = {};
|
|
try { body = JSON.parse(raw); } catch { /* ignore */ }
|
|
posted.push({ body, idem: req.headers["idempotency-key"] });
|
|
if (/duplicato/i.test(body.text ?? "")) {
|
|
res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ detail: { error: "duplicate_memory", reason: "KNOWN_SOLUTION", matches: [{ memory_id: ID_A, score: 0.93 }] } }));
|
|
} else if (/invalido/i.test(body.text ?? "")) {
|
|
res.writeHead(422, { "content-type": "application/json" }).end(JSON.stringify({ detail: [{ loc: ["body", "project_id"], msg: "campo obbligatorio" }] }));
|
|
} else {
|
|
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ memory_id: crypto.randomUUID(), text: body.text, kind: body.kind, project_id: body.project_id, scope: body.scope, created_at: new Date().toISOString(), supersedes_id: body.supersedes_id }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (req.url?.startsWith("/v1/memories:export")) {
|
|
res.writeHead(200, { "content-type": "application/json" }).end(
|
|
JSON.stringify({
|
|
results: [
|
|
{ memory_id: EXPORT_ID, text: "Record arrivato dal pull export del gateway.", kind: "fact", project_id: "export-proj", scope: "agent", created_at: "2026-09-10T10:00:00Z", updated_at: "2026-09-10T10:00:00Z", updated_ts: 1 },
|
|
{ memory_id: TOMBSTONE_ID, text: "Record cancellato sul gateway con soft delete.", kind: "fact", project_id: "export-proj", created_at: "2026-09-09T10:00:00Z", deleted_at: "2026-09-11T10:00:00Z", deleted_ts: 2 },
|
|
],
|
|
count: 2,
|
|
next_cursor: null,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (req.url === `/v1/memories/${ID_D}`) {
|
|
res.writeHead(200, { "content-type": "application/json" }).end(
|
|
JSON.stringify({ memory_id: ID_D, text: "Backup giornaliero: snapshot Qdrant + rsync in /home/enne2/archive/backups (verificato)", kind: "fact", scope: "org", project_id: "infra-security", agent_id: "pi", created_at: "2026-08-16T06:00:00Z", private: true }),
|
|
);
|
|
return;
|
|
}
|
|
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ detail: "Memoria non trovata" }));
|
|
});
|
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
const stub = `http://127.0.0.1:${server.address().port}`;
|
|
fs.writeFileSync(CONFIG, JSON.stringify({ url: stub, apiKey: "test-key", timeoutMs: 3000, connectTimeoutMs: 700, breakerBaseMs: 20000, localDbPath: DB }, null, 2), { mode: 0o600 });
|
|
const enrichOut = await runCliAsync(["enrich", "--json"]);
|
|
let enrich = { stdout: enrichOut };
|
|
let enrichJson = null;
|
|
try {
|
|
enrichJson = JSON.parse(enrich.stdout);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
check("enrich dal gateway aggiorna i campi mancanti", (enrichJson?.updated ?? 0) >= 1, `requested=${enrichJson?.requested} updated=${enrichJson?.updated}`);
|
|
const after = runCli(["status", "--json"]);
|
|
const afterJson = JSON.parse(after.stdout || "{}");
|
|
check("project_id/private arricchiti nel DB", afterJson.withProject > 0, `con project_id: ${afterJson.withProject}/${afterJson.total}, privati: ${afterJson.private}`);
|
|
const pullOut = await runCliAsync(["pull", "--json"]);
|
|
const pull = { stdout: pullOut };
|
|
const pullJson = JSON.parse(pull.stdout || "{}");
|
|
check("pull export: 2 record (1 attivo + 1 tombstone)", pullJson.supported === true && pullJson.fetched === 2, `pagine=${pullJson.pages} record=${pullJson.fetched}`);
|
|
const findExported = JSON.parse(runCli(["find", "pull export gateway", "--json"]).stdout || "[]");
|
|
check("record esportato ricercabile nel mirror", findExported.some((h) => h.memory_id === EXPORT_ID), `${findExported.length} hit`);
|
|
const findTombstone = JSON.parse(runCli(["find", "soft delete", "--json"]).stdout);
|
|
check("tombstone escluso dalle ricerche locali di default", !findTombstone.some((h) => h.memory_id === TOMBSTONE_ID), `${findTombstone.length} hit`);
|
|
const findTombstoneAll = JSON.parse(runCli(["find", "soft delete", "--deleted", "--json"]).stdout || "{}");
|
|
check("tombstone visibile con --deleted", findTombstoneAll.some((h) => h.memory_id === TOMBSTONE_ID && h.deleted_at), `${findTombstoneAll.length} hit`);
|
|
|
|
// ---------------------------------------------------------------- 5) outbox: flush, 409, 422, supersede
|
|
const q1 = JSON.parse((await runCliAsync(["queue", "--json"])) || "{}");
|
|
check("coda: 1 record in attesa dopo lo store offline", q1.stats?.queued >= 1, `queued=${q1.stats?.queued} localId=${String(queuedLocalId).slice(0, 8)}`);
|
|
const findPending = JSON.parse(runCli(["find", "outage gateway", "--json"]).stdout || "[]");
|
|
check("il record accodato è già ricercabile offline (pending=1)", findPending.some((h) => h.pending === 1 && h.memory_id === queuedLocalId), `${findPending.length} hit`);
|
|
|
|
// seconda voce: duplicato (409) e terza: invalida (422), più un supersede verso un local_id
|
|
const dupStore = JSON.parse((await runCliAsync(["store", "--project", "test-project", "--text", "Questo è un duplicato noto del gateway", "--queue-only", "--json"])) || "{}");
|
|
const badStore = JSON.parse((await runCliAsync(["store", "--project", "test-project", "--text", "Record invalido per test 422", "--queue-only", "--json"])) || "{}");
|
|
const childStore = JSON.parse((await runCliAsync(["store", "--project", "test-project", "--text", "Correzione offline di un record locale", "--queue-only", "--json"])) || "{}");
|
|
// supersede verso il record locale: il flush deve rimappare local_id → remote_id
|
|
const dbMod = spawnSync("node", ["-e", `
|
|
const { DatabaseSync } = require("node:sqlite");
|
|
const db = new DatabaseSync(process.env.QMEM_SQLITE);
|
|
const row = db.prepare("SELECT payload FROM pending WHERE local_id = ?").get(${JSON.stringify(childStore.local_id)});
|
|
const p = JSON.parse(row.payload); p.supersedes_id = ${JSON.stringify(queuedLocalId)};
|
|
db.prepare("UPDATE pending SET payload = ? WHERE local_id = ?").run(JSON.stringify(p), ${JSON.stringify(childStore.local_id)});
|
|
`], { env, encoding: "utf8" });
|
|
check("setup supersede offline (payload con supersedes_id locale)", dbMod.status === 0, dbMod.stderr?.slice(0, 80) ?? "");
|
|
|
|
const flush1 = JSON.parse((await runCliAsync(["flush", "--json"])) || "{}");
|
|
check("flush: 2 sincronizzati, 1 duplicato (409), 1 fallito (422), coda vuota", flush1.synced === 2 && flush1.duplicates === 1 && flush1.failed === 1 && flush1.remaining === 0, `processed=${flush1.processed} synced=${flush1.synced} duplicates=${flush1.duplicates} failed=${flush1.failed} remaining=${flush1.remaining}`);
|
|
const remoteOfQueued = flush1.remoteIds?.[queuedLocalId];
|
|
check("Idempotency-Key = local_id inviato al gateway", posted.every((p) => typeof p.idem === "string" && p.idem.length === 36), `${posted.length} POST`);
|
|
check("supersede rimappato da local_id a remote_id", posted.some((p) => p.body.supersedes_id === remoteOfQueued && remoteOfQueued), `remote=${String(remoteOfQueued).slice(0, 8)}`);
|
|
const afterFlush = JSON.parse((await runCliAsync(["status", "--json"])) || "{}");
|
|
check("dopo il flush resta pendente solo il record fallito (422)", afterFlush.queued === 0 && afterFlush.pendingInIndex === 1 && afterFlush.failedQueue === 1, `queued=${afterFlush.queued} pending_in_index=${afterFlush.pendingInIndex} failed=${afterFlush.failedQueue}`);
|
|
const findRemote = JSON.parse(runCli(["find", "outage gateway", "--json"]).stdout || "[]");
|
|
check("il record è ricercabile con l'ID remoto", findRemote.some((h) => h.memory_id === remoteOfQueued && h.pending === 0), `${findRemote.length} hit`);
|
|
|
|
// duplicato (409) e invalido (422)
|
|
const dupFlush = JSON.parse((await runCliAsync(["flush", "--json"])) || "{}");
|
|
const qFinal = JSON.parse((await runCliAsync(["queue", "--json"])) || "{}");
|
|
const dupItem = (qFinal.items ?? []).find((i) => i.local_id === dupStore.local_id);
|
|
const badItem = (qFinal.items ?? []).find((i) => i.local_id === badStore.local_id);
|
|
check("duplicato marcato con remote_id del match", dupItem?.status === "duplicate" && dupItem?.remote_id === ID_A, `status=${dupItem?.status} remote=${String(dupItem?.remote_id).slice(0, 8)}`);
|
|
check("422 marcato failed con errore conservato", badItem?.status === "failed" && /422/.test(badItem?.last_error ?? ""), `err=${String(badItem?.last_error).slice(0, 40)}`);
|
|
// ---------------------------------------------------------------- 6) circuit breaker
|
|
if (process.env.SKIP_BREAKER !== "1") {
|
|
const origEmit = process.emitWarning;
|
|
process.emitWarning = (w, ...r) => {
|
|
const code = r[0]?.code ?? (typeof r[0] === "string" ? r[0] : undefined) ?? w?.code;
|
|
if (code === "MODULE_TYPELESS_PACKAGE_JSON") return;
|
|
return origEmit.call(process, w, ...r);
|
|
};
|
|
const shared = await import("../extensions/shared.ts");
|
|
process.emitWarning = origEmit;
|
|
const BH = { url: "http://127.0.0.1:9", apiKey: "k", connectTimeoutMs: 600, breakerBaseMs: 20000, breakerMaxMs: 60000 };
|
|
|
|
shared.resetBreaker();
|
|
check("breaker inizialmente chiuso", shared.breakerInfo().open === false);
|
|
|
|
// connessione rifiutata → fallimento immediato, nessun retry
|
|
let t = Date.now();
|
|
const r1 = await shared.gatewayRequest(BH, "GET", "/v1/status");
|
|
const ms1 = Date.now() - t;
|
|
check("connessione rifiutata: fallimento immediato senza retry", r1.status === 0 && r1.data?.error === "gateway_unreachable" && ms1 < 1500, `${ms1}ms`);
|
|
const br1 = shared.breakerInfo();
|
|
check("breaker APERTO dopo il fallimento di connessione", br1.open === true, `open=${br1.open} failures=${br1.failures} trips=${br1.trips}`);
|
|
|
|
// fast-fail: nessuna richiesta di rete
|
|
t = Date.now();
|
|
const r2 = await shared.gatewayRequest(BH, "GET", "/v1/status");
|
|
const ms2 = Date.now() - t;
|
|
check("fast-fail con breaker aperto (nessuna rete, < 50ms)", r2.status === 0 && r2.data?.breaker_open === true && ms2 < 50, `${ms2}ms`);
|
|
|
|
// persistenza fra processi (nuovo processo, stesso file di stato)
|
|
const code = `
|
|
const p = ${JSON.stringify(path.join(REPO, "extensions/shared.ts"))};
|
|
import(p).then(async (m) => {
|
|
const t = Date.now();
|
|
const r = await m.gatewayRequest({ url: "http://127.0.0.1:9", apiKey: "k", connectTimeoutMs: 600 }, "GET", "/v1/status");
|
|
console.log(JSON.stringify({ ms: Date.now() - t, status: r.status, breaker_open: !!r.data?.breaker_open, open: m.breakerInfo().open }));
|
|
});`;
|
|
const child = spawnSync("node", ["-e", code], { env, encoding: "utf8" });
|
|
let childOut = null;
|
|
try {
|
|
childOut = JSON.parse((child.stdout || "{}").trim().split("\n").pop());
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
check(
|
|
"breaker persistente fra processi (nuovo processo → fast-fail)",
|
|
childOut?.breaker_open === true && childOut?.ms < 200,
|
|
`ms=${childOut?.ms} open=${childOut?.open} stderr=${(child.stderr || "").slice(0, 60)}`,
|
|
);
|
|
|
|
// server che accetta ma non risponde mai → connect timeout breve (non 30s)
|
|
const hang = createServer(() => {});
|
|
await new Promise((r) => hang.listen(0, "127.0.0.1", r));
|
|
const hangUrl = `http://127.0.0.1:${hang.address().port}`;
|
|
shared.resetBreaker();
|
|
t = Date.now();
|
|
const r3 = await shared.gatewayRequest({ url: hangUrl, apiKey: "k", connectTimeoutMs: 700 }, "GET", "/v1/status");
|
|
const ms3 = Date.now() - t;
|
|
check("server che non risponde: connect timeout ~700ms (non 30s) e breaker aperto", r3.status === 0 && ms3 >= 600 && ms3 < 2600 && shared.breakerInfo().open === true, `${ms3}ms`);
|
|
hang.close();
|
|
|
|
// header subito ma body lento: elaborazione lunga → fallimento ambiguo, breaker NON aperto
|
|
const stall = createServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.write('{"status":');
|
|
// nessun end: il body resta appeso
|
|
});
|
|
await new Promise((r) => stall.listen(0, "127.0.0.1", r));
|
|
const stallUrl = `http://127.0.0.1:${stall.address().port}`;
|
|
shared.resetBreaker();
|
|
t = Date.now();
|
|
const r4 = await shared.gatewayRequest({ url: stallUrl, apiKey: "k", connectTimeoutMs: 700, timeoutMs: 1200 }, "GET", "/v1/status");
|
|
const ms4 = Date.now() - t;
|
|
const br4 = shared.breakerInfo();
|
|
check(
|
|
"body lento: timeout di elaborazione (~1.2s) senza aprire il breaker",
|
|
r4.status === 0 && r4.data?.error === "timeout_body" && ms4 < 2600 && br4.open === false,
|
|
`${ms4}ms failures=${br4.failures} open=${br4.open}`,
|
|
);
|
|
|
|
// store con gateway che accetta ma non risponde: deve accodare, non lanciare
|
|
fs.writeFileSync(CONFIG, JSON.stringify({ url: stallUrl, apiKey: "test-key", timeoutMs: 1200, connectTimeoutMs: 700, breakerBaseMs: 20000, localDbPath: DB }, null, 2), { mode: 0o600 });
|
|
const queuedOnStall = await tools.get("qmem_store").execute("t9", { text: "Record accodato con gateway che non risponde", project_id: "stall-proj" }, undefined, undefined, ctx);
|
|
check(
|
|
"gateway che accetta e non risponde: store ACCODATO (nessuna eccezione)",
|
|
queuedOnStall.details?.queued === true && /ACCODATO/i.test(queuedOnStall.content[0].text),
|
|
`queued=${queuedOnStall.details?.queued} status=${queuedOnStall.details?.gateway_status}`,
|
|
);
|
|
|
|
// cambio di endpoint → il breaker riparte chiuso
|
|
shared.tripBreaker("endpoint A", true, { ...BH, breakerBaseMs: 60000, url: "http://a" });
|
|
const r5 = await shared.gatewayRequest({ url: hangUrl, apiKey: "k", connectTimeoutMs: 400 }, "GET", "/v1/status");
|
|
check("cambio endpoint: il breaker non blocca il nuovo gateway", r5.data?.breaker_open !== true, `error=${r5.data?.error}`);
|
|
shared.resetBreaker();
|
|
check("reset manuale chiude il breaker", shared.breakerInfo().open === false);
|
|
stall.close();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- report
|
|
const failed = results.filter((r) => !r.ok);
|
|
console.log(`\n${results.length - failed.length}/${results.length} controlli superati — HOME di test: ${HOME}`);
|
|
process.exit(failed.length ? 1 : 0);
|