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.
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+ """Runtime power policy helpers.
3
+
4
+ Manages the optional "prevent sleep" helper as an explicit, persisted runtime
5
+ preference. The policy is stored in config/schedule.json to avoid introducing a
6
+ second user-facing config surface.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import plistlib
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+
17
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
19
+ CONFIG_DIR = NEXO_HOME / "config"
20
+ SCHEDULE_FILE = CONFIG_DIR / "schedule.json"
21
+ POWER_POLICY_KEY = "power_policy"
22
+ POWER_POLICY_VERSION_KEY = "power_policy_version"
23
+ POWER_POLICY_VERSION = 1
24
+ POWER_POLICY_ALWAYS_ON = "always_on"
25
+ POWER_POLICY_DISABLED = "disabled"
26
+ POWER_POLICY_UNSET = "unset"
27
+ VALID_POWER_POLICIES = {
28
+ POWER_POLICY_ALWAYS_ON,
29
+ POWER_POLICY_DISABLED,
30
+ POWER_POLICY_UNSET,
31
+ }
32
+ LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
33
+ LINUX_SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
34
+
35
+
36
+ def _schedule_defaults() -> dict:
37
+ return {
38
+ "timezone": "UTC",
39
+ "auto_update": True,
40
+ POWER_POLICY_KEY: POWER_POLICY_UNSET,
41
+ POWER_POLICY_VERSION_KEY: POWER_POLICY_VERSION,
42
+ "processes": {},
43
+ }
44
+
45
+
46
+ def load_schedule_config() -> dict:
47
+ if not SCHEDULE_FILE.is_file():
48
+ return _schedule_defaults()
49
+ try:
50
+ data = json.loads(SCHEDULE_FILE.read_text())
51
+ except Exception:
52
+ return _schedule_defaults()
53
+ if not isinstance(data, dict):
54
+ return _schedule_defaults()
55
+ merged = _schedule_defaults()
56
+ merged.update(data)
57
+ merged.setdefault("processes", {})
58
+ return merged
59
+
60
+
61
+ def save_schedule_config(schedule: dict) -> Path:
62
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
63
+ payload = dict(_schedule_defaults())
64
+ payload.update(schedule or {})
65
+ payload.setdefault("processes", {})
66
+ payload[POWER_POLICY_KEY] = normalize_power_policy(payload.get(POWER_POLICY_KEY))
67
+ payload[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
68
+ SCHEDULE_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
69
+ return SCHEDULE_FILE
70
+
71
+
72
+ def normalize_power_policy(value: str | None) -> str:
73
+ candidate = str(value or "").strip().lower()
74
+ if candidate in {"enabled", "yes", "on", "true", "1"}:
75
+ return POWER_POLICY_ALWAYS_ON
76
+ if candidate in {"disabled", "no", "off", "false", "0"}:
77
+ return POWER_POLICY_DISABLED
78
+ if candidate in VALID_POWER_POLICIES:
79
+ return candidate
80
+ return POWER_POLICY_UNSET
81
+
82
+
83
+ def get_power_policy(schedule: dict | None = None) -> str:
84
+ schedule = schedule or load_schedule_config()
85
+ return normalize_power_policy(schedule.get(POWER_POLICY_KEY))
86
+
87
+
88
+ def is_power_policy_configured(schedule: dict | None = None) -> bool:
89
+ return get_power_policy(schedule) != POWER_POLICY_UNSET
90
+
91
+
92
+ def set_power_policy(policy: str) -> dict:
93
+ schedule = load_schedule_config()
94
+ schedule[POWER_POLICY_KEY] = normalize_power_policy(policy)
95
+ schedule[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
96
+ save_schedule_config(schedule)
97
+ return schedule
98
+
99
+
100
+ def prompt_for_power_policy(
101
+ *,
102
+ reason: str = "install",
103
+ input_fn=input,
104
+ output_fn=print,
105
+ ) -> str:
106
+ prompt = (
107
+ "[NEXO] Keep this machine awake for background work? "
108
+ "[y]es / [n]o / [l]ater: "
109
+ )
110
+ output_fn(
111
+ "[NEXO] This controls the optional prevent-sleep helper. "
112
+ "It improves background availability but should remain opt-in."
113
+ )
114
+ while True:
115
+ answer = str(input_fn(prompt)).strip().lower()
116
+ if answer in {"y", "yes"}:
117
+ return POWER_POLICY_ALWAYS_ON
118
+ if answer in {"n", "no"}:
119
+ return POWER_POLICY_DISABLED
120
+ if answer in {"l", "later", ""}:
121
+ return POWER_POLICY_UNSET
122
+ output_fn("[NEXO] Reply with yes, no, or later.")
123
+
124
+
125
+ def ensure_power_policy_choice(
126
+ *,
127
+ interactive: bool,
128
+ reason: str = "update",
129
+ input_fn=input,
130
+ output_fn=print,
131
+ ) -> dict:
132
+ schedule = load_schedule_config()
133
+ policy = get_power_policy(schedule)
134
+ prompted = False
135
+ if interactive and policy == POWER_POLICY_UNSET:
136
+ prompted = True
137
+ policy = prompt_for_power_policy(reason=reason, input_fn=input_fn, output_fn=output_fn)
138
+ schedule[POWER_POLICY_KEY] = policy
139
+ schedule[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
140
+ save_schedule_config(schedule)
141
+ return {
142
+ "policy": policy,
143
+ "prompted": prompted,
144
+ "schedule_file": str(SCHEDULE_FILE),
145
+ }
146
+
147
+
148
+ def _prevent_sleep_script_path() -> Path:
149
+ runtime_script = NEXO_HOME / "scripts" / "nexo-prevent-sleep.sh"
150
+ if runtime_script.is_file():
151
+ return runtime_script
152
+ source_script = NEXO_CODE / "scripts" / "nexo-prevent-sleep.sh"
153
+ return source_script
154
+
155
+
156
+ def _macos_prevent_sleep_plist() -> tuple[Path, dict]:
157
+ script_path = _prevent_sleep_script_path()
158
+ plist_path = LAUNCH_AGENTS_DIR / "com.nexo.prevent-sleep.plist"
159
+ plist = {
160
+ "Label": "com.nexo.prevent-sleep",
161
+ "ProgramArguments": ["/bin/bash", str(script_path)],
162
+ "RunAtLoad": True,
163
+ "KeepAlive": True,
164
+ "StandardOutPath": str(NEXO_HOME / "logs" / "prevent-sleep-stdout.log"),
165
+ "StandardErrorPath": str(NEXO_HOME / "logs" / "prevent-sleep-stderr.log"),
166
+ "EnvironmentVariables": {
167
+ "HOME": str(Path.home()),
168
+ "NEXO_HOME": str(NEXO_HOME),
169
+ "NEXO_CODE": str(NEXO_HOME),
170
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + str(Path.home() / ".local/bin"),
171
+ },
172
+ }
173
+ return plist_path, plist
174
+
175
+
176
+ def _linux_prevent_sleep_service() -> tuple[Path, str]:
177
+ script_path = _prevent_sleep_script_path()
178
+ service_path = LINUX_SYSTEMD_USER_DIR / "nexo-prevent-sleep.service"
179
+ body = f"""[Unit]
180
+ Description=NEXO prevent sleep
181
+
182
+ [Service]
183
+ Type=simple
184
+ ExecStart=/bin/bash {script_path}
185
+ Environment=HOME={Path.home()}
186
+ Environment=NEXO_HOME={NEXO_HOME}
187
+ Environment=NEXO_CODE={NEXO_HOME}
188
+ Restart=always
189
+ RestartSec=5
190
+
191
+ [Install]
192
+ WantedBy=default.target
193
+ """
194
+ return service_path, body
195
+
196
+
197
+ def apply_power_policy(policy: str | None = None) -> dict:
198
+ policy = normalize_power_policy(policy or get_power_policy())
199
+ system = platform.system()
200
+ logs_dir = NEXO_HOME / "logs"
201
+ logs_dir.mkdir(parents=True, exist_ok=True)
202
+
203
+ if system == "Darwin":
204
+ return _apply_macos_power_policy(policy)
205
+ if system == "Linux":
206
+ return _apply_linux_power_policy(policy)
207
+ return {
208
+ "ok": policy != POWER_POLICY_ALWAYS_ON,
209
+ "policy": policy,
210
+ "platform": system,
211
+ "action": "unsupported",
212
+ "message": f"Unsupported platform for prevent-sleep policy: {system}",
213
+ }
214
+
215
+
216
+ def _apply_macos_power_policy(policy: str) -> dict:
217
+ plist_path, plist = _macos_prevent_sleep_plist()
218
+ label = plist["Label"]
219
+ uid = str(os.getuid())
220
+ if policy == POWER_POLICY_ALWAYS_ON:
221
+ LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
222
+ with plist_path.open("wb") as fh:
223
+ plistlib.dump(plist, fh)
224
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True)
225
+ result = subprocess.run(
226
+ ["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
227
+ capture_output=True,
228
+ text=True,
229
+ )
230
+ ok = result.returncode == 0
231
+ return {
232
+ "ok": ok,
233
+ "policy": policy,
234
+ "platform": "Darwin",
235
+ "action": "enabled",
236
+ "plist_path": str(plist_path),
237
+ "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
238
+ }
239
+
240
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True)
241
+ if plist_path.exists():
242
+ plist_path.unlink()
243
+ subprocess.run(["launchctl", "remove", label], capture_output=True)
244
+ return {
245
+ "ok": True,
246
+ "policy": policy,
247
+ "platform": "Darwin",
248
+ "action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
249
+ "plist_path": str(plist_path),
250
+ }
251
+
252
+
253
+ def _apply_linux_power_policy(policy: str) -> dict:
254
+ service_path, service_body = _linux_prevent_sleep_service()
255
+ if policy == POWER_POLICY_ALWAYS_ON:
256
+ LINUX_SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
257
+ service_path.write_text(service_body)
258
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
259
+ result = subprocess.run(
260
+ ["systemctl", "--user", "enable", "--now", "nexo-prevent-sleep.service"],
261
+ capture_output=True,
262
+ text=True,
263
+ )
264
+ ok = result.returncode == 0
265
+ return {
266
+ "ok": ok,
267
+ "policy": policy,
268
+ "platform": "Linux",
269
+ "action": "enabled",
270
+ "service_path": str(service_path),
271
+ "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
272
+ }
273
+
274
+ subprocess.run(["systemctl", "--user", "disable", "--now", "nexo-prevent-sleep.service"], capture_output=True)
275
+ if service_path.exists():
276
+ service_path.unlink()
277
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
278
+ return {
279
+ "ok": True,
280
+ "policy": policy,
281
+ "platform": "Linux",
282
+ "action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
283
+ "service_path": str(service_path),
284
+ }
@@ -58,9 +58,15 @@ METADATA_KEYS = {
58
58
  "schedule",
59
59
  "interval_seconds",
60
60
  "schedule_required",
61
+ "recovery_policy",
62
+ "run_on_boot",
63
+ "run_on_wake",
64
+ "idempotent",
65
+ "max_catchup_age",
61
66
  }
62
67
  SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
63
68
  PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
69
+ SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
64
70
 
65
71
 
66
72
  def get_nexo_home() -> Path:
@@ -214,8 +220,40 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
214
220
  interval_raw = metadata.get("interval_seconds", "").strip()
215
221
  schedule_raw = metadata.get("schedule", "").strip()
216
222
  schedule_required = _truthy(metadata.get("schedule_required"))
223
+ recovery_policy_raw = metadata.get("recovery_policy", "").strip().lower()
224
+ run_on_boot = _truthy(metadata.get("run_on_boot"))
225
+ run_on_wake = _truthy(metadata.get("run_on_wake"))
226
+ idempotent = _truthy(metadata.get("idempotent"))
227
+ max_catchup_age_raw = metadata.get("max_catchup_age", "").strip()
217
228
  required = schedule_required or bool(interval_raw or schedule_raw)
218
229
 
230
+ if recovery_policy_raw and recovery_policy_raw not in SUPPORTED_RECOVERY_POLICIES:
231
+ return {
232
+ "required": required,
233
+ "valid": False,
234
+ "error": f"Invalid recovery_policy: {recovery_policy_raw}",
235
+ "cron_id": cron_id,
236
+ }
237
+
238
+ max_catchup_age = 0
239
+ if max_catchup_age_raw:
240
+ try:
241
+ max_catchup_age = int(max_catchup_age_raw)
242
+ except ValueError:
243
+ return {
244
+ "required": required,
245
+ "valid": False,
246
+ "error": f"Invalid max_catchup_age: {max_catchup_age_raw}",
247
+ "cron_id": cron_id,
248
+ }
249
+ if max_catchup_age < 0:
250
+ return {
251
+ "required": required,
252
+ "valid": False,
253
+ "error": f"max_catchup_age must be >= 0 (got {max_catchup_age_raw})",
254
+ "cron_id": cron_id,
255
+ }
256
+
219
257
  if required:
220
258
  missing = []
221
259
  if not explicit_name:
@@ -241,6 +279,16 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
241
279
  "cron_id": cron_id,
242
280
  }
243
281
 
282
+ def _effective_run_on_wake(policy: str) -> bool:
283
+ if "run_on_wake" in metadata:
284
+ return run_on_wake
285
+ return policy in {"catchup", "run_once_on_wake"}
286
+
287
+ def _effective_idempotent(policy: str) -> bool:
288
+ if "idempotent" in metadata:
289
+ return idempotent
290
+ return policy in {"catchup", "run_once_on_wake", "restart", "restart_daemon"}
291
+
244
292
  if interval_raw and schedule_raw:
245
293
  return {
246
294
  "required": required,
@@ -275,6 +323,11 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
275
323
  "schedule_label": f"every {interval}s",
276
324
  "schedule": "",
277
325
  "interval_seconds": interval,
326
+ "recovery_policy": recovery_policy_raw or "run_once_on_wake",
327
+ "run_on_boot": run_on_boot,
328
+ "run_on_wake": _effective_run_on_wake(recovery_policy_raw or "run_once_on_wake"),
329
+ "idempotent": _effective_idempotent(recovery_policy_raw or "run_once_on_wake"),
330
+ "max_catchup_age": max_catchup_age or max(interval * 4, interval + 900),
278
331
  }
279
332
 
280
333
  if schedule_raw:
@@ -325,6 +378,11 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
325
378
  "schedule_label": label,
326
379
  "schedule": schedule_raw,
327
380
  "interval_seconds": 0,
381
+ "recovery_policy": recovery_policy_raw or "catchup",
382
+ "run_on_boot": run_on_boot,
383
+ "run_on_wake": _effective_run_on_wake(recovery_policy_raw or "catchup"),
384
+ "idempotent": _effective_idempotent(recovery_policy_raw or "catchup"),
385
+ "max_catchup_age": max_catchup_age or (14 * 86400 if weekday is not None else 48 * 3600),
328
386
  }
329
387
 
330
388
  return {
@@ -332,6 +390,11 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
332
390
  "valid": not required,
333
391
  "error": "" if not required else "schedule_required=true but no schedule metadata was provided.",
334
392
  "cron_id": cron_id,
393
+ "recovery_policy": recovery_policy_raw or "none",
394
+ "run_on_boot": run_on_boot,
395
+ "run_on_wake": run_on_wake,
396
+ "idempotent": idempotent,
397
+ "max_catchup_age": max_catchup_age,
335
398
  }
336
399
 
337
400
 
@@ -580,6 +643,7 @@ def _discover_personal_schedule_records() -> list[dict]:
580
643
  "schedule_type": schedule_type,
581
644
  "schedule_value": schedule_value,
582
645
  "schedule_label": schedule_label,
646
+ "run_at_load": bool(plist_data.get("RunAtLoad")),
583
647
  "launchd_label": label,
584
648
  "plist_path": str(plist_path),
585
649
  "enabled": True,
@@ -766,6 +830,8 @@ def _schedule_matches(existing: dict, declared: dict) -> bool:
766
830
  declared_value = _canonical_schedule_value(declared.get("schedule_type", ""), declared.get("schedule_value", ""))
767
831
  if existing_value != declared_value:
768
832
  return False
833
+ if bool(existing.get("run_at_load")) != bool(declared.get("run_on_boot")):
834
+ return False
769
835
  return True
770
836
 
771
837
 
@@ -123,17 +123,22 @@ def _acquire_lock():
123
123
  def run_task(candidate: dict, state: dict) -> bool:
124
124
  """Execute a task and update state."""
125
125
  name = candidate["cron_id"]
126
- script_name = Path(candidate["script"]).name
127
- script_path = str(SCRIPTS / script_name)
128
- if not Path(script_path).exists():
126
+ raw_script = str(candidate.get("script", ""))
127
+ script_candidate = Path(raw_script)
128
+ if script_candidate.is_absolute():
129
+ script_path = script_candidate
130
+ else:
131
+ script_path = SCRIPTS / script_candidate.name
132
+ script_name = script_path.name
133
+ if not script_path.exists():
129
134
  log(f" SKIP {name}: script not found ({script_path})")
130
135
  return False
131
136
 
132
137
  runtime_cmd = _resolve_runtime_command(candidate.get("type", "python"))
133
138
  if WRAPPER.exists():
134
- command = ["/bin/bash", str(WRAPPER), name, runtime_cmd, script_path]
139
+ command = ["/bin/bash", str(WRAPPER), name, runtime_cmd, str(script_path)]
135
140
  else:
136
- command = [runtime_cmd, script_path]
141
+ command = [runtime_cmd, str(script_path)]
137
142
 
138
143
  log(f" RUNNING {name}: {script_name}")
139
144
  try:
package/src/server.py CHANGED
@@ -124,12 +124,16 @@ def _server_init():
124
124
 
125
125
  # ── Auto-update check (non-blocking, max 5s) ──────────────────
126
126
  try:
127
- from auto_update import auto_update_check
127
+ from auto_update import startup_preflight
128
128
  import threading
129
129
 
130
130
  def _bg_update():
131
131
  try:
132
- result = auto_update_check()
132
+ result = startup_preflight(entrypoint="server", interactive=False)
133
+ if result.get("updated"):
134
+ print("[NEXO] Startup update applied.", file=sys.stderr)
135
+ if result.get("deferred_reason"):
136
+ print(f"[NEXO] Startup update deferred: {result['deferred_reason']}", file=sys.stderr)
133
137
  if result.get("git_update"):
134
138
  print(f"[NEXO] {result['git_update']}", file=sys.stderr)
135
139
  if result.get("npm_notice"):