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
+33 -3
View File
@@ -10,11 +10,14 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
enrichFromGateway,
flushQueue,
importFromSessions,
localDbPath,
localDbReport,
localSearch,
pullFromGatewayExport,
queueList,
queueStats,
} from "./local-db.ts";
import { loadConfig } from "./shared.ts";
@@ -33,9 +36,10 @@ export function registerQmemLocal(pi: ExtensionAPI) {
return;
}
const dup = r.duplicates.length ? ` | duplicati: ${r.duplicates.length} gruppi` : "";
const coda = r.queued || r.failedQueue || r.syncedQueue ? ` | coda: ${r.queued} in attesa, ${r.syncedQueue} sincronizzati${r.failedQueue ? `, ${r.failedQueue} falliti` : ""}${r.duplicateQueue ? `, ${r.duplicateQueue} duplicati` : ""}` : "";
ctx.ui.notify(
`Indice locale: ${r.total} record (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati) | project_id ${r.withProject}/${r.total} | ${r.sizeKb} KB${dup}\n` +
`ultimo import: ${r.lastImport ?? "-"} | enrich: ${r.lastEnrich ?? "-"} | export: ${r.lastExport ?? "-"}\n` +
`Indice locale: ${r.total} record (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati, ${r.pendingInIndex} in coda) | project_id ${r.withProject}/${r.total} | ${r.sizeKb} KB${dup}${coda}\n` +
`ultimo import: ${r.lastImport ?? "-"} | enrich: ${r.lastEnrich ?? "-"} | export: ${r.lastExport ?? "-"} | flush: ${r.lastFlush ?? "-"}\n` +
`DB: ${r.path}`,
"info",
);
@@ -82,7 +86,7 @@ export function registerQmemLocal(pi: ExtensionAPI) {
}
const lines = hits.map(
(h, i) =>
`${i + 1}. [${h.kind ?? "?"}/${h.scope ?? "?"}${h.project_id ? ` project=${h.project_id}` : ""}${h.match_mode === "or" ? " OR" : ""}${h.superseded_by ? " superseduto" : ""}] ${h.snippet}\n (id: ${h.memory_id}, creato: ${h.created_at ?? "?"})`,
`${i + 1}. [${h.kind ?? "?"}/${h.scope ?? "?"}${h.project_id ? ` project=${h.project_id}` : ""}${h.match_mode === "or" ? " OR" : ""}${h.pending ? " ⏳ in coda" : ""}${h.superseded_by ? " superseduto" : ""}] ${h.snippet}\n (id: ${h.memory_id}, creato: ${h.created_at ?? "?"})`,
);
ctx.ui.notify(`Indice locale (ricerca testuale, non neurale) — ${hits.length} risultati:\n${lines.join("\n")}`, "info");
return;
@@ -103,6 +107,32 @@ export function registerQmemLocal(pi: ExtensionAPI) {
);
return;
}
if (sub === "queue") {
const stats = await queueStats({ dbFile });
const items = await queueList({ dbFile, status: "queued", limit: 8 });
const lines = items.map(
(q, i) =>
`${i + 1}. [${q.payload.kind ?? "fact"}/${q.payload.project_id}] ${String(q.payload.text).slice(0, 70)}… (locale ${q.local_id.slice(0, 8)}${q.attempts ? `, tentativi ${q.attempts}` : ""}${q.last_error ? `, ultimo errore: ${q.last_error.slice(0, 60)}` : ""})`,
);
ctx.ui.notify(
`Coda offline: ${stats.queued} in attesa, ${stats.synced} sincronizzati, ${stats.duplicate} duplicati, ${stats.failed} falliti\n` +
`più vecchio: ${stats.oldestQueued ?? "-"}${stats.lastError ? ` | ultimo errore: ${stats.lastError.slice(0, 80)}` : ""}` +
(lines.length ? `\n${lines.join("\n")}` : "") +
`\nFlush: /qmem:local flush`,
stats.queued ? "info" : "warning",
);
return;
}
if (sub === "flush") {
ctx.ui.setStatus("pi-qmem", "Invio della coda offline al gateway...");
const res = await flushQueue(cfg, { dbFile, limit: 200, paceMs: 300 });
ctx.ui.setStatus("pi-qmem", "");
const msg =
`Flush outbox: ${res.synced} sincronizzati, ${res.duplicates} duplicati già presenti, ${res.failed} falliti, ${res.remaining} ancora in coda` +
(res.stopped ? ` — fermato: ${res.stopped}` : "");
ctx.ui.notify(msg, res.synced || res.duplicates ? "info" : "warning");
return;
}
if (sub === "pull") {
ctx.ui.setStatus("pi-qmem", "Pull export dal gateway...");
const res = await pullFromGatewayExport(cfg, { dbFile });