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
|
@@ -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
|
+
}
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
128
|
-
if
|
|
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
|
|
127
|
+
from auto_update import startup_preflight
|
|
128
128
|
import threading
|
|
129
129
|
|
|
130
130
|
def _bg_update():
|
|
131
131
|
try:
|
|
132
|
-
result =
|
|
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"):
|