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.
@@ -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