superlocalmemory 3.8.13 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- package/src/superlocalmemory/storage/access_control.py +0 -182
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Stable per-node identity for the SLM mesh distributed protocol (3c-0).
|
|
6
|
+
|
|
7
|
+
Each daemon owns a durable ``node_id`` (uuid4 hex) persisted once in the mesh
|
|
8
|
+
SQLite DB (single-row ``mesh_node_identity`` table). It is the deterministic
|
|
9
|
+
tie-breaker for the distributed protocol layer:
|
|
10
|
+
|
|
11
|
+
* **LWW state convergence** — when two nodes hold the same key at the same
|
|
12
|
+
``revision``, the winner is the one with the higher ``node_id`` (a total
|
|
13
|
+
order → every node converges on the same value).
|
|
14
|
+
* **Distributed lock ordering** — the effective lock holder is the one with the
|
|
15
|
+
higher ``(fencing_token, node_id)``; the fence guarantees single-writer
|
|
16
|
+
safety downstream even during a brief split.
|
|
17
|
+
|
|
18
|
+
Design:
|
|
19
|
+
* Persisted → stable across restarts (a restart must NOT change the tie-break).
|
|
20
|
+
* ``INSERT OR IGNORE`` + re-read → two processes racing first-creation converge
|
|
21
|
+
on ONE id.
|
|
22
|
+
* **Fail-soft**: any DB error returns a process-stable fallback
|
|
23
|
+
(``hostname-pid``) so callers never crash; the mesh degrades to node-local
|
|
24
|
+
behavior rather than breaking.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import socket
|
|
32
|
+
import sqlite3
|
|
33
|
+
import time
|
|
34
|
+
import uuid
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("superlocalmemory.mesh.node_identity")
|
|
37
|
+
|
|
38
|
+
_CREATE_SQL = (
|
|
39
|
+
"CREATE TABLE IF NOT EXISTS mesh_node_identity ("
|
|
40
|
+
" id INTEGER PRIMARY KEY CHECK (id = 1),"
|
|
41
|
+
" node_id TEXT NOT NULL,"
|
|
42
|
+
" created_at REAL NOT NULL)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Process-stable fallback cache (only used when the DB is unavailable).
|
|
46
|
+
_fallback_cache: dict[str, str] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fallback(db_path: str) -> str:
|
|
50
|
+
"""Return a process-stable, non-persisted id for the fail-soft path."""
|
|
51
|
+
value = _fallback_cache.get(db_path)
|
|
52
|
+
if value is None:
|
|
53
|
+
value = f"{socket.gethostname()}-{os.getpid()}"
|
|
54
|
+
_fallback_cache[db_path] = value
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_node_id(db_path: str) -> str:
|
|
59
|
+
"""Return this node's stable id, creating it once if absent.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
db_path: Path to the mesh SQLite DB (same file the broker uses).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A stable hex node id (persisted), or a process-stable ``hostname-pid``
|
|
66
|
+
fallback if the DB cannot be read/written.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
conn = sqlite3.connect(db_path, timeout=5.0)
|
|
70
|
+
try:
|
|
71
|
+
conn.execute(_CREATE_SQL)
|
|
72
|
+
row = conn.execute(
|
|
73
|
+
"SELECT node_id FROM mesh_node_identity WHERE id = 1"
|
|
74
|
+
).fetchone()
|
|
75
|
+
if row is not None:
|
|
76
|
+
return row[0]
|
|
77
|
+
new_id = uuid.uuid4().hex
|
|
78
|
+
# INSERT OR IGNORE lets a concurrent first-creator win; we then
|
|
79
|
+
# re-read so every racer returns the SAME persisted id.
|
|
80
|
+
conn.execute(
|
|
81
|
+
"INSERT OR IGNORE INTO mesh_node_identity (id, node_id, created_at)"
|
|
82
|
+
" VALUES (1, ?, ?)",
|
|
83
|
+
(new_id, time.time()),
|
|
84
|
+
)
|
|
85
|
+
conn.commit()
|
|
86
|
+
row = conn.execute(
|
|
87
|
+
"SELECT node_id FROM mesh_node_identity WHERE id = 1"
|
|
88
|
+
).fetchone()
|
|
89
|
+
return row[0] if row is not None else new_id
|
|
90
|
+
finally:
|
|
91
|
+
conn.close()
|
|
92
|
+
except sqlite3.Error as exc:
|
|
93
|
+
logger.error(
|
|
94
|
+
"get_node_id: DB error at %s — using process fallback: %s",
|
|
95
|
+
db_path, exc,
|
|
96
|
+
)
|
|
97
|
+
return _fallback(db_path)
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Durable remote outbox for SLM mesh (3b-1).
|
|
6
|
+
|
|
7
|
+
Persists unsent remote mesh messages in the shared SQLite DB so they
|
|
8
|
+
survive peer downtime and process restarts. A background drain in
|
|
9
|
+
RemoteSyncClient._sync_loop re-attempts delivery with exponential
|
|
10
|
+
back-off + jitter.
|
|
11
|
+
|
|
12
|
+
Design choices documented in class docstring.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import math
|
|
20
|
+
import random
|
|
21
|
+
import sqlite3
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("superlocalmemory.mesh.outbox_remote")
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Constants — aligned with broker.py values
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
#: 48h TTL matches broker MESSAGE_TTL_HOURS so remote dead-letter horizon
|
|
31
|
+
#: is consistent with the local (broker-side) message lifetime.
|
|
32
|
+
_TTL_SECONDS: int = 48 * 3600
|
|
33
|
+
|
|
34
|
+
#: Per-peer cap matches broker MAX_QUEUED_PER_TARGET = 50.
|
|
35
|
+
_CAP_PER_PEER: int = 50
|
|
36
|
+
|
|
37
|
+
#: Maximum rows returned by due() per cycle. A wall-clock budget in the drain
|
|
38
|
+
#: loop (remote_sync._DRAIN_BUDGET_SECONDS) is the primary guard against a slow
|
|
39
|
+
#: peer monopolizing the sync thread; this is a secondary belt.
|
|
40
|
+
_BATCH_LIMIT: int = 10
|
|
41
|
+
|
|
42
|
+
#: Header keys never persisted to disk (audit P0). Callers pass headers=None,
|
|
43
|
+
#: but enqueue() also scrubs these defensively so a future caller cannot leak
|
|
44
|
+
#: a bearer token / HMAC signature into the outbox table. Matched lowercase.
|
|
45
|
+
_SENSITIVE_HEADER_KEYS: frozenset[str] = frozenset({
|
|
46
|
+
"authorization",
|
|
47
|
+
"x-mesh-sig",
|
|
48
|
+
"x-mesh-nonce",
|
|
49
|
+
"x-mesh-ts",
|
|
50
|
+
"cookie",
|
|
51
|
+
"x-api-key",
|
|
52
|
+
"x-install-token",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
#: Hard dead-letter threshold. After this many attempts the row is deleted.
|
|
56
|
+
_MAX_RETRIES: int = 12
|
|
57
|
+
|
|
58
|
+
#: Backoff formula: min(BASE * 2^retry, CAP) * jitter_factor.
|
|
59
|
+
_BACKOFF_BASE: float = 5.0
|
|
60
|
+
_BACKOFF_CAP: float = 300.0 # 5 minutes
|
|
61
|
+
|
|
62
|
+
#: ±25% jitter fraction applied to the raw backoff to prevent thundering-herd
|
|
63
|
+
#: when many stalled sends all become due simultaneously after a peer restart.
|
|
64
|
+
_JITTER_FRACTION: float = 0.25
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# DDL
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
_CREATE_TABLE_SQL = """
|
|
70
|
+
CREATE TABLE IF NOT EXISTS mesh_outbox_remote (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
peer_url TEXT NOT NULL,
|
|
73
|
+
to_peer TEXT NOT NULL,
|
|
74
|
+
payload TEXT NOT NULL,
|
|
75
|
+
headers TEXT,
|
|
76
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
next_retry_at REAL NOT NULL,
|
|
78
|
+
created_at REAL NOT NULL,
|
|
79
|
+
expires_at REAL NOT NULL
|
|
80
|
+
);
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
_CREATE_INDEX_SQL = """
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_remote_next_retry
|
|
85
|
+
ON mesh_outbox_remote (next_retry_at);
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Internal helpers
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _backoff(retry_count: int) -> float:
|
|
94
|
+
"""Exponential backoff with ±25% jitter.
|
|
95
|
+
|
|
96
|
+
Formula: min(BASE * 2^retry_count, CAP) × (1 + U[-0.25, +0.25])
|
|
97
|
+
Floor at 1.0s so a jitter-deflated value stays positive.
|
|
98
|
+
|
|
99
|
+
The jitter prevents the thundering-herd problem: when a peer restarts
|
|
100
|
+
after downtime, all enqueued rows that became due at roughly the same
|
|
101
|
+
time would otherwise pile onto the peer simultaneously.
|
|
102
|
+
"""
|
|
103
|
+
raw = min(_BACKOFF_BASE * math.pow(2, retry_count), _BACKOFF_CAP)
|
|
104
|
+
jitter = raw * _JITTER_FRACTION * (2.0 * random.random() - 1.0)
|
|
105
|
+
return max(1.0, raw + jitter)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Public class
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class RemoteOutbox:
|
|
114
|
+
"""Durable store for unsent remote mesh messages (3b-1).
|
|
115
|
+
|
|
116
|
+
Persists messages into the shared SQLite DB (same file as the mesh
|
|
117
|
+
broker) so sends that fail due to peer downtime survive restarts.
|
|
118
|
+
|
|
119
|
+
All public methods are **fail-soft**: SQLite errors are logged but
|
|
120
|
+
never propagated to callers so the online send path is never blocked
|
|
121
|
+
by outbox failures. The sole exception is ``__init__``: if the table
|
|
122
|
+
cannot be created ``_active`` is set to False and every subsequent
|
|
123
|
+
method becomes a no-op — callers check ``_active`` before use.
|
|
124
|
+
|
|
125
|
+
Design choices
|
|
126
|
+
--------------
|
|
127
|
+
*Drop oldest on cap*: when a peer_url accumulates 50 rows, the oldest
|
|
128
|
+
row is evicted before the new one is inserted. This keeps a
|
|
129
|
+
permanently-down peer from filling the DB and ensures newer (more
|
|
130
|
+
relevant) messages are preserved. Clients must expect at-least-once
|
|
131
|
+
delivery semantics regardless.
|
|
132
|
+
|
|
133
|
+
*No header replay*: headers stored here are informational/audit-only.
|
|
134
|
+
The drain loop in RemoteSyncClient re-signs each message fresh with a
|
|
135
|
+
new nonce + timestamp to avoid stale-timestamp rejections at the peer.
|
|
136
|
+
|
|
137
|
+
*Bounded drain*: due() returns at most _BATCH_LIMIT rows so each
|
|
138
|
+
sync-loop cycle has a predictable worst-case duration.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, db_path: str) -> None:
|
|
142
|
+
self._db_path = db_path
|
|
143
|
+
self._active: bool = False
|
|
144
|
+
self._init_table()
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Internal helpers
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _connect(self) -> sqlite3.Connection:
|
|
151
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False, timeout=5.0)
|
|
152
|
+
conn.row_factory = sqlite3.Row
|
|
153
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
154
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
155
|
+
return conn
|
|
156
|
+
|
|
157
|
+
def _init_table(self) -> None:
|
|
158
|
+
"""Create the outbox table and index idempotently.
|
|
159
|
+
|
|
160
|
+
Uses CREATE TABLE/INDEX IF NOT EXISTS so calling this on an existing
|
|
161
|
+
DB with the table already present is safe (backward-compatible).
|
|
162
|
+
Sets _active=False on any error so every subsequent method no-ops.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
conn = self._connect()
|
|
166
|
+
try:
|
|
167
|
+
conn.execute(_CREATE_TABLE_SQL)
|
|
168
|
+
conn.execute(_CREATE_INDEX_SQL)
|
|
169
|
+
conn.commit()
|
|
170
|
+
finally:
|
|
171
|
+
conn.close()
|
|
172
|
+
self._active = True
|
|
173
|
+
logger.debug("RemoteOutbox: table ready at %s", self._db_path)
|
|
174
|
+
except sqlite3.Error as exc:
|
|
175
|
+
logger.error(
|
|
176
|
+
"RemoteOutbox: failed to initialise table at %s — outbox "
|
|
177
|
+
"disabled (online send path unaffected): %s",
|
|
178
|
+
self._db_path,
|
|
179
|
+
exc,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Public API
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def enqueue(
|
|
187
|
+
self,
|
|
188
|
+
peer_url: str,
|
|
189
|
+
to_peer: str,
|
|
190
|
+
payload: dict[str, Any],
|
|
191
|
+
headers: dict[str, str] | None,
|
|
192
|
+
now: float,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Persist a message for later delivery.
|
|
195
|
+
|
|
196
|
+
*Cap enforcement*: when peer_url already has _CAP_PER_PEER (50) rows,
|
|
197
|
+
the oldest is evicted (FIFO) before insertion so a permanently-down
|
|
198
|
+
peer never exhausts the DB.
|
|
199
|
+
|
|
200
|
+
*TTL*: 48h from ``now`` — consistent with broker MESSAGE_TTL_HOURS.
|
|
201
|
+
|
|
202
|
+
*next_retry_at = now* so the drain loop attempts delivery on the
|
|
203
|
+
very next sync cycle (~30s after enqueueing).
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
peer_url: Remote peer base URL (cap scope key).
|
|
207
|
+
to_peer: Target peer ID on remote machine.
|
|
208
|
+
payload: Message dict — serialised as JSON.
|
|
209
|
+
headers: HTTP headers at time of original send — serialised as
|
|
210
|
+
JSON for audit purposes. The drain re-signs fresh headers
|
|
211
|
+
rather than replaying these to avoid stale-timestamp errors.
|
|
212
|
+
now: Epoch seconds (caller-supplied for testability).
|
|
213
|
+
"""
|
|
214
|
+
if not self._active:
|
|
215
|
+
return
|
|
216
|
+
try:
|
|
217
|
+
payload_str = json.dumps(payload)
|
|
218
|
+
# Defense-in-depth (audit P0): never persist auth material. Callers
|
|
219
|
+
# pass headers=None; scrub sensitive keys here too so a future
|
|
220
|
+
# caller cannot leak a bearer token / signature into the DB.
|
|
221
|
+
headers_str = None
|
|
222
|
+
if headers is not None:
|
|
223
|
+
safe_headers = {
|
|
224
|
+
k: v for k, v in headers.items()
|
|
225
|
+
if k.lower() not in _SENSITIVE_HEADER_KEYS
|
|
226
|
+
}
|
|
227
|
+
headers_str = json.dumps(safe_headers) if safe_headers else None
|
|
228
|
+
expires_at = now + _TTL_SECONDS
|
|
229
|
+
|
|
230
|
+
conn = self._connect()
|
|
231
|
+
try:
|
|
232
|
+
count_row = conn.execute(
|
|
233
|
+
"SELECT COUNT(*) FROM mesh_outbox_remote WHERE peer_url=?",
|
|
234
|
+
(peer_url,),
|
|
235
|
+
).fetchone()
|
|
236
|
+
count = count_row[0] if count_row else 0
|
|
237
|
+
|
|
238
|
+
if count >= _CAP_PER_PEER:
|
|
239
|
+
# Evict the single oldest row to make room.
|
|
240
|
+
conn.execute(
|
|
241
|
+
"""
|
|
242
|
+
DELETE FROM mesh_outbox_remote
|
|
243
|
+
WHERE id = (
|
|
244
|
+
SELECT id FROM mesh_outbox_remote
|
|
245
|
+
WHERE peer_url = ?
|
|
246
|
+
ORDER BY created_at ASC
|
|
247
|
+
LIMIT 1
|
|
248
|
+
)
|
|
249
|
+
""",
|
|
250
|
+
(peer_url,),
|
|
251
|
+
)
|
|
252
|
+
logger.debug(
|
|
253
|
+
"RemoteOutbox: per-peer cap reached for %s — evicted oldest row",
|
|
254
|
+
peer_url,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
conn.execute(
|
|
258
|
+
"""
|
|
259
|
+
INSERT INTO mesh_outbox_remote
|
|
260
|
+
(peer_url, to_peer, payload, headers,
|
|
261
|
+
retry_count, next_retry_at, created_at, expires_at)
|
|
262
|
+
VALUES (?, ?, ?, ?, 0, ?, ?, ?)
|
|
263
|
+
""",
|
|
264
|
+
(peer_url, to_peer, payload_str, headers_str, now, now, expires_at),
|
|
265
|
+
)
|
|
266
|
+
conn.commit()
|
|
267
|
+
logger.debug(
|
|
268
|
+
"RemoteOutbox: enqueued message for %s → %s",
|
|
269
|
+
peer_url,
|
|
270
|
+
to_peer,
|
|
271
|
+
)
|
|
272
|
+
finally:
|
|
273
|
+
conn.close()
|
|
274
|
+
except (sqlite3.Error, TypeError, ValueError) as exc:
|
|
275
|
+
# TypeError/ValueError guard non-JSON-serializable payloads (audit
|
|
276
|
+
# P2) so enqueue never propagates into the online send path.
|
|
277
|
+
logger.error("RemoteOutbox.enqueue: enqueue failed: %s", exc)
|
|
278
|
+
|
|
279
|
+
def due(self, now: float) -> list[sqlite3.Row]:
|
|
280
|
+
"""Return up to _BATCH_LIMIT rows ready for re-delivery (oldest first).
|
|
281
|
+
|
|
282
|
+
A row is due when ``next_retry_at <= now`` and ``expires_at > now``.
|
|
283
|
+
Bounded by _BATCH_LIMIT to cap sync-thread blocking time.
|
|
284
|
+
"""
|
|
285
|
+
if not self._active:
|
|
286
|
+
return []
|
|
287
|
+
try:
|
|
288
|
+
conn = self._connect()
|
|
289
|
+
try:
|
|
290
|
+
rows = conn.execute(
|
|
291
|
+
"""
|
|
292
|
+
SELECT id, peer_url, to_peer, payload, headers,
|
|
293
|
+
retry_count, next_retry_at, created_at, expires_at
|
|
294
|
+
FROM mesh_outbox_remote
|
|
295
|
+
WHERE next_retry_at <= ? AND expires_at > ?
|
|
296
|
+
ORDER BY next_retry_at ASC
|
|
297
|
+
LIMIT ?
|
|
298
|
+
""",
|
|
299
|
+
(now, now, _BATCH_LIMIT),
|
|
300
|
+
).fetchall()
|
|
301
|
+
return list(rows)
|
|
302
|
+
finally:
|
|
303
|
+
conn.close()
|
|
304
|
+
except sqlite3.Error as exc:
|
|
305
|
+
logger.error("RemoteOutbox.due: DB error: %s", exc)
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
def mark_retry(self, row_id: int, now: float) -> None:
|
|
309
|
+
"""Increment retry_count and schedule the next attempt with backoff.
|
|
310
|
+
|
|
311
|
+
Deletes the row (dead-letters it) if:
|
|
312
|
+
- ``retry_count + 1 > _MAX_RETRIES`` — too many failures, or
|
|
313
|
+
- ``now >= expires_at`` — TTL elapsed since enqueue.
|
|
314
|
+
|
|
315
|
+
Backoff uses ±25% jitter to prevent thundering-herd.
|
|
316
|
+
"""
|
|
317
|
+
if not self._active:
|
|
318
|
+
return
|
|
319
|
+
try:
|
|
320
|
+
conn = self._connect()
|
|
321
|
+
try:
|
|
322
|
+
row = conn.execute(
|
|
323
|
+
"SELECT retry_count, expires_at FROM mesh_outbox_remote WHERE id=?",
|
|
324
|
+
(row_id,),
|
|
325
|
+
).fetchone()
|
|
326
|
+
|
|
327
|
+
if row is None:
|
|
328
|
+
return # Already deleted by a concurrent drain cycle
|
|
329
|
+
|
|
330
|
+
new_count = row["retry_count"] + 1
|
|
331
|
+
expires_at = row["expires_at"]
|
|
332
|
+
|
|
333
|
+
if new_count > _MAX_RETRIES or now >= expires_at:
|
|
334
|
+
conn.execute(
|
|
335
|
+
"DELETE FROM mesh_outbox_remote WHERE id=?",
|
|
336
|
+
(row_id,),
|
|
337
|
+
)
|
|
338
|
+
conn.commit()
|
|
339
|
+
logger.debug(
|
|
340
|
+
"RemoteOutbox: row %d dead-lettered "
|
|
341
|
+
"(retries=%d, ttl_expired=%s)",
|
|
342
|
+
row_id,
|
|
343
|
+
new_count,
|
|
344
|
+
now >= expires_at,
|
|
345
|
+
)
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
next_retry = now + _backoff(new_count)
|
|
349
|
+
conn.execute(
|
|
350
|
+
"""
|
|
351
|
+
UPDATE mesh_outbox_remote
|
|
352
|
+
SET retry_count = ?, next_retry_at = ?
|
|
353
|
+
WHERE id = ?
|
|
354
|
+
""",
|
|
355
|
+
(new_count, next_retry, row_id),
|
|
356
|
+
)
|
|
357
|
+
conn.commit()
|
|
358
|
+
logger.debug(
|
|
359
|
+
"RemoteOutbox: row %d rescheduled (attempt=%d, next=+%.0fs)",
|
|
360
|
+
row_id,
|
|
361
|
+
new_count,
|
|
362
|
+
next_retry - now,
|
|
363
|
+
)
|
|
364
|
+
finally:
|
|
365
|
+
conn.close()
|
|
366
|
+
except sqlite3.Error as exc:
|
|
367
|
+
logger.error("RemoteOutbox.mark_retry(%d): DB error: %s", row_id, exc)
|
|
368
|
+
|
|
369
|
+
def delete(self, row_id: int) -> None:
|
|
370
|
+
"""Remove a successfully-delivered row."""
|
|
371
|
+
if not self._active:
|
|
372
|
+
return
|
|
373
|
+
try:
|
|
374
|
+
conn = self._connect()
|
|
375
|
+
try:
|
|
376
|
+
conn.execute(
|
|
377
|
+
"DELETE FROM mesh_outbox_remote WHERE id=?",
|
|
378
|
+
(row_id,),
|
|
379
|
+
)
|
|
380
|
+
conn.commit()
|
|
381
|
+
finally:
|
|
382
|
+
conn.close()
|
|
383
|
+
except sqlite3.Error as exc:
|
|
384
|
+
logger.error("RemoteOutbox.delete(%d): DB error: %s", row_id, exc)
|
|
385
|
+
|
|
386
|
+
def prune_expired(self, now: float) -> None:
|
|
387
|
+
"""Delete all rows whose TTL has elapsed."""
|
|
388
|
+
if not self._active:
|
|
389
|
+
return
|
|
390
|
+
try:
|
|
391
|
+
conn = self._connect()
|
|
392
|
+
try:
|
|
393
|
+
deleted = conn.execute(
|
|
394
|
+
"DELETE FROM mesh_outbox_remote WHERE expires_at <= ?",
|
|
395
|
+
(now,),
|
|
396
|
+
).rowcount
|
|
397
|
+
conn.commit()
|
|
398
|
+
if deleted:
|
|
399
|
+
logger.debug("RemoteOutbox: pruned %d expired rows", deleted)
|
|
400
|
+
finally:
|
|
401
|
+
conn.close()
|
|
402
|
+
except sqlite3.Error as exc:
|
|
403
|
+
logger.error("RemoteOutbox.prune_expired: DB error: %s", exc)
|
|
404
|
+
|
|
405
|
+
def row_count(self, peer_url: str | None = None) -> int:
|
|
406
|
+
"""Return total rows in the outbox, optionally scoped to a peer_url.
|
|
407
|
+
|
|
408
|
+
Utility method for testing and monitoring.
|
|
409
|
+
"""
|
|
410
|
+
if not self._active:
|
|
411
|
+
return 0
|
|
412
|
+
try:
|
|
413
|
+
conn = self._connect()
|
|
414
|
+
try:
|
|
415
|
+
if peer_url is not None:
|
|
416
|
+
row = conn.execute(
|
|
417
|
+
"SELECT COUNT(*) FROM mesh_outbox_remote WHERE peer_url=?",
|
|
418
|
+
(peer_url,),
|
|
419
|
+
).fetchone()
|
|
420
|
+
else:
|
|
421
|
+
row = conn.execute(
|
|
422
|
+
"SELECT COUNT(*) FROM mesh_outbox_remote",
|
|
423
|
+
).fetchone()
|
|
424
|
+
return row[0] if row else 0
|
|
425
|
+
finally:
|
|
426
|
+
conn.close()
|
|
427
|
+
except sqlite3.Error as exc:
|
|
428
|
+
logger.error("RemoteOutbox.row_count: DB error: %s", exc)
|
|
429
|
+
return 0
|