nexo-brain 2.6.0 → 2.6.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/README.md +2 -2
- package/bin/nexo-brain.js +37 -2
- 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 +4 -4
- 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/cli.py
CHANGED
|
@@ -450,62 +450,14 @@ def _update(args):
|
|
|
450
450
|
- Explicit dev env: sync from NEXO_CODE/src
|
|
451
451
|
- Packaged/runtime-only install: delegate to plugins.update handle_update()
|
|
452
452
|
"""
|
|
453
|
-
import
|
|
453
|
+
from auto_update import manual_sync_update, _resolve_sync_source
|
|
454
|
+
from runtime_power import ensure_power_policy_choice, apply_power_policy
|
|
454
455
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
def _runtime_version_source() -> Path | None:
|
|
458
|
-
version_file = NEXO_HOME / "version.json"
|
|
459
|
-
if not version_file.is_file():
|
|
460
|
-
return None
|
|
461
|
-
try:
|
|
462
|
-
data = json.loads(version_file.read_text())
|
|
463
|
-
except Exception:
|
|
464
|
-
return None
|
|
465
|
-
source = str(data.get("source", "")).strip()
|
|
466
|
-
if not source:
|
|
467
|
-
return None
|
|
468
|
-
candidate = Path(source).expanduser()
|
|
469
|
-
if (candidate / "src").is_dir() and (candidate / "package.json").is_file():
|
|
470
|
-
return candidate
|
|
471
|
-
return None
|
|
472
|
-
|
|
473
|
-
def _resolve_sync_source() -> tuple[Path | None, Path | None]:
|
|
474
|
-
try:
|
|
475
|
-
same_as_runtime = NEXO_CODE.resolve() == dest.resolve()
|
|
476
|
-
except Exception:
|
|
477
|
-
same_as_runtime = NEXO_CODE == dest
|
|
478
|
-
|
|
479
|
-
# Explicit dev mode: NEXO_CODE points at repo/src, never the installed runtime itself.
|
|
480
|
-
if (
|
|
481
|
-
not same_as_runtime
|
|
482
|
-
and (NEXO_CODE / "db").is_dir()
|
|
483
|
-
and (NEXO_CODE.parent / "package.json").is_file()
|
|
484
|
-
):
|
|
485
|
-
return NEXO_CODE, NEXO_CODE.parent
|
|
486
|
-
|
|
487
|
-
# Installed runtime linked back to a source checkout
|
|
488
|
-
version_source = _runtime_version_source()
|
|
489
|
-
if version_source:
|
|
490
|
-
return version_source / "src", version_source
|
|
491
|
-
|
|
492
|
-
return None, None
|
|
456
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
493
457
|
|
|
458
|
+
dest = NEXO_HOME
|
|
494
459
|
src_dir, repo_dir = _resolve_sync_source()
|
|
495
460
|
|
|
496
|
-
if src_dir is not None:
|
|
497
|
-
try:
|
|
498
|
-
if src_dir.resolve() == dest.resolve():
|
|
499
|
-
version_source = _runtime_version_source()
|
|
500
|
-
if version_source:
|
|
501
|
-
src_dir = version_source / "src"
|
|
502
|
-
repo_dir = version_source
|
|
503
|
-
else:
|
|
504
|
-
src_dir = None
|
|
505
|
-
repo_dir = None
|
|
506
|
-
except Exception:
|
|
507
|
-
pass
|
|
508
|
-
|
|
509
461
|
if src_dir is None or repo_dir is None:
|
|
510
462
|
try:
|
|
511
463
|
from plugins.update import handle_update
|
|
@@ -518,151 +470,42 @@ def _update(args):
|
|
|
518
470
|
return 1
|
|
519
471
|
|
|
520
472
|
result = handle_update()
|
|
473
|
+
choice = ensure_power_policy_choice(interactive=interactive, reason="update")
|
|
474
|
+
power_result = apply_power_policy(choice.get("policy"))
|
|
521
475
|
if args.json:
|
|
522
476
|
print(json.dumps({
|
|
523
477
|
"mode": "packaged",
|
|
524
478
|
"message": result,
|
|
479
|
+
"power_policy": choice.get("policy"),
|
|
480
|
+
"power_action": power_result.get("action"),
|
|
525
481
|
}, indent=2, ensure_ascii=False))
|
|
526
482
|
else:
|
|
527
483
|
print(result)
|
|
484
|
+
if choice.get("prompted"):
|
|
485
|
+
print(f"Power policy: {choice.get('policy')}")
|
|
528
486
|
return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
|
|
529
487
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
pkg_dest = dest / pkg
|
|
536
|
-
if pkg_src.is_dir():
|
|
537
|
-
if pkg_dest.exists():
|
|
538
|
-
shutil.rmtree(str(pkg_dest), ignore_errors=True)
|
|
539
|
-
shutil.copytree(
|
|
540
|
-
str(pkg_src), str(pkg_dest),
|
|
541
|
-
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.db"),
|
|
542
|
-
)
|
|
543
|
-
copied_packages += 1
|
|
544
|
-
|
|
545
|
-
# Flat Python files
|
|
546
|
-
flat_files = [
|
|
547
|
-
"server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
|
|
548
|
-
"maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
|
|
549
|
-
"evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
|
|
550
|
-
"auto_update.py", "tools_sessions.py", "tools_coordination.py",
|
|
551
|
-
"tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
|
|
552
|
-
"tools_credentials.py", "tools_task_history.py", "tools_menu.py",
|
|
553
|
-
"cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
|
|
554
|
-
"cron_recovery.py",
|
|
555
|
-
"requirements.txt",
|
|
556
|
-
]
|
|
557
|
-
copied_files = 0
|
|
558
|
-
for f in flat_files:
|
|
559
|
-
src_f = src_dir / f
|
|
560
|
-
if src_f.is_file():
|
|
561
|
-
shutil.copy2(str(src_f), str(dest / f))
|
|
562
|
-
copied_files += 1
|
|
563
|
-
|
|
564
|
-
# Plugins
|
|
565
|
-
plugins_src = src_dir / "plugins"
|
|
566
|
-
plugins_dest = dest / "plugins"
|
|
567
|
-
if plugins_src.is_dir():
|
|
568
|
-
plugins_dest.mkdir(parents=True, exist_ok=True)
|
|
569
|
-
for f in plugins_src.iterdir():
|
|
570
|
-
if f.is_file() and f.suffix == ".py":
|
|
571
|
-
shutil.copy2(str(f), str(plugins_dest / f.name))
|
|
572
|
-
|
|
573
|
-
# Scripts
|
|
574
|
-
scripts_src = src_dir / "scripts"
|
|
575
|
-
scripts_dest = dest / "scripts"
|
|
576
|
-
if scripts_src.is_dir():
|
|
577
|
-
scripts_dest.mkdir(parents=True, exist_ok=True)
|
|
578
|
-
for f in scripts_src.iterdir():
|
|
579
|
-
if f.name == "__pycache__" or f.name.startswith("."):
|
|
580
|
-
continue
|
|
581
|
-
dst = scripts_dest / f.name
|
|
582
|
-
if f.is_dir():
|
|
583
|
-
if dst.exists():
|
|
584
|
-
shutil.rmtree(str(dst), ignore_errors=True)
|
|
585
|
-
shutil.copytree(str(f), str(dst), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
586
|
-
elif f.is_file():
|
|
587
|
-
shutil.copy2(str(f), str(dst))
|
|
588
|
-
if f.suffix == ".sh":
|
|
589
|
-
dst.chmod(0o755)
|
|
590
|
-
|
|
591
|
-
# Templates
|
|
592
|
-
templates_src = repo_dir / "templates"
|
|
593
|
-
templates_dest = dest / "templates"
|
|
594
|
-
if templates_src.is_dir():
|
|
595
|
-
templates_dest.mkdir(parents=True, exist_ok=True)
|
|
596
|
-
for f in templates_src.iterdir():
|
|
597
|
-
if f.is_file():
|
|
598
|
-
shutil.copy2(str(f), str(templates_dest / f.name))
|
|
599
|
-
|
|
600
|
-
# Runtime version metadata
|
|
601
|
-
package_json = repo_dir / "package.json"
|
|
602
|
-
if package_json.is_file():
|
|
603
|
-
shutil.copy2(str(package_json), str(dest / "package.json"))
|
|
604
|
-
try:
|
|
605
|
-
pkg = json.loads(package_json.read_text())
|
|
606
|
-
version_payload = {
|
|
607
|
-
"version": pkg.get("version", "?"),
|
|
608
|
-
"source": str(repo_dir),
|
|
609
|
-
}
|
|
610
|
-
(dest / "version.json").write_text(json.dumps(version_payload, indent=2))
|
|
611
|
-
except Exception:
|
|
612
|
-
pass
|
|
613
|
-
|
|
614
|
-
# Core skills
|
|
615
|
-
skills_src = src_dir / "skills"
|
|
616
|
-
skills_dest = dest / "skills-core"
|
|
617
|
-
if skills_src.is_dir():
|
|
618
|
-
if skills_dest.exists():
|
|
619
|
-
shutil.rmtree(str(skills_dest), ignore_errors=True)
|
|
620
|
-
shutil.copytree(
|
|
621
|
-
str(skills_src), str(skills_dest),
|
|
622
|
-
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
|
623
|
-
)
|
|
624
|
-
|
|
625
|
-
# Runtime CLI wrapper
|
|
626
|
-
bin_dir = dest / "bin"
|
|
627
|
-
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
628
|
-
wrapper = bin_dir / "nexo"
|
|
629
|
-
wrapper_content = (
|
|
630
|
-
"#!/usr/bin/env bash\n"
|
|
631
|
-
"set -euo pipefail\n\n"
|
|
632
|
-
f'NEXO_HOME="{dest}"\n'
|
|
633
|
-
'PYTHON="$NEXO_HOME/.venv/bin/python3"\n'
|
|
634
|
-
'if [ ! -x "$PYTHON" ]; then\n'
|
|
635
|
-
' if command -v python3 >/dev/null 2>&1; then PYTHON="python3"; else PYTHON="python"; fi\n'
|
|
636
|
-
'fi\n'
|
|
637
|
-
'export NEXO_HOME\n'
|
|
638
|
-
'export NEXO_CODE="$NEXO_HOME"\n'
|
|
639
|
-
'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"\n'
|
|
640
|
-
)
|
|
641
|
-
wrapper.write_text(wrapper_content)
|
|
642
|
-
wrapper.chmod(0o755)
|
|
643
|
-
|
|
644
|
-
try:
|
|
645
|
-
from db import init_db
|
|
646
|
-
from script_registry import sync_personal_scripts
|
|
647
|
-
|
|
648
|
-
init_db()
|
|
649
|
-
sync_personal_scripts()
|
|
650
|
-
except Exception:
|
|
651
|
-
pass
|
|
652
|
-
|
|
653
|
-
result = {
|
|
654
|
-
"mode": "sync",
|
|
655
|
-
"packages": copied_packages,
|
|
656
|
-
"files": copied_files,
|
|
657
|
-
"nexo_home": str(dest),
|
|
658
|
-
"source": str(src_dir),
|
|
659
|
-
}
|
|
488
|
+
choice = ensure_power_policy_choice(interactive=interactive, reason="update")
|
|
489
|
+
power_result = apply_power_policy(choice.get("policy"))
|
|
490
|
+
result = manual_sync_update(interactive=interactive, allow_source_pull=True)
|
|
491
|
+
result["power_policy"] = choice.get("policy")
|
|
492
|
+
result["power_action"] = power_result.get("action")
|
|
660
493
|
if args.json:
|
|
661
|
-
print(json.dumps(result, indent=2))
|
|
494
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
662
495
|
else:
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
496
|
+
if result.get("ok"):
|
|
497
|
+
print(f"Updated NEXO_HOME ({dest})")
|
|
498
|
+
print(
|
|
499
|
+
f" {result.get('packages', 0)} packages, {result.get('files', 0)} files synced from "
|
|
500
|
+
f"{result.get('source', src_dir)}"
|
|
501
|
+
)
|
|
502
|
+
if result.get("pulled_source"):
|
|
503
|
+
print(" Source repo: pulled latest fast-forward before sync")
|
|
504
|
+
if choice.get("prompted"):
|
|
505
|
+
print(f" Power policy: {choice.get('policy')}")
|
|
506
|
+
else:
|
|
507
|
+
print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
|
|
508
|
+
return 0 if result.get("ok") else 1
|
|
666
509
|
|
|
667
510
|
|
|
668
511
|
def _service_control(service_name: str, action: str) -> int:
|
|
@@ -733,6 +576,23 @@ def _chat(args):
|
|
|
733
576
|
print("Claude Code launcher not found in PATH. Install `claude` first.", file=sys.stderr)
|
|
734
577
|
return 1
|
|
735
578
|
|
|
579
|
+
try:
|
|
580
|
+
from auto_update import startup_preflight
|
|
581
|
+
|
|
582
|
+
preflight = startup_preflight(entrypoint="chat", interactive=False)
|
|
583
|
+
if preflight.get("updated"):
|
|
584
|
+
print("[NEXO] Startup update applied before chat.", file=sys.stderr)
|
|
585
|
+
elif preflight.get("deferred_reason"):
|
|
586
|
+
print(f"[NEXO] Update deferred: {preflight['deferred_reason']}", file=sys.stderr)
|
|
587
|
+
elif preflight.get("git_update"):
|
|
588
|
+
print(f"[NEXO] {preflight['git_update']}", file=sys.stderr)
|
|
589
|
+
elif preflight.get("npm_notice"):
|
|
590
|
+
print(f"[NEXO] {preflight['npm_notice']}", file=sys.stderr)
|
|
591
|
+
if preflight.get("error"):
|
|
592
|
+
print(f"[NEXO] Startup preflight warning: {preflight['error']}", file=sys.stderr)
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
|
|
736
596
|
result = subprocess.run(
|
|
737
597
|
[claude_bin, "--dangerously-skip-permissions", target],
|
|
738
598
|
env=os.environ.copy(),
|
package/src/cron_recovery.py
CHANGED
|
@@ -57,6 +57,60 @@ def load_enabled_crons() -> list[dict]:
|
|
|
57
57
|
return []
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def _calendar_payload_from_declared(value: str) -> dict | None:
|
|
61
|
+
parts = str(value or "").split(":")
|
|
62
|
+
if len(parts) not in {2, 3}:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
hour = int(parts[0])
|
|
66
|
+
minute = int(parts[1])
|
|
67
|
+
weekday = int(parts[2]) if len(parts) == 3 else None
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
payload = {"hour": hour, "minute": minute}
|
|
71
|
+
if weekday is not None:
|
|
72
|
+
payload["weekday"] = weekday
|
|
73
|
+
return payload
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_managed_personal_crons() -> list[dict]:
|
|
77
|
+
try:
|
|
78
|
+
from script_registry import classify_scripts_dir, discover_personal_schedules
|
|
79
|
+
except Exception:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
scripts_by_path: dict[str, dict] = {}
|
|
83
|
+
for entry in classify_scripts_dir().get("entries", []):
|
|
84
|
+
if entry.get("classification") != "personal":
|
|
85
|
+
continue
|
|
86
|
+
scripts_by_path[str(entry.get("path", ""))] = entry
|
|
87
|
+
|
|
88
|
+
personal: list[dict] = []
|
|
89
|
+
for schedule in discover_personal_schedules():
|
|
90
|
+
script = scripts_by_path.get(str(schedule.get("script_path", "")))
|
|
91
|
+
declared = (script or {}).get("declared_schedule", {})
|
|
92
|
+
if not script or not declared.get("valid"):
|
|
93
|
+
continue
|
|
94
|
+
schedule_type = declared.get("schedule_type")
|
|
95
|
+
if schedule_type not in {"calendar", "interval"}:
|
|
96
|
+
continue
|
|
97
|
+
personal.append({
|
|
98
|
+
"id": schedule["cron_id"],
|
|
99
|
+
"script": schedule["script_path"],
|
|
100
|
+
"type": script.get("runtime", "python"),
|
|
101
|
+
"schedule": declared.get("schedule", ""),
|
|
102
|
+
"interval_seconds": int(declared.get("interval_seconds", 0) or 0),
|
|
103
|
+
"schedule_type": schedule_type,
|
|
104
|
+
"recovery_policy": declared.get("recovery_policy", "none"),
|
|
105
|
+
"idempotent": bool(declared.get("idempotent", False)),
|
|
106
|
+
"max_catchup_age": int(declared.get("max_catchup_age", 0) or 0),
|
|
107
|
+
"run_on_boot": bool(declared.get("run_on_boot", False)),
|
|
108
|
+
"run_on_wake": bool(declared.get("run_on_wake", False)),
|
|
109
|
+
"personal_managed": True,
|
|
110
|
+
})
|
|
111
|
+
return personal
|
|
112
|
+
|
|
113
|
+
|
|
60
114
|
def default_recovery_policy(cron: dict) -> str:
|
|
61
115
|
if cron.get("keep_alive") or cron.get("interval_seconds"):
|
|
62
116
|
return "restart"
|
|
@@ -122,6 +176,23 @@ def launchagent_schedule(cron_id: str) -> dict:
|
|
|
122
176
|
|
|
123
177
|
|
|
124
178
|
def effective_schedule(cron: dict) -> dict:
|
|
179
|
+
if cron.get("personal_managed"):
|
|
180
|
+
if cron.get("schedule_type") == "interval":
|
|
181
|
+
return {
|
|
182
|
+
"source": "personal",
|
|
183
|
+
"schedule_type": "interval",
|
|
184
|
+
"interval_seconds": int(cron.get("interval_seconds", 0) or 0),
|
|
185
|
+
"run_at_load": bool(cron.get("run_on_boot")),
|
|
186
|
+
}
|
|
187
|
+
if cron.get("schedule_type") == "calendar":
|
|
188
|
+
calendar = _calendar_payload_from_declared(str(cron.get("schedule", ""))) or {}
|
|
189
|
+
return {
|
|
190
|
+
"source": "personal",
|
|
191
|
+
"schedule_type": "calendar",
|
|
192
|
+
"calendar": calendar,
|
|
193
|
+
"run_at_load": bool(cron.get("run_on_boot")),
|
|
194
|
+
}
|
|
195
|
+
|
|
125
196
|
actual = launchagent_schedule(cron["id"])
|
|
126
197
|
if actual.get("schedule_type"):
|
|
127
198
|
return actual
|
|
@@ -241,7 +312,7 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
|
|
|
241
312
|
if now.tzinfo is None:
|
|
242
313
|
now = now.replace(tzinfo=_local_timezone())
|
|
243
314
|
|
|
244
|
-
crons = load_enabled_crons()
|
|
315
|
+
crons = load_enabled_crons() + load_managed_personal_crons()
|
|
245
316
|
contracts = {cron["id"]: recovery_contract(cron) for cron in crons if cron.get("id")}
|
|
246
317
|
successes = latest_successful_runs(list(contracts), db_path=DB_PATH)
|
|
247
318
|
legacy = legacy_state_runs(state_file=STATE_FILE)
|
|
@@ -253,14 +324,22 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
|
|
|
253
324
|
continue
|
|
254
325
|
contract = contracts[cron_id]
|
|
255
326
|
schedule = effective_schedule(cron)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if schedule.get("schedule_type") != "calendar":
|
|
327
|
+
schedule_type = schedule.get("schedule_type")
|
|
328
|
+
if schedule_type not in {"calendar", "interval"}:
|
|
259
329
|
continue
|
|
260
330
|
if not contract["idempotent"]:
|
|
261
331
|
continue
|
|
262
|
-
|
|
263
|
-
|
|
332
|
+
if schedule_type == "calendar":
|
|
333
|
+
if contract["recovery_policy"] != "catchup":
|
|
334
|
+
continue
|
|
335
|
+
due_at = last_scheduled_time(schedule["calendar"], now)
|
|
336
|
+
else:
|
|
337
|
+
if contract["recovery_policy"] not in {"catchup", "run_once_on_wake"}:
|
|
338
|
+
continue
|
|
339
|
+
interval_seconds = int(schedule.get("interval_seconds", 0) or 0)
|
|
340
|
+
if interval_seconds <= 0:
|
|
341
|
+
continue
|
|
342
|
+
due_at = now - timedelta(seconds=interval_seconds)
|
|
264
343
|
last_success = successes.get(cron_id) or legacy.get(cron_id)
|
|
265
344
|
age_seconds = max(int((now - due_at).total_seconds()), 0)
|
|
266
345
|
missed = last_success is None or last_success < due_at
|
|
@@ -270,6 +349,7 @@ def catchup_candidates(now: datetime | None = None) -> list[dict]:
|
|
|
270
349
|
"cron_id": cron_id,
|
|
271
350
|
"script": cron.get("script", ""),
|
|
272
351
|
"type": cron.get("type", "python"),
|
|
352
|
+
"personal_managed": bool(cron.get("personal_managed")),
|
|
273
353
|
"contract": contract,
|
|
274
354
|
"schedule": schedule,
|
|
275
355
|
"last_due_at": due_at,
|
package/src/crons/sync.py
CHANGED
|
@@ -370,7 +370,7 @@ def sync_linux(dry_run: bool = False):
|
|
|
370
370
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
371
371
|
|
|
372
372
|
manifest_crons = load_manifest()
|
|
373
|
-
wrapper_src =
|
|
373
|
+
wrapper_src = SOURCE_ROOT / "scripts" / "nexo-cron-wrapper.sh"
|
|
374
374
|
wrapper_dest = _copy_script_to_nexo_home(wrapper_src)
|
|
375
375
|
|
|
376
376
|
log(f"Manifest: {len(manifest_crons)} core crons")
|
|
@@ -383,13 +383,13 @@ def sync_linux(dry_run: bool = False):
|
|
|
383
383
|
|
|
384
384
|
for cron in manifest_crons:
|
|
385
385
|
cron_id = cron["id"]
|
|
386
|
-
script_src =
|
|
386
|
+
script_src = SOURCE_ROOT / cron["script"]
|
|
387
387
|
script_dest = _copy_script_to_nexo_home(script_src)
|
|
388
388
|
script_type = cron.get("type", "python")
|
|
389
389
|
|
|
390
390
|
# Copy subdirectories
|
|
391
391
|
subdir_name = script_src.stem.replace("nexo-", "")
|
|
392
|
-
subdir_src =
|
|
392
|
+
subdir_src = SOURCE_ROOT / "scripts" / subdir_name
|
|
393
393
|
if subdir_src.is_dir():
|
|
394
394
|
_copy_script_to_nexo_home(subdir_src)
|
|
395
395
|
|
|
@@ -411,7 +411,7 @@ Description=NEXO: {cron.get('description', cron_id)}
|
|
|
411
411
|
Type=oneshot
|
|
412
412
|
ExecStart={exec_cmd}
|
|
413
413
|
Environment=NEXO_HOME={NEXO_HOME}
|
|
414
|
-
Environment=NEXO_CODE={
|
|
414
|
+
Environment=NEXO_CODE={SOURCE_ROOT}
|
|
415
415
|
Environment=HOME={Path.home()}
|
|
416
416
|
StandardOutput=append:{stdout_log}
|
|
417
417
|
StandardError=append:{stderr_log}
|
package/src/nexo.db
CHANGED
|
Binary file
|
package/src/plugins/schedule.py
CHANGED
|
@@ -10,7 +10,12 @@ from db import (
|
|
|
10
10
|
init_db, cron_runs_recent, cron_runs_summary,
|
|
11
11
|
upsert_personal_script, register_personal_script_schedule,
|
|
12
12
|
)
|
|
13
|
-
from script_registry import
|
|
13
|
+
from script_registry import (
|
|
14
|
+
PERSONAL_SCHEDULE_MANAGED_ENV,
|
|
15
|
+
parse_inline_metadata,
|
|
16
|
+
classify_runtime,
|
|
17
|
+
get_declared_schedule,
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
|
|
@@ -76,6 +81,7 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
|
|
|
76
81
|
|
|
77
82
|
script_meta = parse_inline_metadata(script_path)
|
|
78
83
|
detected_runtime = classify_runtime(script_path, script_meta)
|
|
84
|
+
declared = get_declared_schedule(script_meta, script_meta.get("name", script_path.stem))
|
|
79
85
|
script_type = (script_type or "auto").strip().lower()
|
|
80
86
|
if script_type == "auto":
|
|
81
87
|
script_type = detected_runtime if detected_runtime != "unknown" else "python"
|
|
@@ -96,6 +102,7 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
|
|
|
96
102
|
description or script_meta.get("description", ""),
|
|
97
103
|
script_type,
|
|
98
104
|
nexo_home,
|
|
105
|
+
declared=declared,
|
|
99
106
|
)
|
|
100
107
|
elif system == "Linux":
|
|
101
108
|
return _add_systemd_timer(
|
|
@@ -107,6 +114,7 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
|
|
|
107
114
|
description or script_meta.get("description", ""),
|
|
108
115
|
script_type,
|
|
109
116
|
nexo_home,
|
|
117
|
+
declared=declared,
|
|
110
118
|
)
|
|
111
119
|
else:
|
|
112
120
|
return f"ERROR: unsupported platform: {system}"
|
|
@@ -166,7 +174,7 @@ def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds
|
|
|
166
174
|
|
|
167
175
|
|
|
168
176
|
def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seconds,
|
|
169
|
-
description, script_type, nexo_home):
|
|
177
|
+
description, script_type, nexo_home, *, declared: dict | None = None):
|
|
170
178
|
"""Create and load a macOS LaunchAgent."""
|
|
171
179
|
import plistlib
|
|
172
180
|
|
|
@@ -202,6 +210,10 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
|
|
|
202
210
|
cal["Weekday"] = int(parts[2])
|
|
203
211
|
plist["StartCalendarInterval"] = cal
|
|
204
212
|
|
|
213
|
+
declared = declared or {}
|
|
214
|
+
if declared.get("run_on_boot"):
|
|
215
|
+
plist["RunAtLoad"] = True
|
|
216
|
+
|
|
205
217
|
with open(plist_path, "wb") as f:
|
|
206
218
|
plistlib.dump(plist, f)
|
|
207
219
|
|
|
@@ -222,7 +234,7 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
|
|
|
222
234
|
|
|
223
235
|
|
|
224
236
|
def _add_systemd_timer(cron_id, script_path, wrapper_path, schedule, interval_seconds,
|
|
225
|
-
description, script_type, nexo_home):
|
|
237
|
+
description, script_type, nexo_home, *, declared: dict | None = None):
|
|
226
238
|
"""Create and enable a systemd user timer (Linux)."""
|
|
227
239
|
unit_dir = Path.home() / ".config" / "systemd" / "user"
|
|
228
240
|
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -246,8 +258,12 @@ Environment=NEXO_PERSONAL_CRON_ID={cron_id}
|
|
|
246
258
|
service_path.write_text(service_content)
|
|
247
259
|
|
|
248
260
|
# Timer unit
|
|
261
|
+
declared = declared or {}
|
|
262
|
+
|
|
249
263
|
if interval_seconds:
|
|
250
|
-
timer_spec = f"OnUnitActiveSec={interval_seconds}s
|
|
264
|
+
timer_spec = f"OnUnitActiveSec={interval_seconds}s"
|
|
265
|
+
if declared.get("run_on_boot") or not declared.get("required"):
|
|
266
|
+
timer_spec += "\nOnBootSec=60s"
|
|
251
267
|
elif schedule:
|
|
252
268
|
parts = schedule.split(":")
|
|
253
269
|
hour, minute = int(parts[0]), int(parts[1])
|
package/src/plugins/update.py
CHANGED
|
@@ -9,24 +9,22 @@ import sys
|
|
|
9
9
|
import time
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
#
|
|
12
|
+
# Code root is the parent of plugins/:
|
|
13
|
+
# - source checkout: <repo>/src
|
|
14
|
+
# - packaged runtime: <NEXO_HOME>
|
|
13
15
|
_THIS_DIR = Path(__file__).resolve().parent
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
CODE_ROOT = _THIS_DIR.parent
|
|
17
|
+
_REPO_CANDIDATE = CODE_ROOT.parent
|
|
16
18
|
|
|
17
19
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
18
20
|
DATA_DIR = NEXO_HOME / "data"
|
|
19
21
|
BACKUP_BASE = NEXO_HOME / "backups"
|
|
20
22
|
|
|
21
|
-
# In packaged installs, update.py lives at
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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"
|
|
23
|
+
# In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
|
|
24
|
+
_PACKAGED_INSTALL = not (_REPO_CANDIDATE / ".git").exists() and not (_REPO_CANDIDATE / ".git").is_file()
|
|
25
|
+
REPO_DIR = CODE_ROOT if _PACKAGED_INSTALL else _REPO_CANDIDATE
|
|
26
|
+
SRC_DIR = CODE_ROOT
|
|
27
|
+
PACKAGE_JSON = REPO_DIR / "package.json"
|
|
30
28
|
|
|
31
29
|
|
|
32
30
|
def _find_npm_pkg_src() -> Path | None:
|
|
@@ -64,13 +62,27 @@ def _refresh_installed_manifest():
|
|
|
64
62
|
|
|
65
63
|
|
|
66
64
|
def _read_version() -> str:
|
|
67
|
-
"""Read
|
|
65
|
+
"""Read the installed/runtime version."""
|
|
66
|
+
if _PACKAGED_INSTALL:
|
|
67
|
+
# version.json is the runtime truth for packaged installs.
|
|
68
|
+
try:
|
|
69
|
+
version_file = NEXO_HOME / "version.json"
|
|
70
|
+
if version_file.exists():
|
|
71
|
+
return json.loads(version_file.read_text()).get("version", "unknown")
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
try:
|
|
75
|
+
package_file = NEXO_HOME / "package.json"
|
|
76
|
+
if package_file.exists():
|
|
77
|
+
return json.loads(package_file.read_text()).get("version", "unknown")
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
68
81
|
try:
|
|
69
82
|
if PACKAGE_JSON.exists():
|
|
70
83
|
return json.loads(PACKAGE_JSON.read_text()).get("version", "unknown")
|
|
71
84
|
except Exception:
|
|
72
85
|
pass
|
|
73
|
-
# Packaged installs don't ship package.json — check version.json in NEXO_HOME
|
|
74
86
|
try:
|
|
75
87
|
version_file = NEXO_HOME / "version.json"
|
|
76
88
|
if version_file.exists():
|