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:
+39
-32
@@ -1,6 +1,7 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { gatewayRequest, loadConfig } from "../shared";
|
||||
import { localDbPath, submitOrQueue } from "../local-db.ts";
|
||||
|
||||
export function registerQmemStore(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
@@ -81,45 +82,51 @@ export function registerQmemStore(pi: ExtensionAPI) {
|
||||
signal,
|
||||
);
|
||||
if (dupCheck.ok) dupes = dupCheck.data.results ?? [];
|
||||
// Idempotency: stessa key per tutta l'operazione (e per eventuali retry)
|
||||
const idemKey = crypto.randomUUID();
|
||||
const { ok, status, data } = await gatewayRequest(
|
||||
cfg,
|
||||
"POST",
|
||||
"/v1/memories",
|
||||
{
|
||||
text: p.text,
|
||||
kind: p.kind ?? "fact",
|
||||
agent_id: p.agent_id,
|
||||
project_id: p.project_id,
|
||||
scope: p.scope ?? "agent",
|
||||
source: p.source,
|
||||
confidence: p.confidence ?? "medium",
|
||||
expires_at: p.expires_at,
|
||||
supersedes_id: p.supersedes_id,
|
||||
supersede_reason: p.supersede_reason,
|
||||
parent_id: p.parent_id,
|
||||
private: p.private ?? false,
|
||||
level: p.level,
|
||||
topic: p.topic,
|
||||
links: p.links,
|
||||
},
|
||||
signal,
|
||||
idemKey,
|
||||
);
|
||||
if (!ok) {
|
||||
const down = status === 0 || status >= 500 || status === 429;
|
||||
// Idempotency: la chiave è generata da submitOrQueue (Idempotency-Key)
|
||||
const payload = {
|
||||
text: p.text,
|
||||
kind: p.kind ?? "fact",
|
||||
agent_id: p.agent_id,
|
||||
project_id: p.project_id,
|
||||
scope: p.scope ?? "agent",
|
||||
source: p.source,
|
||||
confidence: p.confidence ?? "medium",
|
||||
expires_at: p.expires_at,
|
||||
supersedes_id: p.supersedes_id,
|
||||
supersede_reason: p.supersede_reason,
|
||||
parent_id: p.parent_id,
|
||||
private: p.private ?? false,
|
||||
level: p.level,
|
||||
topic: p.topic,
|
||||
links: p.links,
|
||||
};
|
||||
// Online → gateway (con Idempotency-Key); offline → coda locale (outbox)
|
||||
const submitted = await submitOrQueue(cfg, payload, { dbFile: localDbPath(cfg) });
|
||||
if (submitted.queued) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Errore ${status}: ${JSON.stringify(data)}` +
|
||||
(down
|
||||
? "\n⚠️ Il record NON è stato salvato: nessuna coda locale (il gateway è la fonte di verità). Riprova quando è raggiungibile, oppure annota il contenuto e usa /qmem:local import per l'indice testuale."
|
||||
: ""),
|
||||
`⚠️ Gateway non raggiungibile (HTTP ${submitted.status}): record ACCODATO in locale (outbox).\n` +
|
||||
`id locale: ${submitted.local_id}\n` +
|
||||
`in coda: ${submitted.queue_size} record\n` +
|
||||
`Il contenuto è già ricercabile offline (indice locale, marcato ⏳) e verrà caricato automaticamente al ritorno della connessione ` +
|
||||
`(/qmem:local flush per forzare, /qmem:local queue per lo stato).`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
queued: true,
|
||||
local_id: submitted.local_id,
|
||||
queue_size: submitted.queue_size,
|
||||
gateway_status: submitted.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { ok, status, data } = submitted;
|
||||
if (!ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Errore ${status}: ${JSON.stringify(data)}` }],
|
||||
details: { error: "gateway_error", status },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user