nexo-brain 2.5.0 → 2.6.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/src/crons/sync.py CHANGED
@@ -20,74 +20,159 @@ import json
20
20
  import os
21
21
  import platform
22
22
  import plistlib
23
+ import shutil
23
24
  import subprocess
24
25
  import sys
25
26
  from pathlib import Path
26
27
 
28
+ _CRONS_DIR = Path(__file__).resolve().parent
29
+ _DEFAULT_RUNTIME_ROOT = _CRONS_DIR.parent
30
+ _runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
31
+ if str(_runtime_root) not in sys.path:
32
+ sys.path.insert(0, str(_runtime_root))
33
+
34
+ from cron_recovery import should_run_at_load
35
+
27
36
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
28
- NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
37
+ SOURCE_ROOT = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
38
+ RUNTIME_ROOT = NEXO_HOME
29
39
  MANIFEST = Path(__file__).resolve().parent / "manifest.json"
30
40
  LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
31
41
  LABEL_PREFIX = "com.nexo."
32
42
  LOG_DIR = NEXO_HOME / "logs"
43
+ OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
44
+ RETIRED_CORE_FILES = (
45
+ Path("scripts") / "nexo-day-orchestrator.sh",
46
+ )
33
47
 
34
48
 
35
49
  def log(msg: str):
36
50
  print(f"[cron-sync] {msg}", flush=True)
37
51
 
38
52
 
53
+ def _sync_watchdog_hash_registry():
54
+ """Keep the immutable-hash registry aligned with the runtime watchdog script."""
55
+ try:
56
+ watchdog_path = RUNTIME_ROOT / "scripts" / "nexo-watchdog.sh"
57
+ if not watchdog_path.exists():
58
+ return
59
+ registry_path = RUNTIME_ROOT / "scripts" / ".watchdog-hashes"
60
+ entries: dict[str, str] = {}
61
+ if registry_path.exists():
62
+ for line in registry_path.read_text().splitlines():
63
+ if "|" not in line:
64
+ continue
65
+ file_path, expected_hash = line.split("|", 1)
66
+ if file_path:
67
+ entries[file_path] = expected_hash
68
+ import hashlib
69
+ entries[str(watchdog_path)] = hashlib.sha256(watchdog_path.read_bytes()).hexdigest()
70
+ registry_path.write_text(
71
+ "\n".join(f"{file_path}|{digest}" for file_path, digest in sorted(entries.items())) + "\n"
72
+ )
73
+ except Exception as e:
74
+ log(f"WARNING: could not sync watchdog hash registry: {e}")
75
+
76
+
77
+ def _refresh_runtime_manifest():
78
+ """Keep the installed crons manifest aligned with the source manifest."""
79
+ try:
80
+ runtime_manifest = RUNTIME_ROOT / "crons" / "manifest.json"
81
+ runtime_manifest.parent.mkdir(parents=True, exist_ok=True)
82
+ shutil.copy2(MANIFEST, runtime_manifest)
83
+ except Exception as e:
84
+ log(f"WARNING: could not refresh runtime manifest: {e}")
85
+
86
+
87
+ def _cleanup_retired_core_files():
88
+ """Remove retired core runtime files that should no longer survive updates."""
89
+ for rel_path in RETIRED_CORE_FILES:
90
+ try:
91
+ target = RUNTIME_ROOT / rel_path
92
+ if target.exists():
93
+ if target.is_dir():
94
+ shutil.rmtree(target)
95
+ else:
96
+ target.unlink()
97
+ log(f" Removed retired core file: {rel_path}")
98
+ except Exception as e:
99
+ log(f"WARNING: could not remove retired core file {rel_path}: {e}")
100
+
101
+
39
102
  def load_manifest() -> list[dict]:
40
103
  with open(MANIFEST) as f:
41
104
  data = json.load(f)
42
- return data.get("crons", [])
105
+ crons = data.get("crons", [])
106
+
107
+ enabled_optionals: dict[str, bool] = {}
108
+ if OPTIONALS_FILE.is_file():
109
+ try:
110
+ enabled_optionals = json.loads(OPTIONALS_FILE.read_text())
111
+ except Exception as e:
112
+ log(f"WARNING: could not read optionals.json: {e}")
113
+
114
+ filtered = []
115
+ for cron in crons:
116
+ optional_key = cron.get("optional")
117
+ if optional_key and not enabled_optionals.get(optional_key, False):
118
+ continue
119
+ filtered.append(cron)
120
+ return filtered
121
+
43
122
 
