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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.28",
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.28",
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")
@@ -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/hourly backup classes; the
519
- pruner enforces that policy.
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())
@@ -384,7 +384,71 @@ def _runtime_command(script_type: str) -> str:
384
384
  return "python3"
385
385
 
386
386
 
387
- def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds, description, script_type, label="", plist_path="", keep_alive: bool = False):
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
- if keep_alive:
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
- parts = schedule.split(":")
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
- parts = schedule.split(":")
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}"
@@ -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
- if interval_raw and schedule_raw:
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": "Both schedule and interval_seconds are set; choose one.",
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
- compact_schedule = _compact_schedule_from_record(record)
1247
- if not compact_schedule:
1248
- return None
1249
- lines.append(f"{prefix} nexo: schedule={compact_schedule}")
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 = {"schedule", "interval_seconds", "recovery_policy", "run_on_boot", "run_on_wake", "idempotent", "max_catchup_age"}
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": env.get(PERSONAL_SCHEDULE_MANAGED_ENV) == "1",
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" --root "$BACKUP_DIR" --apply --max-bytes "$BACKUP_MAX_BYTES" >/dev/null 2>&1 || true
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 technical rollback snapshots under runtime/backups by family. Never touches business (shopify-backups) or hourly_db (nexo-backup.sh) artifacts.
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 *operational* snapshots (shopify-backups, hourly
14
- DB dumps, weekly archives) so the former can be rotated without risk to the
15
- latter.
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
- These are already rotated by nexo-backup.sh (48h retention). We skip
42
- them here to avoid double-rotation logic.
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 — managed by nexo-backup.sh.
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", "HOURLY_DB", "ROOT_DB", "UNKNOWN"}
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(items, to_delete)
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)} (managed by nexo-backup.sh)")
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/hourly DB backups remain untouched")
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)")