nexo-brain 7.30.28 → 7.30.29
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/package.json +1 -1
- package/src/cli.py +13 -0
- package/src/local_context/api.py +112 -0
- package/src/paths.py +2 -2
- package/src/plugins/schedule.py +82 -18
- package/src/script_registry.py +279 -10
- package/src/scripts/nexo-backup.sh +6 -1
- package/src/scripts/nexo-cron-wrapper.sh +67 -0
- package/src/scripts/prune_runtime_backups.py +28 -15
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.29",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.29",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
package/src/cli.py
CHANGED
|
@@ -1287,17 +1287,27 @@ def _agents_set_schedule(args):
|
|
|
1287
1287
|
|
|
1288
1288
|
interval_seconds = None
|
|
1289
1289
|
daily_at = None
|
|
1290
|
+
schedule_freq = None
|
|
1291
|
+
schedule_at = None
|
|
1292
|
+
schedule_day = None
|
|
1290
1293
|
if getattr(args, "every_minutes", None) is not None:
|
|
1291
1294
|
interval_seconds = int(args.every_minutes) * 60
|
|
1292
1295
|
elif getattr(args, "every_seconds", None) is not None:
|
|
1293
1296
|
interval_seconds = int(args.every_seconds)
|
|
1294
1297
|
elif getattr(args, "daily_at", None):
|
|
1295
1298
|
daily_at = str(args.daily_at).strip()
|
|
1299
|
+
elif getattr(args, "schedule_freq", None):
|
|
1300
|
+
schedule_freq = str(args.schedule_freq).strip()
|
|
1301
|
+
schedule_at = str(getattr(args, "schedule_at", "") or "").strip()
|
|
1302
|
+
schedule_day = getattr(args, "schedule_day", None)
|
|
1296
1303
|
|
|
1297
1304
|
result = set_agent_schedule(
|
|
1298
1305
|
args.name,
|
|
1299
1306
|
interval_seconds=interval_seconds,
|
|
1300
1307
|
daily_at=daily_at,
|
|
1308
|
+
schedule_freq=schedule_freq,
|
|
1309
|
+
schedule_at=schedule_at,
|
|
1310
|
+
schedule_day=schedule_day,
|
|
1301
1311
|
clear=bool(getattr(args, "reset", False)),
|
|
1302
1312
|
)
|
|
1303
1313
|
if args.json:
|
|
@@ -4160,7 +4170,10 @@ def main():
|
|
|
4160
4170
|
agents_schedule_group.add_argument("--every-minutes", type=int, help="Run the agent every N minutes")
|
|
4161
4171
|
agents_schedule_group.add_argument("--every-seconds", type=int, help="Run the agent every N seconds")
|
|
4162
4172
|
agents_schedule_group.add_argument("--daily-at", type=str, help="Run the agent every day at HH:MM or HH:MM:weekday")
|
|
4173
|
+
agents_schedule_group.add_argument("--schedule-freq", choices=["daily", "weekly", "monthly", "every_n_days"], help="Run the agent on an anchored cadence")
|
|
4163
4174
|
agents_schedule_group.add_argument("--reset", action="store_true", help="Clear the agent schedule")
|
|
4175
|
+
agents_schedule_p.add_argument("--schedule-at", type=str, help="Anchored schedule time HH:MM")
|
|
4176
|
+
agents_schedule_p.add_argument("--schedule-day", type=int, help="Weekday 0-6, month day 1-28, or every N days")
|
|
4164
4177
|
agents_schedule_p.add_argument("--json", action="store_true", help="JSON output")
|
|
4165
4178
|
|
|
4166
4179
|
agents_run_p = agents_sub.add_parser("run", help="Run an agent now")
|
package/src/local_context/api.py
CHANGED
|
@@ -43,6 +43,8 @@ DEFAULT_MAX_JOB_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_INDEX_MAX_JOB_ATTEMPTS
|
|
|
43
43
|
DEFAULT_SQLITE_BUSY_RETRY_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_CONTEXT_BUSY_RETRY_ATTEMPTS", "5") or "5")
|
|
44
44
|
DEFAULT_SQLITE_BUSY_RETRY_DELAY_SECONDS = float(os.environ.get("NEXO_LOCAL_CONTEXT_BUSY_RETRY_DELAY_SECONDS", "0.35") or "0.35")
|
|
45
45
|
DEFAULT_HYGIENE_QUICK_SCAN_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_HYGIENE_QUICK_SCAN_LIMIT", "5000") or "5000")
|
|
46
|
+
LOCAL_CONTEXT_DEFAULT_MAX_DB_BYTES = 60 * 1024 ** 3
|
|
47
|
+
LOCAL_CONTEXT_DEFAULT_MIN_FREE_BYTES = 5 * 1024 ** 3
|
|
46
48
|
INITIAL_INDEX_COMPLETE_KEY = "initial_index_complete"
|
|
47
49
|
INITIAL_INDEX_STARTED_AT_KEY = "initial_index_started_at"
|
|
48
50
|
PERFORMANCE_PROFILE_KEY = "performance_profile"
|
|
@@ -1845,6 +1847,73 @@ def _is_paused_conn(conn) -> bool:
|
|
|
1845
1847
|
return _get_state_conn(conn, "paused", "0") == "1"
|
|
1846
1848
|
|
|
1847
1849
|
|
|
1850
|
+
def _local_context_storage_bytes(db_path: Path | None = None) -> int:
|
|
1851
|
+
db = Path(db_path or local_context_db_path())
|
|
1852
|
+
total = 0
|
|
1853
|
+
for candidate in (db, Path(str(db) + "-wal"), Path(str(db) + "-shm")):
|
|
1854
|
+
try:
|
|
1855
|
+
total += int(candidate.stat().st_size)
|
|
1856
|
+
except OSError:
|
|
1857
|
+
pass
|
|
1858
|
+
return total
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def _local_context_disk_budget() -> dict:
|
|
1862
|
+
db_path = Path(local_context_db_path())
|
|
1863
|
+
max_bytes = paths.parse_size_bytes(
|
|
1864
|
+
os.environ.get("NEXO_LOCAL_CONTEXT_MAX_DB_BYTES"),
|
|
1865
|
+
default=LOCAL_CONTEXT_DEFAULT_MAX_DB_BYTES,
|
|
1866
|
+
)
|
|
1867
|
+
min_free_bytes = paths.parse_size_bytes(
|
|
1868
|
+
os.environ.get("NEXO_LOCAL_CONTEXT_MIN_FREE_BYTES"),
|
|
1869
|
+
default=LOCAL_CONTEXT_DEFAULT_MIN_FREE_BYTES,
|
|
1870
|
+
)
|
|
1871
|
+
db_bytes = _local_context_storage_bytes(db_path)
|
|
1872
|
+
try:
|
|
1873
|
+
probe = db_path.parent if db_path.parent.exists() else paths.memory_dir()
|
|
1874
|
+
free_bytes = int(shutil.disk_usage(str(probe)).free)
|
|
1875
|
+
except Exception:
|
|
1876
|
+
free_bytes = None
|
|
1877
|
+
|
|
1878
|
+
reason = ""
|
|
1879
|
+
if max_bytes > 0 and db_bytes > max_bytes:
|
|
1880
|
+
reason = "local_context_db_too_large"
|
|
1881
|
+
elif min_free_bytes > 0 and free_bytes is not None and free_bytes < min_free_bytes:
|
|
1882
|
+
reason = "disk_free_below_floor"
|
|
1883
|
+
return {
|
|
1884
|
+
"ok": not reason,
|
|
1885
|
+
"paused": bool(reason),
|
|
1886
|
+
"reason": reason,
|
|
1887
|
+
"db_path": str(db_path),
|
|
1888
|
+
"db_bytes": db_bytes,
|
|
1889
|
+
"max_bytes": max_bytes,
|
|
1890
|
+
"free_bytes": free_bytes,
|
|
1891
|
+
"min_free_bytes": min_free_bytes,
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def enforce_local_context_disk_budget() -> dict:
|
|
1896
|
+
budget = _local_context_disk_budget()
|
|
1897
|
+
if budget["ok"]:
|
|
1898
|
+
return budget
|
|
1899
|
+
try:
|
|
1900
|
+
_set_state("paused", "1")
|
|
1901
|
+
_set_state("pause_reason", str(budget["reason"]))
|
|
1902
|
+
log_event(
|
|
1903
|
+
"warn",
|
|
1904
|
+
"index_paused_disk_budget",
|
|
1905
|
+
"Local memory indexing paused to protect disk space",
|
|
1906
|
+
reason=budget["reason"],
|
|
1907
|
+
db_bytes=budget["db_bytes"],
|
|
1908
|
+
max_bytes=budget["max_bytes"],
|
|
1909
|
+
free_bytes=budget["free_bytes"],
|
|
1910
|
+
min_free_bytes=budget["min_free_bytes"],
|
|
1911
|
+
)
|
|
1912
|
+
except Exception as exc:
|
|
1913
|
+
budget["pause_error"] = type(exc).__name__
|
|
1914
|
+
return budget
|
|
1915
|
+
|
|
1916
|
+
|
|
1848
1917
|
def _allow_explicit_blocked_root(path: str) -> bool:
|
|
1849
1918
|
# Test and controlled diagnostics may explicitly index a temporary fixture
|
|
1850
1919
|
# root while production root discovery still skips temp/system trees.
|
|
@@ -3332,6 +3401,33 @@ def run_once(
|
|
|
3332
3401
|
live_dir_limit: int | None = None,
|
|
3333
3402
|
live_file_limit: int | None = None,
|
|
3334
3403
|
) -> dict:
|
|
3404
|
+
disk_budget = enforce_local_context_disk_budget()
|
|
3405
|
+
if disk_budget.get("paused"):
|
|
3406
|
+
config = performance_config()
|
|
3407
|
+
return {
|
|
3408
|
+
"ok": True,
|
|
3409
|
+
"paused": True,
|
|
3410
|
+
"disk_budget": disk_budget,
|
|
3411
|
+
"initial_scan": {
|
|
3412
|
+
"complete": False,
|
|
3413
|
+
"mode": "paused",
|
|
3414
|
+
"pending_roots": 0,
|
|
3415
|
+
"total_roots": 0,
|
|
3416
|
+
"checkpoint_count": 0,
|
|
3417
|
+
},
|
|
3418
|
+
"initial_index_complete": False,
|
|
3419
|
+
"live": {"ok": True, "paused": True, "assets": {}, "dirs": {}},
|
|
3420
|
+
"scan": {"ok": True, "paused": True, "roots": 0, "seen": 0, "changed": 0, "errors": 0, "partial": False},
|
|
3421
|
+
"jobs": {"ok": True, "paused": True, "processed": 0, "failed": 0},
|
|
3422
|
+
"performance": {
|
|
3423
|
+
"profile": config["profile"],
|
|
3424
|
+
"scan_limit": 0,
|
|
3425
|
+
"process_limit": 0,
|
|
3426
|
+
"live_asset_limit": 0,
|
|
3427
|
+
"live_dir_limit": 0,
|
|
3428
|
+
"live_file_limit": 0,
|
|
3429
|
+
},
|
|
3430
|
+
}
|
|
3335
3431
|
if _get_state("privacy_hygiene_v2", "0") != "1":
|
|
3336
3432
|
local_index_privacy_hygiene(fix=True)
|
|
3337
3433
|
_set_state("privacy_hygiene_v2", "1")
|
|
@@ -3832,6 +3928,21 @@ def _status_from_conn(conn, *, readonly: bool = False) -> dict:
|
|
|
3832
3928
|
service["state"] = "paused" if paused else ("attention" if problem else ("idle" if active_jobs == 0 and initial_index_complete else "indexing"))
|
|
3833
3929
|
performance = performance_config(conn=conn)
|
|
3834
3930
|
problems = _problem_rows(conn)
|
|
3931
|
+
disk_budget = _local_context_disk_budget()
|
|
3932
|
+
if not disk_budget["ok"]:
|
|
3933
|
+
problems.insert(0, {
|
|
3934
|
+
"user_message": "Local memory indexing is paused to protect disk space",
|
|
3935
|
+
"message_key": "local_context.disk_budget.paused",
|
|
3936
|
+
"recommended_action": "Review local memory size and free disk space",
|
|
3937
|
+
"recommended_action_key": "local_context.disk_budget.review",
|
|
3938
|
+
"technical_detail": json_dumps(disk_budget),
|
|
3939
|
+
"support_code": "LOCAL_CONTEXT_DISK_BUDGET",
|
|
3940
|
+
"severity": "warning",
|
|
3941
|
+
"retryable": True,
|
|
3942
|
+
"path": disk_budget["db_path"],
|
|
3943
|
+
"phase": "storage",
|
|
3944
|
+
"created_at": now(),
|
|
3945
|
+
})
|
|
3835
3946
|
if problem:
|
|
3836
3947
|
problems.insert(0, {
|
|
3837
3948
|
"user_message": problem["user_message"],
|
|
@@ -3881,6 +3992,7 @@ def _status_from_conn(conn, *, readonly: bool = False) -> dict:
|
|
|
3881
3992
|
"performance_profile": performance["profile"],
|
|
3882
3993
|
},
|
|
3883
3994
|
"performance": performance,
|
|
3995
|
+
"disk_budget": disk_budget,
|
|
3884
3996
|
"initial_scan": initial_scan,
|
|
3885
3997
|
"initial_index_complete": bool(initial_index_complete),
|
|
3886
3998
|
"volumes": volumes,
|
package/src/paths.py
CHANGED
|
@@ -515,8 +515,8 @@ def aggressive_runtime_backup_prune(
|
|
|
515
515
|
) -> dict:
|
|
516
516
|
"""Escalate NEXO-owned backup pruning before any user-facing disk alert.
|
|
517
517
|
|
|
518
|
-
Escalation never targets protected business/
|
|
519
|
-
|
|
518
|
+
Escalation never targets protected business/weekly restore classes. Hourly
|
|
519
|
+
DB dumps are pruned only down to their configured restore floor.
|
|
520
520
|
"""
|
|
521
521
|
root = Path(backups_root or backups_dir())
|
|
522
522
|
floor = int(min_free_bytes if min_free_bytes is not None else backup_min_free_bytes())
|
package/src/plugins/schedule.py
CHANGED
|
@@ -384,7 +384,71 @@ def _runtime_command(script_type: str) -> str:
|
|
|
384
384
|
return "python3"
|
|
385
385
|
|
|
386
386
|
|
|
387
|
-
def
|
|
387
|
+
def _declared_schedule_payload(declared: dict | None) -> dict:
|
|
388
|
+
declared = declared or {}
|
|
389
|
+
raw = str(declared.get("schedule_value") or "").strip()
|
|
390
|
+
if not raw.startswith("{"):
|
|
391
|
+
return {}
|
|
392
|
+
try:
|
|
393
|
+
payload = json.loads(raw)
|
|
394
|
+
except Exception:
|
|
395
|
+
return {}
|
|
396
|
+
return payload if isinstance(payload, dict) else {}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _calendar_from_schedule(schedule: str, declared: dict | None = None) -> dict:
|
|
400
|
+
payload = _declared_schedule_payload(declared)
|
|
401
|
+
if payload:
|
|
402
|
+
freq = str(payload.get("freq") or "")
|
|
403
|
+
cal = {"Hour": int(payload.get("hour")), "Minute": int(payload.get("minute"))}
|
|
404
|
+
if freq == "weekly":
|
|
405
|
+
cal["Weekday"] = int(payload.get("weekday"))
|
|
406
|
+
elif freq == "monthly":
|
|
407
|
+
cal["Day"] = int(payload.get("day"))
|
|
408
|
+
return cal
|
|
409
|
+
|
|
410
|
+
parts = str(schedule or "").split(":")
|
|
411
|
+
cal = {"Hour": int(parts[0]), "Minute": int(parts[1])}
|
|
412
|
+
if len(parts) > 2:
|
|
413
|
+
cal["Weekday"] = int(parts[2])
|
|
414
|
+
return cal
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _systemd_calendar_from_schedule(schedule: str, declared: dict | None = None) -> str:
|
|
418
|
+
payload = _declared_schedule_payload(declared)
|
|
419
|
+
if payload:
|
|
420
|
+
freq = str(payload.get("freq") or "")
|
|
421
|
+
hour = int(payload.get("hour"))
|
|
422
|
+
minute = int(payload.get("minute"))
|
|
423
|
+
if freq == "weekly":
|
|
424
|
+
days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
425
|
+
return f"OnCalendar={days[int(payload.get('weekday'))]} *-*-* {hour:02d}:{minute:02d}:00"
|
|
426
|
+
if freq == "monthly":
|
|
427
|
+
return f"OnCalendar=*-*-{int(payload.get('day')):02d} {hour:02d}:{minute:02d}:00"
|
|
428
|
+
return f"OnCalendar=*-*-* {hour:02d}:{minute:02d}:00"
|
|
429
|
+
|
|
430
|
+
parts = str(schedule or "").split(":")
|
|
431
|
+
hour, minute = int(parts[0]), int(parts[1])
|
|
432
|
+
if len(parts) > 2:
|
|
433
|
+
days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
434
|
+
return f"OnCalendar={days[int(parts[2])]} *-*-* {hour:02d}:{minute:02d}:00"
|
|
435
|
+
return f"OnCalendar=*-*-* {hour:02d}:{minute:02d}:00"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _schedule_gate_env(declared: dict | None = None) -> dict:
|
|
439
|
+
payload = _declared_schedule_payload(declared)
|
|
440
|
+
if not payload:
|
|
441
|
+
return {}
|
|
442
|
+
env = {
|
|
443
|
+
"NEXO_SCHEDULE_FREQ": str(payload.get("freq") or ""),
|
|
444
|
+
"NEXO_SCHEDULE_AT": str(payload.get("at") or ""),
|
|
445
|
+
}
|
|
446
|
+
if payload.get("freq") == "every_n_days":
|
|
447
|
+
env["NEXO_SCHEDULE_EVERY_DAYS"] = str(int(payload.get("every_days") or 0))
|
|
448
|
+
return {key: value for key, value in env.items() if value}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds, description, script_type, label="", plist_path="", keep_alive: bool = False, declared: dict | None = None):
|
|
388
452
|
init_db()
|
|
389
453
|
script_meta = parse_inline_metadata(Path(script_path))
|
|
390
454
|
runtime = classify_runtime(Path(script_path), script_meta)
|
|
@@ -398,7 +462,12 @@ def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds
|
|
|
398
462
|
source="filesystem",
|
|
399
463
|
has_inline_metadata=bool(script_meta),
|
|
400
464
|
)
|
|
401
|
-
|
|
465
|
+
declared = declared or {}
|
|
466
|
+
if declared.get("valid") and declared.get("schedule_type"):
|
|
467
|
+
schedule_type = str(declared.get("schedule_type") or "")
|
|
468
|
+
schedule_value = str(declared.get("schedule_value") or "")
|
|
469
|
+
schedule_label = str(declared.get("schedule_label") or "")
|
|
470
|
+
elif keep_alive:
|
|
402
471
|
schedule_type = "keep_alive"
|
|
403
472
|
schedule_value = "true"
|
|
404
473
|
schedule_label = "keep alive"
|
|
@@ -452,6 +521,7 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
|
|
|
452
521
|
"PATH": resolve_launchagent_path(),
|
|
453
522
|
PERSONAL_SCHEDULE_MANAGED_ENV: "1",
|
|
454
523
|
"NEXO_PERSONAL_CRON_ID": cron_id,
|
|
524
|
+
**_schedule_gate_env(declared),
|
|
455
525
|
},
|
|
456
526
|
}
|
|
457
527
|
|
|
@@ -460,11 +530,7 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
|
|
|
460
530
|
elif interval_seconds:
|
|
461
531
|
plist["StartInterval"] = interval_seconds
|
|
462
532
|
elif schedule:
|
|
463
|
-
|
|
464
|
-
cal = {"Hour": int(parts[0]), "Minute": int(parts[1])}
|
|
465
|
-
if len(parts) > 2:
|
|
466
|
-
cal["Weekday"] = int(parts[2])
|
|
467
|
-
plist["StartCalendarInterval"] = cal
|
|
533
|
+
plist["StartCalendarInterval"] = _calendar_from_schedule(schedule, declared)
|
|
468
534
|
|
|
469
535
|
declared = declared or {}
|
|
470
536
|
if declared.get("run_on_boot") or (keep_alive and "run_on_boot" not in declared):
|
|
@@ -485,6 +551,7 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
|
|
|
485
551
|
label=label,
|
|
486
552
|
plist_path=str(plist_path),
|
|
487
553
|
keep_alive=keep_alive,
|
|
554
|
+
declared=declared,
|
|
488
555
|
)
|
|
489
556
|
|
|
490
557
|
if keep_alive:
|
|
@@ -517,6 +584,8 @@ Environment=HOME={Path.home()}
|
|
|
517
584
|
Environment={PERSONAL_SCHEDULE_MANAGED_ENV}=1
|
|
518
585
|
Environment=NEXO_PERSONAL_CRON_ID={cron_id}
|
|
519
586
|
"""
|
|
587
|
+
for key, value in _schedule_gate_env(declared).items():
|
|
588
|
+
service_content += f"Environment={key}={value}\n"
|
|
520
589
|
service_path = unit_dir / f"nexo-{cron_id}.service"
|
|
521
590
|
service_path.write_text(service_content)
|
|
522
591
|
|
|
@@ -535,10 +604,10 @@ Environment=NEXO_HOME={nexo_home}
|
|
|
535
604
|
Environment=HOME={Path.home()}
|
|
536
605
|
Environment={PERSONAL_SCHEDULE_MANAGED_ENV}=1
|
|
537
606
|
Environment=NEXO_PERSONAL_CRON_ID={cron_id}
|
|
538
|
-
|
|
539
|
-
[Install]
|
|
540
|
-
WantedBy=default.target
|
|
541
607
|
"""
|
|
608
|
+
for key, value in _schedule_gate_env(declared).items():
|
|
609
|
+
service_content += f"Environment={key}={value}\n"
|
|
610
|
+
service_content += "\n[Install]\nWantedBy=default.target\n"
|
|
542
611
|
service_path = unit_dir / f"nexo-{cron_id}.service"
|
|
543
612
|
service_path.write_text(service_content)
|
|
544
613
|
|
|
@@ -555,6 +624,7 @@ WantedBy=default.target
|
|
|
555
624
|
label=f"nexo-{cron_id}",
|
|
556
625
|
plist_path="",
|
|
557
626
|
keep_alive=True,
|
|
627
|
+
declared=declared,
|
|
558
628
|
)
|
|
559
629
|
|
|
560
630
|
return f"Cron '{cron_id}' installed as KeepAlive systemd service and enabled. Service: {service_path}"
|
|
@@ -565,14 +635,7 @@ WantedBy=default.target
|
|
|
565
635
|
if declared.get("run_on_boot") or not declared.get("required"):
|
|
566
636
|
timer_spec += "\nOnBootSec=60s"
|
|
567
637
|
elif schedule:
|
|
568
|
-
|
|
569
|
-
hour, minute = int(parts[0]), int(parts[1])
|
|
570
|
-
if len(parts) > 2:
|
|
571
|
-
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
572
|
-
day = days[int(parts[2])]
|
|
573
|
-
timer_spec = f"OnCalendar={day} *-*-* {hour:02d}:{minute:02d}:00"
|
|
574
|
-
else:
|
|
575
|
-
timer_spec = f"OnCalendar=*-*-* {hour:02d}:{minute:02d}:00"
|
|
638
|
+
timer_spec = _systemd_calendar_from_schedule(schedule, declared)
|
|
576
639
|
else:
|
|
577
640
|
return "ERROR: no schedule or interval"
|
|
578
641
|
|
|
@@ -602,6 +665,7 @@ WantedBy=timers.target
|
|
|
602
665
|
label=f"nexo-{cron_id}",
|
|
603
666
|
plist_path="",
|
|
604
667
|
keep_alive=False,
|
|
668
|
+
declared=declared,
|
|
605
669
|
)
|
|
606
670
|
|
|
607
671
|
return f"Cron '{cron_id}' installed as systemd timer and enabled. Service: {service_path}, Timer: {timer_path}"
|
package/src/script_registry.py
CHANGED
|
@@ -83,6 +83,9 @@ METADATA_KEYS = {
|
|
|
83
83
|
"category",
|
|
84
84
|
"cron_id",
|
|
85
85
|
"schedule",
|
|
86
|
+
"schedule_freq",
|
|
87
|
+
"schedule_at",
|
|
88
|
+
"schedule_day",
|
|
86
89
|
"interval_seconds",
|
|
87
90
|
"schedule_required",
|
|
88
91
|
"recovery_policy",
|
|
@@ -125,6 +128,9 @@ METADATA_WRITE_ORDER = [
|
|
|
125
128
|
"cron_id",
|
|
126
129
|
"schedule_required",
|
|
127
130
|
"schedule",
|
|
131
|
+
"schedule_freq",
|
|
132
|
+
"schedule_at",
|
|
133
|
+
"schedule_day",
|
|
128
134
|
"interval_seconds",
|
|
129
135
|
"recovery_policy",
|
|
130
136
|
"run_on_boot",
|
|
@@ -160,6 +166,8 @@ _SCHEDULE_WEEKDAY_PREFIX_RE = re.compile(
|
|
|
160
166
|
r"^weekday\s*=\s*(?P<weekday>\d)\s+(?P<hour>\d{1,2}):(?P<minute>\d{2})$",
|
|
161
167
|
re.IGNORECASE,
|
|
162
168
|
)
|
|
169
|
+
_SCHEDULE_AT_RE = re.compile(r"^(?P<hour>\d{1,2}):(?P<minute>\d{2})$")
|
|
170
|
+
SUPPORTED_ANCHORED_SCHEDULE_FREQS = {"daily", "weekly", "monthly", "every_n_days"}
|
|
163
171
|
_LEGACY_CORE_SCRIPT_ALIASES = {
|
|
164
172
|
"nexo-postcompact.sh": "post-compact.sh",
|
|
165
173
|
"nexo-memory-precompact.sh": "pre-compact.sh",
|
|
@@ -415,6 +423,12 @@ def _normalize_metadata_value(key: str, value: str) -> str:
|
|
|
415
423
|
return _normalize_runtime_metadata(value)
|
|
416
424
|
if key == "schedule":
|
|
417
425
|
return _normalize_schedule_metadata(value)
|
|
426
|
+
if key == "schedule_freq":
|
|
427
|
+
return str(value or "").strip().lower().replace("-", "_")
|
|
428
|
+
if key == "schedule_at":
|
|
429
|
+
return _normalize_schedule_at_metadata(value)
|
|
430
|
+
if key == "schedule_day":
|
|
431
|
+
return str(value or "").strip()
|
|
418
432
|
return value
|
|
419
433
|
|
|
420
434
|
|
|
@@ -440,6 +454,35 @@ def _normalize_schedule_metadata(value: str) -> str:
|
|
|
440
454
|
return candidate
|
|
441
455
|
|
|
442
456
|
|
|
457
|
+
def _normalize_schedule_at_metadata(value: str) -> str:
|
|
458
|
+
candidate = str(value or "").strip()
|
|
459
|
+
match = _SCHEDULE_AT_RE.match(candidate)
|
|
460
|
+
if not match:
|
|
461
|
+
return candidate
|
|
462
|
+
try:
|
|
463
|
+
hour = int(match.group("hour"))
|
|
464
|
+
minute = int(match.group("minute"))
|
|
465
|
+
except ValueError:
|
|
466
|
+
return candidate
|
|
467
|
+
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
|
468
|
+
return f"{hour:02d}:{minute:02d}"
|
|
469
|
+
return candidate
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _parse_schedule_at(value: str) -> tuple[int, int] | None:
|
|
473
|
+
match = _SCHEDULE_AT_RE.match(str(value or "").strip())
|
|
474
|
+
if not match:
|
|
475
|
+
return None
|
|
476
|
+
try:
|
|
477
|
+
hour = int(match.group("hour"))
|
|
478
|
+
minute = int(match.group("minute"))
|
|
479
|
+
except ValueError:
|
|
480
|
+
return None
|
|
481
|
+
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
|
482
|
+
return hour, minute
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
|
|
443
486
|
def _detect_shebang(path: Path) -> str | None:
|
|
444
487
|
"""Read first line for shebang."""
|
|
445
488
|
try:
|
|
@@ -563,13 +606,17 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
|
|
|
563
606
|
cron_id = explicit_cron_id or _safe_slug(default_name or explicit_name or "script")
|
|
564
607
|
interval_raw = metadata.get("interval_seconds", "").strip()
|
|
565
608
|
schedule_raw = metadata.get("schedule", "").strip()
|
|
609
|
+
schedule_freq_raw = metadata.get("schedule_freq", "").strip().lower().replace("-", "_")
|
|
610
|
+
schedule_at_raw = metadata.get("schedule_at", "").strip()
|
|
611
|
+
schedule_day_raw = metadata.get("schedule_day", "").strip()
|
|
612
|
+
anchored_raw_present = bool(schedule_freq_raw or schedule_at_raw or schedule_day_raw)
|
|
566
613
|
schedule_required = _truthy(metadata.get("schedule_required"))
|
|
567
614
|
recovery_policy_raw = metadata.get("recovery_policy", "").strip().lower()
|
|
568
615
|
run_on_boot = _truthy(metadata.get("run_on_boot"))
|
|
569
616
|
run_on_wake = _truthy(metadata.get("run_on_wake"))
|
|
570
617
|
idempotent = _truthy(metadata.get("idempotent"))
|
|
571
618
|
max_catchup_age_raw = metadata.get("max_catchup_age", "").strip()
|
|
572
|
-
required = schedule_required or bool(interval_raw or schedule_raw)
|
|
619
|
+
required = schedule_required or bool(interval_raw or schedule_raw or anchored_raw_present)
|
|
573
620
|
|
|
574
621
|
if recovery_policy_raw and recovery_policy_raw not in SUPPORTED_RECOVERY_POLICIES:
|
|
575
622
|
return {
|
|
@@ -638,11 +685,12 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
|
|
|
638
685
|
return idempotent
|
|
639
686
|
return policy in {"catchup", "run_once_on_wake", "restart", "restart_daemon"}
|
|
640
687
|
|
|
641
|
-
|
|
688
|
+
configured_modes = sum(bool(value) for value in (interval_raw, schedule_raw, anchored_raw_present))
|
|
689
|
+
if configured_modes > 1:
|
|
642
690
|
return {
|
|
643
691
|
"required": required,
|
|
644
692
|
"valid": False,
|
|
645
|
-
"error": "
|
|
693
|
+
"error": "Choose only one schedule mode: schedule, interval_seconds, or schedule_freq/schedule_at.",
|
|
646
694
|
"cron_id": cron_id,
|
|
647
695
|
}
|
|
648
696
|
|
|
@@ -679,6 +727,101 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
|
|
|
679
727
|
"max_catchup_age": max_catchup_age or max(interval * 4, interval + 900),
|
|
680
728
|
}
|
|
681
729
|
|
|
730
|
+
if anchored_raw_present:
|
|
731
|
+
freq = schedule_freq_raw
|
|
732
|
+
if freq not in SUPPORTED_ANCHORED_SCHEDULE_FREQS:
|
|
733
|
+
return {
|
|
734
|
+
"required": required,
|
|
735
|
+
"valid": False,
|
|
736
|
+
"error": f"Invalid schedule_freq: {schedule_freq_raw or '(missing)'}",
|
|
737
|
+
"cron_id": cron_id,
|
|
738
|
+
}
|
|
739
|
+
parsed_at = _parse_schedule_at(schedule_at_raw)
|
|
740
|
+
if parsed_at is None:
|
|
741
|
+
return {
|
|
742
|
+
"required": required,
|
|
743
|
+
"valid": False,
|
|
744
|
+
"error": f"Invalid schedule_at: {schedule_at_raw or '(missing)'}",
|
|
745
|
+
"cron_id": cron_id,
|
|
746
|
+
}
|
|
747
|
+
hour, minute = parsed_at
|
|
748
|
+
day: int | None = None
|
|
749
|
+
if freq in {"weekly", "monthly", "every_n_days"}:
|
|
750
|
+
try:
|
|
751
|
+
day = int(schedule_day_raw)
|
|
752
|
+
except ValueError:
|
|
753
|
+
return {
|
|
754
|
+
"required": required,
|
|
755
|
+
"valid": False,
|
|
756
|
+
"error": f"Invalid schedule_day: {schedule_day_raw or '(missing)'}",
|
|
757
|
+
"cron_id": cron_id,
|
|
758
|
+
}
|
|
759
|
+
if freq == "weekly" and not (day is not None and 0 <= day <= 6):
|
|
760
|
+
return {
|
|
761
|
+
"required": required,
|
|
762
|
+
"valid": False,
|
|
763
|
+
"error": f"schedule_day for weekly schedules must be 0-6 (got {schedule_day_raw})",
|
|
764
|
+
"cron_id": cron_id,
|
|
765
|
+
}
|
|
766
|
+
if freq == "monthly" and not (day is not None and 1 <= day <= 28):
|
|
767
|
+
return {
|
|
768
|
+
"required": required,
|
|
769
|
+
"valid": False,
|
|
770
|
+
"error": f"schedule_day for monthly schedules must be 1-28 (got {schedule_day_raw})",
|
|
771
|
+
"cron_id": cron_id,
|
|
772
|
+
}
|
|
773
|
+
if freq == "every_n_days" and not (day is not None and 1 <= day <= 31):
|
|
774
|
+
return {
|
|
775
|
+
"required": required,
|
|
776
|
+
"valid": False,
|
|
777
|
+
"error": f"schedule_day for every_n_days schedules must be 1-31 (got {schedule_day_raw})",
|
|
778
|
+
"cron_id": cron_id,
|
|
779
|
+
}
|
|
780
|
+
at = f"{hour:02d}:{minute:02d}"
|
|
781
|
+
payload = {
|
|
782
|
+
"freq": freq,
|
|
783
|
+
"at": at,
|
|
784
|
+
"hour": hour,
|
|
785
|
+
"minute": minute,
|
|
786
|
+
}
|
|
787
|
+
if freq == "weekly":
|
|
788
|
+
payload["weekday"] = day
|
|
789
|
+
schedule = f"{at}:{day}"
|
|
790
|
+
label = f"weekly weekday={day} {at}"
|
|
791
|
+
max_age = 14 * 86400
|
|
792
|
+
elif freq == "monthly":
|
|
793
|
+
payload["day"] = day
|
|
794
|
+
schedule = at
|
|
795
|
+
label = f"monthly day={day} {at}"
|
|
796
|
+
max_age = 45 * 86400
|
|
797
|
+
elif freq == "every_n_days":
|
|
798
|
+
payload["every_days"] = day
|
|
799
|
+
schedule = at
|
|
800
|
+
label = f"every {day}d {at}"
|
|
801
|
+
max_age = max((day or 1) * 86400 + 48 * 3600, 72 * 3600)
|
|
802
|
+
else:
|
|
803
|
+
schedule = at
|
|
804
|
+
label = f"{at} daily"
|
|
805
|
+
max_age = 48 * 3600
|
|
806
|
+
return {
|
|
807
|
+
"required": required,
|
|
808
|
+
"valid": True,
|
|
809
|
+
"cron_id": cron_id,
|
|
810
|
+
"schedule_type": "calendar",
|
|
811
|
+
"schedule_value": json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False),
|
|
812
|
+
"schedule_label": label,
|
|
813
|
+
"schedule": schedule,
|
|
814
|
+
"schedule_freq": freq,
|
|
815
|
+
"schedule_at": at,
|
|
816
|
+
"schedule_day": day or 0,
|
|
817
|
+
"interval_seconds": 0,
|
|
818
|
+
"recovery_policy": recovery_policy_raw or "catchup",
|
|
819
|
+
"run_on_boot": _effective_run_on_boot(recovery_policy_raw or "catchup"),
|
|
820
|
+
"run_on_wake": _effective_run_on_wake(recovery_policy_raw or "catchup"),
|
|
821
|
+
"idempotent": _effective_idempotent(recovery_policy_raw or "catchup"),
|
|
822
|
+
"max_catchup_age": max_catchup_age or max_age,
|
|
823
|
+
}
|
|
824
|
+
|
|
682
825
|
if schedule_raw:
|
|
683
826
|
parts = schedule_raw.split(":")
|
|
684
827
|
if len(parts) not in {2, 3}:
|
|
@@ -1122,6 +1265,9 @@ def _format_schedule_from_plist(plist_data: dict) -> tuple[str, str, str]:
|
|
|
1122
1265
|
hour = cal.get("Hour")
|
|
1123
1266
|
minute = cal.get("Minute")
|
|
1124
1267
|
weekday = cal.get("Weekday")
|
|
1268
|
+
day = cal.get("Day")
|
|
1269
|
+
if day is not None and hour is not None and minute is not None:
|
|
1270
|
+
return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} day={day}"
|
|
1125
1271
|
if weekday is not None and hour is not None and minute is not None:
|
|
1126
1272
|
return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} weekday={weekday}"
|
|
1127
1273
|
if hour is not None and minute is not None:
|
|
@@ -1131,6 +1277,27 @@ def _format_schedule_from_plist(plist_data: dict) -> tuple[str, str, str]:
|
|
|
1131
1277
|
return "manual", "", ""
|
|
1132
1278
|
|
|
1133
1279
|
|
|
1280
|
+
def _anchored_schedule_payload(schedule_value: str | dict | list | None) -> dict | None:
|
|
1281
|
+
if isinstance(schedule_value, dict):
|
|
1282
|
+
payload = schedule_value
|
|
1283
|
+
elif isinstance(schedule_value, str) and schedule_value.lstrip().startswith("{"):
|
|
1284
|
+
try:
|
|
1285
|
+
payload = json.loads(schedule_value)
|
|
1286
|
+
except Exception:
|
|
1287
|
+
return None
|
|
1288
|
+
else:
|
|
1289
|
+
return None
|
|
1290
|
+
if not isinstance(payload, dict):
|
|
1291
|
+
return None
|
|
1292
|
+
freq = str(payload.get("freq") or "").strip()
|
|
1293
|
+
if freq not in SUPPORTED_ANCHORED_SCHEDULE_FREQS:
|
|
1294
|
+
return None
|
|
1295
|
+
at = str(payload.get("at") or "").strip()
|
|
1296
|
+
if _parse_schedule_at(at) is None:
|
|
1297
|
+
return None
|
|
1298
|
+
return payload
|
|
1299
|
+
|
|
1300
|
+
|
|
1134
1301
|
def _calendar_payload_from_declared(schedule_value: str) -> dict | list | None:
|
|
1135
1302
|
if not schedule_value:
|
|
1136
1303
|
return None
|
|
@@ -1178,6 +1345,9 @@ def _compact_schedule_from_record(record: dict) -> str:
|
|
|
1178
1345
|
if raw.lstrip().startswith("{") or raw.lstrip().startswith("["):
|
|
1179
1346
|
with contextlib.suppress(Exception):
|
|
1180
1347
|
payload = json.loads(raw)
|
|
1348
|
+
anchored = _anchored_schedule_payload(payload)
|
|
1349
|
+
if anchored:
|
|
1350
|
+
return str(anchored.get("at") or "")
|
|
1181
1351
|
if isinstance(payload, list):
|
|
1182
1352
|
if len(payload) == 1 and isinstance(payload[0], dict):
|
|
1183
1353
|
payload = payload[0]
|
|
@@ -1243,10 +1413,25 @@ def _inferred_schedule_metadata_lines(path: Path, metadata: dict, record: dict)
|
|
|
1243
1413
|
lines.append(f"{prefix} nexo: interval_seconds={interval}")
|
|
1244
1414
|
lines.append(f"{prefix} nexo: recovery_policy=run_once_on_wake")
|
|
1245
1415
|
elif schedule_type == "calendar":
|
|
1246
|
-
|
|
1247
|
-
if
|
|
1248
|
-
|
|
1249
|
-
|
|
1416
|
+
anchored = _anchored_schedule_payload(record.get("schedule_value"))
|
|
1417
|
+
if anchored:
|
|
1418
|
+
freq = str(anchored.get("freq") or "")
|
|
1419
|
+
at = str(anchored.get("at") or "")
|
|
1420
|
+
if freq == "weekly":
|
|
1421
|
+
day = anchored.get("weekday")
|
|
1422
|
+
elif freq == "every_n_days":
|
|
1423
|
+
day = anchored.get("every_days")
|
|
1424
|
+
else:
|
|
1425
|
+
day = anchored.get("day")
|
|
1426
|
+
lines.append(f"{prefix} nexo: schedule_freq={freq}")
|
|
1427
|
+
lines.append(f"{prefix} nexo: schedule_at={at}")
|
|
1428
|
+
if freq in {"weekly", "monthly", "every_n_days"}:
|
|
1429
|
+
lines.append(f"{prefix} nexo: schedule_day={day}")
|
|
1430
|
+
else:
|
|
1431
|
+
compact_schedule = _compact_schedule_from_record(record)
|
|
1432
|
+
if not compact_schedule:
|
|
1433
|
+
return None
|
|
1434
|
+
lines.append(f"{prefix} nexo: schedule={compact_schedule}")
|
|
1250
1435
|
lines.append(f"{prefix} nexo: recovery_policy=catchup")
|
|
1251
1436
|
elif schedule_type == "keep_alive":
|
|
1252
1437
|
lines.append(f"{prefix} nexo: recovery_policy=restart_daemon")
|
|
@@ -1336,6 +1521,18 @@ def _is_agent_archived(metadata: dict | None) -> bool:
|
|
|
1336
1521
|
|
|
1337
1522
|
|
|
1338
1523
|
def _format_agent_calendar_value(raw: str) -> tuple[str, str]:
|
|
1524
|
+
payload = _anchored_schedule_payload(raw)
|
|
1525
|
+
if payload:
|
|
1526
|
+
at = str(payload.get("at") or "")
|
|
1527
|
+
freq = str(payload.get("freq") or "")
|
|
1528
|
+
if freq == "weekly":
|
|
1529
|
+
return at, f"weekly weekday={int(payload.get('weekday'))} {at}"
|
|
1530
|
+
if freq == "monthly":
|
|
1531
|
+
return at, f"monthly day={int(payload.get('day'))} {at}"
|
|
1532
|
+
if freq == "every_n_days":
|
|
1533
|
+
return at, f"every {int(payload.get('every_days'))}d {at}"
|
|
1534
|
+
return at, f"{at} daily"
|
|
1535
|
+
|
|
1339
1536
|
compact = _compact_schedule_from_record({
|
|
1340
1537
|
"schedule_type": "calendar",
|
|
1341
1538
|
"schedule_value": raw,
|
|
@@ -1357,6 +1554,30 @@ def _format_agent_calendar_value(raw: str) -> tuple[str, str]:
|
|
|
1357
1554
|
return compact, f"{hour:02d}:{minute:02d} daily"
|
|
1358
1555
|
|
|
1359
1556
|
|
|
1557
|
+
def _agent_anchored_schedule_fields(schedule_value: str | dict | list | None) -> dict:
|
|
1558
|
+
payload = _anchored_schedule_payload(schedule_value)
|
|
1559
|
+
if not payload:
|
|
1560
|
+
return {
|
|
1561
|
+
"schedule_freq": "",
|
|
1562
|
+
"schedule_at": "",
|
|
1563
|
+
"schedule_day": 0,
|
|
1564
|
+
}
|
|
1565
|
+
freq = str(payload.get("freq") or "")
|
|
1566
|
+
if freq == "weekly":
|
|
1567
|
+
day = int(payload.get("weekday"))
|
|
1568
|
+
elif freq == "monthly":
|
|
1569
|
+
day = int(payload.get("day"))
|
|
1570
|
+
elif freq == "every_n_days":
|
|
1571
|
+
day = int(payload.get("every_days"))
|
|
1572
|
+
else:
|
|
1573
|
+
day = 0
|
|
1574
|
+
return {
|
|
1575
|
+
"schedule_freq": freq,
|
|
1576
|
+
"schedule_at": str(payload.get("at") or ""),
|
|
1577
|
+
"schedule_day": day,
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
|
|
1360
1581
|
def _agent_schedule_from_script(script: dict) -> dict:
|
|
1361
1582
|
schedules = script.get("schedules") if isinstance(script.get("schedules"), list) else []
|
|
1362
1583
|
if schedules:
|
|
@@ -1372,6 +1593,7 @@ def _agent_schedule_from_script(script: dict) -> dict:
|
|
|
1372
1593
|
elif schedule_type == "calendar":
|
|
1373
1594
|
daily_at, formatted = _format_agent_calendar_value(schedule_value)
|
|
1374
1595
|
label = formatted or label
|
|
1596
|
+
anchored_fields = _agent_anchored_schedule_fields(schedule_value)
|
|
1375
1597
|
return {
|
|
1376
1598
|
"schedule_type": schedule_type,
|
|
1377
1599
|
"schedule_value": schedule_value,
|
|
@@ -1379,6 +1601,7 @@ def _agent_schedule_from_script(script: dict) -> dict:
|
|
|
1379
1601
|
"effective_schedule_label": label,
|
|
1380
1602
|
"interval_seconds": interval_seconds,
|
|
1381
1603
|
"daily_at": daily_at,
|
|
1604
|
+
**anchored_fields,
|
|
1382
1605
|
"cron_id": str(schedule.get("cron_id") or ""),
|
|
1383
1606
|
"schedule_source": "runtime",
|
|
1384
1607
|
"schedules": schedules,
|
|
@@ -1394,6 +1617,9 @@ def _agent_schedule_from_script(script: dict) -> dict:
|
|
|
1394
1617
|
"effective_schedule_label": str(declared.get("schedule_label") or ""),
|
|
1395
1618
|
"interval_seconds": int(declared.get("interval_seconds", 0) or 0),
|
|
1396
1619
|
"daily_at": str(declared.get("schedule") or ""),
|
|
1620
|
+
"schedule_freq": str(declared.get("schedule_freq") or ""),
|
|
1621
|
+
"schedule_at": str(declared.get("schedule_at") or ""),
|
|
1622
|
+
"schedule_day": int(declared.get("schedule_day", 0) or 0),
|
|
1397
1623
|
"cron_id": str(declared.get("cron_id") or ""),
|
|
1398
1624
|
"schedule_source": "metadata",
|
|
1399
1625
|
"schedules": [],
|
|
@@ -1406,6 +1632,9 @@ def _agent_schedule_from_script(script: dict) -> dict:
|
|
|
1406
1632
|
"effective_schedule_label": "",
|
|
1407
1633
|
"interval_seconds": 0,
|
|
1408
1634
|
"daily_at": "",
|
|
1635
|
+
"schedule_freq": "",
|
|
1636
|
+
"schedule_at": "",
|
|
1637
|
+
"schedule_day": 0,
|
|
1409
1638
|
"cron_id": str(metadata.get("cron_id") or script.get("name") or ""),
|
|
1410
1639
|
"schedule_source": "",
|
|
1411
1640
|
"schedules": [],
|
|
@@ -1579,6 +1808,9 @@ def set_agent_schedule(
|
|
|
1579
1808
|
*,
|
|
1580
1809
|
interval_seconds: int | None = None,
|
|
1581
1810
|
daily_at: str | None = None,
|
|
1811
|
+
schedule_freq: str | None = None,
|
|
1812
|
+
schedule_at: str | None = None,
|
|
1813
|
+
schedule_day: int | str | None = None,
|
|
1582
1814
|
clear: bool = False,
|
|
1583
1815
|
) -> dict:
|
|
1584
1816
|
status = get_agent_status(name_or_path)
|
|
@@ -1592,7 +1824,18 @@ def set_agent_schedule(
|
|
|
1592
1824
|
if runtime == "unknown":
|
|
1593
1825
|
runtime = "shell" if path.suffix.lower() == ".sh" else "python"
|
|
1594
1826
|
|
|
1595
|
-
remove = {
|
|
1827
|
+
remove = {
|
|
1828
|
+
"schedule",
|
|
1829
|
+
"schedule_freq",
|
|
1830
|
+
"schedule_at",
|
|
1831
|
+
"schedule_day",
|
|
1832
|
+
"interval_seconds",
|
|
1833
|
+
"recovery_policy",
|
|
1834
|
+
"run_on_boot",
|
|
1835
|
+
"run_on_wake",
|
|
1836
|
+
"idempotent",
|
|
1837
|
+
"max_catchup_age",
|
|
1838
|
+
}
|
|
1596
1839
|
updates = {
|
|
1597
1840
|
"agent": "true",
|
|
1598
1841
|
"name": metadata.get("name") or agent.get("name") or _logical_personal_script_name(path.stem),
|
|
@@ -1615,6 +1858,11 @@ def set_agent_schedule(
|
|
|
1615
1858
|
"agent": refreshed.get("agent") if refreshed.get("ok") else agent,
|
|
1616
1859
|
}
|
|
1617
1860
|
|
|
1861
|
+
anchored_requested = bool(schedule_freq or schedule_at or schedule_day is not None)
|
|
1862
|
+
mode_count = sum(bool(value) for value in (interval_seconds is not None, bool(daily_at), anchored_requested))
|
|
1863
|
+
if mode_count > 1:
|
|
1864
|
+
return {"ok": False, "error": "Choose interval_seconds, daily_at, or schedule_freq/schedule_at."}
|
|
1865
|
+
|
|
1618
1866
|
if interval_seconds is not None:
|
|
1619
1867
|
try:
|
|
1620
1868
|
interval = int(interval_seconds)
|
|
@@ -1627,6 +1875,17 @@ def set_agent_schedule(
|
|
|
1627
1875
|
"interval_seconds": str(interval),
|
|
1628
1876
|
"recovery_policy": "run_once_on_wake",
|
|
1629
1877
|
})
|
|
1878
|
+
elif anchored_requested:
|
|
1879
|
+
freq = str(schedule_freq or "").strip().lower().replace("-", "_")
|
|
1880
|
+
at = _normalize_schedule_at_metadata(str(schedule_at or "").strip())
|
|
1881
|
+
updates.update({
|
|
1882
|
+
"schedule_required": "true",
|
|
1883
|
+
"schedule_freq": freq,
|
|
1884
|
+
"schedule_at": at,
|
|
1885
|
+
"recovery_policy": "catchup",
|
|
1886
|
+
})
|
|
1887
|
+
if schedule_day is not None:
|
|
1888
|
+
updates["schedule_day"] = str(schedule_day)
|
|
1630
1889
|
elif daily_at:
|
|
1631
1890
|
schedule_value = _normalize_schedule_metadata(str(daily_at).strip())
|
|
1632
1891
|
updates.update({
|
|
@@ -1635,7 +1894,7 @@ def set_agent_schedule(
|
|
|
1635
1894
|
"recovery_policy": "catchup",
|
|
1636
1895
|
})
|
|
1637
1896
|
else:
|
|
1638
|
-
return {"ok": False, "error": "Choose interval_seconds, daily_at, or clear=true"}
|
|
1897
|
+
return {"ok": False, "error": "Choose interval_seconds, daily_at, schedule_freq/schedule_at, or clear=true"}
|
|
1639
1898
|
|
|
1640
1899
|
candidate_metadata = dict(metadata)
|
|
1641
1900
|
for key in remove:
|
|
@@ -1660,6 +1919,7 @@ def set_agent_schedule(
|
|
|
1660
1919
|
"ok": not ensure_error,
|
|
1661
1920
|
"name": agent["name"],
|
|
1662
1921
|
"cron_id": cron_id,
|
|
1922
|
+
"declared_schedule": declared,
|
|
1663
1923
|
"error": ensure_error,
|
|
1664
1924
|
"removed": removed,
|
|
1665
1925
|
"ensure_schedules": ensured,
|
|
@@ -1860,6 +2120,15 @@ def _discover_personal_schedule_records() -> list[dict]:
|
|
|
1860
2120
|
continue
|
|
1861
2121
|
|
|
1862
2122
|
schedule_type, schedule_value, schedule_label = _format_schedule_from_plist(plist_data)
|
|
2123
|
+
managed_marker = env.get(PERSONAL_SCHEDULE_MANAGED_ENV) == "1"
|
|
2124
|
+
if managed_marker and exists and script_path is not None:
|
|
2125
|
+
with contextlib.suppress(Exception):
|
|
2126
|
+
meta = parse_inline_metadata(script_path)
|
|
2127
|
+
declared = get_declared_schedule(meta, meta.get("name", script_path.stem))
|
|
2128
|
+
if declared.get("valid") and declared.get("cron_id") == cron_id:
|
|
2129
|
+
schedule_type = str(declared.get("schedule_type") or schedule_type)
|
|
2130
|
+
schedule_value = str(declared.get("schedule_value") or schedule_value)
|
|
2131
|
+
schedule_label = str(declared.get("schedule_label") or schedule_label)
|
|
1863
2132
|
results.append({
|
|
1864
2133
|
"cron_id": cron_id,
|
|
1865
2134
|
"script_path": str(script_path) if script_path else "",
|
|
@@ -1871,7 +2140,7 @@ def _discover_personal_schedule_records() -> list[dict]:
|
|
|
1871
2140
|
"plist_path": str(plist_path),
|
|
1872
2141
|
"enabled": True,
|
|
1873
2142
|
"description": "",
|
|
1874
|
-
"managed_marker":
|
|
2143
|
+
"managed_marker": managed_marker,
|
|
1875
2144
|
"script_exists": exists,
|
|
1876
2145
|
"script_within_scripts_dir": in_scripts_dir,
|
|
1877
2146
|
})
|
|
@@ -59,7 +59,12 @@ cleanup_backups() {
|
|
|
59
59
|
PRUNER="$(dirname "$0")/prune_runtime_backups.py"
|
|
60
60
|
fi
|
|
61
61
|
if [ -f "$PRUNER" ]; then
|
|
62
|
-
python3 "$PRUNER"
|
|
62
|
+
python3 "$PRUNER" \
|
|
63
|
+
--root "$BACKUP_DIR" \
|
|
64
|
+
--apply \
|
|
65
|
+
--max-bytes "$BACKUP_MAX_BYTES" \
|
|
66
|
+
--hourly-keep "$KEEP_LAST" \
|
|
67
|
+
--local-context-keep "$LOCAL_CONTEXT_KEEP_LAST" >/dev/null 2>&1 || true
|
|
63
68
|
fi
|
|
64
69
|
|
|
65
70
|
python3 - "$BACKUP_DIR" "$RETENTION_HOURS" "$KEEP_LAST" "$FAMILY_KEEP_LAST" "$LOCAL_CONTEXT_RETENTION_HOURS" "$LOCAL_CONTEXT_KEEP_LAST" <<'PY'
|
|
@@ -331,6 +331,73 @@ if [ "$DISABLED_GATE_OUTPUT" = "disabled" ]; then
|
|
|
331
331
|
exit 0
|
|
332
332
|
fi
|
|
333
333
|
|
|
334
|
+
if [ "${NEXO_SCHEDULE_FREQ:-}" = "every_n_days" ] && [ -n "${NEXO_SCHEDULE_EVERY_DAYS:-}" ]; then
|
|
335
|
+
EVERY_N_DAYS_GATE_OUTPUT=$(python3 - "$DB" "$CRON_ID" "$NEXO_SCHEDULE_EVERY_DAYS" <<'PYGATE' 2>/dev/null || true
|
|
336
|
+
from __future__ import annotations
|
|
337
|
+
from datetime import datetime, timezone
|
|
338
|
+
import sqlite3
|
|
339
|
+
import sys
|
|
340
|
+
|
|
341
|
+
db_path, cron_id, every_days_raw = sys.argv[1:]
|
|
342
|
+
try:
|
|
343
|
+
every_days = int(every_days_raw)
|
|
344
|
+
except ValueError:
|
|
345
|
+
sys.exit(0)
|
|
346
|
+
if every_days <= 1:
|
|
347
|
+
sys.exit(0)
|
|
348
|
+
try:
|
|
349
|
+
row = None
|
|
350
|
+
conn = sqlite3.connect(db_path)
|
|
351
|
+
row = conn.execute(
|
|
352
|
+
"""
|
|
353
|
+
SELECT started_at
|
|
354
|
+
FROM cron_runs
|
|
355
|
+
WHERE cron_id = ?
|
|
356
|
+
AND ended_at IS NOT NULL
|
|
357
|
+
AND exit_code = 0
|
|
358
|
+
AND COALESCE(summary, '') NOT LIKE '[schedule-gate]%'
|
|
359
|
+
AND COALESCE(summary, '') NOT LIKE '[disabled]%'
|
|
360
|
+
ORDER BY started_at DESC, id DESC
|
|
361
|
+
LIMIT 1
|
|
362
|
+
""",
|
|
363
|
+
(cron_id,),
|
|
364
|
+
).fetchone()
|
|
365
|
+
except Exception:
|
|
366
|
+
sys.exit(0)
|
|
367
|
+
finally:
|
|
368
|
+
try:
|
|
369
|
+
conn.close()
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
if row is None or not row[0]:
|
|
373
|
+
sys.exit(0)
|
|
374
|
+
raw = str(row[0])
|
|
375
|
+
try:
|
|
376
|
+
last = datetime.strptime(raw[:19], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
|
377
|
+
except ValueError:
|
|
378
|
+
try:
|
|
379
|
+
last = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
380
|
+
except ValueError:
|
|
381
|
+
sys.exit(0)
|
|
382
|
+
if last.tzinfo is None:
|
|
383
|
+
last = last.replace(tzinfo=timezone.utc)
|
|
384
|
+
elapsed = (datetime.now(timezone.utc) - last.astimezone(timezone.utc)).total_seconds()
|
|
385
|
+
if elapsed < every_days * 86400:
|
|
386
|
+
remaining_hours = max(1, int(((every_days * 86400) - elapsed + 3599) // 3600))
|
|
387
|
+
print(f"skip\tlast={raw}\tremaining_hours={remaining_hours}")
|
|
388
|
+
PYGATE
|
|
389
|
+
)
|
|
390
|
+
if [ -n "$EVERY_N_DAYS_GATE_OUTPUT" ]; then
|
|
391
|
+
EXIT_CODE=0
|
|
392
|
+
SIGNAL_NAME=""
|
|
393
|
+
: > "$OUTPUT_FILE"
|
|
394
|
+
echo "[schedule-gate] $CRON_ID skipped - every ${NEXO_SCHEDULE_EVERY_DAYS} days (${EVERY_N_DAYS_GATE_OUTPUT})" > "$OUTPUT_FILE"
|
|
395
|
+
finalize_row
|
|
396
|
+
cleanup
|
|
397
|
+
exit 0
|
|
398
|
+
fi
|
|
399
|
+
fi
|
|
400
|
+
|
|
334
401
|
"$@" > "$OUTPUT_FILE" 2>&1 &
|
|
335
402
|
CHILD_PID=$!
|
|
336
403
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# nexo: name=prune-runtime-backups
|
|
3
|
-
# nexo: description=Rotate
|
|
3
|
+
# nexo: description=Rotate runtime/backups by class, including hourly DB dumps under a bounded restore policy.
|
|
4
4
|
# nexo: category=maintenance
|
|
5
5
|
# nexo: runtime=python
|
|
6
6
|
# nexo: timeout=300
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
prune_runtime_backups.py — NEXO backup retention by class.
|
|
11
11
|
|
|
12
12
|
Separates *technical* rollback snapshots (throwaway, produced by the installer,
|
|
13
|
-
updater and backfills) from
|
|
14
|
-
DB dumps
|
|
15
|
-
|
|
13
|
+
updater and backfills) from protected business/weekly restore points. Hourly
|
|
14
|
+
DB dumps and local memory DB dumps are product-generated runtime artifacts and
|
|
15
|
+
are rotated here with explicit minimum restore counts plus a global cap.
|
|
16
16
|
|
|
17
17
|
Target: $NEXO_HOME/runtime/backups/ (default ~/.nexo/runtime/backups)
|
|
18
18
|
|
|
@@ -38,8 +38,8 @@ Class taxonomy (prefix-based) and retention policy:
|
|
|
38
38
|
|
|
39
39
|
HOURLY_DB (sqlite dumps, managed by nexo-backup.sh):
|
|
40
40
|
Prefix: nexo-YYYY-MM-DD-HHMM.db in runtime/backups/ root
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
Retention: keep --hourly-keep newest unconditionally; older dumps are
|
|
42
|
+
pruned and may also be budget-pruned down to the same minimum.
|
|
43
43
|
|
|
44
44
|
WEEKLY_DB (weekly/ directory):
|
|
45
45
|
Already rotated by nexo-backup.sh (90d retention). Skip.
|
|
@@ -109,7 +109,7 @@ TECHNICAL_PREFIXES = (
|
|
|
109
109
|
|
|
110
110
|
# Entries that must never be considered for pruning.
|
|
111
111
|
PROTECTED_NAMES = {"shopify-backups", "weekly"}
|
|
112
|
-
# Hourly DB dumps at the root of runtime/backups
|
|
112
|
+
# Hourly DB dumps at the root of runtime/backups.
|
|
113
113
|
HOURLY_DB_RE = re.compile(r"^nexo-\d{4}-\d{2}-\d{2}-\d{4}\.db$")
|
|
114
114
|
LOCAL_CONTEXT_DB_RE = re.compile(r"^local-context-\d{4}-\d{2}-\d{2}-\d{4}(\d{2})?\.db$")
|
|
115
115
|
TEMPORARY_RE = re.compile(r"(^|.*[.])tmp([.-].*)?$|.*\.tmp\..*|.*-journal$|.*\.db-(wal|shm)$")
|
|
@@ -328,7 +328,7 @@ def plan_prunes(
|
|
|
328
328
|
else:
|
|
329
329
|
to_keep.append(it)
|
|
330
330
|
|
|
331
|
-
for klass, keep_count in (("LOCAL_CONTEXT_DB", local_context_keep),):
|
|
331
|
+
for klass, keep_count in (("LOCAL_CONTEXT_DB", local_context_keep), ("HOURLY_DB", hourly_keep)):
|
|
332
332
|
group = [it for it in items if it["class"] == klass]
|
|
333
333
|
group.sort(key=lambda x: (x["ts"] or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
|
|
334
334
|
for it in group[:max(0, keep_count)]:
|
|
@@ -346,11 +346,13 @@ def plan_prunes(
|
|
|
346
346
|
for it in items:
|
|
347
347
|
by_budget_family.setdefault((it["class"], it["family"]), []).append(it)
|
|
348
348
|
for (klass, _family), group in by_budget_family.items():
|
|
349
|
-
if klass not in {"TECHNICAL", "LOCAL_CONTEXT_DB", "TEMPORARY"}:
|
|
349
|
+
if klass not in {"TECHNICAL", "LOCAL_CONTEXT_DB", "HOURLY_DB", "TEMPORARY"}:
|
|
350
350
|
continue
|
|
351
351
|
min_keep = 0
|
|
352
352
|
if klass == "LOCAL_CONTEXT_DB":
|
|
353
353
|
min_keep = max(0, local_context_keep)
|
|
354
|
+
elif klass == "HOURLY_DB":
|
|
355
|
+
min_keep = max(0, hourly_keep)
|
|
354
356
|
group.sort(key=lambda x: (x["ts"] or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
|
|
355
357
|
for it in group[:min_keep]:
|
|
356
358
|
protected_keep_ids.add(id(it))
|
|
@@ -359,7 +361,7 @@ def plan_prunes(
|
|
|
359
361
|
it for it in items
|
|
360
362
|
if id(it) not in delete_ids
|
|
361
363
|
and id(it) not in protected_keep_ids
|
|
362
|
-
and it["class"] in {"TECHNICAL", "LOCAL_CONTEXT_DB", "TEMPORARY"}
|
|
364
|
+
and it["class"] in {"TECHNICAL", "LOCAL_CONTEXT_DB", "HOURLY_DB", "TEMPORARY"}
|
|
363
365
|
]
|
|
364
366
|
budget_candidates.sort(
|
|
365
367
|
key=lambda x: (
|
|
@@ -381,10 +383,10 @@ def plan_prunes(
|
|
|
381
383
|
return to_delete, to_keep
|
|
382
384
|
|
|
383
385
|
|
|
384
|
-
def restore_point_guard(items: list[dict], to_delete: list[dict]) -> tuple[bool, list[str], dict]:
|
|
386
|
+
def restore_point_guard(items: list[dict], to_delete: list[dict], *, hourly_keep: int) -> tuple[bool, list[str], dict]:
|
|
385
387
|
"""Validate that apply mode never removes protected restore classes."""
|
|
386
388
|
delete_ids = {id(item) for item in to_delete}
|
|
387
|
-
protected_classes = {"BUSINESS", "
|
|
389
|
+
protected_classes = {"BUSINESS", "ROOT_DB", "UNKNOWN"}
|
|
388
390
|
protected_names = set(PROTECTED_NAMES)
|
|
389
391
|
violations: list[str] = []
|
|
390
392
|
for item in items:
|
|
@@ -393,10 +395,17 @@ def restore_point_guard(items: list[dict], to_delete: list[dict]) -> tuple[bool,
|
|
|
393
395
|
if item["class"] in protected_classes or item["name"] in protected_names:
|
|
394
396
|
violations.append(f"{item['name']} ({item['class']})")
|
|
395
397
|
hourly_count = sum(1 for item in items if item["class"] == "HOURLY_DB")
|
|
398
|
+
hourly_delete_count = sum(1 for item in items if item["class"] == "HOURLY_DB" and id(item) in delete_ids)
|
|
399
|
+
hourly_after_prune = hourly_count - hourly_delete_count
|
|
400
|
+
hourly_floor = min(hourly_count, max(0, hourly_keep))
|
|
401
|
+
if hourly_after_prune < hourly_floor:
|
|
402
|
+
violations.append(f"hourly_db_floor ({hourly_after_prune} < {hourly_floor})")
|
|
396
403
|
weekly_present = any(item["name"] == "weekly" for item in items)
|
|
397
404
|
business_count = sum(1 for item in items if item["class"] == "BUSINESS")
|
|
398
405
|
return not violations, violations, {
|
|
399
406
|
"hourly_db_present": hourly_count,
|
|
407
|
+
"hourly_db_after_prune": hourly_after_prune,
|
|
408
|
+
"hourly_keep_floor": hourly_floor,
|
|
400
409
|
"weekly_present": weekly_present,
|
|
401
410
|
"business_protected": business_count,
|
|
402
411
|
"protected_delete_violations": violations,
|
|
@@ -450,7 +459,11 @@ def run(args: argparse.Namespace) -> int:
|
|
|
450
459
|
delete_ids.add(id(item))
|
|
451
460
|
to_keep = [item for item in items if id(item) not in delete_ids]
|
|
452
461
|
|
|
453
|
-
restore_guard_ok, restore_guard_violations, restore_guard = restore_point_guard(
|
|
462
|
+
restore_guard_ok, restore_guard_violations, restore_guard = restore_point_guard(
|
|
463
|
+
items,
|
|
464
|
+
to_delete,
|
|
465
|
+
hourly_keep=max(0, args.hourly_keep),
|
|
466
|
+
)
|
|
454
467
|
|
|
455
468
|
total_all = sum(i["size"] for i in items)
|
|
456
469
|
total_del = sum(i["size"] for i in to_delete)
|
|
@@ -505,7 +518,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
505
518
|
print(f" total on disk: {human_size(total_all)} ({len(items)} entries)")
|
|
506
519
|
print(f" technical: {len(tech_items)}")
|
|
507
520
|
print(f" business: {len(biz_items)} (protected)")
|
|
508
|
-
print(f" hourly_db: {len(hourly_items)} (
|
|
521
|
+
print(f" hourly_db: {len(hourly_items)} (bounded; keep {args.hourly_keep})")
|
|
509
522
|
print(f" local_context: {len(local_context_items)}")
|
|
510
523
|
print(f" temporary: {len(temporary_items)}")
|
|
511
524
|
print(f" root_db: {len(root_db_items)} (never auto-pruned)")
|
|
@@ -578,7 +591,7 @@ def main() -> int:
|
|
|
578
591
|
ap.add_argument("--window-days", type=int, default=90, help="month-spaced retention window (default: 90)")
|
|
579
592
|
ap.add_argument("--only", help="restrict to one technical family (e.g. 'pre-backfill-owner')")
|
|
580
593
|
ap.add_argument("--max-bytes", default=os.environ.get("NEXO_BACKUP_MAX_BYTES", str(DEFAULT_MAX_BYTES)), help="global product-generated backup hard cap, bytes or K/M/G/T (default: 50G)")
|
|
581
|
-
ap.add_argument("--delete-all-technical", action="store_true", help="emergency mode: delete all technical rollback snapshots; protected business/weekly
|
|
594
|
+
ap.add_argument("--delete-all-technical", action="store_true", help="emergency mode: delete all technical rollback snapshots; protected business/weekly backups remain untouched")
|
|
582
595
|
ap.add_argument("--tmp-ttl-minutes", type=int, default=int(os.environ.get("NEXO_BACKUP_TMP_TTL_MINUTES", "30")), help="delete orphan temporary backup files older than this (default: 30)")
|
|
583
596
|
ap.add_argument("--local-context-keep", type=int, default=int(os.environ.get("NEXO_LOCAL_CONTEXT_BACKUP_KEEP_LAST", "1")), help="local-context backup files to keep under the global cap (default: 1)")
|
|
584
597
|
ap.add_argument("--hourly-keep", type=int, default=int(os.environ.get("NEXO_BACKUP_KEEP_LAST", "3")), help="hourly nexo DB backups to keep under the global cap (default: 3)")
|