feat(outbox): store offline con coda locale e sincronizzazione al ritorno della rete

Prima qmem_store falliva se il gateway non era raggiungibile: la conoscenza
andava persa. Ora il record entra in una coda locale persistente e viene
inviato automaticamente quando la connessione torna.

Core (extensions/local-db.ts):
- tabella `pending` (local_id, payload JSON, attempts, last_error, status,
  remote_id) + colonna `records.pending` (migrazione automatica dei DB esistenti)
- queueStore(): accoda e crea subito il placeholder locale ricercabile ()
- flushQueue(): POST /v1/memories con Idempotency-Key = local_id (retry senza
  duplicati), FIFO, pacing sotto il rate limit, timeout 12s per richiesta
- esiti: synced (il record locale adotta l'ID remoto, niente duplicati) ·
  duplicate (409: registra l'ID del match e NON sovrascrive il testo locale
  autorevole) · failed (4xx di validazione, non ritentato) · 0/429/5xx: resta in
  coda e il flush si ferma
- supersede offline: supersedes_id che punta a un local_id viene rimappato al
  remote_id al flush (se il genitore non è sincronizzato → failed esplicito)
- submitOrQueue(): online → gateway + indicizzazione locale; offline → coda
- maybeBackgroundFlush() (single-flight) e flushQueueIfPending() per session_start
- stato/report: queued/synced/duplicate/failed, più vecchio, ultimo errore,
  last_flush, record pendenti in indice

Estensione:
- qmem_store: gateway giù → accoda e risponde con id locale, dimensione coda e
  spiegazione (details.queued/local_id/queue_size)
- fallback offline di session_start: flush in background (non blocca l'avvio)
- /qmem:local queue|flush; status con la coda; marker " in coda" nei risultati
  locali di qmem_search/qmem_get
- regole e skill: un record in coda NON è ancora nella memoria condivisa

CLI: store [--queue-only], queue, flush (+ status con la coda).
Test: scripts/test-local.mjs ora copre anche outbox → 24 controlli (flush con
2 sync + 1 duplicato 409 + 1 fallito 422, Idempotency-Key, rimappatura del
supersede, ricerca del record con l'ID remoto dopo il sync).

Verifiche: 24/24 test superati; demo reale su DB temporaneo: store accodato,
queue con local_id, flush con gateway giù → "fermato: HTTP 0" e voce che resta
in coda con l'errore registrato.
This commit is contained in:
Matteo Benedetto
2026-09-13 17:29:09 +02:00
parent 1832562a7f
commit 322b4cf446
12 changed files with 705 additions and 67 deletions
+59 -3
View File
@@ -7,6 +7,9 @@
* node scripts/qmem-sqlite.mjs import [--db FILE]
* node scripts/qmem-sqlite.mjs find "query" [--kind K] [--project P] [--scope S]
* [--top N] [--all] [--private] [--exact] [--json]
* node scripts/qmem-sqlite.mjs store --project P [--kind K] [--text "..."] [--queue-only]
* node scripts/qmem-sqlite.mjs queue [--status queued|synced|duplicate|failed]
* node scripts/qmem-sqlite.mjs flush [--limit N]
* node scripts/qmem-sqlite.mjs enrich [--all] [--limit N] [--pace MS]
* node scripts/qmem-sqlite.mjs pull [--limit N]
*
@@ -23,7 +26,7 @@ process.emitWarning = (warning, ...rest) => {
return originalEmitWarning.call(process, warning, ...rest);
};
const localDb = await import("../extensions/local-db.ts");
const { DEFAULT_DB_FILE, enrichFromGateway, importFromSessions, localDbPath, localDbReport, localSearch, pullFromGatewayExport, sessionRoots } = localDb;
const { DEFAULT_DB_FILE, enrichFromGateway, flushQueue, importFromSessions, localDbPath, localDbReport, localSearch, pullFromGatewayExport, queueList, queueStats, queueStore, sessionRoots, submitOrQueue } = localDb;
const { loadConfig } = await import("../extensions/shared.ts");
process.emitWarning = originalEmitWarning;
@@ -55,7 +58,12 @@ if (cmd === "status") {
console.log(` dimensione : ${r.sizeKb} KB`);
console.log(` record : ${r.total} (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati)`);
console.log(` con project_id: ${r.withProject}/${r.total}`);
console.log(` ultimo import : ${r.lastImport ?? "-"} enrich: ${r.lastEnrich ?? "-"} export: ${r.lastExport ?? "-"}`);
console.log(` ultimo import : ${r.lastImport ?? "-"} enrich: ${r.lastEnrich ?? "-"} export: ${r.lastExport ?? "-"} flush: ${r.lastFlush ?? "-"}`);
if (r.queued || r.syncedQueue || r.failedQueue || r.duplicateQueue) {
console.log(` coda offline : ${r.queued} in attesa, ${r.syncedQueue} sincronizzati, ${r.duplicateQueue} duplicati, ${r.failedQueue} falliti${r.oldestQueued ? ` (più vecchio: ${r.oldestQueued})` : ""}`);
if (r.queueLastError) console.log(` ultimo errore : ${r.queueLastError.slice(0, 120)}`);
}
if (r.pendingInIndex) console.log(` in indice : ${r.pendingInIndex} record marcati ⏳ (creati offline, non ancora sul gateway)`);
console.log(` top progetti : ${r.topProjects.map((p) => `${p.project_id ?? "(null)"}=${p.n}`).join(", ") || "-"}`);
if (r.duplicates.length) {
console.log(` possibili duplicati (testo identico): ${r.duplicates.length} gruppi — es. ${r.duplicates[0].ids.map((i) => i.slice(0, 8)).join(", ")}`);
@@ -120,6 +128,54 @@ if (cmd === "status") {
if (stats.errors.length) console.log(` errori : ${stats.errors.join(" | ")}`);
if (stats.failed && !stats.ok) console.log(" (gateway non raggiungibile: riprova quando torna online)");
}
} else if (cmd === "store") {
// store con fallback offline: prova il gateway, altrimenti accoda
const cfg = loadConfig();
const text = typeof opt("text", null) === "string" ? opt("text", null) : fs.readFileSync(String(opt("file", "/dev/stdin")), "utf8").trim();
const project = typeof opt("project", null) === "string" ? opt("project", null) : "";
if (!text || !project) {
console.error('Uso: store --project P [--kind K] [--scope S] [--text "..."] | --file FILE [--queue-only]');
process.exit(2);
}
const payload = {
text,
project_id: project,
kind: typeof opt("kind", null) === "string" ? opt("kind", null) : "fact",
scope: typeof opt("scope", null) === "string" ? opt("scope", null) : "agent",
agent_id: typeof opt("agent", null) === "string" ? opt("agent", null) : undefined,
topic: typeof opt("topic", null) === "string" ? opt("topic", null) : undefined,
};
if (has("queue-only")) {
const q = await queueStore(payload, { dbFile });
out(json ? q : `Accodato localmente: ${q.local_id} (in coda: ${q.queue_size})`);
} else {
const res = await submitOrQueue(cfg, payload, { dbFile });
if (json) out(res);
else if (res.queued) out(`Gateway non raggiungibile (HTTP ${res.status}): accodato localmente ${res.local_id} (in coda: ${res.queue_size}). Flush: qmem-sqlite flush`);
else if (res.remote_id) out(`Salvato sul gateway: ${res.remote_id}`);
else out(`Errore HTTP ${res.status}: ${JSON.stringify(res.data)}`);
}
} else if (cmd === "queue") {
const stats = await queueStats({ dbFile });
const items = await queueList({ dbFile, status: typeof opt("status", null) === "string" ? opt("status", null) : undefined, limit: Number(opt("limit", 20)) || 20 });
if (json) out({ stats, items });
else {
console.log(`Coda offline (${dbFile})`);
console.log(` in attesa: ${stats.queued} sincronizzati: ${stats.synced} duplicati: ${stats.duplicate} falliti: ${stats.failed}`);
if (stats.oldestQueued) console.log(` più vecchio: ${stats.oldestQueued}`);
if (stats.lastError) console.log(` ultimo errore: ${stats.lastError.slice(0, 140)}`);
items.forEach((q, i) => console.log(` ${i + 1}. [${q.status}] ${q.local_id.slice(0, 8)} [${q.payload.kind ?? "fact"}/${q.payload.project_id}] ${String(q.payload.text).slice(0, 70)}`));
}
} else if (cmd === "flush") {
const cfg = loadConfig();
const res = await flushQueue(cfg, { dbFile, limit: Number(opt("limit", 200)) || 200, paceMs: Number(opt("pace", 300)) });
if (json) out(res);
else {
console.log(`Flush outbox verso ${cfg.url}`);
console.log(` sincronizzati: ${res.synced} duplicati: ${res.duplicates} falliti: ${res.failed} ancora in coda: ${res.remaining}`);
if (res.stopped) console.log(` fermato: ${res.stopped}`);
if (res.errors.length) console.log(` errori: ${res.errors.join(" | ")}`);
}
} else if (cmd === "pull") {
const cfg = loadConfig();
const res = await pullFromGatewayExport(cfg, { dbFile, limit: Number(opt("limit", 500)) || 500 });
@@ -129,7 +185,7 @@ if (cmd === "status") {
console.log(` supportato: ${res.supported} pagine: ${res.pages} record: ${res.fetched}${res.message ? `${res.message}` : ""}`);
}
} else {
console.error(`Comando sconosciuto: ${cmd}\nComandi: status | import | find | enrich | pull`);
console.error(`Comando sconosciuto: ${cmd}\nComandi: status | import | find | store | queue | flush | enrich | pull`);
process.exit(2);
}
+67 -4
View File
@@ -10,6 +10,7 @@
* 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";
@@ -84,8 +85,15 @@ 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.on("close", () => resolve(out));
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;
@@ -148,11 +156,30 @@ check("risultato locale etichettato come testuale", /INDICE LOCALE/i.test(search
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: "prova", project_id: "test-project" }, undefined, undefined, ctx);
check("qmem_store avvisa che il record NON è salvato", /NON è stato salvato/i.test(storeRes.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(404, { "content-type": "application/json" }).end(JSON.stringify({ detail: "non trovato" }));
return;
@@ -184,8 +211,44 @@ const pullOut = await runCliAsync(["pull", "--json"]);
const pull = { stdout: pullOut };
const pullJson = JSON.parse(pull.stdout || "{}");
check("pull segnala endpoint export assente", pullJson.supported === false, pullJson.message ?? "");
server.close();
// ---------------------------------------------------------------- 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)}`);
// ---------------------------------------------------------------- report
const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} controlli superati — HOME di test: ${HOME}`);