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:
+59
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user