nexo-brain 2.6.1 → 2.6.3
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/plugin.json +1 -1
- package/README.md +2 -2
- package/bin/nexo-brain.js +53 -10
- package/package.json +1 -1
- package/src/auto_update.py +534 -0
- package/src/cli.py +46 -186
- package/src/cron_recovery.py +86 -6
- package/src/crons/sync.py +11 -3
- package/src/nexo.db +0 -0
- package/src/plugins/schedule.py +20 -4
- package/src/plugins/update.py +26 -14
- package/src/runtime_power.py +284 -0
- package/src/script_registry.py +66 -0
- package/src/scripts/nexo-catchup.py +10 -5
- package/src/server.py +6 -2
package/src/auto_update.py
CHANGED
|
@@ -22,6 +22,7 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
22
22
|
|
|
23
23
|
# Repo root: go up from src/
|
|
24
24
|
SRC_DIR = Path(__file__).resolve().parent
|
|
25
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(SRC_DIR)))
|
|
25
26
|
REPO_DIR = SRC_DIR.parent
|
|
26
27
|
|
|
27
28
|
LAST_CHECK_FILE = DATA_DIR / "auto_update_last_check.json"
|
|
@@ -1073,3 +1074,536 @@ def auto_update_check() -> dict:
|
|
|
1073
1074
|
result["error"] = error_msg
|
|
1074
1075
|
|
|
1075
1076
|
return result
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
UPDATE_SUMMARY_FILE = NEXO_HOME / "logs" / "update-last-summary.json"
|
|
1080
|
+
UPDATE_HISTORY_FILE = NEXO_HOME / "logs" / "update-history.jsonl"
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _resolve_sync_source() -> tuple[Path | None, Path | None]:
|
|
1084
|
+
dest = NEXO_HOME
|
|
1085
|
+
|
|
1086
|
+
def _runtime_version_source() -> Path | None:
|
|
1087
|
+
version_file = NEXO_HOME / "version.json"
|
|
1088
|
+
if not version_file.is_file():
|
|
1089
|
+
return None
|
|
1090
|
+
try:
|
|
1091
|
+
data = json.loads(version_file.read_text())
|
|
1092
|
+
except Exception:
|
|
1093
|
+
return None
|
|
1094
|
+
source = str(data.get("source", "")).strip()
|
|
1095
|
+
if not source:
|
|
1096
|
+
return None
|
|
1097
|
+
candidate = Path(source).expanduser()
|
|
1098
|
+
if (candidate / "src").is_dir() and (candidate / "package.json").is_file():
|
|
1099
|
+
return candidate
|
|
1100
|
+
return None
|
|
1101
|
+
|
|
1102
|
+
try:
|
|
1103
|
+
same_as_runtime = NEXO_CODE.resolve() == dest.resolve()
|
|
1104
|
+
except Exception:
|
|
1105
|
+
same_as_runtime = NEXO_CODE == dest
|
|
1106
|
+
|
|
1107
|
+
if (
|
|
1108
|
+
not same_as_runtime
|
|
1109
|
+
and (NEXO_CODE / "db").is_dir()
|
|
1110
|
+
and (NEXO_CODE.parent / "package.json").is_file()
|
|
1111
|
+
):
|
|
1112
|
+
return NEXO_CODE, NEXO_CODE.parent
|
|
1113
|
+
|
|
1114
|
+
version_source = _runtime_version_source()
|
|
1115
|
+
if version_source:
|
|
1116
|
+
return version_source / "src", version_source
|
|
1117
|
+
return None, None
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _git_in_repo(repo_dir: Path, *args, timeout: int = 10) -> tuple[int, str, str]:
|
|
1121
|
+
result = subprocess.run(
|
|
1122
|
+
["git"] + list(args),
|
|
1123
|
+
cwd=str(repo_dir),
|
|
1124
|
+
capture_output=True,
|
|
1125
|
+
text=True,
|
|
1126
|
+
timeout=timeout,
|
|
1127
|
+
)
|
|
1128
|
+
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _source_repo_status(repo_dir: Path) -> dict:
|
|
1132
|
+
if not (repo_dir / ".git").exists() and not (repo_dir / ".git").is_file():
|
|
1133
|
+
return {"is_git": False, "dirty": False, "behind": False, "diverged": False, "ahead": False}
|
|
1134
|
+
|
|
1135
|
+
rc, dirty_out, dirty_err = _git_in_repo(repo_dir, "status", "--porcelain")
|
|
1136
|
+
dirty = rc == 0 and bool(dirty_out.strip())
|
|
1137
|
+
if rc != 0:
|
|
1138
|
+
return {
|
|
1139
|
+
"is_git": True,
|
|
1140
|
+
"dirty": True,
|
|
1141
|
+
"behind": False,
|
|
1142
|
+
"diverged": False,
|
|
1143
|
+
"ahead": False,
|
|
1144
|
+
"error": dirty_err or "git status failed",
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
rc, _, fetch_err = _git_in_repo(repo_dir, "fetch", "--quiet")
|
|
1148
|
+
if rc != 0:
|
|
1149
|
+
return {
|
|
1150
|
+
"is_git": True,
|
|
1151
|
+
"dirty": dirty,
|
|
1152
|
+
"behind": False,
|
|
1153
|
+
"diverged": False,
|
|
1154
|
+
"ahead": False,
|
|
1155
|
+
"error": fetch_err or "git fetch failed",
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
rc, local_head, _ = _git_in_repo(repo_dir, "rev-parse", "HEAD")
|
|
1159
|
+
rc2, remote_head, remote_err = _git_in_repo(repo_dir, "rev-parse", "@{u}")
|
|
1160
|
+
if rc != 0 or rc2 != 0:
|
|
1161
|
+
return {
|
|
1162
|
+
"is_git": True,
|
|
1163
|
+
"dirty": dirty,
|
|
1164
|
+
"behind": False,
|
|
1165
|
+
"diverged": False,
|
|
1166
|
+
"ahead": False,
|
|
1167
|
+
"error": remote_err or "no upstream configured",
|
|
1168
|
+
}
|
|
1169
|
+
rc, merge_base, merge_err = _git_in_repo(repo_dir, "merge-base", "HEAD", "@{u}")
|
|
1170
|
+
if rc != 0:
|
|
1171
|
+
return {
|
|
1172
|
+
"is_git": True,
|
|
1173
|
+
"dirty": dirty,
|
|
1174
|
+
"behind": False,
|
|
1175
|
+
"diverged": False,
|
|
1176
|
+
"ahead": False,
|
|
1177
|
+
"error": merge_err or "merge-base failed",
|
|
1178
|
+
}
|
|
1179
|
+
return {
|
|
1180
|
+
"is_git": True,
|
|
1181
|
+
"dirty": dirty,
|
|
1182
|
+
"behind": local_head != remote_head and merge_base == local_head,
|
|
1183
|
+
"ahead": local_head != remote_head and merge_base == remote_head,
|
|
1184
|
+
"diverged": merge_base not in {local_head, remote_head},
|
|
1185
|
+
"local_head": local_head,
|
|
1186
|
+
"remote_head": remote_head,
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
|
|
1191
|
+
timestamp = time.strftime("%Y-%m-%d-%H%M%S")
|
|
1192
|
+
backup_dir = NEXO_HOME / "backups" / f"runtime-tree-{timestamp}"
|
|
1193
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
1194
|
+
|
|
1195
|
+
code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts", "doctor", "skills-core"]
|
|
1196
|
+
flat_files = [
|
|
1197
|
+
"server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
|
|
1198
|
+
"maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
|
|
1199
|
+
"evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
|
|
1200
|
+
"auto_update.py", "tools_sessions.py", "tools_coordination.py",
|
|
1201
|
+
"tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
|
|
1202
|
+
"tools_credentials.py", "tools_task_history.py", "tools_menu.py",
|
|
1203
|
+
"cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
|
|
1204
|
+
"cron_recovery.py", "runtime_power.py", "requirements.txt", "package.json", "version.json",
|
|
1205
|
+
]
|
|
1206
|
+
for name in code_dirs:
|
|
1207
|
+
src = dest / name
|
|
1208
|
+
if src.is_dir():
|
|
1209
|
+
import shutil
|
|
1210
|
+
shutil.copytree(str(src), str(backup_dir / name), dirs_exist_ok=True)
|
|
1211
|
+
for name in flat_files:
|
|
1212
|
+
src = dest / name
|
|
1213
|
+
if src.is_file():
|
|
1214
|
+
import shutil
|
|
1215
|
+
shutil.copy2(str(src), str(backup_dir / name))
|
|
1216
|
+
if (dest / "bin").is_dir():
|
|
1217
|
+
import shutil
|
|
1218
|
+
shutil.copytree(str(dest / "bin"), str(backup_dir / "bin"), dirs_exist_ok=True)
|
|
1219
|
+
return str(backup_dir)
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
|
|
1223
|
+
import shutil
|
|
1224
|
+
|
|
1225
|
+
bdir = Path(backup_dir)
|
|
1226
|
+
if not bdir.is_dir():
|
|
1227
|
+
return
|
|
1228
|
+
for item in bdir.iterdir():
|
|
1229
|
+
target = dest / item.name
|
|
1230
|
+
if item.is_dir():
|
|
1231
|
+
if target.exists():
|
|
1232
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
1233
|
+
shutil.copytree(str(item), str(target))
|
|
1234
|
+
else:
|
|
1235
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1236
|
+
shutil.copy2(str(item), str(target))
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME) -> dict:
|
|
1240
|
+
import shutil
|
|
1241
|
+
|
|
1242
|
+
packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks"]
|
|
1243
|
+
flat_files = [
|
|
1244
|
+
"server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
|
|
1245
|
+
"maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
|
|
1246
|
+
"evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
|
|
1247
|
+
"auto_update.py", "tools_sessions.py", "tools_coordination.py",
|
|
1248
|
+
"tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
|
|
1249
|
+
"tools_credentials.py", "tools_task_history.py", "tools_menu.py",
|
|
1250
|
+
"cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
|
|
1251
|
+
"cron_recovery.py", "runtime_power.py", "requirements.txt",
|
|
1252
|
+
]
|
|
1253
|
+
copied_packages = 0
|
|
1254
|
+
copied_files = 0
|
|
1255
|
+
|
|
1256
|
+
for pkg in packages:
|
|
1257
|
+
pkg_src = src_dir / pkg
|
|
1258
|
+
pkg_dest = dest / pkg
|
|
1259
|
+
if pkg_src.is_dir():
|
|
1260
|
+
if pkg_dest.exists():
|
|
1261
|
+
shutil.rmtree(str(pkg_dest), ignore_errors=True)
|
|
1262
|
+
shutil.copytree(
|
|
1263
|
+
str(pkg_src),
|
|
1264
|
+
str(pkg_dest),
|
|
1265
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.db"),
|
|
1266
|
+
)
|
|
1267
|
+
copied_packages += 1
|
|
1268
|
+
|
|
1269
|
+
for name in flat_files:
|
|
1270
|
+
src_file = src_dir / name
|
|
1271
|
+
if src_file.is_file():
|
|
1272
|
+
shutil.copy2(str(src_file), str(dest / name))
|
|
1273
|
+
copied_files += 1
|
|
1274
|
+
|
|
1275
|
+
plugins_src = src_dir / "plugins"
|
|
1276
|
+
plugins_dest = dest / "plugins"
|
|
1277
|
+
if plugins_src.is_dir():
|
|
1278
|
+
plugins_dest.mkdir(parents=True, exist_ok=True)
|
|
1279
|
+
for item in plugins_src.iterdir():
|
|
1280
|
+
if item.is_file() and item.suffix == ".py":
|
|
1281
|
+
shutil.copy2(str(item), str(plugins_dest / item.name))
|
|
1282
|
+
|
|
1283
|
+
scripts_src = src_dir / "scripts"
|
|
1284
|
+
scripts_dest = dest / "scripts"
|
|
1285
|
+
if scripts_src.is_dir():
|
|
1286
|
+
scripts_dest.mkdir(parents=True, exist_ok=True)
|
|
1287
|
+
for item in scripts_src.iterdir():
|
|
1288
|
+
if item.name == "__pycache__" or item.name.startswith("."):
|
|
1289
|
+
continue
|
|
1290
|
+
dst = scripts_dest / item.name
|
|
1291
|
+
if item.is_dir():
|
|
1292
|
+
if dst.exists():
|
|
1293
|
+
shutil.rmtree(str(dst), ignore_errors=True)
|
|
1294
|
+
shutil.copytree(str(item), str(dst), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
1295
|
+
elif item.is_file():
|
|
1296
|
+
shutil.copy2(str(item), str(dst))
|
|
1297
|
+
if item.suffix == ".sh":
|
|
1298
|
+
dst.chmod(0o755)
|
|
1299
|
+
|
|
1300
|
+
templates_src = repo_dir / "templates"
|
|
1301
|
+
templates_dest = dest / "templates"
|
|
1302
|
+
if templates_src.is_dir():
|
|
1303
|
+
templates_dest.mkdir(parents=True, exist_ok=True)
|
|
1304
|
+
for item in templates_src.iterdir():
|
|
1305
|
+
if item.is_file():
|
|
1306
|
+
shutil.copy2(str(item), str(templates_dest / item.name))
|
|
1307
|
+
|
|
1308
|
+
package_json = repo_dir / "package.json"
|
|
1309
|
+
if package_json.is_file():
|
|
1310
|
+
shutil.copy2(str(package_json), str(dest / "package.json"))
|
|
1311
|
+
try:
|
|
1312
|
+
pkg = json.loads(package_json.read_text())
|
|
1313
|
+
(dest / "version.json").write_text(json.dumps({
|
|
1314
|
+
"version": pkg.get("version", "?"),
|
|
1315
|
+
"source": str(repo_dir),
|
|
1316
|
+
}, indent=2))
|
|
1317
|
+
except Exception:
|
|
1318
|
+
pass
|
|
1319
|
+
|
|
1320
|
+
skills_src = src_dir / "skills"
|
|
1321
|
+
skills_dest = dest / "skills-core"
|
|
1322
|
+
if skills_src.is_dir():
|
|
1323
|
+
if skills_dest.exists():
|
|
1324
|
+
shutil.rmtree(str(skills_dest), ignore_errors=True)
|
|
1325
|
+
shutil.copytree(str(skills_src), str(skills_dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
1326
|
+
|
|
1327
|
+
bin_dir = dest / "bin"
|
|
1328
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
1329
|
+
wrapper = bin_dir / "nexo"
|
|
1330
|
+
wrapper.write_text(_runtime_cli_wrapper_text())
|
|
1331
|
+
wrapper.chmod(0o755)
|
|
1332
|
+
|
|
1333
|
+
return {
|
|
1334
|
+
"packages": copied_packages,
|
|
1335
|
+
"files": copied_files,
|
|
1336
|
+
"source": str(src_dir),
|
|
1337
|
+
"repo": str(repo_dir),
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
|
|
1342
|
+
req_file = runtime_root / "requirements.txt"
|
|
1343
|
+
if not req_file.exists():
|
|
1344
|
+
return True
|
|
1345
|
+
venv_pip = runtime_root / ".venv" / "bin" / "pip"
|
|
1346
|
+
if not venv_pip.exists():
|
|
1347
|
+
venv_pip = runtime_root / ".venv" / "bin" / "pip3"
|
|
1348
|
+
try:
|
|
1349
|
+
if venv_pip.exists():
|
|
1350
|
+
result = subprocess.run(
|
|
1351
|
+
[str(venv_pip), "install", "--quiet", "-r", str(req_file)],
|
|
1352
|
+
capture_output=True,
|
|
1353
|
+
text=True,
|
|
1354
|
+
timeout=120,
|
|
1355
|
+
)
|
|
1356
|
+
else:
|
|
1357
|
+
result = subprocess.run(
|
|
1358
|
+
[sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
|
|
1359
|
+
capture_output=True,
|
|
1360
|
+
text=True,
|
|
1361
|
+
timeout=120,
|
|
1362
|
+
)
|
|
1363
|
+
return result.returncode == 0
|
|
1364
|
+
except Exception:
|
|
1365
|
+
return False
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _run_runtime_post_sync(dest: Path = NEXO_HOME) -> tuple[bool, list[str]]:
|
|
1369
|
+
actions: list[str] = []
|
|
1370
|
+
env = {**os.environ, "NEXO_HOME": str(dest), "NEXO_CODE": str(dest)}
|
|
1371
|
+
try:
|
|
1372
|
+
init_result = subprocess.run(
|
|
1373
|
+
[
|
|
1374
|
+
sys.executable,
|
|
1375
|
+
"-c",
|
|
1376
|
+
(
|
|
1377
|
+
"import db; "
|
|
1378
|
+
"init_db = getattr(db, 'init_db', None); "
|
|
1379
|
+
"init_db() if callable(init_db) else None; "
|
|
1380
|
+
"import script_registry; "
|
|
1381
|
+
"sync_scripts = getattr(script_registry, 'sync_personal_scripts', None); "
|
|
1382
|
+
"sync_scripts() if callable(sync_scripts) else None"
|
|
1383
|
+
),
|
|
1384
|
+
],
|
|
1385
|
+
cwd=str(dest),
|
|
1386
|
+
capture_output=True,
|
|
1387
|
+
text=True,
|
|
1388
|
+
timeout=60,
|
|
1389
|
+
env=env,
|
|
1390
|
+
)
|
|
1391
|
+
if init_result.returncode != 0:
|
|
1392
|
+
return False, [init_result.stderr.strip() or init_result.stdout.strip() or "runtime init failed"]
|
|
1393
|
+
actions.append("db+personal-sync")
|
|
1394
|
+
except Exception as e:
|
|
1395
|
+
return False, [f"runtime init error: {e}"]
|
|
1396
|
+
|
|
1397
|
+
if _reinstall_runtime_pip_deps(dest):
|
|
1398
|
+
actions.append("pip-deps")
|
|
1399
|
+
else:
|
|
1400
|
+
actions.append("pip-deps-warning")
|
|
1401
|
+
|
|
1402
|
+
sync_path = dest / "crons" / "sync.py"
|
|
1403
|
+
if sync_path.is_file():
|
|
1404
|
+
try:
|
|
1405
|
+
sync_result = subprocess.run(
|
|
1406
|
+
[sys.executable, str(sync_path)],
|
|
1407
|
+
cwd=str(dest),
|
|
1408
|
+
capture_output=True,
|
|
1409
|
+
text=True,
|
|
1410
|
+
timeout=30,
|
|
1411
|
+
env=env,
|
|
1412
|
+
)
|
|
1413
|
+
if sync_result.returncode != 0:
|
|
1414
|
+
return False, [sync_result.stderr.strip() or sync_result.stdout.strip() or "cron sync failed"]
|
|
1415
|
+
actions.append("cron-sync")
|
|
1416
|
+
except Exception as e:
|
|
1417
|
+
return False, [f"cron sync error: {e}"]
|
|
1418
|
+
|
|
1419
|
+
from runtime_power import apply_power_policy
|
|
1420
|
+
|
|
1421
|
+
power_result = apply_power_policy()
|
|
1422
|
+
if power_result.get("ok"):
|
|
1423
|
+
actions.append(f"power:{power_result.get('action')}")
|
|
1424
|
+
|
|
1425
|
+
verify = subprocess.run(
|
|
1426
|
+
[sys.executable, "-c", "import server"],
|
|
1427
|
+
cwd=str(dest),
|
|
1428
|
+
capture_output=True,
|
|
1429
|
+
text=True,
|
|
1430
|
+
timeout=20,
|
|
1431
|
+
env=env,
|
|
1432
|
+
)
|
|
1433
|
+
if verify.returncode != 0:
|
|
1434
|
+
return False, [verify.stderr.strip() or verify.stdout.strip() or "import verify failed"]
|
|
1435
|
+
actions.append("verify")
|
|
1436
|
+
return True, actions
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _runtime_busy_reason() -> str | None:
|
|
1440
|
+
try:
|
|
1441
|
+
from db import get_active_sessions
|
|
1442
|
+
active = get_active_sessions()
|
|
1443
|
+
except Exception:
|
|
1444
|
+
return None
|
|
1445
|
+
if active:
|
|
1446
|
+
return f"active sessions: {len(active)}"
|
|
1447
|
+
return None
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _write_update_summary(summary: dict):
|
|
1451
|
+
try:
|
|
1452
|
+
logs_dir = NEXO_HOME / "logs"
|
|
1453
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
1454
|
+
payload = dict(summary)
|
|
1455
|
+
payload.setdefault("timestamp", time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
1456
|
+
UPDATE_SUMMARY_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
1457
|
+
with UPDATE_HISTORY_FILE.open("a") as fh:
|
|
1458
|
+
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
1459
|
+
except Exception as e:
|
|
1460
|
+
_log(f"Failed to write update summary: {e}")
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True) -> dict:
|
|
1464
|
+
src_dir, repo_dir = _resolve_sync_source()
|
|
1465
|
+
if src_dir is None or repo_dir is None:
|
|
1466
|
+
return {"ok": False, "mode": "sync", "error": "No source repo recorded for this runtime."}
|
|
1467
|
+
|
|
1468
|
+
source_status = _source_repo_status(repo_dir)
|
|
1469
|
+
pulled = False
|
|
1470
|
+
old_head = source_status.get("local_head")
|
|
1471
|
+
if allow_source_pull and source_status.get("is_git"):
|
|
1472
|
+
if source_status.get("dirty"):
|
|
1473
|
+
_log("Source repo has local changes; syncing local tree without remote pull.")
|
|
1474
|
+
elif source_status.get("diverged"):
|
|
1475
|
+
_log("Source repo diverged; syncing local tree without remote pull.")
|
|
1476
|
+
elif source_status.get("behind"):
|
|
1477
|
+
rc, _, pull_err = _git_in_repo(repo_dir, "pull", "--ff-only", timeout=60)
|
|
1478
|
+
if rc != 0:
|
|
1479
|
+
return {"ok": False, "mode": "sync", "error": pull_err or "git pull failed"}
|
|
1480
|
+
pulled = True
|
|
1481
|
+
|
|
1482
|
+
db_backup_dir = _backup_dbs()
|
|
1483
|
+
tree_backup_dir = _backup_runtime_tree(NEXO_HOME)
|
|
1484
|
+
sync_result = {"ok": False, "mode": "sync", "pulled_source": pulled, "backup_dir": db_backup_dir, "tree_backup": tree_backup_dir}
|
|
1485
|
+
try:
|
|
1486
|
+
copy_stats = _copy_runtime_from_source(src_dir, repo_dir, NEXO_HOME)
|
|
1487
|
+
ok, actions = _run_runtime_post_sync(NEXO_HOME)
|
|
1488
|
+
if not ok:
|
|
1489
|
+
raise RuntimeError("; ".join(actions))
|
|
1490
|
+
sync_result.update({
|
|
1491
|
+
"ok": True,
|
|
1492
|
+
"updated": True,
|
|
1493
|
+
"packages": copy_stats["packages"],
|
|
1494
|
+
"files": copy_stats["files"],
|
|
1495
|
+
"actions": actions,
|
|
1496
|
+
"source": copy_stats["source"],
|
|
1497
|
+
"repo": copy_stats["repo"],
|
|
1498
|
+
})
|
|
1499
|
+
except Exception as e:
|
|
1500
|
+
_restore_runtime_tree(tree_backup_dir, NEXO_HOME)
|
|
1501
|
+
if db_backup_dir:
|
|
1502
|
+
_restore_dbs(db_backup_dir)
|
|
1503
|
+
_reinstall_runtime_pip_deps(NEXO_HOME)
|
|
1504
|
+
if pulled and old_head:
|
|
1505
|
+
_git_in_repo(repo_dir, "reset", "--hard", old_head, timeout=60)
|
|
1506
|
+
sync_result.update({"error": str(e), "rolled_back": True})
|
|
1507
|
+
_write_update_summary(sync_result)
|
|
1508
|
+
return sync_result
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
|
|
1512
|
+
result = {
|
|
1513
|
+
"entrypoint": entrypoint,
|
|
1514
|
+
"checked": False,
|
|
1515
|
+
"updated": False,
|
|
1516
|
+
"actions": [],
|
|
1517
|
+
"skipped_reason": None,
|
|
1518
|
+
"deferred_reason": None,
|
|
1519
|
+
"git_update": None,
|
|
1520
|
+
"npm_notice": None,
|
|
1521
|
+
"claude_md_update": None,
|
|
1522
|
+
"migrations": [],
|
|
1523
|
+
"power_policy": None,
|
|
1524
|
+
"error": None,
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
from runtime_power import apply_power_policy, ensure_power_policy_choice, get_power_policy
|
|
1528
|
+
|
|
1529
|
+
choice = ensure_power_policy_choice(interactive=interactive, reason=entrypoint)
|
|
1530
|
+
power_result = apply_power_policy(choice.get("policy"))
|
|
1531
|
+
result["power_policy"] = choice.get("policy") or get_power_policy()
|
|
1532
|
+
if power_result.get("ok"):
|
|
1533
|
+
result["actions"].append(f"power:{power_result.get('action')}")
|
|
1534
|
+
|
|
1535
|
+
src_dir, repo_dir = _resolve_sync_source()
|
|
1536
|
+
if src_dir is not None and repo_dir is not None:
|
|
1537
|
+
try:
|
|
1538
|
+
from db import init_db
|
|
1539
|
+
from script_registry import sync_personal_scripts
|
|
1540
|
+
|
|
1541
|
+
_run_db_migrations()
|
|
1542
|
+
result["migrations"] = run_file_migrations()
|
|
1543
|
+
result["claude_md_update"] = _migrate_claude_md()
|
|
1544
|
+
_sync_watchdog_hash_registry()
|
|
1545
|
+
_warn_protected_runtime_location()
|
|
1546
|
+
_ensure_runtime_cli_wrapper()
|
|
1547
|
+
_ensure_runtime_cli_in_shell()
|
|
1548
|
+
init_db()
|
|
1549
|
+
sync_personal_scripts()
|
|
1550
|
+
result["actions"].append("db+personal-sync")
|
|
1551
|
+
except Exception as e:
|
|
1552
|
+
result["error"] = str(e)
|
|
1553
|
+
_write_update_summary(result)
|
|
1554
|
+
return result
|
|
1555
|
+
|
|
1556
|
+
try:
|
|
1557
|
+
last_check = _read_last_check()
|
|
1558
|
+
now = time.time()
|
|
1559
|
+
schedule_data = json.loads((NEXO_HOME / "config" / "schedule.json").read_text()) if (NEXO_HOME / "config" / "schedule.json").exists() else {}
|
|
1560
|
+
if not schedule_data.get("auto_update", True):
|
|
1561
|
+
result["skipped_reason"] = "auto_update disabled in schedule.json"
|
|
1562
|
+
_write_update_summary(result)
|
|
1563
|
+
return result
|
|
1564
|
+
if now - float(last_check.get("timestamp", 0) or 0) < CHECK_COOLDOWN_SECONDS:
|
|
1565
|
+
result["skipped_reason"] = "cooldown"
|
|
1566
|
+
_write_update_summary(result)
|
|
1567
|
+
return result
|
|
1568
|
+
busy_reason = _runtime_busy_reason()
|
|
1569
|
+
if busy_reason:
|
|
1570
|
+
result["deferred_reason"] = busy_reason
|
|
1571
|
+
_write_last_check({"timestamp": now, "mode": "sync", "deferred_reason": busy_reason})
|
|
1572
|
+
_write_update_summary(result)
|
|
1573
|
+
return result
|
|
1574
|
+
|
|
1575
|
+
source_status = _source_repo_status(repo_dir)
|
|
1576
|
+
if source_status.get("dirty"):
|
|
1577
|
+
result["deferred_reason"] = "source repo has local changes"
|
|
1578
|
+
elif source_status.get("diverged"):
|
|
1579
|
+
result["deferred_reason"] = "source repo diverged from upstream"
|
|
1580
|
+
elif source_status.get("behind"):
|
|
1581
|
+
result["checked"] = True
|
|
1582
|
+
sync_result = manual_sync_update(interactive=False, allow_source_pull=True)
|
|
1583
|
+
result["updated"] = bool(sync_result.get("ok") and sync_result.get("updated"))
|
|
1584
|
+
result["actions"].extend(sync_result.get("actions", []))
|
|
1585
|
+
if sync_result.get("error"):
|
|
1586
|
+
result["error"] = sync_result["error"]
|
|
1587
|
+
else:
|
|
1588
|
+
result["checked"] = True
|
|
1589
|
+
|
|
1590
|
+
_write_last_check({
|
|
1591
|
+
"timestamp": now,
|
|
1592
|
+
"mode": "sync",
|
|
1593
|
+
"updated": result["updated"],
|
|
1594
|
+
"deferred_reason": result["deferred_reason"],
|
|
1595
|
+
})
|
|
1596
|
+
except Exception as e:
|
|
1597
|
+
result["error"] = f"sync startup preflight failed: {e}"
|
|
1598
|
+
_write_update_summary(result)
|
|
1599
|
+
return result
|
|
1600
|
+
|
|
1601
|
+
result = auto_update_check()
|
|
1602
|
+
result["entrypoint"] = entrypoint
|
|
1603
|
+
result["power_policy"] = choice.get("policy") or get_power_policy()
|
|
1604
|
+
if power_result.get("ok"):
|
|
1605
|
+
actions = result.setdefault("actions", [])
|
|
1606
|
+
actions.append(f"power:{power_result.get('action')}")
|
|
1607
|
+
result["updated"] = bool(result.get("git_update"))
|
|
1608
|
+
_write_update_summary(result)
|
|
1609
|
+
return result
|