nucleus-mcp 1.0.7 → 1.2.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/package.json +2 -4
- package/.github/workflows/ci.yml +0 -72
- package/.smithery/shttp/module.js +0 -3
- package/.smithery/shttp/module.js.map +0 -7
- package/.smithery/stdio/index.cjs +0 -74
- package/.smithery/stdio/index.cjs.map +0 -7
- package/CHANGELOG.md +0 -65
- package/CODE_OF_CONDUCT.md +0 -34
- package/COMPARISON.md +0 -20
- package/CONTRIBUTING.md +0 -137
- package/DEVELOPMENT.md +0 -109
- package/LICENSE +0 -21
- package/README.md +0 -319
- package/ROADMAP.md +0 -24
- package/SECURITY.md +0 -88
- package/docs/COMMUNITY_FAQ.md +0 -48
- package/docs/COMPETITIVE_ANALYSIS.md +0 -90
- package/docs/DEMO_SCRIPT.md +0 -86
- package/docs/ENTERPRISE.md +0 -169
- package/docs/FAQ.md +0 -228
- package/docs/QUICK_START.md +0 -155
- package/docs/README.md +0 -67
- package/docs/RECURSIVE_SNAP_DEMO.gif +0 -0
- package/docs/social-preview-hq.png +0 -0
- package/docs/social-preview.png +0 -0
- package/examples/basic_usage.py +0 -84
- package/package.json.real +0 -24
- package/pyproject.toml +0 -50
- package/registry-server.json +0 -20
- package/scripts/demo_governance.py +0 -109
- package/scripts/sse_bridge.py +0 -87
- package/scripts/verify_windows_paths.py +0 -48
- package/server.json +0 -20
- package/src/mcp_server_nucleus/__init__.py +0 -8583
- package/src/mcp_server_nucleus/__main__.py +0 -9
- package/src/mcp_server_nucleus/cli.py +0 -1225
- package/src/mcp_server_nucleus/commitment_core.py +0 -146
- package/src/mcp_server_nucleus/commitment_ledger.py +0 -841
- package/src/mcp_server_nucleus/core/__init__.py +0 -6
- package/src/mcp_server_nucleus/core/orchestrator.py +0 -9
- package/src/mcp_server_nucleus/core/tool_registration_impl.py +0 -99
- package/src/mcp_server_nucleus/daemon/safety/lock.py +0 -121
- package/src/mcp_server_nucleus/deployment/render_integration.py +0 -4
- package/src/mcp_server_nucleus/hypervisor/__init__.py +0 -0
- package/src/mcp_server_nucleus/hypervisor/injector.py +0 -83
- package/src/mcp_server_nucleus/hypervisor/locker.py +0 -130
- package/src/mcp_server_nucleus/hypervisor/watchdog.py +0 -71
- package/src/mcp_server_nucleus/runtime/__init__.py +0 -58
- package/src/mcp_server_nucleus/runtime/agent.py +0 -517
- package/src/mcp_server_nucleus/runtime/agent_pool.py +0 -797
- package/src/mcp_server_nucleus/runtime/agents/base.py +0 -34
- package/src/mcp_server_nucleus/runtime/agents/oracle/manifest.json +0 -28
- package/src/mcp_server_nucleus/runtime/auth.py +0 -98
- package/src/mcp_server_nucleus/runtime/autopilot.py +0 -1057
- package/src/mcp_server_nucleus/runtime/broker.py +0 -126
- package/src/mcp_server_nucleus/runtime/budget.py +0 -153
- package/src/mcp_server_nucleus/runtime/capabilities/base.py +0 -20
- package/src/mcp_server_nucleus/runtime/capabilities/brain_ops.py +0 -253
- package/src/mcp_server_nucleus/runtime/capabilities/code_ops.py +0 -192
- package/src/mcp_server_nucleus/runtime/capabilities/depth_tracker.py +0 -81
- package/src/mcp_server_nucleus/runtime/capabilities/feature_map.py +0 -274
- package/src/mcp_server_nucleus/runtime/capabilities/marketing_engine.py +0 -205
- package/src/mcp_server_nucleus/runtime/capabilities/memory_ops.py +0 -79
- package/src/mcp_server_nucleus/runtime/capabilities/proof_system.py +0 -127
- package/src/mcp_server_nucleus/runtime/capabilities/render_ops.py +0 -80
- package/src/mcp_server_nucleus/runtime/capabilities/render_poller_cap.py +0 -266
- package/src/mcp_server_nucleus/runtime/capabilities/self_healing.py +0 -110
- package/src/mcp_server_nucleus/runtime/capabilities/strategy.py +0 -138
- package/src/mcp_server_nucleus/runtime/capabilities/synthesizer.py +0 -134
- package/src/mcp_server_nucleus/runtime/capabilities/web_ops.py +0 -94
- package/src/mcp_server_nucleus/runtime/common.py +0 -134
- package/src/mcp_server_nucleus/runtime/context_manager.py +0 -342
- package/src/mcp_server_nucleus/runtime/context_rules.json +0 -86
- package/src/mcp_server_nucleus/runtime/crdt_task_store.py +0 -349
- package/src/mcp_server_nucleus/runtime/critic.py +0 -150
- package/src/mcp_server_nucleus/runtime/daemon.py +0 -142
- package/src/mcp_server_nucleus/runtime/dashboard.py +0 -1087
- package/src/mcp_server_nucleus/runtime/depth_ops.py +0 -339
- package/src/mcp_server_nucleus/runtime/event_ops.py +0 -103
- package/src/mcp_server_nucleus/runtime/event_stream.py +0 -213
- package/src/mcp_server_nucleus/runtime/export.py +0 -64
- package/src/mcp_server_nucleus/runtime/factory.py +0 -594
- package/src/mcp_server_nucleus/runtime/federation.py +0 -1040
- package/src/mcp_server_nucleus/runtime/fetcher.py +0 -92
- package/src/mcp_server_nucleus/runtime/file_monitor.py +0 -208
- package/src/mcp_server_nucleus/runtime/firestore_bridge.py +0 -91
- package/src/mcp_server_nucleus/runtime/gcloud_ops.py +0 -186
- package/src/mcp_server_nucleus/runtime/hooks.py +0 -246
- package/src/mcp_server_nucleus/runtime/identity/gatekeeper.py +0 -106
- package/src/mcp_server_nucleus/runtime/identity/keygen.py +0 -147
- package/src/mcp_server_nucleus/runtime/identity/manifest.py +0 -62
- package/src/mcp_server_nucleus/runtime/identity/trust.py +0 -27
- package/src/mcp_server_nucleus/runtime/inspector.py +0 -74
- package/src/mcp_server_nucleus/runtime/installer.py +0 -81
- package/src/mcp_server_nucleus/runtime/ipc_auth.py +0 -377
- package/src/mcp_server_nucleus/runtime/lifecycle.py +0 -129
- package/src/mcp_server_nucleus/runtime/llm_client.py +0 -408
- package/src/mcp_server_nucleus/runtime/llm_tiers.json +0 -88
- package/src/mcp_server_nucleus/runtime/locking.py +0 -228
- package/src/mcp_server_nucleus/runtime/loops/fixer.py +0 -110
- package/src/mcp_server_nucleus/runtime/memory.py +0 -158
- package/src/mcp_server_nucleus/runtime/mounter.py +0 -210
- package/src/mcp_server_nucleus/runtime/mounter_ops.py +0 -250
- package/src/mcp_server_nucleus/runtime/nuke_protocol.py +0 -147
- package/src/mcp_server_nucleus/runtime/orchestrator.py +0 -370
- package/src/mcp_server_nucleus/runtime/orchestrator_unified.py +0 -263
- package/src/mcp_server_nucleus/runtime/orchestrator_v3.py +0 -617
- package/src/mcp_server_nucleus/runtime/plugin_loader.py +0 -84
- package/src/mcp_server_nucleus/runtime/policy.py +0 -117
- package/src/mcp_server_nucleus/runtime/profiling.py +0 -219
- package/src/mcp_server_nucleus/runtime/prometheus.py +0 -286
- package/src/mcp_server_nucleus/runtime/proposals.py +0 -101
- package/src/mcp_server_nucleus/runtime/publisher.py +0 -109
- package/src/mcp_server_nucleus/runtime/pulse.py +0 -516
- package/src/mcp_server_nucleus/runtime/registry.py +0 -82
- package/src/mcp_server_nucleus/runtime/render_ops.py +0 -83
- package/src/mcp_server_nucleus/runtime/schema_gen.py +0 -140
- package/src/mcp_server_nucleus/runtime/session_ops.py +0 -224
- package/src/mcp_server_nucleus/runtime/stdio_server.py +0 -633
- package/src/mcp_server_nucleus/runtime/storage.py +0 -83
- package/src/mcp_server_nucleus/runtime/strategy.py +0 -91
- package/src/mcp_server_nucleus/runtime/swarm.py +0 -170
- package/src/mcp_server_nucleus/runtime/sync_ops.py +0 -878
- package/src/mcp_server_nucleus/runtime/task_ingestion.py +0 -1020
- package/src/mcp_server_nucleus/runtime/task_ops.py +0 -449
- package/src/mcp_server_nucleus/runtime/task_scheduler.py +0 -488
- package/src/mcp_server_nucleus/runtime/team.py +0 -59
- package/src/mcp_server_nucleus/runtime/triggers.py +0 -237
- package/src/mcp_server_nucleus/runtime/vector_store.py +0 -125
- package/src/mcp_server_nucleus/runtime/watcher.py +0 -112
- package/src/mcp_server_nucleus/tool_tiers.py +0 -243
- package/tests/__init__.py +0 -1
- package/tests/conftest.py +0 -42
- package/tests/test_core.py +0 -235
- package/tests/test_search.py +0 -69
|
@@ -1,878 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Nucleus Multi-Agent Sync Operations - Production Ready
|
|
3
|
-
Version: 0.7.0
|
|
4
|
-
|
|
5
|
-
Tier 1: Project-Level Multi-Agent Sync
|
|
6
|
-
- Zero-config by default (works without setup)
|
|
7
|
-
- Progressive enhancement with .brain/config/nucleus.yaml
|
|
8
|
-
- File locking for concurrent access
|
|
9
|
-
- Auto-sync with watchdog file watcher
|
|
10
|
-
- Conflict detection and resolution
|
|
11
|
-
|
|
12
|
-
Author: Boss Opus (CORE_SYN)
|
|
13
|
-
Date: 2026-02-08
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
import os
|
|
17
|
-
import json
|
|
18
|
-
import fcntl
|
|
19
|
-
import time
|
|
20
|
-
import hashlib
|
|
21
|
-
import logging
|
|
22
|
-
import threading
|
|
23
|
-
import queue
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
from datetime import datetime
|
|
26
|
-
from typing import Dict, Any, Optional, List
|
|
27
|
-
from contextlib import contextmanager
|
|
28
|
-
|
|
29
|
-
logger = logging.getLogger("nucleus.sync")
|
|
30
|
-
|
|
31
|
-
# =============================================================================
|
|
32
|
-
# CONFIGURATION LOADER
|
|
33
|
-
# =============================================================================
|
|
34
|
-
|
|
35
|
-
def _load_sync_config(brain_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
36
|
-
"""
|
|
37
|
-
Load .brain/config/nucleus.yaml if it exists.
|
|
38
|
-
Returns empty config if file doesn't exist (Tier 0 behavior).
|
|
39
|
-
"""
|
|
40
|
-
if brain_path is None:
|
|
41
|
-
from .common import get_brain_path
|
|
42
|
-
brain_path = get_brain_path()
|
|
43
|
-
|
|
44
|
-
config_file = brain_path / "config" / "nucleus.yaml"
|
|
45
|
-
|
|
46
|
-
if not config_file.exists():
|
|
47
|
-
return {"sync": {"enabled": False}}
|
|
48
|
-
|
|
49
|
-
try:
|
|
50
|
-
import yaml
|
|
51
|
-
config = yaml.safe_load(config_file.read_text())
|
|
52
|
-
return config or {"sync": {"enabled": False}}
|
|
53
|
-
except ImportError:
|
|
54
|
-
# Fallback to JSON-style YAML parsing if PyYAML not installed
|
|
55
|
-
try:
|
|
56
|
-
content = config_file.read_text()
|
|
57
|
-
# Simple YAML to dict for basic configs
|
|
58
|
-
config = _parse_simple_yaml(content)
|
|
59
|
-
return config
|
|
60
|
-
except Exception as e:
|
|
61
|
-
logger.warning(f"Could not parse nucleus.yaml: {e}")
|
|
62
|
-
return {"sync": {"enabled": False}}
|
|
63
|
-
except Exception as e:
|
|
64
|
-
logger.warning(f"Error loading sync config: {e}")
|
|
65
|
-
return {"sync": {"enabled": False}}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _parse_simple_yaml(content: str) -> Dict[str, Any]:
|
|
69
|
-
"""Simple YAML parser for basic nucleus.yaml configs."""
|
|
70
|
-
result = {}
|
|
71
|
-
current_section = result
|
|
72
|
-
section_stack = [result]
|
|
73
|
-
indent_stack = [0]
|
|
74
|
-
|
|
75
|
-
for line in content.split("\n"):
|
|
76
|
-
# Skip comments and empty lines
|
|
77
|
-
stripped = line.strip()
|
|
78
|
-
if not stripped or stripped.startswith("#"):
|
|
79
|
-
continue
|
|
80
|
-
|
|
81
|
-
# Calculate indentation
|
|
82
|
-
indent = len(line) - len(line.lstrip())
|
|
83
|
-
|
|
84
|
-
# Handle key-value pairs
|
|
85
|
-
if ":" in stripped:
|
|
86
|
-
key, _, value = stripped.partition(":")
|
|
87
|
-
key = key.strip()
|
|
88
|
-
value = value.strip()
|
|
89
|
-
|
|
90
|
-
# Pop sections if we're at a lower indent level
|
|
91
|
-
while indent_stack and indent <= indent_stack[-1] and len(section_stack) > 1:
|
|
92
|
-
section_stack.pop()
|
|
93
|
-
indent_stack.pop()
|
|
94
|
-
current_section = section_stack[-1]
|
|
95
|
-
|
|
96
|
-
if value:
|
|
97
|
-
# Simple value
|
|
98
|
-
if value.startswith('"') and value.endswith('"'):
|
|
99
|
-
value = value[1:-1]
|
|
100
|
-
elif value.startswith("'") and value.endswith("'"):
|
|
101
|
-
value = value[1:-1]
|
|
102
|
-
elif value.lower() == "true":
|
|
103
|
-
value = True
|
|
104
|
-
elif value.lower() == "false":
|
|
105
|
-
value = False
|
|
106
|
-
elif value.isdigit():
|
|
107
|
-
value = int(value)
|
|
108
|
-
current_section[key] = value
|
|
109
|
-
else:
|
|
110
|
-
# New section
|
|
111
|
-
current_section[key] = {}
|
|
112
|
-
section_stack.append(current_section[key])
|
|
113
|
-
indent_stack.append(indent)
|
|
114
|
-
current_section = current_section[key]
|
|
115
|
-
|
|
116
|
-
# Handle list items
|
|
117
|
-
elif stripped.startswith("- "):
|
|
118
|
-
item = stripped[2:].strip()
|
|
119
|
-
if item.startswith('"') and item.endswith('"'):
|
|
120
|
-
item = item[1:-1]
|
|
121
|
-
# Find the parent key for this list
|
|
122
|
-
for key in reversed(list(current_section.keys())):
|
|
123
|
-
if isinstance(current_section[key], dict) and not current_section[key]:
|
|
124
|
-
current_section[key] = [item]
|
|
125
|
-
break
|
|
126
|
-
elif isinstance(current_section[key], list):
|
|
127
|
-
current_section[key].append(item)
|
|
128
|
-
break
|
|
129
|
-
|
|
130
|
-
return result
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def is_sync_enabled(brain_path: Optional[Path] = None) -> bool:
|
|
134
|
-
"""Check if sync is enabled for this project."""
|
|
135
|
-
config = _load_sync_config(brain_path)
|
|
136
|
-
return config.get("sync", {}).get("enabled", False)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def get_sync_mode(brain_path: Optional[Path] = None) -> str:
|
|
140
|
-
"""Get sync mode: 'auto' or 'manual'."""
|
|
141
|
-
config = _load_sync_config(brain_path)
|
|
142
|
-
return config.get("sync", {}).get("mode", "manual")
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def get_watch_files(brain_path: Optional[Path] = None) -> List[str]:
|
|
146
|
-
"""Get list of files to watch for sync."""
|
|
147
|
-
config = _load_sync_config(brain_path)
|
|
148
|
-
return config.get("sync", {}).get("watch_files", [
|
|
149
|
-
"ledger/state.json",
|
|
150
|
-
"ledger/decisions.md",
|
|
151
|
-
"task.md"
|
|
152
|
-
])
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def get_sync_interval(brain_path: Optional[Path] = None) -> int:
|
|
156
|
-
"""Get sync interval in seconds."""
|
|
157
|
-
config = _load_sync_config(brain_path)
|
|
158
|
-
return config.get("sync", {}).get("interval", 5)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# =============================================================================
|
|
162
|
-
# AGENT IDENTIFICATION
|
|
163
|
-
# =============================================================================
|
|
164
|
-
|
|
165
|
-
# Process-local identity cache (Concurrency Hotfix)
|
|
166
|
-
_current_identity: Optional[str] = None
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def get_current_agent(brain_path: Optional[Path] = None) -> str:
|
|
170
|
-
"""
|
|
171
|
-
Get current agent ID, with auto-detection fallback.
|
|
172
|
-
|
|
173
|
-
Priority:
|
|
174
|
-
1. Process-Local Memory (Sticky & Safe)
|
|
175
|
-
2. Environment variable detection
|
|
176
|
-
3. .brain/.nucleus_agent file (Last Resort / Logging only)
|
|
177
|
-
4. "unknown_agent" fallback
|
|
178
|
-
"""
|
|
179
|
-
global _current_identity
|
|
180
|
-
|
|
181
|
-
# Priority 1: Process-Local Memory (The Golden Source)
|
|
182
|
-
if _current_identity:
|
|
183
|
-
return _current_identity
|
|
184
|
-
|
|
185
|
-
if brain_path is None:
|
|
186
|
-
from .common import get_brain_path
|
|
187
|
-
brain_path = get_brain_path()
|
|
188
|
-
|
|
189
|
-
# Priority 2: Environment detection
|
|
190
|
-
# Check for IDE-specific environment variables
|
|
191
|
-
if os.environ.get("WINDSURF_SESSION"):
|
|
192
|
-
return "windsurf_auto"
|
|
193
|
-
elif os.environ.get("CURSOR_SESSION"):
|
|
194
|
-
return "cursor_auto"
|
|
195
|
-
elif os.environ.get("CLAUDE_DESKTOP"):
|
|
196
|
-
return "claude_desktop_auto"
|
|
197
|
-
elif os.environ.get("VSCODE_PID"):
|
|
198
|
-
return "vscode_auto"
|
|
199
|
-
|
|
200
|
-
# Check for custom agent ID
|
|
201
|
-
custom_id = os.environ.get("NUCLEUS_AGENT_ID")
|
|
202
|
-
if custom_id:
|
|
203
|
-
return custom_id
|
|
204
|
-
|
|
205
|
-
# Priority 3: Explicit registration file (Last Resort / Cold Start)
|
|
206
|
-
agent_file = brain_path / ".nucleus_agent"
|
|
207
|
-
if agent_file.exists():
|
|
208
|
-
try:
|
|
209
|
-
agent_info = json.loads(agent_file.read_text())
|
|
210
|
-
# Only trust file if it matches our PID (Session Recovery)
|
|
211
|
-
# Otherwise we risk reading another agent's identity
|
|
212
|
-
if agent_info.get("pid") == os.getpid():
|
|
213
|
-
return agent_info.get("agent_id", "unknown_agent")
|
|
214
|
-
except Exception:
|
|
215
|
-
pass
|
|
216
|
-
|
|
217
|
-
# Priority 4: Fallback
|
|
218
|
-
return "unknown_agent"
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def set_current_agent(agent_id: str, environment: str, role: str = "",
|
|
222
|
-
brain_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
223
|
-
"""
|
|
224
|
-
Register current agent identity.
|
|
225
|
-
Updates process-local memory FIRST, then persists to file.
|
|
226
|
-
"""
|
|
227
|
-
global _current_identity
|
|
228
|
-
|
|
229
|
-
# Update memory immediately (Atomic for this process)
|
|
230
|
-
_current_identity = agent_id
|
|
231
|
-
|
|
232
|
-
if brain_path is None:
|
|
233
|
-
from .common import get_brain_path
|
|
234
|
-
brain_path = get_brain_path()
|
|
235
|
-
|
|
236
|
-
agent_file = brain_path / ".nucleus_agent"
|
|
237
|
-
|
|
238
|
-
agent_info = {
|
|
239
|
-
"agent_id": agent_id,
|
|
240
|
-
"environment": environment,
|
|
241
|
-
"role": role,
|
|
242
|
-
"registered_at": datetime.now().isoformat(),
|
|
243
|
-
"pid": os.getpid()
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
agent_file.write_text(json.dumps(agent_info, indent=2))
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
**agent_info,
|
|
250
|
-
"stored_in": str(agent_file)
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def get_agent_info(brain_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
255
|
-
"""Get full agent info if registered."""
|
|
256
|
-
if brain_path is None:
|
|
257
|
-
from .common import get_brain_path
|
|
258
|
-
brain_path = get_brain_path()
|
|
259
|
-
|
|
260
|
-
agent_file = brain_path / ".nucleus_agent"
|
|
261
|
-
|
|
262
|
-
if agent_file.exists():
|
|
263
|
-
try:
|
|
264
|
-
return json.loads(agent_file.read_text())
|
|
265
|
-
except Exception:
|
|
266
|
-
pass
|
|
267
|
-
|
|
268
|
-
return {
|
|
269
|
-
"agent_id": get_current_agent(brain_path),
|
|
270
|
-
"environment": "unknown",
|
|
271
|
-
"role": "",
|
|
272
|
-
"registered_at": None,
|
|
273
|
-
"auto_detected": True
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
# =============================================================================
|
|
278
|
-
# FILE LOCKING
|
|
279
|
-
# =============================================================================
|
|
280
|
-
|
|
281
|
-
@contextmanager
|
|
282
|
-
def sync_lock(brain_path: Optional[Path] = None, timeout: int = 5):
|
|
283
|
-
"""
|
|
284
|
-
Acquire exclusive lock for syncing.
|
|
285
|
-
Uses fcntl.flock for cross-process synchronization.
|
|
286
|
-
|
|
287
|
-
Args:
|
|
288
|
-
brain_path: Path to .brain directory
|
|
289
|
-
timeout: Maximum seconds to wait for lock
|
|
290
|
-
|
|
291
|
-
Raises:
|
|
292
|
-
Exception if lock cannot be acquired within timeout
|
|
293
|
-
"""
|
|
294
|
-
if brain_path is None:
|
|
295
|
-
from .common import get_brain_path
|
|
296
|
-
brain_path = get_brain_path()
|
|
297
|
-
|
|
298
|
-
lock_file_path = brain_path / ".sync.lock"
|
|
299
|
-
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
-
|
|
301
|
-
# Open lock file
|
|
302
|
-
lock_fd = open(lock_file_path, 'w')
|
|
303
|
-
|
|
304
|
-
# Try to acquire lock with timeout
|
|
305
|
-
start_time = time.time()
|
|
306
|
-
|
|
307
|
-
while True:
|
|
308
|
-
try:
|
|
309
|
-
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
310
|
-
break
|
|
311
|
-
except IOError:
|
|
312
|
-
if time.time() - start_time > timeout:
|
|
313
|
-
lock_fd.close()
|
|
314
|
-
raise Exception(f"Could not acquire sync lock after {timeout}s - another agent may be syncing")
|
|
315
|
-
time.sleep(0.1)
|
|
316
|
-
|
|
317
|
-
try:
|
|
318
|
-
# Write lock holder info
|
|
319
|
-
lock_info = {
|
|
320
|
-
"agent": get_current_agent(brain_path),
|
|
321
|
-
"acquired_at": datetime.now().isoformat(),
|
|
322
|
-
"pid": os.getpid()
|
|
323
|
-
}
|
|
324
|
-
lock_fd.write(json.dumps(lock_info))
|
|
325
|
-
lock_fd.flush()
|
|
326
|
-
|
|
327
|
-
yield
|
|
328
|
-
finally:
|
|
329
|
-
# Release lock
|
|
330
|
-
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
|
|
331
|
-
lock_fd.close()
|
|
332
|
-
try:
|
|
333
|
-
lock_file_path.unlink(missing_ok=True)
|
|
334
|
-
except Exception:
|
|
335
|
-
pass
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _verify_brain_path_safety(brain_path: Path):
|
|
339
|
-
"""
|
|
340
|
-
Ensure the brain path is technically safe/allowed.
|
|
341
|
-
Requirement from Turning 23/29 Resilience Audit.
|
|
342
|
-
"""
|
|
343
|
-
if os.environ.get("NUCLEUS_UNSAFE_SYNC") == "true":
|
|
344
|
-
return
|
|
345
|
-
|
|
346
|
-
try:
|
|
347
|
-
from .common import get_brain_path
|
|
348
|
-
configured_brain = get_brain_path().resolve()
|
|
349
|
-
|
|
350
|
-
# Must be exactly the configured brain path or a descendant
|
|
351
|
-
if not str(brain_path.resolve()).startswith(str(configured_brain)):
|
|
352
|
-
raise PermissionError(f"Access denied: Path {brain_path} is outside the configured Nucleus project scope.")
|
|
353
|
-
except Exception as e:
|
|
354
|
-
if isinstance(e, PermissionError):
|
|
355
|
-
raise
|
|
356
|
-
# Fallback for transient resolution errors
|
|
357
|
-
pass
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def _calculate_file_hash(file_path: Path) -> str:
|
|
361
|
-
"""
|
|
362
|
-
Calculate SHA256 hash in chunks to prevent memory spikes.
|
|
363
|
-
Requirement from Finding 2 (Performance).
|
|
364
|
-
"""
|
|
365
|
-
if not file_path.exists():
|
|
366
|
-
return ""
|
|
367
|
-
|
|
368
|
-
h = hashlib.sha256()
|
|
369
|
-
try:
|
|
370
|
-
with open(file_path, "rb") as f:
|
|
371
|
-
while True:
|
|
372
|
-
chunk = f.read(8192)
|
|
373
|
-
if not chunk:
|
|
374
|
-
break
|
|
375
|
-
h.update(chunk)
|
|
376
|
-
return h.hexdigest()[:16]
|
|
377
|
-
except Exception as e:
|
|
378
|
-
logger.warning(f"Error hashing {file_path}: {e}")
|
|
379
|
-
return ""
|
|
380
|
-
# =============================================================================
|
|
381
|
-
# SYNC METADATA TRACKING
|
|
382
|
-
# =============================================================================
|
|
383
|
-
|
|
384
|
-
def _get_meta_file(file_path: Path) -> Path:
|
|
385
|
-
"""Get the metadata file path for a given file."""
|
|
386
|
-
return file_path.parent / f".{file_path.name}.meta"
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def get_last_modifier(file_path: Path) -> str:
|
|
390
|
-
"""Get agent that last modified this file."""
|
|
391
|
-
meta_file = _get_meta_file(file_path)
|
|
392
|
-
|
|
393
|
-
if meta_file.exists():
|
|
394
|
-
try:
|
|
395
|
-
meta = json.loads(meta_file.read_text())
|
|
396
|
-
return meta.get("last_agent", "unknown")
|
|
397
|
-
except Exception:
|
|
398
|
-
pass
|
|
399
|
-
|
|
400
|
-
return "unknown"
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def set_last_modifier(file_path: Path, agent_id: str):
|
|
404
|
-
"""Record which agent last modified this file with stat optimization."""
|
|
405
|
-
meta_file = _get_meta_file(file_path)
|
|
406
|
-
|
|
407
|
-
# Calculate file hash using the new chunked method
|
|
408
|
-
file_hash = _calculate_file_hash(file_path)
|
|
409
|
-
|
|
410
|
-
# Stat capture for high-speed conflict bypass
|
|
411
|
-
stat = file_path.stat() if file_path.exists() else None
|
|
412
|
-
|
|
413
|
-
meta = {
|
|
414
|
-
"last_agent": agent_id,
|
|
415
|
-
"last_modified": datetime.now().isoformat(),
|
|
416
|
-
"file": str(file_path.name),
|
|
417
|
-
"expected_hash": file_hash,
|
|
418
|
-
"st_mtime_ns": stat.st_mtime_ns if stat else 0,
|
|
419
|
-
"st_size": stat.st_size if stat else 0
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
# Atomic write pattern (tmp + replace)
|
|
423
|
-
tmp_meta = meta_file.with_suffix(".tmp")
|
|
424
|
-
try:
|
|
425
|
-
tmp_meta.write_text(json.dumps(meta, indent=2))
|
|
426
|
-
os.replace(tmp_meta, meta_file)
|
|
427
|
-
except Exception as e:
|
|
428
|
-
logger.error(f"Failed to write metadata for {file_path}: {e}")
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
def _cleanup_stale_conflicts(brain_path: Path):
|
|
432
|
-
"""Automatic cleanup of resolved .conflict files (Hygiene)."""
|
|
433
|
-
try:
|
|
434
|
-
from .sync_ops import get_watch_files
|
|
435
|
-
for rel_path in get_watch_files(brain_path):
|
|
436
|
-
file_path = brain_path / rel_path
|
|
437
|
-
conflict_file = file_path.parent / f".{file_path.name}.conflict"
|
|
438
|
-
if conflict_file.exists():
|
|
439
|
-
# If no active conflict detected, remove the stale marker
|
|
440
|
-
if not detect_conflict(file_path):
|
|
441
|
-
conflict_file.unlink(missing_ok=True)
|
|
442
|
-
logger.info(f"Cleaned up stale conflict marker for {rel_path}")
|
|
443
|
-
except Exception as e:
|
|
444
|
-
logger.debug(f"Cleanup failed (non-critical): {e}")
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def get_file_meta(file_path: Path) -> Dict[str, Any]:
|
|
448
|
-
"""Get full metadata for a file."""
|
|
449
|
-
meta_file = _get_meta_file(file_path)
|
|
450
|
-
|
|
451
|
-
if meta_file.exists():
|
|
452
|
-
try:
|
|
453
|
-
return json.loads(meta_file.read_text())
|
|
454
|
-
except Exception:
|
|
455
|
-
pass
|
|
456
|
-
|
|
457
|
-
return {}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
# =============================================================================
|
|
461
|
-
# CONFLICT DETECTION
|
|
462
|
-
# =============================================================================
|
|
463
|
-
|
|
464
|
-
def detect_conflict(file_path: Path) -> Optional[Dict[str, Any]]:
|
|
465
|
-
"""
|
|
466
|
-
Detect if file has conflicting changes with stat optimization.
|
|
467
|
-
|
|
468
|
-
Returns conflict info if detected, None otherwise.
|
|
469
|
-
"""
|
|
470
|
-
meta_file = _get_meta_file(file_path)
|
|
471
|
-
|
|
472
|
-
if not meta_file.exists() or not file_path.exists():
|
|
473
|
-
return None
|
|
474
|
-
|
|
475
|
-
try:
|
|
476
|
-
meta = json.loads(meta_file.read_text())
|
|
477
|
-
|
|
478
|
-
# Fast Check Bypass (Stat Capture)
|
|
479
|
-
if os.environ.get("NUCLEUS_SYNC_FORCE_HASH") != "true":
|
|
480
|
-
stat = file_path.stat()
|
|
481
|
-
if "st_mtime_ns" in meta and meta["st_mtime_ns"] == stat.st_mtime_ns:
|
|
482
|
-
if "st_size" in meta and meta["st_size"] == stat.st_size:
|
|
483
|
-
# Skip expensive hashing
|
|
484
|
-
return None
|
|
485
|
-
|
|
486
|
-
# Fallback to absolute content hash verification
|
|
487
|
-
current_hash = _calculate_file_hash(file_path)
|
|
488
|
-
|
|
489
|
-
if "expected_hash" in meta and meta["expected_hash"] != current_hash:
|
|
490
|
-
return {
|
|
491
|
-
"file": str(file_path),
|
|
492
|
-
"expected_agent": meta.get("last_agent"),
|
|
493
|
-
"expected_hash": meta["expected_hash"],
|
|
494
|
-
"actual_hash": current_hash,
|
|
495
|
-
"last_modified": meta.get("last_modified"),
|
|
496
|
-
"conflict_type": "unexpected_modification"
|
|
497
|
-
}
|
|
498
|
-
except Exception as e:
|
|
499
|
-
logger.warning(f"Error detecting conflict for {file_path}: {e}")
|
|
500
|
-
|
|
501
|
-
return None
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def resolve_conflict(conflict: Dict[str, Any], strategy: str,
|
|
505
|
-
brain_path: Optional[Path] = None) -> str:
|
|
506
|
-
"""
|
|
507
|
-
Resolve a detected conflict.
|
|
508
|
-
|
|
509
|
-
Strategies:
|
|
510
|
-
- "last_write_wins": Accept current state
|
|
511
|
-
- "manual": Create conflict marker file
|
|
512
|
-
"""
|
|
513
|
-
if brain_path is None:
|
|
514
|
-
from .common import get_brain_path
|
|
515
|
-
brain_path = get_brain_path()
|
|
516
|
-
|
|
517
|
-
file_path = Path(conflict["file"])
|
|
518
|
-
|
|
519
|
-
if strategy == "last_write_wins":
|
|
520
|
-
# Accept current state, update metadata
|
|
521
|
-
set_last_modifier(file_path, get_current_agent(brain_path))
|
|
522
|
-
return "resolved_accept_current"
|
|
523
|
-
|
|
524
|
-
elif strategy == "manual":
|
|
525
|
-
# Create conflict marker file for manual resolution
|
|
526
|
-
conflict_file = file_path.parent / f".{file_path.name}.conflict"
|
|
527
|
-
conflict["detected_at"] = datetime.now().isoformat()
|
|
528
|
-
conflict["detected_by"] = get_current_agent(brain_path)
|
|
529
|
-
conflict_file.write_text(json.dumps(conflict, indent=2))
|
|
530
|
-
return "requires_manual_resolution"
|
|
531
|
-
|
|
532
|
-
else:
|
|
533
|
-
raise ValueError(f"Unknown conflict strategy: {strategy}")
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
# =============================================================================
|
|
537
|
-
# CORE SYNC LOGIC
|
|
538
|
-
# =============================================================================
|
|
539
|
-
|
|
540
|
-
def perform_sync(force: bool = False, brain_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
541
|
-
"""
|
|
542
|
-
Core sync logic with hardened v0.7.1 refinements.
|
|
543
|
-
"""
|
|
544
|
-
if brain_path is None:
|
|
545
|
-
from .common import get_brain_path
|
|
546
|
-
brain_path = get_brain_path()
|
|
547
|
-
|
|
548
|
-
# Pillar 1: Path Security Verify
|
|
549
|
-
_verify_brain_path_safety(brain_path)
|
|
550
|
-
|
|
551
|
-
config = _load_sync_config(brain_path)
|
|
552
|
-
current_agent = get_current_agent(brain_path)
|
|
553
|
-
|
|
554
|
-
result = {
|
|
555
|
-
"timestamp": datetime.now().isoformat(),
|
|
556
|
-
"agent": current_agent,
|
|
557
|
-
"files_synced": [],
|
|
558
|
-
"conflicts": [],
|
|
559
|
-
"sync_duration_ms": 0
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
start_time = time.time()
|
|
563
|
-
|
|
564
|
-
# Get files to check
|
|
565
|
-
watch_files = get_watch_files(brain_path)
|
|
566
|
-
conflict_strategy = config.get("sync", {}).get("conflict_resolution", "last_write_wins")
|
|
567
|
-
|
|
568
|
-
for watch_file in watch_files:
|
|
569
|
-
file_path = brain_path / watch_file
|
|
570
|
-
|
|
571
|
-
if not file_path.exists():
|
|
572
|
-
continue
|
|
573
|
-
|
|
574
|
-
# Check for conflicts
|
|
575
|
-
conflict = detect_conflict(file_path)
|
|
576
|
-
if conflict:
|
|
577
|
-
resolution = resolve_conflict(conflict, conflict_strategy, brain_path)
|
|
578
|
-
result["conflicts"].append({
|
|
579
|
-
**conflict,
|
|
580
|
-
"resolution": resolution
|
|
581
|
-
})
|
|
582
|
-
|
|
583
|
-
# Check if file was modified by another agent
|
|
584
|
-
last_modifier = get_last_modifier(file_path)
|
|
585
|
-
|
|
586
|
-
if last_modifier != current_agent or force:
|
|
587
|
-
# File was changed by another agent or force sync
|
|
588
|
-
result["files_synced"].append({
|
|
589
|
-
"file": watch_file,
|
|
590
|
-
"action": "reloaded" if last_modifier != current_agent else "refreshed",
|
|
591
|
-
"previous_agent": last_modifier,
|
|
592
|
-
"current_agent": current_agent
|
|
593
|
-
})
|
|
594
|
-
|
|
595
|
-
# Update metadata to mark current agent
|
|
596
|
-
set_last_modifier(file_path, current_agent)
|
|
597
|
-
|
|
598
|
-
# Pillar 4: Garbage Collection (Marker Cleanup)
|
|
599
|
-
_cleanup_stale_conflicts(brain_path)
|
|
600
|
-
|
|
601
|
-
result["sync_duration_ms"] = int((time.time() - start_time) * 1000)
|
|
602
|
-
|
|
603
|
-
return result
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
def get_sync_status(brain_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
607
|
-
"""
|
|
608
|
-
Get comprehensive sync status.
|
|
609
|
-
|
|
610
|
-
Returns current state including:
|
|
611
|
-
- Sync enabled/mode
|
|
612
|
-
- Current agent
|
|
613
|
-
- Detected agents from metadata
|
|
614
|
-
- Pending conflicts
|
|
615
|
-
- Auto-sync status
|
|
616
|
-
"""
|
|
617
|
-
if brain_path is None:
|
|
618
|
-
from .common import get_brain_path
|
|
619
|
-
brain_path = get_brain_path()
|
|
620
|
-
|
|
621
|
-
config = _load_sync_config(brain_path)
|
|
622
|
-
sync_config = config.get("sync", {})
|
|
623
|
-
|
|
624
|
-
# Detect all agents that have modified files
|
|
625
|
-
detected_agents = set()
|
|
626
|
-
pending_conflicts = []
|
|
627
|
-
|
|
628
|
-
for watch_file in get_watch_files(brain_path):
|
|
629
|
-
file_path = brain_path / watch_file
|
|
630
|
-
if file_path.exists():
|
|
631
|
-
meta = get_file_meta(file_path)
|
|
632
|
-
if meta.get("last_agent"):
|
|
633
|
-
detected_agents.add(meta["last_agent"])
|
|
634
|
-
|
|
635
|
-
conflict = detect_conflict(file_path)
|
|
636
|
-
if conflict:
|
|
637
|
-
pending_conflicts.append(conflict)
|
|
638
|
-
|
|
639
|
-
# Get last sync time
|
|
640
|
-
last_sync = None
|
|
641
|
-
sync_log = brain_path / ".sync_last"
|
|
642
|
-
if sync_log.exists():
|
|
643
|
-
try:
|
|
644
|
-
last_sync = sync_log.read_text().strip()
|
|
645
|
-
except Exception:
|
|
646
|
-
pass
|
|
647
|
-
|
|
648
|
-
return {
|
|
649
|
-
"sync_enabled": sync_config.get("enabled", False),
|
|
650
|
-
"mode": sync_config.get("mode", "manual"),
|
|
651
|
-
"last_sync": last_sync,
|
|
652
|
-
"current_agent": get_current_agent(brain_path),
|
|
653
|
-
"agent_info": get_agent_info(brain_path),
|
|
654
|
-
"detected_agents": list(detected_agents),
|
|
655
|
-
"files_watched": get_watch_files(brain_path),
|
|
656
|
-
"pending_conflicts": pending_conflicts,
|
|
657
|
-
"auto_sync_running": _is_watcher_running(),
|
|
658
|
-
"config_file": str(brain_path / "config" / "nucleus.yaml"),
|
|
659
|
-
"config_exists": (brain_path / "config" / "nucleus.yaml").exists()
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
def record_sync_time(brain_path: Optional[Path] = None):
|
|
664
|
-
"""Record the last sync timestamp."""
|
|
665
|
-
if brain_path is None:
|
|
666
|
-
from .common import get_brain_path
|
|
667
|
-
brain_path = get_brain_path()
|
|
668
|
-
|
|
669
|
-
sync_log = brain_path / ".sync_last"
|
|
670
|
-
sync_log.write_text(datetime.now().isoformat())
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
# =============================================================================
|
|
674
|
-
# AUTO-SYNC FILE WATCHER
|
|
675
|
-
# =============================================================================
|
|
676
|
-
|
|
677
|
-
# Global observer instance
|
|
678
|
-
_observer = None
|
|
679
|
-
_observer_lock = threading.Lock()
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
def _is_watcher_running() -> bool:
|
|
683
|
-
"""Check if file watcher is running."""
|
|
684
|
-
global _observer
|
|
685
|
-
return _observer is not None and _observer.is_alive()
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
class BrainSyncHandler:
|
|
689
|
-
"""File system event handler for auto-sync with serialized worker (v0.7.1)."""
|
|
690
|
-
|
|
691
|
-
def __init__(self, brain_path: Path, config: Dict[str, Any]):
|
|
692
|
-
self.brain_path = brain_path
|
|
693
|
-
self.config = config
|
|
694
|
-
self.watch_files = set(config.get("sync", {}).get("watch_files", []))
|
|
695
|
-
self.last_sync: Dict[str, float] = {}
|
|
696
|
-
self.min_interval = config.get("sync", {}).get("interval", 5)
|
|
697
|
-
|
|
698
|
-
# Serialized Work Queue
|
|
699
|
-
self.work_queue = queue.Queue()
|
|
700
|
-
self.worker = threading.Thread(target=self._worker_loop, daemon=True)
|
|
701
|
-
self.worker.start()
|
|
702
|
-
|
|
703
|
-
def _worker_loop(self):
|
|
704
|
-
"""Single worker thread to process sync requests one at a time."""
|
|
705
|
-
while True:
|
|
706
|
-
try:
|
|
707
|
-
rel_path = self.work_queue.get()
|
|
708
|
-
if rel_path is None:
|
|
709
|
-
break
|
|
710
|
-
self._sync_file(rel_path)
|
|
711
|
-
self.work_queue.task_done()
|
|
712
|
-
except Exception as e:
|
|
713
|
-
logger.error(f"Worker loop error: {e}")
|
|
714
|
-
time.sleep(1)
|
|
715
|
-
|
|
716
|
-
def on_modified(self, event):
|
|
717
|
-
"""Handle file modification events by queuing them."""
|
|
718
|
-
if event.is_directory:
|
|
719
|
-
return
|
|
720
|
-
|
|
721
|
-
try:
|
|
722
|
-
# Check if this is a watched file
|
|
723
|
-
file_path = Path(event.src_path)
|
|
724
|
-
rel_path = file_path.relative_to(self.brain_path)
|
|
725
|
-
|
|
726
|
-
if str(rel_path) not in self.watch_files:
|
|
727
|
-
return
|
|
728
|
-
|
|
729
|
-
# Debounce - don't queue too frequently for the same file
|
|
730
|
-
now = time.time()
|
|
731
|
-
key = str(rel_path)
|
|
732
|
-
if key in self.last_sync:
|
|
733
|
-
if now - self.last_sync[key] < self.min_interval:
|
|
734
|
-
return
|
|
735
|
-
|
|
736
|
-
self.last_sync[key] = now
|
|
737
|
-
|
|
738
|
-
# Queue the work instead of spawning a thread (Finding 3)
|
|
739
|
-
self.work_queue.put(rel_path)
|
|
740
|
-
except Exception as e:
|
|
741
|
-
logger.error(f"Error in file watcher: {e}")
|
|
742
|
-
|
|
743
|
-
def _sync_file(self, rel_path: Path):
|
|
744
|
-
"""Sync a single file."""
|
|
745
|
-
try:
|
|
746
|
-
with sync_lock(self.brain_path, timeout=2):
|
|
747
|
-
logger.info(f"Auto-syncing due to change in {rel_path}")
|
|
748
|
-
result = perform_sync(force=False, brain_path=self.brain_path)
|
|
749
|
-
record_sync_time(self.brain_path)
|
|
750
|
-
|
|
751
|
-
# Emit event ONLY if something changed or conflict occurred (Finding 3: Noise)
|
|
752
|
-
if result.get("files_synced") or result.get("conflicts"):
|
|
753
|
-
from .event_ops import _emit_event
|
|
754
|
-
_emit_event(
|
|
755
|
-
event_type="SYNC_AUTO",
|
|
756
|
-
emitter=get_current_agent(self.brain_path),
|
|
757
|
-
data=result,
|
|
758
|
-
description=f"Auto-sync detected changes triggered by {rel_path}"
|
|
759
|
-
)
|
|
760
|
-
except Exception as e:
|
|
761
|
-
logger.error(f"Auto-sync failed: {e}")
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
def start_file_watcher(brain_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
765
|
-
"""
|
|
766
|
-
Start watching files for changes.
|
|
767
|
-
|
|
768
|
-
Uses watchdog library for cross-platform file watching.
|
|
769
|
-
"""
|
|
770
|
-
global _observer
|
|
771
|
-
|
|
772
|
-
if brain_path is None:
|
|
773
|
-
from .common import get_brain_path
|
|
774
|
-
brain_path = get_brain_path()
|
|
775
|
-
|
|
776
|
-
config = _load_sync_config(brain_path)
|
|
777
|
-
|
|
778
|
-
if not config.get("sync", {}).get("enabled"):
|
|
779
|
-
return {
|
|
780
|
-
"error": "Sync not enabled",
|
|
781
|
-
"hint": "Add sync.enabled: true to .brain/config/nucleus.yaml"
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
with _observer_lock:
|
|
785
|
-
# Stop existing watcher if running
|
|
786
|
-
if _observer is not None:
|
|
787
|
-
stop_file_watcher()
|
|
788
|
-
|
|
789
|
-
try:
|
|
790
|
-
from watchdog.observers import Observer
|
|
791
|
-
from watchdog.events import FileSystemEventHandler
|
|
792
|
-
|
|
793
|
-
# Create handler
|
|
794
|
-
handler = BrainSyncHandler(brain_path, config)
|
|
795
|
-
|
|
796
|
-
# Create a watchdog-compatible wrapper
|
|
797
|
-
class WatchdogHandler(FileSystemEventHandler):
|
|
798
|
-
def on_modified(self, event):
|
|
799
|
-
handler.on_modified(event)
|
|
800
|
-
|
|
801
|
-
_observer = Observer()
|
|
802
|
-
_observer.schedule(WatchdogHandler(), str(brain_path), recursive=True)
|
|
803
|
-
_observer.start()
|
|
804
|
-
|
|
805
|
-
logger.info(f"File watcher started for {brain_path}")
|
|
806
|
-
|
|
807
|
-
return {
|
|
808
|
-
"auto_sync_enabled": True,
|
|
809
|
-
"watching_files": list(handler.watch_files),
|
|
810
|
-
"check_interval": handler.min_interval,
|
|
811
|
-
"status": "watcher_started",
|
|
812
|
-
"brain_path": str(brain_path)
|
|
813
|
-
}
|
|
814
|
-
except ImportError:
|
|
815
|
-
return {
|
|
816
|
-
"error": "watchdog library not installed",
|
|
817
|
-
"hint": "pip install watchdog",
|
|
818
|
-
"status": "watcher_not_available"
|
|
819
|
-
}
|
|
820
|
-
except Exception as e:
|
|
821
|
-
logger.error(f"Failed to start file watcher: {e}")
|
|
822
|
-
return {
|
|
823
|
-
"error": str(e),
|
|
824
|
-
"status": "watcher_failed"
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
def stop_file_watcher() -> Dict[str, Any]:
|
|
829
|
-
"""Stop file watcher."""
|
|
830
|
-
global _observer
|
|
831
|
-
|
|
832
|
-
with _observer_lock:
|
|
833
|
-
if _observer is not None:
|
|
834
|
-
try:
|
|
835
|
-
_observer.stop()
|
|
836
|
-
_observer.join(timeout=5)
|
|
837
|
-
_observer = None
|
|
838
|
-
logger.info("File watcher stopped")
|
|
839
|
-
return {
|
|
840
|
-
"auto_sync_enabled": False,
|
|
841
|
-
"status": "watcher_stopped"
|
|
842
|
-
}
|
|
843
|
-
except Exception as e:
|
|
844
|
-
logger.error(f"Error stopping watcher: {e}")
|
|
845
|
-
_observer = None
|
|
846
|
-
return {
|
|
847
|
-
"auto_sync_enabled": False,
|
|
848
|
-
"status": "watcher_stopped_with_error",
|
|
849
|
-
"error": str(e)
|
|
850
|
-
}
|
|
851
|
-
else:
|
|
852
|
-
return {
|
|
853
|
-
"auto_sync_enabled": False,
|
|
854
|
-
"status": "watcher_not_running"
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
# =============================================================================
|
|
859
|
-
# AUTO-START HOOK
|
|
860
|
-
# =============================================================================
|
|
861
|
-
|
|
862
|
-
def auto_start_sync_if_configured(brain_path: Optional[Path] = None):
|
|
863
|
-
"""
|
|
864
|
-
Auto-start file watcher if configured for auto mode.
|
|
865
|
-
Called during MCP server initialization.
|
|
866
|
-
"""
|
|
867
|
-
if brain_path is None:
|
|
868
|
-
try:
|
|
869
|
-
from .common import get_brain_path
|
|
870
|
-
brain_path = get_brain_path()
|
|
871
|
-
except Exception:
|
|
872
|
-
return
|
|
873
|
-
|
|
874
|
-
config = _load_sync_config(brain_path)
|
|
875
|
-
|
|
876
|
-
if config.get("sync", {}).get("enabled") and config.get("sync", {}).get("mode") == "auto":
|
|
877
|
-
logger.info("Auto-starting sync watcher (mode=auto)")
|
|
878
|
-
start_file_watcher(brain_path)
|