123
+ def _runtime_relative_path(src: Path) -> Path:
124
+ """Return the path inside NEXO_HOME that mirrors the source tree."""
125
+ src = src.resolve()
126
+ try:
127
+ return src.relative_to(SOURCE_ROOT.resolve())
128
+ except Exception:
129
+ # Best effort fallback for unexpected inputs.
130
+ return Path("scripts") / src.name
44
131
 
45
- def _copy_script_to_nexo_home(src: Path) -> Path:
46
- """Copy a script from NEXO_CODE to NEXO_HOME/scripts/ for Sandbox compatibility.
47
132
 
48
- macOS Sandbox blocks LaunchAgents from executing scripts in ~/Documents/.
49
- We copy scripts to NEXO_HOME/scripts/ which is outside the Sandbox restricted paths.
133
+ def _copy_into_runtime(src: Path) -> Path:
134
+ """Copy a script or directory from the source tree into NEXO_HOME.
135
+
136
+ LaunchAgents should execute from NEXO_HOME, not directly from repo paths
137
+ under macOS-protected folders such as ~/Documents.
50
138
  """
51
- dest_dir = NEXO_HOME / "scripts"
52
- dest_dir.mkdir(parents=True, exist_ok=True)
139
+ dest = RUNTIME_ROOT / _runtime_relative_path(src)
140
+ dest.parent.mkdir(parents=True, exist_ok=True)
53
141
 
54
142
  if src.is_dir():
55
- import shutil
56
- dest = dest_dir / src.name
57
143
  if dest.exists():
58
144
  shutil.rmtree(dest)
59
145
  shutil.copytree(src, dest)
60
146
  return dest
61
- else:
62
- dest = dest_dir / src.name
63
- import shutil
64
- shutil.copy2(src, dest)
147
+
148
+ shutil.copy2(src, dest)
149
+ if src.suffix in {".sh", ".py"} or os.access(src, os.X_OK):
65
150
  dest.chmod(0o755)
66
- return dest
151
+ return dest
67
152
 
68
153
 
69
154
  def build_plist(cron: dict) -> dict:
70
155
  """Build a macOS LaunchAgent plist dict from a manifest entry."""
71
156
  cron_id = cron["id"]
72
157
  label = f"{LABEL_PREFIX}{cron_id}"
73
- script_src = NEXO_CODE / cron["script"]
158
+ script_src = SOURCE_ROOT / cron["script"]
74
159
  script_type = cron.get("type", "python")
75
160
 
76
- # Copy scripts to NEXO_HOME/scripts/ to avoid macOS Sandbox restrictions
77
- script_dest = _copy_script_to_nexo_home(script_src)
161
+ # Copy scripts into NEXO_HOME preserving the source tree layout.
162
+ script_dest = _copy_into_runtime(script_src)
78
163
  script_path = str(script_dest)
79
164
 
80
165
  # Also copy the wrapper and any subdirectories (e.g., deep-sleep/)
81
- wrapper_src = NEXO_CODE / "scripts" / "nexo-cron-wrapper.sh"
82
- wrapper_dest = _copy_script_to_nexo_home(wrapper_src)
166
+ wrapper_src = SOURCE_ROOT / "scripts" / "nexo-cron-wrapper.sh"
167
+ wrapper_dest = _copy_into_runtime(wrapper_src)
83
168
  wrapper_path = str(wrapper_dest)
84
169
 
85
170
  # Copy script subdirectories if they exist (e.g., deep-sleep/ for nexo-deep-sleep.sh)
86
171
  script_name = script_src.stem # e.g., "nexo-deep-sleep"
87
172
  subdir_name = script_name.replace("nexo-", "") # e.g., "deep-sleep"
88
- subdir_src = NEXO_CODE / "scripts" / subdir_name
173
+ subdir_src = SOURCE_ROOT / "scripts" / subdir_name
89
174
  if subdir_src.is_dir():
90
- _copy_script_to_nexo_home(subdir_src)
175
+ _copy_into_runtime(subdir_src)
91
176
 
92
177
  if script_type == "shell":
93
178
  program_args = ["/bin/bash", wrapper_path, cron_id, "/bin/bash", script_path]
@@ -118,17 +203,23 @@ def build_plist(cron: dict) -> dict:
118
203
  + "/Library/Frameworks/Python.framework/Versions/3.12/bin",
119
204
  "HOME": str(Path.home()),
120
205
  "NEXO_HOME": str(NEXO_HOME),
121
- "NEXO_CODE": str(NEXO_CODE),
206
+ "NEXO_CODE": str(RUNTIME_ROOT),
207
+ "NEXO_SOURCE_CODE": str(SOURCE_ROOT),
208
+ "NEXO_MANAGED_CORE_CRON": "1",
122
209
  "PYTHONUNBUFFERED": "1",
123
210
  },
124
211
  }
125
212
 
126
213
  # Schedule
127
- if cron.get("run_at_load"):
214
+ if cron.get("keep_alive"):
128
215
  plist["RunAtLoad"] = True
129
- elif "interval_seconds" in cron:
216
+ plist["KeepAlive"] = True
217
+ else:
218
+ if should_run_at_load(cron):
219
+ plist["RunAtLoad"] = True
220
+ if "interval_seconds" in cron and not cron.get("keep_alive"):
130
221
  plist["StartInterval"] = cron["interval_seconds"]
131
- elif "schedule" in cron:
222
+ elif "schedule" in cron and not cron.get("keep_alive"):
132
223
  cal = {}
133
224
  s = cron["schedule"]
134
225
  if "hour" in s:
@@ -170,6 +261,10 @@ def plist_needs_update(existing_path: Path, new_plist: dict) -> bool:
170
261
  return True
171
262
  if existing.get("RunAtLoad") != new_plist.get("RunAtLoad"):
172
263
  return True
264
+ if existing.get("KeepAlive") != new_plist.get("KeepAlive"):
265
+ return True
266
+ if existing.get("EnvironmentVariables") != new_plist.get("EnvironmentVariables"):
267
+ return True
173
268
  return False
174
269
 
175
270
 
@@ -244,8 +339,15 @@ def sync(dry_run: bool = False):
244
339
  try:
245
340
  with open(plist_path, "rb") as f:
246
341
  existing = plistlib.load(f)
342
+ env = existing.get("EnvironmentVariables", {}) or {}
247
343
  args = existing.get("ProgramArguments", [])
248
- is_core = any(str(NEXO_CODE) in str(a) for a in args)
344
+ is_core = env.get("NEXO_MANAGED_CORE_CRON") == "1"
345
+ if not is_core:
346
+ arg_blob = " ".join(str(a) for a in args)
347
+ is_core = (
348
+ "nexo-cron-wrapper.sh" in arg_blob
349
+ and (str(SOURCE_ROOT) in arg_blob or str(NEXO_HOME) in arg_blob)
350
+ )
249
351
  except Exception:
250
352
  is_core = False
251
353
 
@@ -255,6 +357,9 @@ def sync(dry_run: bool = False):
255
357
  else:
256
358
  log(f" SKIP (personal): {cron_id}")
257
359
 
360
+ _cleanup_retired_core_files()
361
+ _refresh_runtime_manifest()
362
+ _sync_watchdog_hash_registry()
258
363
  log("Sync complete.")
259
364
 
260
365
 
@@ -93,6 +93,17 @@ from db._cron_runs import (
93
93
  cron_run_start, cron_run_end, cron_runs_recent, cron_runs_summary,
94
94
  )
95
95
 
96
+ # Personal scripts registry
97
+ from db._personal_scripts import (
98
+ upsert_personal_script, list_personal_scripts, get_personal_script,
99
+ delete_missing_personal_scripts, register_personal_script_schedule,
100
+ delete_missing_personal_schedules, list_personal_script_schedules,
101
+ get_personal_script_schedule, delete_personal_script_schedule,
102
+ delete_personal_script,
103
+ record_personal_script_run, sync_personal_scripts_registry,
104
+ get_personal_script_health_report,
105
+ )
106
+
96
107
  # Skills
97
108
  from db._skills import (
98
109
  create_skill, get_skill, list_skills, search_skills,