superlocalmemory 3.7.8 → 3.8.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/.claude-plugin/marketplace.json +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +69 -0
- package/README.md +199 -29
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/agents/slm-governance-advisor.md +80 -0
- package/plugin-src/agents/slm-loop-runner.md +71 -0
- package/plugin-src/agents/slm-memory-advisor.md +10 -5
- package/plugin-src/agents/slm-optimize-advisor.md +9 -3
- package/plugin-src/commands/slm-loop.md +31 -0
- package/plugin-src/hooks/hooks.json +79 -0
- package/plugin-src/manifest.json +7 -2
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/rules/CLAUDE.md.fragment +8 -8
- package/plugin-src/scripts/slm-launch +46 -7
- package/plugin-src/settings.json +9 -0
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- 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 +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +1 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +94 -10
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/loop_cmd.py +192 -0
- package/src/superlocalmemory/cli/main.py +39 -4
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +17 -2
- package/src/superlocalmemory/core/embeddings.py +12 -1
- package/src/superlocalmemory/core/engine.py +17 -1
- package/src/superlocalmemory/core/engine_ingestion.py +29 -0
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +2 -0
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +35 -11
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +114 -1
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +243 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +21 -49
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +8 -1
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +168 -26
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +34 -10
- package/src/superlocalmemory/server/routes/behavioral.py +13 -12
- package/src/superlocalmemory/server/routes/brain.py +21 -5
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +436 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +9 -4
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +135 -17
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +14 -14
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +95 -15
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +13 -6
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/v3_api.py +183 -69
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +384 -56
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +53 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +87 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +306 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +47 -31
- package/src/superlocalmemory/ui/js/dashboard.js +314 -63
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +780 -0
- package/src/superlocalmemory/ui/js/od-brain.js +779 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +593 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +887 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
- package/src/superlocalmemory/ui/js/od-shell.js +593 -0
- package/src/superlocalmemory/ui/js/od-skills.js +573 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +2 -2
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -17,7 +17,7 @@ import sqlite3
|
|
|
17
17
|
import threading
|
|
18
18
|
import time
|
|
19
19
|
import uuid
|
|
20
|
-
from datetime import datetime, timezone
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Any, Callable, TypeVar
|
|
23
23
|
|
|
@@ -36,12 +36,17 @@ LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
|
36
36
|
|
|
37
37
|
MAX_MESSAGE_SIZE = 4096 # 4KB cap — mesh messages are notifications, not data dumps
|
|
38
38
|
MESSAGE_TTL_HOURS = 48 # Offline messages expire after 48h
|
|
39
|
+
LOCK_TTL_HOURS = 8 # M-02: file locks auto-expire so a crashed session can't deadlock a path
|
|
40
|
+
_NEVER_EXPIRES = "9999-12-31T23:59:59Z" # legacy sentinel default; treated as stale/free
|
|
39
41
|
MAX_QUEUED_PER_TARGET = 50 # Max unread messages per broadcast/project target
|
|
40
42
|
|
|
41
43
|
_T = TypeVar("_T")
|
|
42
|
-
|
|
44
|
+
# Retry budget for a transient writer collision. 250ms was too thin under
|
|
45
|
+
# multi-agent load; 2000ms matches the RBAC store. 4 attempts keeps the total
|
|
46
|
+
# worst-case budget bounded (~2s x backoff) rather than unbounded stalls.
|
|
47
|
+
_WRITE_RETRY_ATTEMPTS = 4
|
|
43
48
|
_WRITE_RETRY_BASE_SECONDS = 0.025
|
|
44
|
-
_WRITE_BUSY_TIMEOUT_MS =
|
|
49
|
+
_WRITE_BUSY_TIMEOUT_MS = 2000
|
|
45
50
|
|
|
46
51
|
|
|
47
52
|
class MeshBroker:
|
|
@@ -64,6 +69,7 @@ class MeshBroker:
|
|
|
64
69
|
self._ws_port = int(_os.environ.get("SLM_MESH_WS_PORT", "7900"))
|
|
65
70
|
self._discovery_enabled = self._is_remote and _os.environ.get("SLM_MESH_DISCOVERY", "on") != "off"
|
|
66
71
|
self._remote_peers: dict[str, dict] = {}
|
|
72
|
+
self._remote_peers_lock = threading.RLock()
|
|
67
73
|
self._peer_url: str | None = _os.environ.get("SLM_MESH_PEER_URL", "") or None
|
|
68
74
|
self._sync_client: Any = None
|
|
69
75
|
if self._is_remote and not self._shared_secret:
|
|
@@ -74,22 +80,33 @@ class MeshBroker:
|
|
|
74
80
|
|
|
75
81
|
# -- Remote / Multi-Machine support (v3.4.47) --
|
|
76
82
|
|
|
77
|
-
def get_remote_peers(self) -> list[dict]:
|
|
78
|
-
"""Return peers from discovered remote brokers.
|
|
79
|
-
|
|
83
|
+
def get_remote_peers(self, profile_id: str = "default") -> list[dict]:
|
|
84
|
+
"""Return peers from discovered remote brokers for this tenant.
|
|
85
|
+
|
|
86
|
+
Remote peers carry their home ``profile_id`` in the info dict (populated
|
|
87
|
+
by the /mesh/peers sync). A peer with no profile_id is a legacy remote
|
|
88
|
+
and treated as 'default'. Cross-tenant peers are never returned.
|
|
89
|
+
"""
|
|
90
|
+
with self._remote_peers_lock:
|
|
91
|
+
return [
|
|
92
|
+
info for info in self._remote_peers.values()
|
|
93
|
+
if info.get("profile_id", "default") == profile_id
|
|
94
|
+
]
|
|
80
95
|
|
|
81
96
|
def add_remote_peer(self, peer_id: str, info: dict) -> None:
|
|
82
97
|
"""Register a peer from a remote broker."""
|
|
83
|
-
self.
|
|
98
|
+
with self._remote_peers_lock:
|
|
99
|
+
self._remote_peers[peer_id] = info
|
|
84
100
|
|
|
85
101
|
def remove_remote_peer(self, peer_id: str) -> None:
|
|
86
102
|
"""Remove a remote peer."""
|
|
87
|
-
self.
|
|
103
|
+
with self._remote_peers_lock:
|
|
104
|
+
self._remote_peers.pop(peer_id, None)
|
|
88
105
|
|
|
89
|
-
def list_all_peers(self) -> list[dict]:
|
|
90
|
-
"""Return local + remote peers merged."""
|
|
91
|
-
local = self.list_peers()
|
|
92
|
-
remote = self.get_remote_peers()
|
|
106
|
+
def list_all_peers(self, profile_id: str = "default") -> list[dict]:
|
|
107
|
+
"""Return local + remote peers for this tenant, merged."""
|
|
108
|
+
local = self.list_peers(profile_id)
|
|
109
|
+
remote = self.get_remote_peers(profile_id)
|
|
93
110
|
return local + remote
|
|
94
111
|
|
|
95
112
|
def start_cleanup(self) -> None:
|
|
@@ -168,59 +185,72 @@ class MeshBroker:
|
|
|
168
185
|
|
|
169
186
|
def register_peer(self, session_id: str, summary: str = "",
|
|
170
187
|
host: str = "", port: int = 0,
|
|
171
|
-
project_path: str = "", agent_type: str = "unknown"
|
|
188
|
+
project_path: str = "", agent_type: str = "unknown",
|
|
189
|
+
profile_id: str = "default") -> dict:
|
|
172
190
|
def _register(conn: sqlite3.Connection) -> dict:
|
|
173
191
|
now = datetime.now(timezone.utc).isoformat()
|
|
174
192
|
effective_host = host or self._host
|
|
175
|
-
# Idempotent: update if same session_id exists
|
|
193
|
+
# Idempotent within the tenant: update if same session_id exists
|
|
194
|
+
# under this profile. A session_id in another tenant is a different
|
|
195
|
+
# peer and must not be adopted here.
|
|
176
196
|
existing = conn.execute(
|
|
177
|
-
"SELECT peer_id FROM mesh_peers WHERE session_id = ?",
|
|
178
|
-
(session_id,),
|
|
197
|
+
"SELECT peer_id FROM mesh_peers WHERE session_id = ? AND profile_id = ?",
|
|
198
|
+
(session_id, profile_id),
|
|
179
199
|
).fetchone()
|
|
180
200
|
if existing:
|
|
181
201
|
peer_id = existing["peer_id"]
|
|
182
202
|
conn.execute(
|
|
183
203
|
"UPDATE mesh_peers SET summary=?, host=?, port=?, last_heartbeat=?, "
|
|
184
|
-
"status='active', project_path=?, agent_type=?
|
|
185
|
-
|
|
204
|
+
"status='active', project_path=?, agent_type=? "
|
|
205
|
+
"WHERE peer_id=? AND profile_id=?",
|
|
206
|
+
(summary, effective_host, port, now, project_path, agent_type,
|
|
207
|
+
peer_id, profile_id),
|
|
186
208
|
)
|
|
187
209
|
else:
|
|
188
210
|
peer_id = str(uuid.uuid4())[:12]
|
|
189
211
|
conn.execute(
|
|
190
212
|
"INSERT INTO mesh_peers (peer_id, session_id, summary, status, host, port, "
|
|
191
|
-
"registered_at, last_heartbeat, project_path, agent_type) "
|
|
192
|
-
"VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)",
|
|
193
|
-
(peer_id, session_id, summary, effective_host, port, now, now,
|
|
213
|
+
"registered_at, last_heartbeat, project_path, agent_type, profile_id) "
|
|
214
|
+
"VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)",
|
|
215
|
+
(peer_id, session_id, summary, effective_host, port, now, now,
|
|
216
|
+
project_path, agent_type, profile_id),
|
|
194
217
|
)
|
|
195
218
|
self._log_event(conn, "peer_registered", peer_id, {
|
|
196
219
|
"session_id": session_id, "project_path": project_path,
|
|
197
|
-
})
|
|
220
|
+
}, profile_id=profile_id)
|
|
198
221
|
conn.commit()
|
|
199
222
|
|
|
200
223
|
# v3.4.6: Deliver pending broadcast/project messages on registration
|
|
201
|
-
pending = self._get_pending_for_peer(conn, peer_id, project_path)
|
|
224
|
+
pending = self._get_pending_for_peer(conn, peer_id, project_path, profile_id)
|
|
202
225
|
return {"peer_id": peer_id, "ok": True, "pending_messages": len(pending)}
|
|
203
226
|
|
|
204
227
|
return self._write_with_retry(_register)
|
|
205
228
|
|
|
206
|
-
def deregister_peer(self, peer_id: str) -> dict:
|
|
229
|
+
def deregister_peer(self, peer_id: str, profile_id: str = "default") -> dict:
|
|
207
230
|
def _deregister(conn: sqlite3.Connection) -> dict:
|
|
208
|
-
row = conn.execute(
|
|
231
|
+
row = conn.execute(
|
|
232
|
+
"SELECT 1 FROM mesh_peers WHERE peer_id=? AND profile_id=?",
|
|
233
|
+
(peer_id, profile_id),
|
|
234
|
+
).fetchone()
|
|
209
235
|
if not row:
|
|
210
236
|
return {"ok": False, "error": "peer not found"}
|
|
211
|
-
conn.execute(
|
|
212
|
-
|
|
237
|
+
conn.execute(
|
|
238
|
+
"DELETE FROM mesh_peers WHERE peer_id=? AND profile_id=?",
|
|
239
|
+
(peer_id, profile_id),
|
|
240
|
+
)
|
|
241
|
+
self._log_event(conn, "peer_deregistered", peer_id, profile_id=profile_id)
|
|
213
242
|
conn.commit()
|
|
214
243
|
return {"ok": True}
|
|
215
244
|
|
|
216
245
|
return self._write_with_retry(_deregister)
|
|
217
246
|
|
|
218
|
-
def heartbeat(self, peer_id: str) -> dict:
|
|
247
|
+
def heartbeat(self, peer_id: str, profile_id: str = "default") -> dict:
|
|
219
248
|
def _heartbeat(conn: sqlite3.Connection) -> dict:
|
|
220
249
|
now = datetime.now(timezone.utc).isoformat()
|
|
221
250
|
cursor = conn.execute(
|
|
222
|
-
"UPDATE mesh_peers SET last_heartbeat=?, status='active'
|
|
223
|
-
|
|
251
|
+
"UPDATE mesh_peers SET last_heartbeat=?, status='active' "
|
|
252
|
+
"WHERE peer_id=? AND profile_id=?",
|
|
253
|
+
(now, peer_id, profile_id),
|
|
224
254
|
)
|
|
225
255
|
if cursor.rowcount == 0:
|
|
226
256
|
return {"ok": False, "error": "peer not found"}
|
|
@@ -229,11 +259,12 @@ class MeshBroker:
|
|
|
229
259
|
|
|
230
260
|
return self._write_with_retry(_heartbeat)
|
|
231
261
|
|
|
232
|
-
def update_summary(self, peer_id: str, summary: str
|
|
262
|
+
def update_summary(self, peer_id: str, summary: str,
|
|
263
|
+
profile_id: str = "default") -> dict:
|
|
233
264
|
def _update_summary(conn: sqlite3.Connection) -> dict:
|
|
234
265
|
cursor = conn.execute(
|
|
235
|
-
"UPDATE mesh_peers SET summary=? WHERE peer_id=?",
|
|
236
|
-
(summary, peer_id),
|
|
266
|
+
"UPDATE mesh_peers SET summary=? WHERE peer_id=? AND profile_id=?",
|
|
267
|
+
(summary, peer_id, profile_id),
|
|
237
268
|
)
|
|
238
269
|
if cursor.rowcount == 0:
|
|
239
270
|
return {"ok": False, "error": "peer not found"}
|
|
@@ -242,13 +273,14 @@ class MeshBroker:
|
|
|
242
273
|
|
|
243
274
|
return self._write_with_retry(_update_summary)
|
|
244
275
|
|
|
245
|
-
def list_peers(self) -> list[dict]:
|
|
276
|
+
def list_peers(self, profile_id: str = "default") -> list[dict]:
|
|
246
277
|
conn = self._conn()
|
|
247
278
|
try:
|
|
248
279
|
rows = conn.execute(
|
|
249
280
|
"SELECT peer_id, session_id, summary, status, host, port, "
|
|
250
|
-
"registered_at, last_heartbeat, project_path, agent_type "
|
|
251
|
-
"FROM mesh_peers ORDER BY last_heartbeat DESC",
|
|
281
|
+
"registered_at, last_heartbeat, project_path, agent_type, profile_id "
|
|
282
|
+
"FROM mesh_peers WHERE profile_id=? ORDER BY last_heartbeat DESC",
|
|
283
|
+
(profile_id,),
|
|
252
284
|
).fetchall()
|
|
253
285
|
return [dict(r) for r in rows]
|
|
254
286
|
finally:
|
|
@@ -257,7 +289,8 @@ class MeshBroker:
|
|
|
257
289
|
# -- Messages --
|
|
258
290
|
|
|
259
291
|
def send_message(self, from_peer: str, to_peer: str, content: str,
|
|
260
|
-
msg_type: str = "text", project_path: str = ""
|
|
292
|
+
msg_type: str = "text", project_path: str = "",
|
|
293
|
+
profile_id: str = "default") -> dict:
|
|
261
294
|
# Guard: 4KB message size cap
|
|
262
295
|
if len(content) > MAX_MESSAGE_SIZE:
|
|
263
296
|
return {"ok": False, "error": f"message too large ({len(content)} bytes, max {MAX_MESSAGE_SIZE}). "
|
|
@@ -266,64 +299,91 @@ class MeshBroker:
|
|
|
266
299
|
# Remote delivery is an external side effect, so do it outside the
|
|
267
300
|
# retry envelope. Local writes below are retried as a whole short
|
|
268
301
|
# transaction when another daemon-owned operation has SQLite's writer.
|
|
269
|
-
|
|
302
|
+
# Hold the lock only for the membership check, not for the HTTP call.
|
|
303
|
+
# A remote peer only counts if it belongs to this tenant (profile).
|
|
304
|
+
with self._remote_peers_lock:
|
|
305
|
+
remote_info = self._remote_peers.get(to_peer)
|
|
306
|
+
is_remote = (
|
|
307
|
+
remote_info is not None
|
|
308
|
+
and remote_info.get("profile_id", "default") == profile_id
|
|
309
|
+
)
|
|
310
|
+
if is_remote and self._sync_client:
|
|
270
311
|
return self._sync_client.send_to_remote(to_peer, {
|
|
271
312
|
"from_peer": from_peer,
|
|
272
313
|
"to": to_peer,
|
|
273
314
|
"content": content,
|
|
274
315
|
"type": msg_type,
|
|
316
|
+
"profile_id": profile_id,
|
|
275
317
|
})
|
|
276
318
|
|
|
277
319
|
def _send(conn: sqlite3.Connection) -> dict:
|
|
278
|
-
nonlocal to_peer
|
|
320
|
+
# Derive locals fresh on EVERY call. A nonlocal mutation of to_peer
|
|
321
|
+
# persisted across _write_with_retry attempts: a 'project:' address
|
|
322
|
+
# rewritten to 'project' on the first try then misrouted to the
|
|
323
|
+
# direct-peer branch on retry ("recipient peer not found").
|
|
324
|
+
_to_peer = to_peer
|
|
325
|
+
_project_path = project_path
|
|
279
326
|
now = datetime.now(timezone.utc).isoformat()
|
|
280
327
|
expires_at = self._compute_expires(now)
|
|
281
328
|
|
|
282
329
|
# Determine target type
|
|
283
|
-
if
|
|
330
|
+
if _to_peer == "broadcast":
|
|
284
331
|
target_type = "broadcast"
|
|
285
|
-
elif
|
|
332
|
+
elif _to_peer.startswith("project:"):
|
|
286
333
|
target_type = "project"
|
|
287
|
-
|
|
288
|
-
|
|
334
|
+
_project_path = _to_peer[len("project:"):]
|
|
335
|
+
_to_peer = "project"
|
|
289
336
|
else:
|
|
290
337
|
target_type = "peer"
|
|
291
|
-
# Verify recipient exists for direct messages
|
|
292
|
-
|
|
338
|
+
# Verify recipient exists WITHIN this tenant for direct messages.
|
|
339
|
+
# A peer_id in another profile is not a valid recipient here.
|
|
340
|
+
if not conn.execute(
|
|
341
|
+
"SELECT 1 FROM mesh_peers WHERE peer_id=? AND profile_id=?",
|
|
342
|
+
(_to_peer, profile_id),
|
|
343
|
+
).fetchone():
|
|
293
344
|
return {"ok": False, "error": "recipient peer not found"}
|
|
294
345
|
|
|
295
|
-
# Enforce per-target queue cap
|
|
346
|
+
# Enforce per-target queue cap (per tenant)
|
|
296
347
|
if target_type in ("broadcast", "project"):
|
|
297
348
|
count = conn.execute(
|
|
298
|
-
"SELECT COUNT(*) FROM mesh_messages
|
|
299
|
-
|
|
349
|
+
"SELECT COUNT(*) FROM mesh_messages "
|
|
350
|
+
"WHERE profile_id=? AND target_type=? AND project_path=? AND read=0",
|
|
351
|
+
(profile_id, target_type, _project_path),
|
|
300
352
|
).fetchone()[0]
|
|
301
353
|
if count >= MAX_QUEUED_PER_TARGET:
|
|
302
354
|
# Delete oldest to make room
|
|
303
355
|
conn.execute(
|
|
304
356
|
"DELETE FROM mesh_messages WHERE id IN ("
|
|
305
|
-
" SELECT id FROM mesh_messages
|
|
357
|
+
" SELECT id FROM mesh_messages "
|
|
358
|
+
" WHERE profile_id=? AND target_type=? AND project_path=? AND read=0 "
|
|
306
359
|
" ORDER BY created_at ASC LIMIT ?)",
|
|
307
|
-
(
|
|
360
|
+
(profile_id, target_type, _project_path,
|
|
361
|
+
count - MAX_QUEUED_PER_TARGET + 1),
|
|
308
362
|
)
|
|
309
363
|
|
|
310
364
|
cursor = conn.execute(
|
|
311
365
|
"INSERT INTO mesh_messages (from_peer, to_peer, msg_type, content, read, "
|
|
312
|
-
"created_at, expires_at, target_type, project_path) "
|
|
313
|
-
"VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)",
|
|
314
|
-
(from_peer,
|
|
366
|
+
"created_at, expires_at, target_type, project_path, profile_id) "
|
|
367
|
+
"VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)",
|
|
368
|
+
(from_peer, _to_peer, msg_type, content, now, expires_at,
|
|
369
|
+
target_type, _project_path, profile_id),
|
|
315
370
|
)
|
|
316
371
|
self._log_event(conn, "message_sent", from_peer, {
|
|
317
|
-
"to":
|
|
318
|
-
})
|
|
372
|
+
"to": _to_peer, "target_type": target_type, "project": _project_path,
|
|
373
|
+
}, profile_id=profile_id)
|
|
319
374
|
conn.commit()
|
|
320
375
|
return {"ok": True, "id": cursor.lastrowid, "target_type": target_type,
|
|
321
376
|
"expires_at": expires_at}
|
|
322
377
|
|
|
323
378
|
return self._write_with_retry(_send)
|
|
324
379
|
|
|
325
|
-
def get_inbox(self, peer_id: str, project_path: str = ""
|
|
326
|
-
|
|
380
|
+
def get_inbox(self, peer_id: str, project_path: str = "",
|
|
381
|
+
profile_id: str = "default") -> list[dict]:
|
|
382
|
+
"""Get all messages for this peer: direct + broadcast + project.
|
|
383
|
+
|
|
384
|
+
Scoped to the peer's tenant (profile_id): a peer never sees another
|
|
385
|
+
tenant's direct, broadcast, or project traffic.
|
|
386
|
+
"""
|
|
327
387
|
conn = self._conn()
|
|
328
388
|
try:
|
|
329
389
|
now = datetime.now(timezone.utc).isoformat()
|
|
@@ -334,10 +394,11 @@ class MeshBroker:
|
|
|
334
394
|
direct = conn.execute(
|
|
335
395
|
"SELECT id, from_peer, to_peer, msg_type, content, read, created_at, "
|
|
336
396
|
"target_type, project_path FROM mesh_messages "
|
|
337
|
-
"WHERE to_peer=? AND target_type='peer'
|
|
397
|
+
"WHERE profile_id=? AND to_peer=? AND target_type='peer' "
|
|
398
|
+
"AND COALESCE(read, 0) = 0 "
|
|
338
399
|
"AND (expires_at IS NULL OR expires_at > ?) "
|
|
339
400
|
"ORDER BY created_at DESC LIMIT 100",
|
|
340
|
-
(peer_id, now),
|
|
401
|
+
(profile_id, peer_id, now),
|
|
341
402
|
).fetchall()
|
|
342
403
|
|
|
343
404
|
# Broadcast messages not from this peer and not yet read by this peer
|
|
@@ -347,11 +408,11 @@ class MeshBroker:
|
|
|
347
408
|
"m.created_at, m.target_type, m.project_path "
|
|
348
409
|
"FROM mesh_messages m "
|
|
349
410
|
"LEFT JOIN mesh_reads r ON m.id = r.message_id AND r.peer_id = ? "
|
|
350
|
-
"WHERE m.target_type='broadcast' AND m.from_peer != ? "
|
|
411
|
+
"WHERE m.profile_id=? AND m.target_type='broadcast' AND m.from_peer != ? "
|
|
351
412
|
"AND r.peer_id IS NULL "
|
|
352
413
|
"AND (m.expires_at IS NULL OR m.expires_at > ?) "
|
|
353
414
|
"ORDER BY m.created_at DESC LIMIT 50",
|
|
354
|
-
(peer_id, peer_id, now),
|
|
415
|
+
(peer_id, profile_id, peer_id, now),
|
|
355
416
|
).fetchall()
|
|
356
417
|
|
|
357
418
|
# Project messages for my project, not from me, not yet read
|
|
@@ -363,11 +424,12 @@ class MeshBroker:
|
|
|
363
424
|
"m.created_at, m.target_type, m.project_path "
|
|
364
425
|
"FROM mesh_messages m "
|
|
365
426
|
"LEFT JOIN mesh_reads r ON m.id = r.message_id AND r.peer_id = ? "
|
|
366
|
-
"WHERE m.
|
|
427
|
+
"WHERE m.profile_id=? AND m.target_type='project' "
|
|
428
|
+
"AND m.project_path=? AND m.from_peer != ? "
|
|
367
429
|
"AND r.peer_id IS NULL "
|
|
368
430
|
"AND (m.expires_at IS NULL OR m.expires_at > ?) "
|
|
369
431
|
"ORDER BY m.created_at DESC LIMIT 50",
|
|
370
|
-
(peer_id, project_path, peer_id, now),
|
|
432
|
+
(peer_id, profile_id, project_path, peer_id, now),
|
|
371
433
|
).fetchall()
|
|
372
434
|
|
|
373
435
|
all_msgs = [dict(r) for r in direct] + [dict(r) for r in broadcast] + [dict(r) for r in project_msgs]
|
|
@@ -377,29 +439,35 @@ class MeshBroker:
|
|
|
377
439
|
finally:
|
|
378
440
|
conn.close()
|
|
379
441
|
|
|
380
|
-
def mark_read(self, peer_id: str, message_ids: list[int]
|
|
442
|
+
def mark_read(self, peer_id: str, message_ids: list[int],
|
|
443
|
+
profile_id: str = "default") -> dict:
|
|
381
444
|
def _mark_read(conn: sqlite3.Connection) -> dict:
|
|
445
|
+
if not message_ids:
|
|
446
|
+
return {"ok": True, "marked": 0}
|
|
382
447
|
now = datetime.now(timezone.utc).isoformat()
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
)
|
|
448
|
+
ph = ",".join("?" * len(message_ids))
|
|
449
|
+
# One batched read of target types (tenant-scoped), then batched
|
|
450
|
+
# writes — was 2N round-trips per N messages.
|
|
451
|
+
rows = conn.execute(
|
|
452
|
+
f"SELECT id, target_type FROM mesh_messages "
|
|
453
|
+
f"WHERE id IN ({ph}) AND profile_id=?",
|
|
454
|
+
(*message_ids, profile_id),
|
|
455
|
+
).fetchall()
|
|
456
|
+
direct_ids = [r["id"] for r in rows if r["target_type"] == "peer"]
|
|
457
|
+
shared_ids = [r["id"] for r in rows if r["target_type"] != "peer"]
|
|
458
|
+
if direct_ids:
|
|
459
|
+
dph = ",".join("?" * len(direct_ids))
|
|
460
|
+
conn.execute(
|
|
461
|
+
f"UPDATE mesh_messages SET read=1 "
|
|
462
|
+
f"WHERE id IN ({dph}) AND to_peer=? AND profile_id=?",
|
|
463
|
+
(*direct_ids, peer_id, profile_id),
|
|
464
|
+
)
|
|
465
|
+
if shared_ids:
|
|
466
|
+
conn.executemany(
|
|
467
|
+
"INSERT OR IGNORE INTO mesh_reads (message_id, peer_id, read_at) "
|
|
468
|
+
"VALUES (?, ?, ?)",
|
|
469
|
+
[(mid, peer_id, now) for mid in shared_ids],
|
|
470
|
+
)
|
|
403
471
|
conn.commit()
|
|
404
472
|
return {"ok": True, "marked": len(message_ids)}
|
|
405
473
|
|
|
@@ -407,32 +475,40 @@ class MeshBroker:
|
|
|
407
475
|
|
|
408
476
|
# -- State --
|
|
409
477
|
|
|
410
|
-
def get_state(self) -> dict:
|
|
478
|
+
def get_state(self, profile_id: str = "default") -> dict:
|
|
411
479
|
conn = self._conn()
|
|
412
480
|
try:
|
|
413
|
-
rows = conn.execute(
|
|
481
|
+
rows = conn.execute(
|
|
482
|
+
"SELECT key, value, set_by, updated_at FROM mesh_state WHERE profile_id=?",
|
|
483
|
+
(profile_id,),
|
|
484
|
+
).fetchall()
|
|
414
485
|
return {r["key"]: {"value": r["value"], "set_by": r["set_by"], "updated_at": r["updated_at"]} for r in rows}
|
|
415
486
|
finally:
|
|
416
487
|
conn.close()
|
|
417
488
|
|
|
418
|
-
def set_state(self, key: str, value: str, set_by: str
|
|
489
|
+
def set_state(self, key: str, value: str, set_by: str,
|
|
490
|
+
profile_id: str = "default") -> dict:
|
|
419
491
|
def _set_state(conn: sqlite3.Connection) -> dict:
|
|
420
492
|
now = datetime.now(timezone.utc).isoformat()
|
|
421
493
|
conn.execute(
|
|
422
|
-
"INSERT INTO mesh_state (key, value, set_by, updated_at)
|
|
423
|
-
"
|
|
424
|
-
(
|
|
494
|
+
"INSERT INTO mesh_state (profile_id, key, value, set_by, updated_at) "
|
|
495
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
496
|
+
"ON CONFLICT(profile_id, key) DO UPDATE SET value=excluded.value, "
|
|
497
|
+
"set_by=excluded.set_by, updated_at=excluded.updated_at",
|
|
498
|
+
(profile_id, key, value, set_by, now),
|
|
425
499
|
)
|
|
426
500
|
conn.commit()
|
|
427
501
|
return {"ok": True}
|
|
428
502
|
|
|
429
503
|
return self._write_with_retry(_set_state)
|
|
430
504
|
|
|
431
|
-
def get_state_key(self, key: str) -> dict | None:
|
|
505
|
+
def get_state_key(self, key: str, profile_id: str = "default") -> dict | None:
|
|
432
506
|
conn = self._conn()
|
|
433
507
|
try:
|
|
434
508
|
row = conn.execute(
|
|
435
|
-
"SELECT key, value, set_by, updated_at FROM mesh_state
|
|
509
|
+
"SELECT key, value, set_by, updated_at FROM mesh_state "
|
|
510
|
+
"WHERE profile_id=? AND key=?",
|
|
511
|
+
(profile_id, key),
|
|
436
512
|
).fetchone()
|
|
437
513
|
return dict(row) if row else None
|
|
438
514
|
finally:
|
|
@@ -440,13 +516,15 @@ class MeshBroker:
|
|
|
440
516
|
|
|
441
517
|
# -- Locks --
|
|
442
518
|
|
|
443
|
-
def lock_action(self, file_path: str, locked_by: str, action: str
|
|
519
|
+
def lock_action(self, file_path: str, locked_by: str, action: str,
|
|
520
|
+
profile_id: str = "default") -> dict:
|
|
444
521
|
if action == "query":
|
|
445
522
|
conn = self._conn()
|
|
446
523
|
try:
|
|
447
524
|
row = conn.execute(
|
|
448
|
-
"SELECT locked_by, locked_at FROM mesh_locks
|
|
449
|
-
|
|
525
|
+
"SELECT locked_by, locked_at FROM mesh_locks "
|
|
526
|
+
"WHERE profile_id=? AND file_path=?",
|
|
527
|
+
(profile_id, file_path),
|
|
450
528
|
).fetchone()
|
|
451
529
|
if row:
|
|
452
530
|
return {"locked": True, "by": row["locked_by"], "since": row["locked_at"]}
|
|
@@ -462,26 +540,40 @@ class MeshBroker:
|
|
|
462
540
|
|
|
463
541
|
if action == "acquire":
|
|
464
542
|
existing = conn.execute(
|
|
465
|
-
"SELECT locked_by, locked_at FROM mesh_locks
|
|
466
|
-
|
|
543
|
+
"SELECT locked_by, locked_at, expires_at FROM mesh_locks "
|
|
544
|
+
"WHERE profile_id=? AND file_path=?",
|
|
545
|
+
(profile_id, file_path),
|
|
467
546
|
).fetchone()
|
|
547
|
+
# M-02: another peer's lock only blocks while it is still live.
|
|
548
|
+
# An expired lock — or a legacy never-expires sentinel left by a
|
|
549
|
+
# crashed session — is treated as free, so a stale lock can never
|
|
550
|
+
# deadlock a path forever.
|
|
468
551
|
if existing and existing["locked_by"] != locked_by:
|
|
469
|
-
|
|
552
|
+
exp = existing["expires_at"]
|
|
553
|
+
still_live = bool(exp) and exp != _NEVER_EXPIRES and exp > now
|
|
554
|
+
if still_live:
|
|
555
|
+
return {"locked": True, "by": existing["locked_by"],
|
|
556
|
+
"since": existing["locked_at"]}
|
|
557
|
+
lock_expires = (
|
|
558
|
+
datetime.fromisoformat(now) + timedelta(hours=LOCK_TTL_HOURS)
|
|
559
|
+
).isoformat()
|
|
470
560
|
conn.execute(
|
|
471
|
-
"INSERT INTO mesh_locks (file_path, locked_by, locked_at
|
|
472
|
-
"
|
|
473
|
-
(
|
|
561
|
+
"INSERT INTO mesh_locks (profile_id, file_path, locked_by, locked_at, expires_at) "
|
|
562
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
563
|
+
"ON CONFLICT(profile_id, file_path) DO UPDATE SET locked_by=excluded.locked_by, "
|
|
564
|
+
"locked_at=excluded.locked_at, expires_at=excluded.expires_at",
|
|
565
|
+
(profile_id, file_path, locked_by, now, lock_expires),
|
|
474
566
|
)
|
|
475
567
|
conn.commit()
|
|
476
|
-
return {"ok": True, "action": "acquired"}
|
|
568
|
+
return {"ok": True, "action": "acquired", "expires_at": lock_expires}
|
|
477
569
|
|
|
478
570
|
elif action == "release":
|
|
479
571
|
# v3.6.12 (mesh-2): report whether we actually released. The
|
|
480
572
|
# DELETE is correctly owner-scoped, but it previously returned
|
|
481
573
|
# released=ok:true even when a NON-owner released nothing.
|
|
482
574
|
cur = conn.execute(
|
|
483
|
-
"DELETE FROM mesh_locks WHERE file_path=? AND locked_by=?",
|
|
484
|
-
(file_path, locked_by),
|
|
575
|
+
"DELETE FROM mesh_locks WHERE profile_id=? AND file_path=? AND locked_by=?",
|
|
576
|
+
(profile_id, file_path, locked_by),
|
|
485
577
|
)
|
|
486
578
|
conn.commit()
|
|
487
579
|
if cur.rowcount and cur.rowcount > 0:
|
|
@@ -503,59 +595,67 @@ class MeshBroker:
|
|
|
503
595
|
return (now + timedelta(hours=MESSAGE_TTL_HOURS)).isoformat()
|
|
504
596
|
|
|
505
597
|
def _get_pending_for_peer(self, conn: sqlite3.Connection,
|
|
506
|
-
peer_id: str, project_path: str
|
|
507
|
-
|
|
598
|
+
peer_id: str, project_path: str,
|
|
599
|
+
profile_id: str = "default") -> list[dict]:
|
|
600
|
+
"""Get unread broadcast/project messages for a newly registered peer
|
|
601
|
+
within this tenant."""
|
|
508
602
|
now = datetime.now(timezone.utc).isoformat()
|
|
509
603
|
rows = conn.execute(
|
|
510
604
|
"SELECT m.id, m.from_peer, m.content, m.target_type, m.project_path, m.created_at "
|
|
511
605
|
"FROM mesh_messages m "
|
|
512
606
|
"LEFT JOIN mesh_reads r ON m.id = r.message_id AND r.peer_id = ? "
|
|
513
|
-
"WHERE r.peer_id IS NULL AND m.from_peer != ? "
|
|
607
|
+
"WHERE m.profile_id = ? AND r.peer_id IS NULL AND m.from_peer != ? "
|
|
514
608
|
"AND (m.expires_at IS NULL OR m.expires_at > ?) "
|
|
515
609
|
"AND (m.target_type = 'broadcast' "
|
|
516
610
|
" OR (m.target_type = 'project' AND m.project_path = ?)) "
|
|
517
611
|
"ORDER BY m.created_at DESC LIMIT 50",
|
|
518
|
-
(peer_id, peer_id, now, project_path),
|
|
612
|
+
(peer_id, profile_id, peer_id, now, project_path),
|
|
519
613
|
).fetchall()
|
|
520
614
|
return [dict(r) for r in rows]
|
|
521
615
|
|
|
522
|
-
def get_pending(self, peer_id: str, project_path: str = ""
|
|
616
|
+
def get_pending(self, peer_id: str, project_path: str = "",
|
|
617
|
+
profile_id: str = "default") -> list[dict]:
|
|
523
618
|
"""Public API to get pending broadcast/project messages."""
|
|
524
619
|
conn = self._conn()
|
|
525
620
|
try:
|
|
526
|
-
return self._get_pending_for_peer(conn, peer_id, project_path)
|
|
621
|
+
return self._get_pending_for_peer(conn, peer_id, project_path, profile_id)
|
|
527
622
|
finally:
|
|
528
623
|
conn.close()
|
|
529
624
|
|
|
530
625
|
# -- Events --
|
|
531
626
|
|
|
532
|
-
def get_events(self, limit: int = 100) -> list[dict]:
|
|
627
|
+
def get_events(self, limit: int = 100, profile_id: str = "default") -> list[dict]:
|
|
533
628
|
conn = self._conn()
|
|
534
629
|
try:
|
|
535
630
|
rows = conn.execute(
|
|
536
631
|
"SELECT id, event_type, payload, emitted_by, created_at "
|
|
537
|
-
"FROM mesh_events ORDER BY id DESC LIMIT ?",
|
|
538
|
-
(limit
|
|
632
|
+
"FROM mesh_events WHERE profile_id=? ORDER BY id DESC LIMIT ?",
|
|
633
|
+
(profile_id, limit),
|
|
539
634
|
).fetchall()
|
|
540
635
|
return [dict(r) for r in rows]
|
|
541
636
|
finally:
|
|
542
637
|
conn.close()
|
|
543
638
|
|
|
544
639
|
def _log_event(self, conn: sqlite3.Connection, event_type: str,
|
|
545
|
-
emitted_by: str, payload: dict | None = None
|
|
640
|
+
emitted_by: str, payload: dict | None = None,
|
|
641
|
+
profile_id: str = "default") -> None:
|
|
546
642
|
import json as _json
|
|
547
643
|
now = datetime.now(timezone.utc).isoformat()
|
|
548
644
|
conn.execute(
|
|
549
|
-
"INSERT INTO mesh_events (event_type, payload, emitted_by, created_at)
|
|
550
|
-
|
|
645
|
+
"INSERT INTO mesh_events (event_type, payload, emitted_by, created_at, profile_id) "
|
|
646
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
647
|
+
(event_type, _json.dumps(payload or {}), emitted_by, now, profile_id),
|
|
551
648
|
)
|
|
552
649
|
|
|
553
650
|
# -- Status --
|
|
554
651
|
|
|
555
|
-
def get_status(self) -> dict:
|
|
652
|
+
def get_status(self, profile_id: str = "default") -> dict:
|
|
556
653
|
conn = self._conn()
|
|
557
654
|
try:
|
|
558
|
-
peer_count = conn.execute(
|
|
655
|
+
peer_count = conn.execute(
|
|
656
|
+
"SELECT COUNT(*) FROM mesh_peers WHERE profile_id=? AND status='active'",
|
|
657
|
+
(profile_id,),
|
|
658
|
+
).fetchone()[0]
|
|
559
659
|
return {
|
|
560
660
|
"broker_up": True,
|
|
561
661
|
"peer_count": peer_count,
|
|
@@ -579,31 +679,39 @@ class MeshBroker:
|
|
|
579
679
|
|
|
580
680
|
def _run_cleanup(self) -> None:
|
|
581
681
|
def _cleanup(conn: sqlite3.Connection) -> None:
|
|
682
|
+
# Precompute ISO cutoffs in Python and compare the stored ISO strings
|
|
683
|
+
# directly. ISO-8601 UTC timestamps sort lexicographically, so a bare
|
|
684
|
+
# `col < ?` is both correct AND sargable — the datetime() wrapper
|
|
685
|
+
# previously forced a full table scan on every 5-minute cleanup.
|
|
582
686
|
now = datetime.now(timezone.utc)
|
|
583
687
|
now_iso = now.isoformat()
|
|
688
|
+
five_min = (now - timedelta(minutes=5)).isoformat()
|
|
689
|
+
thirty_min = (now - timedelta(minutes=30)).isoformat()
|
|
690
|
+
day_ago = (now - timedelta(hours=24)).isoformat()
|
|
691
|
+
week_ago = (now - timedelta(days=7)).isoformat()
|
|
584
692
|
# Mark stale peers (no heartbeat for 5 min)
|
|
585
693
|
conn.execute(
|
|
586
694
|
"UPDATE mesh_peers SET status='stale' "
|
|
587
|
-
"WHERE status='active' AND
|
|
588
|
-
(
|
|
695
|
+
"WHERE status='active' AND last_heartbeat < ?",
|
|
696
|
+
(five_min,),
|
|
589
697
|
)
|
|
590
698
|
# Delete dead peers (stale > 30 min)
|
|
591
699
|
conn.execute(
|
|
592
700
|
"UPDATE mesh_peers SET status='dead' "
|
|
593
|
-
"WHERE status='stale' AND
|
|
594
|
-
(
|
|
701
|
+
"WHERE status='stale' AND last_heartbeat < ?",
|
|
702
|
+
(thirty_min,),
|
|
595
703
|
)
|
|
596
704
|
conn.execute("DELETE FROM mesh_peers WHERE status='dead'")
|
|
597
705
|
# Delete read direct messages > 24hr old
|
|
598
706
|
conn.execute(
|
|
599
707
|
"DELETE FROM mesh_messages WHERE target_type='peer' AND read=1 "
|
|
600
|
-
"AND
|
|
601
|
-
(
|
|
708
|
+
"AND created_at < ?",
|
|
709
|
+
(day_ago,),
|
|
602
710
|
)
|
|
603
711
|
# v3.4.6: Delete EXPIRED messages (48h TTL for broadcast/project)
|
|
604
712
|
conn.execute(
|
|
605
713
|
"DELETE FROM mesh_messages WHERE expires_at IS NOT NULL "
|
|
606
|
-
"AND
|
|
714
|
+
"AND expires_at < ?",
|
|
607
715
|
(now_iso,),
|
|
608
716
|
)
|
|
609
717
|
# v3.4.6: Clean up orphaned mesh_reads entries
|
|
@@ -611,15 +719,15 @@ class MeshBroker:
|
|
|
611
719
|
"DELETE FROM mesh_reads WHERE message_id NOT IN "
|
|
612
720
|
"(SELECT id FROM mesh_messages)",
|
|
613
721
|
)
|
|
614
|
-
# Delete expired locks
|
|
722
|
+
# Delete expired locks (the 9999-… sentinel sorts after any real now)
|
|
615
723
|
conn.execute(
|
|
616
|
-
"DELETE FROM mesh_locks WHERE
|
|
724
|
+
"DELETE FROM mesh_locks WHERE expires_at < ?",
|
|
617
725
|
(now_iso,),
|
|
618
726
|
)
|
|
619
727
|
# v3.4.6: Delete old events (keep last 7 days)
|
|
620
728
|
conn.execute(
|
|
621
|
-
"DELETE FROM mesh_events WHERE
|
|
622
|
-
(
|
|
729
|
+
"DELETE FROM mesh_events WHERE created_at < ?",
|
|
730
|
+
(week_ago,),
|
|
623
731
|
)
|
|
624
732
|
conn.commit()
|
|
625
733
|
|