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);
|
||||
}
|
||||
|
||||
|
||||
+67
-4
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user