nexo-brain 2.3.1 → 2.3.2
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/bin/nexo-brain.js +92 -9
- package/bin/postinstall.js +22 -15
- package/package.json +2 -2
- package/src/auto_update.py +193 -5
- package/src/crons/sync.py +5 -0
- package/src/db/_schema.py +11 -1
- package/src/hooks/capture-tool-logs.sh +23 -6
- package/src/hooks/session-start.sh +4 -3
- package/src/plugins/update.py +376 -26
- package/src/scripts/nexo-catchup.py +29 -4
- package/src/scripts/nexo-daily-self-audit.py +21 -1
- package/src/scripts/nexo-evolution-run.py +21 -1
- package/src/scripts/nexo-postmortem-consolidator.py +34 -9
- package/src/scripts/nexo-sleep.py +32 -10
- package/src/scripts/nexo-synthesis.py +29 -9
- package/src/scripts/nexo-update.sh +109 -7
- package/src/scripts/nexo-watchdog.sh +103 -47
- package/src/server.py +65 -1
package/src/plugins/update.py
CHANGED
|
@@ -13,19 +13,71 @@ from pathlib import Path
|
|
|
13
13
|
_THIS_DIR = Path(__file__).resolve().parent
|
|
14
14
|
REPO_DIR = _THIS_DIR.parent.parent
|
|
15
15
|
PACKAGE_JSON = REPO_DIR / "package.json"
|
|
16
|
-
SRC_DIR = REPO_DIR / "src"
|
|
17
16
|
|
|
18
17
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
19
18
|
DATA_DIR = NEXO_HOME / "data"
|
|
20
19
|
BACKUP_BASE = NEXO_HOME / "backups"
|
|
21
20
|
|
|
21
|
+
# In packaged installs, update.py lives at ~/.nexo/plugins/update.py
|
|
22
|
+
# so REPO_DIR would be ~/ (wrong). Detect this and fix paths.
|
|
23
|
+
_PACKAGED_INSTALL = not (REPO_DIR / ".git").exists() and not (REPO_DIR / ".git").is_file()
|
|
24
|
+
|
|
25
|
+
if _PACKAGED_INSTALL:
|
|
26
|
+
# In packaged mode, core .py files live directly in NEXO_HOME
|
|
27
|
+
SRC_DIR = NEXO_HOME
|
|
28
|
+
else:
|
|
29
|
+
SRC_DIR = REPO_DIR / "src"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_npm_pkg_src() -> Path | None:
|
|
33
|
+
"""Locate the nexo-brain npm package's src/ directory for requirements.txt."""
|
|
34
|
+
try:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["npm", "root", "-g"],
|
|
37
|
+
capture_output=True, text=True, timeout=10,
|
|
38
|
+
)
|
|
39
|
+
if result.returncode == 0:
|
|
40
|
+
npm_src = Path(result.stdout.strip()) / "nexo-brain" / "src"
|
|
41
|
+
if npm_src.is_dir():
|
|
42
|
+
return npm_src
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def _is_git_repo() -> bool:
|
|
48
|
+
"""Check if REPO_DIR is a valid git repository."""
|
|
49
|
+
return (REPO_DIR / ".git").exists() or (REPO_DIR / ".git").is_file()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _refresh_installed_manifest():
|
|
53
|
+
"""Copy source crons/ to NEXO_HOME/crons/ so catchup & watchdog stay current."""
|
|
54
|
+
try:
|
|
55
|
+
src_crons = SRC_DIR / "crons"
|
|
56
|
+
dst_crons = NEXO_HOME / "crons"
|
|
57
|
+
if src_crons.exists():
|
|
58
|
+
dst_crons.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
for f in src_crons.iterdir():
|
|
60
|
+
if f.is_file():
|
|
61
|
+
shutil.copy2(str(f), str(dst_crons / f.name))
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
22
65
|
|
|
23
66
|
def _read_version() -> str:
|
|
24
|
-
"""Read version from package.json."""
|
|
67
|
+
"""Read version from package.json or NEXO_HOME/version.json (packaged installs)."""
|
|
25
68
|
try:
|
|
26
|
-
|
|
69
|
+
if PACKAGE_JSON.exists():
|
|
70
|
+
return json.loads(PACKAGE_JSON.read_text()).get("version", "unknown")
|
|
27
71
|
except Exception:
|
|
28
|
-
|
|
72
|
+
pass
|
|
73
|
+
# Packaged installs don't ship package.json — check version.json in NEXO_HOME
|
|
74
|
+
try:
|
|
75
|
+
version_file = NEXO_HOME / "version.json"
|
|
76
|
+
if version_file.exists():
|
|
77
|
+
return json.loads(version_file.read_text()).get("version", "unknown")
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
return "unknown"
|
|
29
81
|
|
|
30
82
|
|
|
31
83
|
def _git(*args, cwd=None) -> tuple[int, str, str]:
|
|
@@ -40,13 +92,28 @@ def _git(*args, cwd=None) -> tuple[int, str, str]:
|
|
|
40
92
|
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
41
93
|
|
|
42
94
|
|
|
95
|
+
def _requirements_hash() -> str:
|
|
96
|
+
"""Return a content hash of requirements.txt, or empty string if missing."""
|
|
97
|
+
import hashlib
|
|
98
|
+
req_file = SRC_DIR / "requirements.txt"
|
|
99
|
+
if not req_file.exists() and _PACKAGED_INSTALL:
|
|
100
|
+
npm_src = _find_npm_pkg_src()
|
|
101
|
+
if npm_src:
|
|
102
|
+
req_file = npm_src / "requirements.txt"
|
|
103
|
+
if req_file.exists():
|
|
104
|
+
return hashlib.sha256(req_file.read_bytes()).hexdigest()
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
43
108
|
def _check_dirty() -> str | None:
|
|
44
|
-
"""Return error message if
|
|
45
|
-
|
|
109
|
+
"""Return error message if worktree has uncommitted changes, else None."""
|
|
110
|
+
if not _is_git_repo():
|
|
111
|
+
return None # Not a git repo, skip dirty check
|
|
112
|
+
rc, out, _ = _git("status", "--porcelain")
|
|
46
113
|
if rc != 0:
|
|
47
114
|
return "Failed to check git status."
|
|
48
115
|
if out:
|
|
49
|
-
return f"Uncommitted changes
|
|
116
|
+
return f"Uncommitted changes:\n{out}\nCommit or stash before updating."
|
|
50
117
|
return None
|
|
51
118
|
|
|
52
119
|
|
|
@@ -102,12 +169,51 @@ def _restore_databases(backup_dir: str):
|
|
|
102
169
|
break
|
|
103
170
|
|
|
104
171
|
|
|
172
|
+
def _reinstall_pip_deps() -> str | None:
|
|
173
|
+
"""Reinstall Python dependencies from requirements.txt into the managed venv."""
|
|
174
|
+
req_file = SRC_DIR / "requirements.txt"
|
|
175
|
+
if not req_file.exists() and _PACKAGED_INSTALL:
|
|
176
|
+
# In packaged mode, requirements.txt lives in the npm package's src/ dir
|
|
177
|
+
npm_src = _find_npm_pkg_src()
|
|
178
|
+
if npm_src:
|
|
179
|
+
req_file = npm_src / "requirements.txt"
|
|
180
|
+
if not req_file.exists():
|
|
181
|
+
return None # No requirements file, skip
|
|
182
|
+
venv_pip = NEXO_HOME / ".venv" / "bin" / "pip"
|
|
183
|
+
if not venv_pip.exists():
|
|
184
|
+
venv_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
|
|
185
|
+
if not venv_pip.exists():
|
|
186
|
+
# No venv, try system pip with --break-system-packages
|
|
187
|
+
try:
|
|
188
|
+
result = subprocess.run(
|
|
189
|
+
[sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
|
|
190
|
+
capture_output=True, text=True, timeout=120,
|
|
191
|
+
)
|
|
192
|
+
if result.returncode != 0:
|
|
193
|
+
return f"pip install failed: {result.stderr or result.stdout}"
|
|
194
|
+
except Exception as e:
|
|
195
|
+
return f"pip install error: {e}"
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
result = subprocess.run(
|
|
199
|
+
[str(venv_pip), "install", "--quiet", "-r", str(req_file)],
|
|
200
|
+
capture_output=True, text=True, timeout=120,
|
|
201
|
+
)
|
|
202
|
+
if result.returncode != 0:
|
|
203
|
+
return f"pip install failed: {result.stderr or result.stdout}"
|
|
204
|
+
except Exception as e:
|
|
205
|
+
return f"pip install error: {e}"
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
105
209
|
def _run_migrations() -> str | None:
|
|
106
210
|
"""Run init_db() to apply pending migrations. Returns error or None."""
|
|
211
|
+
# In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
|
|
212
|
+
cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
|
|
107
213
|
try:
|
|
108
214
|
result = subprocess.run(
|
|
109
215
|
[sys.executable, "-c", "import db; db.init_db()"],
|
|
110
|
-
cwd=
|
|
216
|
+
cwd=cwd,
|
|
111
217
|
capture_output=True,
|
|
112
218
|
text=True,
|
|
113
219
|
timeout=30,
|
|
@@ -121,10 +227,12 @@ def _run_migrations() -> str | None:
|
|
|
121
227
|
|
|
122
228
|
def _verify_import() -> str | None:
|
|
123
229
|
"""Verify server.py can be imported successfully."""
|
|
230
|
+
# In packaged mode, server.py lives in NEXO_HOME; in dev mode, in SRC_DIR
|
|
231
|
+
cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
|
|
124
232
|
try:
|
|
125
233
|
result = subprocess.run(
|
|
126
234
|
[sys.executable, "-c", "import server"],
|
|
127
|
-
cwd=
|
|
235
|
+
cwd=cwd,
|
|
128
236
|
capture_output=True,
|
|
129
237
|
text=True,
|
|
130
238
|
timeout=15,
|
|
@@ -136,27 +244,235 @@ def _verify_import() -> str | None:
|
|
|
136
244
|
return None
|
|
137
245
|
|
|
138
246
|
|
|
247
|
+
def _sync_hooks_to_home():
|
|
248
|
+
"""Copy hook scripts from src/hooks/ to NEXO_HOME/hooks/ after update."""
|
|
249
|
+
import shutil
|
|
250
|
+
hooks_src = SRC_DIR / "hooks"
|
|
251
|
+
hooks_dest = NEXO_HOME / "hooks"
|
|
252
|
+
if not hooks_src.is_dir():
|
|
253
|
+
return
|
|
254
|
+
hooks_dest.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
synced = 0
|
|
256
|
+
for f in hooks_src.iterdir():
|
|
257
|
+
if f.is_file() and f.suffix == ".sh":
|
|
258
|
+
dest = hooks_dest / f.name
|
|
259
|
+
shutil.copy2(str(f), str(dest))
|
|
260
|
+
os.chmod(str(dest), 0o755)
|
|
261
|
+
synced += 1
|
|
262
|
+
if synced:
|
|
263
|
+
print(f"[NEXO update] Synced {synced} hook(s) to {hooks_dest}", file=sys.stderr)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _backup_code_tree() -> tuple[str | None, str | None]:
|
|
267
|
+
"""Snapshot NEXO_HOME code dirs before npm update. Returns (backup_dir, error)."""
|
|
268
|
+
timestamp = time.strftime("%Y-%m-%d-%H%M%S")
|
|
269
|
+
backup_dir = BACKUP_BASE / f"code-tree-{timestamp}"
|
|
270
|
+
# Directories and flat files that postinstall copies into NEXO_HOME
|
|
271
|
+
code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts"]
|
|
272
|
+
code_files_glob = ["*.py", "requirements.txt"]
|
|
273
|
+
try:
|
|
274
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
# Backup directories
|
|
276
|
+
for d in code_dirs:
|
|
277
|
+
src = NEXO_HOME / d
|
|
278
|
+
if src.is_dir():
|
|
279
|
+
shutil.copytree(src, backup_dir / d, dirs_exist_ok=True)
|
|
280
|
+
# Backup flat code files in NEXO_HOME root
|
|
281
|
+
for pattern in code_files_glob:
|
|
282
|
+
for f in NEXO_HOME.glob(pattern):
|
|
283
|
+
if f.is_file():
|
|
284
|
+
shutil.copy2(f, backup_dir / f.name)
|
|
285
|
+
# Backup version.json
|
|
286
|
+
vf = NEXO_HOME / "version.json"
|
|
287
|
+
if vf.is_file():
|
|
288
|
+
shutil.copy2(vf, backup_dir / "version.json")
|
|
289
|
+
except Exception as e:
|
|
290
|
+
return None, f"Code tree backup failed: {e}"
|
|
291
|
+
return str(backup_dir), None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _restore_code_tree(backup_dir: str) -> str | None:
|
|
295
|
+
"""Restore NEXO_HOME code dirs from a backup snapshot. Returns error or None."""
|
|
296
|
+
bdir = Path(backup_dir)
|
|
297
|
+
if not bdir.is_dir():
|
|
298
|
+
return f"Code tree backup dir not found: {backup_dir}"
|
|
299
|
+
try:
|
|
300
|
+
for item in bdir.iterdir():
|
|
301
|
+
dest = NEXO_HOME / item.name
|
|
302
|
+
if item.is_dir():
|
|
303
|
+
if dest.is_dir():
|
|
304
|
+
shutil.rmtree(dest)
|
|
305
|
+
shutil.copytree(item, dest)
|
|
306
|
+
elif item.is_file():
|
|
307
|
+
shutil.copy2(item, dest)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
return f"Code tree restore failed: {e}"
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _rollback_npm_package(target_version: str) -> str | None:
|
|
314
|
+
"""Rollback nexo-brain npm package to a specific version.
|
|
315
|
+
|
|
316
|
+
Uses NEXO_SKIP_POSTINSTALL because we restore the code tree
|
|
317
|
+
from our own pre-update backup — no need for postinstall migration.
|
|
318
|
+
"""
|
|
319
|
+
try:
|
|
320
|
+
result = subprocess.run(
|
|
321
|
+
["npm", "install", "-g", f"nexo-brain@{target_version}"],
|
|
322
|
+
capture_output=True, text=True, timeout=120,
|
|
323
|
+
env={**os.environ, "NEXO_SKIP_POSTINSTALL": "1", "NEXO_HOME": str(NEXO_HOME)},
|
|
324
|
+
)
|
|
325
|
+
if result.returncode != 0:
|
|
326
|
+
return f"npm rollback failed: {result.stderr or result.stdout}"
|
|
327
|
+
except Exception as e:
|
|
328
|
+
return f"npm rollback error: {e}"
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _handle_packaged_update() -> str:
|
|
333
|
+
"""Update a packaged (npm) install — no git repo available."""
|
|
334
|
+
old_version = _read_version()
|
|
335
|
+
|
|
336
|
+
# 1. Backup databases BEFORE any changes
|
|
337
|
+
backup_dir, backup_err = _backup_databases()
|
|
338
|
+
if backup_err:
|
|
339
|
+
return f"ABORTED at backup: {backup_err}"
|
|
340
|
+
|
|
341
|
+
# 2. Backup NEXO_HOME code tree BEFORE npm update
|
|
342
|
+
# postinstall copies hooks/core/plugins/scripts into NEXO_HOME,
|
|
343
|
+
# so we need a full snapshot to restore on failure.
|
|
344
|
+
code_backup_dir, code_err = _backup_code_tree()
|
|
345
|
+
if code_err:
|
|
346
|
+
return f"ABORTED at code tree backup: {code_err}"
|
|
347
|
+
|
|
348
|
+
# 3. Run npm update (postinstall.js will migrate NEXO_HOME in-place)
|
|
349
|
+
try:
|
|
350
|
+
result = subprocess.run(
|
|
351
|
+
["npm", "update", "-g", "nexo-brain"],
|
|
352
|
+
capture_output=True, text=True, timeout=120,
|
|
353
|
+
env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
|
|
354
|
+
)
|
|
355
|
+
if result.returncode != 0:
|
|
356
|
+
# npm failed (including postinstall failures) — full rollback
|
|
357
|
+
if backup_dir:
|
|
358
|
+
_restore_databases(backup_dir)
|
|
359
|
+
if code_backup_dir:
|
|
360
|
+
_restore_code_tree(code_backup_dir)
|
|
361
|
+
# Reinstall pip deps from restored old requirements.txt
|
|
362
|
+
_reinstall_pip_deps()
|
|
363
|
+
rollback_err = _rollback_npm_package(old_version)
|
|
364
|
+
msg = f"ABORTED: npm update failed: {result.stderr or result.stdout}"
|
|
365
|
+
if rollback_err:
|
|
366
|
+
msg += f"\n WARNING: npm rollback also failed: {rollback_err}"
|
|
367
|
+
msg += f"\n Manual rollback: npm install -g nexo-brain@{old_version}"
|
|
368
|
+
return msg
|
|
369
|
+
except FileNotFoundError:
|
|
370
|
+
return "ABORTED: npm not found. Install Node.js to update packaged installs."
|
|
371
|
+
except Exception as e:
|
|
372
|
+
if backup_dir:
|
|
373
|
+
_restore_databases(backup_dir)
|
|
374
|
+
if code_backup_dir:
|
|
375
|
+
_restore_code_tree(code_backup_dir)
|
|
376
|
+
# Reinstall pip deps from restored old requirements.txt
|
|
377
|
+
_reinstall_pip_deps()
|
|
378
|
+
rollback_err = _rollback_npm_package(old_version)
|
|
379
|
+
msg = f"ABORTED: npm update error: {e}"
|
|
380
|
+
if rollback_err:
|
|
381
|
+
msg += f"\n WARNING: npm rollback also failed: {rollback_err}"
|
|
382
|
+
msg += f"\n Manual rollback: npm install -g nexo-brain@{old_version}"
|
|
383
|
+
return msg
|
|
384
|
+
|
|
385
|
+
new_version = _read_version()
|
|
386
|
+
if old_version == new_version:
|
|
387
|
+
return f"Already up to date (v{old_version}). No changes."
|
|
388
|
+
|
|
389
|
+
# 4. Post-npm verification steps
|
|
390
|
+
errors = []
|
|
391
|
+
|
|
392
|
+
# Reinstall pip deps for new version
|
|
393
|
+
pip_err = _reinstall_pip_deps()
|
|
394
|
+
if pip_err:
|
|
395
|
+
errors.append(f"pip deps: {pip_err}")
|
|
396
|
+
|
|
397
|
+
# Run migrations
|
|
398
|
+
mig_err = _run_migrations()
|
|
399
|
+
if mig_err:
|
|
400
|
+
errors.append(f"migrations: {mig_err}")
|
|
401
|
+
|
|
402
|
+
# Verify server can still import
|
|
403
|
+
verify_err = _verify_import()
|
|
404
|
+
if verify_err:
|
|
405
|
+
errors.append(f"verification: {verify_err}")
|
|
406
|
+
|
|
407
|
+
if errors:
|
|
408
|
+
# 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
|
|
409
|
+
if code_backup_dir:
|
|
410
|
+
tree_err = _restore_code_tree(code_backup_dir)
|
|
411
|
+
else:
|
|
412
|
+
tree_err = "no code tree backup available"
|
|
413
|
+
if backup_dir:
|
|
414
|
+
_restore_databases(backup_dir)
|
|
415
|
+
# Reinstall pip deps from the restored (old) requirements.txt
|
|
416
|
+
# so the venv matches the rolled-back code tree
|
|
417
|
+
pip_rollback_err = _reinstall_pip_deps() if not tree_err else None
|
|
418
|
+
rollback_err = _rollback_npm_package(old_version)
|
|
419
|
+
lines = [f"UPDATE FAILED (packaged install, v{old_version} -> v{new_version})"]
|
|
420
|
+
for err in errors:
|
|
421
|
+
lines.append(f" ERROR: {err}")
|
|
422
|
+
lines.append(f" Databases restored from: {backup_dir}")
|
|
423
|
+
if tree_err:
|
|
424
|
+
lines.append(f" WARNING: code tree restore failed: {tree_err}")
|
|
425
|
+
else:
|
|
426
|
+
lines.append(f" Code tree restored from: {code_backup_dir}")
|
|
427
|
+
if pip_rollback_err:
|
|
428
|
+
lines.append(f" WARNING: pip deps rollback failed: {pip_rollback_err}")
|
|
429
|
+
elif not tree_err:
|
|
430
|
+
lines.append(" Python deps: reinstalled from old requirements.txt")
|
|
431
|
+
if rollback_err:
|
|
432
|
+
lines.append(f" WARNING: npm rollback failed: {rollback_err}")
|
|
433
|
+
lines.append(f" Manual rollback: npm install -g nexo-brain@{old_version}")
|
|
434
|
+
else:
|
|
435
|
+
lines.append(f" npm package rolled back to v{old_version}")
|
|
436
|
+
lines.append("")
|
|
437
|
+
lines.append("Fix the errors above, then run nexo_update again.")
|
|
438
|
+
return "\n".join(lines)
|
|
439
|
+
|
|
440
|
+
lines = ["UPDATE SUCCESSFUL (packaged install)"]
|
|
441
|
+
lines.append(f" Version: {old_version} -> {new_version}")
|
|
442
|
+
lines.append(f" Backup: {backup_dir}")
|
|
443
|
+
lines.append("")
|
|
444
|
+
lines.append("MCP server restart needed to load new code.")
|
|
445
|
+
return "\n".join(lines)
|
|
446
|
+
|
|
447
|
+
|
|
139
448
|
def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
140
449
|
"""Pull latest NEXO code, backup databases, run migrations, and verify.
|
|
141
450
|
|
|
142
|
-
|
|
143
|
-
|
|
451
|
+
Supports both git checkouts and packaged (npm) installs.
|
|
452
|
+
|
|
453
|
+
Full update flow (git):
|
|
454
|
+
1. Check for uncommitted changes in entire worktree
|
|
144
455
|
2. Backup all .db files
|
|
145
456
|
3. git pull
|
|
146
|
-
4.
|
|
147
|
-
5.
|
|
148
|
-
6.
|
|
457
|
+
4. Reinstall Python dependencies if version changed
|
|
458
|
+
5. Run migrations if version changed
|
|
459
|
+
6. Verify server.py imports
|
|
460
|
+
7. Rollback on failure (git reset --hard to saved commit)
|
|
149
461
|
|
|
150
462
|
Args:
|
|
151
463
|
remote: Git remote name (default: origin)
|
|
152
464
|
branch: Git branch to pull (default: main)
|
|
153
465
|
"""
|
|
466
|
+
# Packaged install — no git repo
|
|
467
|
+
if not _is_git_repo():
|
|
468
|
+
return _handle_packaged_update()
|
|
469
|
+
|
|
154
470
|
steps_done = []
|
|
155
471
|
old_commit = None
|
|
156
472
|
backup_dir = None
|
|
157
473
|
|
|
158
474
|
try:
|
|
159
|
-
# Step 1: Check dirty
|
|
475
|
+
# Step 1: Check dirty (full worktree)
|
|
160
476
|
dirty_err = _check_dirty()
|
|
161
477
|
if dirty_err:
|
|
162
478
|
return f"ABORTED: {dirty_err}"
|
|
@@ -164,6 +480,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
164
480
|
|
|
165
481
|
# Record current state
|
|
166
482
|
old_version = _read_version()
|
|
483
|
+
old_req_hash = _requirements_hash()
|
|
167
484
|
rc, old_commit, _ = _git("rev-parse", "HEAD")
|
|
168
485
|
if rc != 0:
|
|
169
486
|
return "ABORTED: Not a git repository or git not available."
|
|
@@ -180,39 +497,59 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
180
497
|
return f"ABORTED at git pull: {pull_err or pull_out}"
|
|
181
498
|
steps_done.append("git-pull")
|
|
182
499
|
|
|
183
|
-
# Step 4: Check version
|
|
500
|
+
# Step 4: Check version and dependency changes
|
|
184
501
|
new_version = _read_version()
|
|
185
502
|
version_changed = old_version != new_version
|
|
503
|
+
new_req_hash = _requirements_hash()
|
|
504
|
+
deps_changed = old_req_hash != new_req_hash
|
|
186
505
|
|
|
187
|
-
# Step 5:
|
|
506
|
+
# Step 5: Reinstall pip dependencies if requirements.txt changed
|
|
507
|
+
if deps_changed or version_changed:
|
|
508
|
+
pip_err = _reinstall_pip_deps()
|
|
509
|
+
if pip_err:
|
|
510
|
+
raise RuntimeError(f"Pip install failed: {pip_err}")
|
|
511
|
+
steps_done.append("pip-deps")
|
|
512
|
+
|
|
513
|
+
# Step 6: Run migrations if version changed
|
|
188
514
|
if version_changed:
|
|
189
515
|
mig_err = _run_migrations()
|
|
190
516
|
if mig_err:
|
|
191
517
|
raise RuntimeError(f"Migration failed: {mig_err}")
|
|
192
518
|
steps_done.append("migrations")
|
|
193
519
|
|
|
194
|
-
# Step
|
|
520
|
+
# Step 7: Verify import
|
|
195
521
|
verify_err = _verify_import()
|
|
196
522
|
if verify_err:
|
|
197
523
|
raise RuntimeError(f"Verification failed: {verify_err}")
|
|
198
524
|
steps_done.append("verify")
|
|
199
525
|
|
|
200
|
-
# Step
|
|
526
|
+
# Step 8: Sync crons with manifest
|
|
201
527
|
cron_sync_result = ""
|
|
202
528
|
try:
|
|
203
|
-
cron_sync_path =
|
|
529
|
+
cron_sync_path = SRC_DIR / "crons" / "sync.py"
|
|
204
530
|
if cron_sync_path.exists():
|
|
205
|
-
|
|
206
|
-
r = _sp.run(
|
|
531
|
+
r = subprocess.run(
|
|
207
532
|
[sys.executable, str(cron_sync_path)],
|
|
208
533
|
capture_output=True, text=True, timeout=30,
|
|
209
|
-
env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(
|
|
534
|
+
env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(SRC_DIR)},
|
|
210
535
|
)
|
|
211
536
|
cron_sync_result = r.stdout.strip()
|
|
212
|
-
|
|
537
|
+
if r.returncode == 0:
|
|
538
|
+
steps_done.append("cron-sync")
|
|
539
|
+
# Refresh installed manifest only after successful sync
|
|
540
|
+
_refresh_installed_manifest()
|
|
541
|
+
else:
|
|
542
|
+
cron_sync_result = f"Cron sync failed (exit {r.returncode}): {r.stderr or r.stdout}"
|
|
213
543
|
except Exception as e:
|
|
214
544
|
cron_sync_result = f"Cron sync warning: {e}"
|
|
215
545
|
|
|
546
|
+
# Step 9: Sync hooks to NEXO_HOME
|
|
547
|
+
try:
|
|
548
|
+
_sync_hooks_to_home()
|
|
549
|
+
steps_done.append("hook-sync")
|
|
550
|
+
except Exception as e:
|
|
551
|
+
pass # Non-critical, log in function
|
|
552
|
+
|
|
216
553
|
# Build result
|
|
217
554
|
if pull_out == "Already up to date.":
|
|
218
555
|
return f"Already up to date (v{old_version}). No changes pulled."
|
|
@@ -224,22 +561,35 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
224
561
|
lines.append(f" Version: {old_version} (unchanged)")
|
|
225
562
|
lines.append(f" Branch: {remote}/{branch}")
|
|
226
563
|
lines.append(f" Backup: {backup_dir}")
|
|
564
|
+
if "pip-deps" in steps_done:
|
|
565
|
+
lines.append(" Python deps: reinstalled")
|
|
227
566
|
if version_changed:
|
|
228
567
|
lines.append(" Migrations: applied")
|
|
229
568
|
if "cron-sync" in steps_done:
|
|
230
569
|
lines.append(" Crons: synced with manifest")
|
|
570
|
+
if "hook-sync" in steps_done:
|
|
571
|
+
lines.append(" Hooks: synced to NEXO_HOME")
|
|
231
572
|
lines.append("")
|
|
232
573
|
lines.append("MCP server restart needed to load new code.")
|
|
233
574
|
return "\n".join(lines)
|
|
234
575
|
|
|
235
576
|
except Exception as e:
|
|
236
|
-
# Rollback
|
|
577
|
+
# Rollback — use git checkout to saved commit (safer than reset --hard)
|
|
237
578
|
rollback_lines = [f"UPDATE FAILED: {e}", "", "Rolling back..."]
|
|
238
579
|
|
|
239
580
|
if old_commit and "git-pull" in steps_done:
|
|
581
|
+
# Full rollback: reset HEAD + index + worktree to old commit
|
|
240
582
|
rc, _, err = _git("reset", "--hard", old_commit)
|
|
241
583
|
if rc == 0:
|
|
242
|
-
rollback_lines.append(f" Git:
|
|
584
|
+
rollback_lines.append(f" Git: restored files to {old_commit[:8]}")
|
|
585
|
+
# Reinstall pip deps from the restored old requirements.txt
|
|
586
|
+
# so the venv matches the rolled-back code
|
|
587
|
+
if "pip-deps" in steps_done:
|
|
588
|
+
pip_rb_err = _reinstall_pip_deps()
|
|
589
|
+
if pip_rb_err:
|
|
590
|
+
rollback_lines.append(f" WARNING: pip deps rollback failed: {pip_rb_err}")
|
|
591
|
+
else:
|
|
592
|
+
rollback_lines.append(" Python deps: reinstalled from old requirements.txt")
|
|
243
593
|
else:
|
|
244
594
|
rollback_lines.append(f" Git rollback FAILED: {err}")
|
|
245
595
|
|
|
@@ -18,10 +18,32 @@ import sys
|
|
|
18
18
|
from datetime import datetime, timedelta
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
-
CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
|
|
22
|
-
|
|
23
21
|
HOME = Path.home()
|
|
24
|
-
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(
|
|
22
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(HOME / ".nexo")))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_claude_cli() -> Path:
|
|
26
|
+
"""Find claude CLI: saved path > PATH > common locations."""
|
|
27
|
+
saved = NEXO_HOME / "config" / "claude-cli-path"
|
|
28
|
+
if saved.exists():
|
|
29
|
+
p = Path(saved.read_text().strip())
|
|
30
|
+
if p.exists():
|
|
31
|
+
return p
|
|
32
|
+
import shutil
|
|
33
|
+
found = shutil.which("claude")
|
|
34
|
+
if found:
|
|
35
|
+
return Path(found)
|
|
36
|
+
for candidate in [
|
|
37
|
+
HOME / ".local" / "bin" / "claude",
|
|
38
|
+
HOME / ".npm-global" / "bin" / "claude",
|
|
39
|
+
Path("/usr/local/bin/claude"),
|
|
40
|
+
]:
|
|
41
|
+
if candidate.exists():
|
|
42
|
+
return candidate
|
|
43
|
+
return HOME / ".local" / "bin" / "claude" # last resort
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
CLAUDE_CLI = _resolve_claude_cli()
|
|
25
47
|
LOG_DIR = NEXO_HOME / "logs"
|
|
26
48
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
49
|
LOG_FILE = LOG_DIR / "catchup.log"
|
|
@@ -47,7 +69,10 @@ def _resolve_python() -> str:
|
|
|
47
69
|
|
|
48
70
|
NEXO_PYTHON = _resolve_python()
|
|
49
71
|
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
|
|
50
|
-
|
|
72
|
+
# Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
|
|
73
|
+
_manifest_home = NEXO_HOME / "crons" / "manifest.json"
|
|
74
|
+
_manifest_code = NEXO_CODE / "crons" / "manifest.json"
|
|
75
|
+
MANIFEST = _manifest_home if _manifest_home.exists() else _manifest_code
|
|
51
76
|
|
|
52
77
|
|
|
53
78
|
def _load_tasks_from_manifest() -> list[tuple]:
|
|
@@ -43,7 +43,27 @@ RUNTIME_PREFLIGHT_SUMMARY = LOG_DIR / "runtime-preflight-summary.json"
|
|
|
43
43
|
WATCHDOG_SMOKE_SUMMARY = LOG_DIR / "watchdog-smoke-summary.json"
|
|
44
44
|
RESTORE_LOG = LOG_DIR / "snapshot-restores.log"
|
|
45
45
|
CORTEX_LOG_DIR = NEXO_HOME / "brain" / "logs"
|
|
46
|
-
|
|
46
|
+
def _resolve_claude_cli() -> Path:
|
|
47
|
+
"""Find claude CLI: saved path > PATH > common locations."""
|
|
48
|
+
import shutil as _shutil
|
|
49
|
+
saved = NEXO_HOME / "config" / "claude-cli-path"
|
|
50
|
+
if saved.exists():
|
|
51
|
+
p = Path(saved.read_text().strip())
|
|
52
|
+
if p.exists():
|
|
53
|
+
return p
|
|
54
|
+
found = _shutil.which("claude")
|
|
55
|
+
if found:
|
|
56
|
+
return Path(found)
|
|
57
|
+
for candidate in [
|
|
58
|
+
Path.home() / ".local" / "bin" / "claude",
|
|
59
|
+
Path.home() / ".npm-global" / "bin" / "claude",
|
|
60
|
+
Path("/usr/local/bin/claude"),
|
|
61
|
+
]:
|
|
62
|
+
if candidate.exists():
|
|
63
|
+
return candidate
|
|
64
|
+
return Path.home() / ".local" / "bin" / "claude"
|
|
65
|
+
|
|
66
|
+
CLAUDE_CLI = _resolve_claude_cli()
|
|
47
67
|
|
|
48
68
|
findings = []
|
|
49
69
|
|
|
@@ -64,7 +64,27 @@ IMMUTABLE_FILES = {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
# ── Claude CLI path ──────────────────────────────────────────────────────
|
|
67
|
-
|
|
67
|
+
def _resolve_claude_cli() -> Path:
|
|
68
|
+
"""Find claude CLI: saved path > PATH > common locations."""
|
|
69
|
+
import shutil as _shutil
|
|
70
|
+
saved = NEXO_HOME / "config" / "claude-cli-path"
|
|
71
|
+
if saved.exists():
|
|
72
|
+
p = Path(saved.read_text().strip())
|
|
73
|
+
if p.exists():
|
|
74
|
+
return p
|
|
75
|
+
found = _shutil.which("claude")
|
|
76
|
+
if found:
|
|
77
|
+
return Path(found)
|
|
78
|
+
for candidate in [
|
|
79
|
+
Path.home() / ".local" / "bin" / "claude",
|
|
80
|
+
Path.home() / ".npm-global" / "bin" / "claude",
|
|
81
|
+
Path("/usr/local/bin/claude"),
|
|
82
|
+
]:
|
|
83
|
+
if candidate.exists():
|
|
84
|
+
return candidate
|
|
85
|
+
return Path.home() / ".local" / "bin" / "claude"
|
|
86
|
+
|
|
87
|
+
CLAUDE_CLI = _resolve_claude_cli()
|
|
68
88
|
|
|
69
89
|
# ── Logging ──────────────────────────────────────────────────────────────
|
|
70
90
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|