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);
}