nexo-brain 7.23.0 → 7.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/agent_runner.py +22 -2
- package/src/auto_update.py +84 -2
- package/src/cli.py +21 -5
- package/src/enforcement_engine.py +131 -67
- package/src/plugins/update.py +66 -1
- package/src/scripts/nexo-backup.sh +41 -2
- package/src/scripts/prune_runtime_backups.py +133 -5
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.23.
|
|
21
|
+
Version `7.23.1` is the current packaged-runtime line. Express patch over v7.23.0 - headless automations no longer hang on silent Claude children, synthetic followup prompts no longer trigger session-end loops, and runtime backups self-prune under a hard cap before creating new large artifacts.
|
|
22
|
+
|
|
23
|
+
Previously in `7.23.0`: minor release over v7.22.0 - pre-answer routing now consults continuity evidence before visible replies, Memory Observations queue processing converges through a bounded processor, and audits expose saved-but-not-used stores, automation drift, MCP live/catalog gaps, artifact location and transcript coverage.
|
|
22
24
|
|
|
23
25
|
Previously in `7.22.0`: minor release over v7.21.0 - heartbeat stays fast in Desktop-managed sessions, MCP writes can be accepted through a durable file-backed queue before SQLite commit, Brain exposes compliance state for Desktop gates, and Local Context adds Entity Dossier for open-domain local evidence aggregation.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.23.
|
|
3
|
+
"version": "7.23.2",
|
|
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/agent_runner.py
CHANGED
|
@@ -1052,9 +1052,10 @@ BARE_MODE_SAFE_CALLERS: frozenset[str] = frozenset({
|
|
|
1052
1052
|
# Execution contracts keep background agents disciplined without polluting
|
|
1053
1053
|
# machine-only child calls that must return strict JSON.
|
|
1054
1054
|
AUTOMATION_CONTRACT_FULL_NEXO_AGENT = "full_nexo_agent"
|
|
1055
|
+
AUTOMATION_CONTRACT_SUPERVISED_CHILD = "supervised_child"
|
|
1055
1056
|
AUTOMATION_CONTRACT_STRICT_CHILD = "strict_child_json"
|
|
1056
1057
|
AUTOMATION_CONTRACT_PUBLIC_CHILD = "public_isolated_child"
|
|
1057
|
-
AUTOMATION_CONTRACT_DEFAULT =
|
|
1058
|
+
AUTOMATION_CONTRACT_DEFAULT = AUTOMATION_CONTRACT_SUPERVISED_CHILD
|
|
1058
1059
|
|
|
1059
1060
|
FULL_NEXO_AGENT_CALLERS: frozenset[str] = frozenset({
|
|
1060
1061
|
"catchup/morning",
|
|
@@ -1087,17 +1088,30 @@ MACHINE_ONLY_LANGUAGE_CONTRACT_CALLERS: frozenset[str] = frozenset({
|
|
|
1087
1088
|
|
|
1088
1089
|
def _automation_contract_for_caller(caller: str) -> str:
|
|
1089
1090
|
clean = str(caller or "").strip()
|
|
1091
|
+
if clean in FULL_NEXO_AGENT_CALLERS:
|
|
1092
|
+
return AUTOMATION_CONTRACT_FULL_NEXO_AGENT
|
|
1090
1093
|
if clean in STRICT_CHILD_CALLERS:
|
|
1091
1094
|
return AUTOMATION_CONTRACT_STRICT_CHILD
|
|
1092
1095
|
if clean in PUBLIC_CHILD_CALLERS:
|
|
1093
1096
|
return AUTOMATION_CONTRACT_PUBLIC_CHILD
|
|
1094
|
-
return
|
|
1097
|
+
return AUTOMATION_CONTRACT_DEFAULT
|
|
1095
1098
|
|
|
1096
1099
|
|
|
1097
1100
|
def _caller_uses_global_discipline(caller: str) -> bool:
|
|
1098
1101
|
return _automation_contract_for_caller(caller) == AUTOMATION_CONTRACT_FULL_NEXO_AGENT
|
|
1099
1102
|
|
|
1100
1103
|
|
|
1104
|
+
def _build_supervised_child_system_prompt() -> str:
|
|
1105
|
+
return (
|
|
1106
|
+
"You are running as a supervised NEXO automation child. "
|
|
1107
|
+
"Return the requested result for this job only. Do not open or close "
|
|
1108
|
+
"NEXO tasks, reminders, diary entries, sessions, or followups from "
|
|
1109
|
+
"inside this child process; the parent NEXO runner owns lifecycle, "
|
|
1110
|
+
"timeouts, evidence, retries, and durable state. If the requested "
|
|
1111
|
+
"work cannot be completed safely, return a concise failure reason."
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
|
|
1101
1115
|
def _should_apply_operator_language_contract(caller: str) -> bool:
|
|
1102
1116
|
clean = str(caller or "").strip()
|
|
1103
1117
|
if not clean:
|
|
@@ -1191,6 +1205,12 @@ def run_automation_prompt(
|
|
|
1191
1205
|
append_system_prompt = append_system_prompt + "\n\n" + enforcement_fragment
|
|
1192
1206
|
else:
|
|
1193
1207
|
append_system_prompt = enforcement_fragment
|
|
1208
|
+
elif automation_contract == AUTOMATION_CONTRACT_SUPERVISED_CHILD:
|
|
1209
|
+
supervised_fragment = _build_supervised_child_system_prompt()
|
|
1210
|
+
if append_system_prompt:
|
|
1211
|
+
append_system_prompt = append_system_prompt + "\n\n" + supervised_fragment
|
|
1212
|
+
else:
|
|
1213
|
+
append_system_prompt = supervised_fragment
|
|
1194
1214
|
|
|
1195
1215
|
prompt = _apply_operator_language_contract(prompt, caller=caller)
|
|
1196
1216
|
|
package/src/auto_update.py
CHANGED
|
@@ -108,6 +108,69 @@ def _backup_validation_tables(db_file: Path) -> tuple[str, ...]:
|
|
|
108
108
|
return PROTECTED_BACKUP_TABLES
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
def _env_int(name: str, default: int) -> int:
|
|
112
|
+
try:
|
|
113
|
+
return int(os.environ.get(name, str(default)))
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return default
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
BACKUP_MAX_BYTES = _env_int("NEXO_BACKUP_MAX_BYTES", 50 * 1024 * 1024 * 1024)
|
|
119
|
+
BACKUP_MIN_FREE_BYTES = _env_int("NEXO_BACKUP_MIN_FREE_BYTES", 5 * 1024 * 1024 * 1024)
|
|
120
|
+
LOCAL_CONTEXT_MAX_BACKUP_BYTES = _env_int("NEXO_LOCAL_CONTEXT_MAX_BACKUP_BYTES", 2 * 1024 * 1024 * 1024)
|
|
121
|
+
_LAST_BACKUP_ERROR = ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _run_runtime_backup_prune() -> None:
|
|
125
|
+
script = SRC_DIR / "scripts" / "prune_runtime_backups.py"
|
|
126
|
+
if not script.is_file():
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
subprocess.run(
|
|
130
|
+
[
|
|
131
|
+
sys.executable,
|
|
132
|
+
str(script),
|
|
133
|
+
"--root",
|
|
134
|
+
str(paths.backups_dir()),
|
|
135
|
+
"--apply",
|
|
136
|
+
"--max-bytes",
|
|
137
|
+
str(BACKUP_MAX_BYTES),
|
|
138
|
+
],
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
timeout=120,
|
|
142
|
+
)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
_log(f"Backup self-clean warning: {e}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _backup_free_bytes() -> int | None:
|
|
148
|
+
backup_root = paths.backups_dir()
|
|
149
|
+
try:
|
|
150
|
+
usage = shutil.disk_usage(backup_root if backup_root.exists() else backup_root.parent)
|
|
151
|
+
return int(usage.free)
|
|
152
|
+
except Exception:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _backup_space_error() -> str | None:
|
|
157
|
+
_run_runtime_backup_prune()
|
|
158
|
+
free = _backup_free_bytes()
|
|
159
|
+
if free is not None and free < BACKUP_MIN_FREE_BYTES:
|
|
160
|
+
return (
|
|
161
|
+
"free disk below NEXO backup safety floor after automatic cleanup "
|
|
162
|
+
f"({free}B < {BACKUP_MIN_FREE_BYTES}B)"
|
|
163
|
+
)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _should_include_local_context_backup(path: Path) -> bool:
|
|
168
|
+
try:
|
|
169
|
+
return path.stat().st_size <= LOCAL_CONTEXT_MAX_BACKUP_BYTES
|
|
170
|
+
except OSError:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
111
174
|
CLASSIFIER_INSTALL_TIMEOUT_SECONDS = 1800
|
|
112
175
|
CLASSIFIER_INSTALL_JOIN_SECONDS = 1500
|
|
113
176
|
CLASSIFIER_INSTALL_LOG = paths.logs_dir() / "classifier-install.log"
|
|
@@ -380,6 +443,17 @@ def _create_validated_db_backup() -> tuple[str | None, dict | None]:
|
|
|
380
443
|
"""Create a DB backup and validate that critical tables still contain data."""
|
|
381
444
|
backup_dir = _backup_dbs()
|
|
382
445
|
if not backup_dir:
|
|
446
|
+
if _LAST_BACKUP_ERROR:
|
|
447
|
+
return None, {
|
|
448
|
+
"ok": False,
|
|
449
|
+
"reports": [{
|
|
450
|
+
"ok": False,
|
|
451
|
+
"source_db": "",
|
|
452
|
+
"backup_db": "",
|
|
453
|
+
"errors": [_LAST_BACKUP_ERROR],
|
|
454
|
+
"regressions": [],
|
|
455
|
+
}],
|
|
456
|
+
}
|
|
383
457
|
return None, None
|
|
384
458
|
|
|
385
459
|
source_dbs: list[Path] = []
|
|
@@ -387,7 +461,7 @@ def _create_validated_db_backup() -> tuple[str | None, dict | None]:
|
|
|
387
461
|
if primary_db is not None:
|
|
388
462
|
source_dbs.append(primary_db)
|
|
389
463
|
local_context_db = paths.memory_dir() / "local-context.db"
|
|
390
|
-
if local_context_db.is_file():
|
|
464
|
+
if local_context_db.is_file() and _should_include_local_context_backup(local_context_db):
|
|
391
465
|
source_dbs.append(local_context_db)
|
|
392
466
|
if not source_dbs:
|
|
393
467
|
return backup_dir, None
|
|
@@ -2061,6 +2135,8 @@ def _backup_dbs() -> str | None:
|
|
|
2061
2135
|
"""Snapshot all .db files before migration. Returns backup dir or None."""
|
|
2062
2136
|
import sqlite3
|
|
2063
2137
|
import time as _time
|
|
2138
|
+
global _LAST_BACKUP_ERROR
|
|
2139
|
+
_LAST_BACKUP_ERROR = ""
|
|
2064
2140
|
# Drop 0-byte .db orphans first — they mask the real DB during primary
|
|
2065
2141
|
# path selection and turn into empty shells in the backup, breaking both
|
|
2066
2142
|
# validation and rollback paths. Safe no-op when there are none.
|
|
@@ -2070,7 +2146,7 @@ def _backup_dbs() -> str | None:
|
|
|
2070
2146
|
|
|
2071
2147
|
db_files = list(DATA_DIR.glob("*.db")) if DATA_DIR.is_dir() else []
|
|
2072
2148
|
local_context_db = paths.memory_dir() / "local-context.db"
|
|
2073
|
-
if local_context_db.is_file():
|
|
2149
|
+
if local_context_db.is_file() and _should_include_local_context_backup(local_context_db):
|
|
2074
2150
|
db_files.append(local_context_db)
|
|
2075
2151
|
db_files += [f for f in NEXO_HOME.glob("*.db") if f.is_file()]
|
|
2076
2152
|
src_db = SRC_DIR / "nexo.db"
|
|
@@ -2080,6 +2156,12 @@ def _backup_dbs() -> str | None:
|
|
|
2080
2156
|
if not db_files:
|
|
2081
2157
|
return None
|
|
2082
2158
|
|
|
2159
|
+
space_err = _backup_space_error()
|
|
2160
|
+
if space_err:
|
|
2161
|
+
_LAST_BACKUP_ERROR = space_err
|
|
2162
|
+
_log(f"DB backup aborted: {space_err}")
|
|
2163
|
+
return None
|
|
2164
|
+
|
|
2083
2165
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
2084
2166
|
for db_file in db_files:
|
|
2085
2167
|
src_conn = None
|
package/src/cli.py
CHANGED
|
@@ -413,13 +413,29 @@ def _version_sort_key(raw: str) -> tuple[tuple[int, ...], int, str]:
|
|
|
413
413
|
return (tuple(parts), 1 if not suffix else 0, suffix)
|
|
414
414
|
|
|
415
415
|
|
|
416
|
-
def _version_status_payload() -> dict:
|
|
416
|
+
def _version_status_payload(*, force_refresh: bool = False) -> dict:
|
|
417
417
|
installed = _get_version()
|
|
418
418
|
latest = _load_latest_version_cache()
|
|
419
419
|
latest_source = "cache" if latest else ""
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
420
|
+
should_refresh = False
|
|
421
|
+
if force_refresh and _should_refresh_latest_version():
|
|
422
|
+
# Desktop reads `nexo --version --json` to decide whether to show the
|
|
423
|
+
# Brain update CTA. If a user checked minutes before a release, the
|
|
424
|
+
# 6-hour cache can otherwise hide the new npm dist-tag until it expires.
|
|
425
|
+
should_refresh = (
|
|
426
|
+
latest is None
|
|
427
|
+
or (
|
|
428
|
+
bool(installed)
|
|
429
|
+
and _version_sort_key(latest) <= _version_sort_key(installed)
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
elif latest is None and _should_refresh_latest_version():
|
|
433
|
+
should_refresh = True
|
|
434
|
+
if should_refresh:
|
|
435
|
+
refreshed = _fetch_latest_version()
|
|
436
|
+
if refreshed:
|
|
437
|
+
latest = refreshed
|
|
438
|
+
latest_source = "npm"
|
|
423
439
|
if latest and installed and _version_sort_key(latest) < _version_sort_key(installed):
|
|
424
440
|
latest = installed
|
|
425
441
|
latest_source = "installed"
|
|
@@ -4030,7 +4046,7 @@ def main():
|
|
|
4030
4046
|
_print_help()
|
|
4031
4047
|
return 0
|
|
4032
4048
|
if args.version:
|
|
4033
|
-
payload = _version_status_payload()
|
|
4049
|
+
payload = _version_status_payload(force_refresh=bool(args.json))
|
|
4034
4050
|
if args.json:
|
|
4035
4051
|
print(json.dumps(payload, ensure_ascii=False))
|
|
4036
4052
|
else:
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
12
|
import os
|
|
13
|
+
import queue
|
|
13
14
|
import subprocess
|
|
14
15
|
import threading
|
|
15
16
|
import time
|
|
@@ -2816,7 +2817,10 @@ def run_with_enforcement(
|
|
|
2816
2817
|
# headless runtime. Without this, correction detection and project-
|
|
2817
2818
|
# context rules only run when tests invoke on_user_message directly.
|
|
2818
2819
|
try:
|
|
2819
|
-
enforcer.on_user_message(
|
|
2820
|
+
enforcer.on_user_message(
|
|
2821
|
+
prompt or "",
|
|
2822
|
+
session_end_detector=lambda _text: False,
|
|
2823
|
+
)
|
|
2820
2824
|
except Exception as _init_exc: # noqa: BLE001 — fail-closed
|
|
2821
2825
|
_logger.warning("on_user_message (initial prompt) failed: %s", _init_exc)
|
|
2822
2826
|
|
|
@@ -2831,6 +2835,16 @@ def run_with_enforcement(
|
|
|
2831
2835
|
stderr_lines = []
|
|
2832
2836
|
start_time = time.time()
|
|
2833
2837
|
waiting_for_injection_response = False
|
|
2838
|
+
timed_out = False
|
|
2839
|
+
stdout_closed = False
|
|
2840
|
+
stdout_lines: queue.Queue[str | None] = queue.Queue()
|
|
2841
|
+
|
|
2842
|
+
def _kill_proc(reason: str) -> None:
|
|
2843
|
+
_logger.warning("%s after %ds", reason, timeout)
|
|
2844
|
+
try:
|
|
2845
|
+
proc.kill()
|
|
2846
|
+
except Exception:
|
|
2847
|
+
pass
|
|
2834
2848
|
|
|
2835
2849
|
def _inject(text: str):
|
|
2836
2850
|
nonlocal waiting_for_injection_response
|
|
@@ -2847,6 +2861,15 @@ def run_with_enforcement(
|
|
|
2847
2861
|
except Exception as e:
|
|
2848
2862
|
_logger.error("INJECT_FAILED: %s", e)
|
|
2849
2863
|
|
|
2864
|
+
def _read_stdout():
|
|
2865
|
+
try:
|
|
2866
|
+
for line in proc.stdout:
|
|
2867
|
+
stdout_lines.put(line)
|
|
2868
|
+
except Exception:
|
|
2869
|
+
pass
|
|
2870
|
+
finally:
|
|
2871
|
+
stdout_lines.put(None)
|
|
2872
|
+
|
|
2850
2873
|
def _read_stderr():
|
|
2851
2874
|
try:
|
|
2852
2875
|
for line in proc.stderr:
|
|
@@ -2854,92 +2877,125 @@ def run_with_enforcement(
|
|
|
2854
2877
|
except Exception:
|
|
2855
2878
|
pass
|
|
2856
2879
|
|
|
2880
|
+
stdout_thread = threading.Thread(target=_read_stdout, daemon=True)
|
|
2857
2881
|
stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
|
|
2882
|
+
stdout_thread.start()
|
|
2858
2883
|
stderr_thread.start()
|
|
2859
2884
|
|
|
2860
2885
|
last_periodic_check = time.time()
|
|
2861
2886
|
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
line = raw_line.strip()
|
|
2865
|
-
if not line:
|
|
2866
|
-
continue
|
|
2867
|
-
|
|
2868
|
-
if time.time() - start_time > timeout:
|
|
2869
|
-
_logger.warning("TIMEOUT after %ds", timeout)
|
|
2870
|
-
proc.kill()
|
|
2871
|
-
break
|
|
2887
|
+
def _handle_stdout_line(raw_line: str) -> bool:
|
|
2888
|
+
"""Process one Claude stream-json line.
|
|
2872
2889
|
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2890
|
+
Return True when the stream turn is complete and the caller should
|
|
2891
|
+
leave the main read loop.
|
|
2892
|
+
"""
|
|
2893
|
+
nonlocal waiting_for_injection_response
|
|
2894
|
+
line = raw_line.strip()
|
|
2895
|
+
if not line:
|
|
2896
|
+
return False
|
|
2877
2897
|
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
if block.get("type") == "tool_use":
|
|
2883
|
-
# v7.7 Gap 7.3 — wire before_tool in the live
|
|
2884
|
-
# stream. Desktop already calls onBeforeToolCall
|
|
2885
|
-
# before onToolCall; Brain's stream was only
|
|
2886
|
-
# calling on_tool_call, silently skipping every
|
|
2887
|
-
# before_tool rule the map declared.
|
|
2888
|
-
enforcer.on_tool_call_before(block.get("name", ""), block.get("input"))
|
|
2889
|
-
enforcer.on_tool_call(block.get("name", ""), block.get("input"))
|
|
2890
|
-
elif event_type == "content_block_start":
|
|
2891
|
-
cb = event.get("content_block", {})
|
|
2892
|
-
if cb.get("type") == "tool_use":
|
|
2893
|
-
enforcer.on_tool_call_before(cb.get("name", ""), cb.get("input"))
|
|
2894
|
-
enforcer.on_tool_call(cb.get("name", ""), cb.get("input"))
|
|
2895
|
-
|
|
2896
|
-
if event_type == "assistant" and not waiting_for_injection_response:
|
|
2897
|
-
msg = event.get("message", {})
|
|
2898
|
-
for block in msg.get("content", []):
|
|
2899
|
-
if block.get("type") == "text":
|
|
2900
|
-
collected_text.append(block["text"])
|
|
2901
|
-
# R16 — probe each assistant text block as it arrives
|
|
2902
|
-
# so a declared-done line is caught on the same turn
|
|
2903
|
-
# rather than only at session end.
|
|
2904
|
-
try:
|
|
2905
|
-
enforcer.on_assistant_text(block["text"])
|
|
2906
|
-
except Exception as _r16_exc: # noqa: BLE001
|
|
2907
|
-
_logger.warning("on_assistant_text failed: %s", _r16_exc)
|
|
2908
|
-
try:
|
|
2909
|
-
enforcer.on_assistant_text_r17(block["text"])
|
|
2910
|
-
except Exception as _r17_exc: # noqa: BLE001
|
|
2911
|
-
_logger.warning("on_assistant_text_r17 failed: %s", _r17_exc)
|
|
2912
|
-
|
|
2913
|
-
if event_type == "result":
|
|
2914
|
-
if waiting_for_injection_response:
|
|
2915
|
-
waiting_for_injection_response = False
|
|
2916
|
-
_logger.info("INJECTION_RESPONSE received")
|
|
2917
|
-
item = enforcer.flush()
|
|
2918
|
-
if item:
|
|
2919
|
-
_inject(item["prompt"])
|
|
2920
|
-
continue
|
|
2898
|
+
try:
|
|
2899
|
+
event = json.loads(line)
|
|
2900
|
+
except json.JSONDecodeError:
|
|
2901
|
+
return False
|
|
2921
2902
|
|
|
2922
|
-
|
|
2903
|
+
event_type = event.get("type", "")
|
|
2904
|
+
|
|
2905
|
+
if event_type == "assistant" and event.get("message", {}).get("content"):
|
|
2906
|
+
for block in event["message"]["content"]:
|
|
2907
|
+
if block.get("type") == "tool_use":
|
|
2908
|
+
# v7.7 Gap 7.3 — wire before_tool in the live
|
|
2909
|
+
# stream. Desktop already calls onBeforeToolCall
|
|
2910
|
+
# before onToolCall; Brain's stream was only
|
|
2911
|
+
# calling on_tool_call, silently skipping every
|
|
2912
|
+
# before_tool rule the map declared.
|
|
2913
|
+
enforcer.on_tool_call_before(block.get("name", ""), block.get("input"))
|
|
2914
|
+
enforcer.on_tool_call(block.get("name", ""), block.get("input"))
|
|
2915
|
+
elif event_type == "content_block_start":
|
|
2916
|
+
cb = event.get("content_block", {})
|
|
2917
|
+
if cb.get("type") == "tool_use":
|
|
2918
|
+
enforcer.on_tool_call_before(cb.get("name", ""), cb.get("input"))
|
|
2919
|
+
enforcer.on_tool_call(cb.get("name", ""), cb.get("input"))
|
|
2920
|
+
|
|
2921
|
+
if event_type == "assistant" and not waiting_for_injection_response:
|
|
2922
|
+
msg = event.get("message", {})
|
|
2923
|
+
for block in msg.get("content", []):
|
|
2924
|
+
if block.get("type") == "text":
|
|
2925
|
+
collected_text.append(block["text"])
|
|
2926
|
+
# R16 — probe each assistant text block as it arrives
|
|
2927
|
+
# so a declared-done line is caught on the same turn
|
|
2928
|
+
# rather than only at session end.
|
|
2929
|
+
try:
|
|
2930
|
+
enforcer.on_assistant_text(block["text"])
|
|
2931
|
+
except Exception as _r16_exc: # noqa: BLE001
|
|
2932
|
+
_logger.warning("on_assistant_text failed: %s", _r16_exc)
|
|
2933
|
+
try:
|
|
2934
|
+
enforcer.on_assistant_text_r17(block["text"])
|
|
2935
|
+
except Exception as _r17_exc: # noqa: BLE001
|
|
2936
|
+
_logger.warning("on_assistant_text_r17 failed: %s", _r17_exc)
|
|
2937
|
+
|
|
2938
|
+
if event_type == "result":
|
|
2939
|
+
if waiting_for_injection_response:
|
|
2940
|
+
waiting_for_injection_response = False
|
|
2941
|
+
_logger.info("INJECTION_RESPONSE received")
|
|
2923
2942
|
item = enforcer.flush()
|
|
2924
2943
|
if item:
|
|
2925
2944
|
_inject(item["prompt"])
|
|
2926
|
-
|
|
2927
|
-
|
|
2945
|
+
return False
|
|
2946
|
+
|
|
2947
|
+
enforcer.check_periodic()
|
|
2948
|
+
item = enforcer.flush()
|
|
2949
|
+
if item:
|
|
2950
|
+
_inject(item["prompt"])
|
|
2951
|
+
else:
|
|
2952
|
+
_logger.info("TURN_END — no pending enforcements, done")
|
|
2953
|
+
return True
|
|
2954
|
+
|
|
2955
|
+
return False
|
|
2956
|
+
|
|
2957
|
+
try:
|
|
2958
|
+
while True:
|
|
2959
|
+
if time.time() - start_time > timeout:
|
|
2960
|
+
timed_out = True
|
|
2961
|
+
_kill_proc("TIMEOUT")
|
|
2962
|
+
break
|
|
2963
|
+
|
|
2964
|
+
try:
|
|
2965
|
+
raw_line = stdout_lines.get(timeout=0.2)
|
|
2966
|
+
except queue.Empty:
|
|
2967
|
+
if stdout_closed and proc.poll() is not None:
|
|
2928
2968
|
break
|
|
2969
|
+
if time.time() - last_periodic_check > 30:
|
|
2970
|
+
enforcer.check_periodic()
|
|
2971
|
+
last_periodic_check = time.time()
|
|
2972
|
+
continue
|
|
2929
2973
|
|
|
2930
|
-
if
|
|
2931
|
-
|
|
2932
|
-
|
|
2974
|
+
if raw_line is None:
|
|
2975
|
+
stdout_closed = True
|
|
2976
|
+
break
|
|
2977
|
+
|
|
2978
|
+
if _handle_stdout_line(raw_line):
|
|
2979
|
+
break
|
|
2933
2980
|
|
|
2934
2981
|
except Exception as e:
|
|
2935
2982
|
_logger.error("EXCEPTION: %s", e)
|
|
2936
2983
|
finally:
|
|
2937
|
-
end_prompts = enforcer.get_end_prompts()
|
|
2984
|
+
end_prompts = [] if timed_out else enforcer.get_end_prompts()
|
|
2938
2985
|
for ep in end_prompts:
|
|
2939
2986
|
try:
|
|
2940
2987
|
_inject(ep)
|
|
2941
2988
|
deadline = time.time() + 15
|
|
2942
|
-
|
|
2989
|
+
while time.time() <= deadline:
|
|
2990
|
+
try:
|
|
2991
|
+
raw_line = stdout_lines.get(timeout=0.2)
|
|
2992
|
+
except queue.Empty:
|
|
2993
|
+
if proc.poll() is not None:
|
|
2994
|
+
break
|
|
2995
|
+
continue
|
|
2996
|
+
if raw_line is None:
|
|
2997
|
+
stdout_closed = True
|
|
2998
|
+
break
|
|
2943
2999
|
if time.time() > deadline:
|
|
2944
3000
|
_logger.warning("END_PROMPT timeout")
|
|
2945
3001
|
break
|
|
@@ -2968,10 +3024,18 @@ def run_with_enforcement(
|
|
|
2968
3024
|
except subprocess.TimeoutExpired:
|
|
2969
3025
|
proc.kill()
|
|
2970
3026
|
|
|
3027
|
+
stdout_thread.join(timeout=2)
|
|
2971
3028
|
stderr_thread.join(timeout=2)
|
|
2972
3029
|
final_text = "\n".join(collected_text)
|
|
2973
3030
|
final_stderr = "".join(stderr_lines)
|
|
3031
|
+
returncode = proc.returncode
|
|
3032
|
+
if timed_out:
|
|
3033
|
+
returncode = 124
|
|
3034
|
+
timeout_msg = f"NEXO enforcement timeout after {timeout}s"
|
|
3035
|
+
final_stderr = f"{final_stderr.rstrip()}\n{timeout_msg}".lstrip()
|
|
3036
|
+
elif returncode is None:
|
|
3037
|
+
returncode = 0
|
|
2974
3038
|
|
|
2975
3039
|
return subprocess.CompletedProcess(
|
|
2976
|
-
stream_cmd,
|
|
3040
|
+
stream_cmd, returncode, final_text, final_stderr
|
|
2977
3041
|
)
|
package/src/plugins/update.py
CHANGED
|
@@ -147,6 +147,18 @@ DATA_DIR = paths.data_dir()
|
|
|
147
147
|
BACKUP_BASE = paths.backups_dir()
|
|
148
148
|
TECHNICAL_BACKUP_KEEP = 5
|
|
149
149
|
|
|
150
|
+
|
|
151
|
+
def _env_int(name: str, default: int) -> int:
|
|
152
|
+
try:
|
|
153
|
+
return int(os.environ.get(name, str(default)))
|
|
154
|
+
except (TypeError, ValueError):
|
|
155
|
+
return default
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
BACKUP_MAX_BYTES = _env_int("NEXO_BACKUP_MAX_BYTES", 50 * 1024 * 1024 * 1024)
|
|
159
|
+
BACKUP_MIN_FREE_BYTES = _env_int("NEXO_BACKUP_MIN_FREE_BYTES", 5 * 1024 * 1024 * 1024)
|
|
160
|
+
LOCAL_CONTEXT_MAX_BACKUP_BYTES = _env_int("NEXO_LOCAL_CONTEXT_MAX_BACKUP_BYTES", 2 * 1024 * 1024 * 1024)
|
|
161
|
+
|
|
150
162
|
# In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
|
|
151
163
|
_PACKAGED_INSTALL = _is_packaged_install()
|
|
152
164
|
REPO_DIR = CODE_ROOT if _PACKAGED_INSTALL else _REPO_CANDIDATE
|
|
@@ -186,6 +198,55 @@ def _rotate_backup_family(prefix: str, keep: int = TECHNICAL_BACKUP_KEEP) -> int
|
|
|
186
198
|
return removed
|
|
187
199
|
|
|
188
200
|
|
|
201
|
+
def _run_runtime_backup_prune() -> None:
|
|
202
|
+
script = SRC_DIR / "scripts" / "prune_runtime_backups.py"
|
|
203
|
+
if not script.is_file():
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
subprocess.run(
|
|
207
|
+
[
|
|
208
|
+
sys.executable,
|
|
209
|
+
str(script),
|
|
210
|
+
"--root",
|
|
211
|
+
str(BACKUP_BASE),
|
|
212
|
+
"--apply",
|
|
213
|
+
"--max-bytes",
|
|
214
|
+
str(BACKUP_MAX_BYTES),
|
|
215
|
+
],
|
|
216
|
+
capture_output=True,
|
|
217
|
+
text=True,
|
|
218
|
+
timeout=120,
|
|
219
|
+
)
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _backup_free_bytes() -> int | None:
|
|
225
|
+
try:
|
|
226
|
+
usage = shutil.disk_usage(BACKUP_BASE if BACKUP_BASE.exists() else BACKUP_BASE.parent)
|
|
227
|
+
return int(usage.free)
|
|
228
|
+
except Exception:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _backup_space_error() -> str | None:
|
|
233
|
+
_run_runtime_backup_prune()
|
|
234
|
+
free = _backup_free_bytes()
|
|
235
|
+
if free is not None and free < BACKUP_MIN_FREE_BYTES:
|
|
236
|
+
return (
|
|
237
|
+
"free disk below NEXO backup safety floor after automatic cleanup "
|
|
238
|
+
f"({free}B < {BACKUP_MIN_FREE_BYTES}B)"
|
|
239
|
+
)
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _should_include_local_context_backup(path: Path) -> bool:
|
|
244
|
+
try:
|
|
245
|
+
return path.stat().st_size <= LOCAL_CONTEXT_MAX_BACKUP_BYTES
|
|
246
|
+
except OSError:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
|
|
189
250
|
def _venv_python_path(runtime_root: Path | None = None) -> Path:
|
|
190
251
|
root = runtime_root or _nexo_home()
|
|
191
252
|
if sys.platform == "win32":
|
|
@@ -522,7 +583,7 @@ def _backup_databases() -> tuple[str, str | None]:
|
|
|
522
583
|
|
|
523
584
|
db_files = list(DATA_DIR.glob("*.db")) if DATA_DIR.is_dir() else []
|
|
524
585
|
local_context_db = paths.memory_dir() / "local-context.db"
|
|
525
|
-
if local_context_db.is_file():
|
|
586
|
+
if local_context_db.is_file() and _should_include_local_context_backup(local_context_db):
|
|
526
587
|
db_files.append(local_context_db)
|
|
527
588
|
# Also check NEXO_HOME root for legacy db location
|
|
528
589
|
db_files += [f for f in NEXO_HOME.glob("*.db") if f.is_file()]
|
|
@@ -534,6 +595,10 @@ def _backup_databases() -> tuple[str, str | None]:
|
|
|
534
595
|
if not db_files:
|
|
535
596
|
return str(backup_dir), None # No DBs to backup, not an error
|
|
536
597
|
|
|
598
|
+
space_err = _backup_space_error()
|
|
599
|
+
if space_err:
|
|
600
|
+
return str(backup_dir), space_err
|
|
601
|
+
|
|
537
602
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
538
603
|
|
|
539
604
|
for db_file in db_files:
|
|
@@ -17,10 +17,21 @@ LOCAL_CONTEXT_RETENTION_HOURS="${NEXO_LOCAL_CONTEXT_BACKUP_RETENTION_HOURS:-24}"
|
|
|
17
17
|
LOCAL_CONTEXT_KEEP_LAST="${NEXO_LOCAL_CONTEXT_BACKUP_KEEP_LAST:-2}"
|
|
18
18
|
BUSY_TIMEOUT_MS="${NEXO_BACKUP_BUSY_TIMEOUT_MS:-5000}"
|
|
19
19
|
RECENT_BACKUP_HOURS="${NEXO_BACKUP_RECENT_BACKUP_HOURS:-6}"
|
|
20
|
+
BACKUP_MAX_BYTES="${NEXO_BACKUP_MAX_BYTES:-53687091200}"
|
|
21
|
+
MIN_FREE_BYTES="${NEXO_BACKUP_MIN_FREE_BYTES:-5368709120}"
|
|
22
|
+
LOCAL_CONTEXT_MAX_BACKUP_BYTES="${NEXO_LOCAL_CONTEXT_MAX_BACKUP_BYTES:-2147483648}"
|
|
20
23
|
|
|
21
24
|
mkdir -p "$BACKUP_DIR" "$WEEKLY_DIR"
|
|
22
25
|
|
|
23
26
|
cleanup_backups() {
|
|
27
|
+
PRUNER="$NEXO_HOME/core/scripts/prune_runtime_backups.py"
|
|
28
|
+
if [ ! -f "$PRUNER" ]; then
|
|
29
|
+
PRUNER="$(dirname "$0")/prune_runtime_backups.py"
|
|
30
|
+
fi
|
|
31
|
+
if [ -f "$PRUNER" ]; then
|
|
32
|
+
python3 "$PRUNER" --root "$BACKUP_DIR" --apply --max-bytes "$BACKUP_MAX_BYTES" >/dev/null 2>&1 || true
|
|
33
|
+
fi
|
|
34
|
+
|
|
24
35
|
python3 - "$BACKUP_DIR" "$RETENTION_HOURS" "$KEEP_LAST" "$FAMILY_KEEP_LAST" "$LOCAL_CONTEXT_RETENTION_HOURS" "$LOCAL_CONTEXT_KEEP_LAST" <<'PY'
|
|
25
36
|
from __future__ import annotations
|
|
26
37
|
|
|
@@ -110,7 +121,30 @@ has_recent_local_context_backup() {
|
|
|
110
121
|
find "$BACKUP_DIR" -maxdepth 1 -name "local-context-*.db" -mmin "-$((RECENT_BACKUP_HOURS * 60))" -print -quit | grep -q .
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
available_backup_bytes() {
|
|
125
|
+
df -Pk "$BACKUP_DIR" 2>/dev/null | awk 'NR==2 { printf "%.0f\n", $4 * 1024 }'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
file_size_bytes() {
|
|
129
|
+
wc -c < "$1" 2>/dev/null | tr -d ' '
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
ensure_backup_space() {
|
|
133
|
+
cleanup_backups
|
|
134
|
+
avail="$(available_backup_bytes)"
|
|
135
|
+
if [ -n "$avail" ] && [ "$avail" -lt "$MIN_FREE_BYTES" ]; then
|
|
136
|
+
echo "NEXO backup skipped: free disk below safety floor after self-cleanup (${avail}B < ${MIN_FREE_BYTES}B)" >&2
|
|
137
|
+
return 1
|
|
138
|
+
fi
|
|
139
|
+
return 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if ! ensure_backup_space; then
|
|
143
|
+
if has_recent_backup; then
|
|
144
|
+
exit 0
|
|
145
|
+
fi
|
|
146
|
+
exit 1
|
|
147
|
+
fi
|
|
114
148
|
|
|
115
149
|
# Hourly backup
|
|
116
150
|
TIMESTAMP=$(date +%Y-%m-%d-%H%M)
|
|
@@ -148,7 +182,12 @@ if [ -f "$LOCAL_CONTEXT_DB" ]; then
|
|
|
148
182
|
LOCAL_CONTEXT_BACKUP_FILE="$BACKUP_DIR/local-context-$TIMESTAMP.db"
|
|
149
183
|
LOCAL_CONTEXT_TMP_BACKUP="$LOCAL_CONTEXT_BACKUP_FILE.tmp.$$"
|
|
150
184
|
rm -f "$LOCAL_CONTEXT_TMP_BACKUP"
|
|
151
|
-
|
|
185
|
+
LOCAL_CONTEXT_SIZE="$(file_size_bytes "$LOCAL_CONTEXT_DB")"
|
|
186
|
+
if [ -n "$LOCAL_CONTEXT_SIZE" ] && [ "$LOCAL_CONTEXT_SIZE" -gt "$LOCAL_CONTEXT_MAX_BACKUP_BYTES" ]; then
|
|
187
|
+
echo "NEXO local memory backup skipped: local-context.db exceeds automatic backup cap (${LOCAL_CONTEXT_SIZE}B > ${LOCAL_CONTEXT_MAX_BACKUP_BYTES}B)"
|
|
188
|
+
elif ! ensure_backup_space; then
|
|
189
|
+
echo "NEXO local memory backup skipped: free disk below safety floor"
|
|
190
|
+
elif [ -f "$LOCK_FILE" ] && find "$LOCK_FILE" -mmin -30 -print -quit | grep -q . && has_recent_local_context_backup; then
|
|
152
191
|
echo "NEXO local memory backup skipped: index is active and a recent local backup exists"
|
|
153
192
|
elif sqlite3 -cmd ".timeout $BUSY_TIMEOUT_MS" "$LOCAL_CONTEXT_DB" <<SQL
|
|
154
193
|
PRAGMA busy_timeout=$BUSY_TIMEOUT_MS;
|
|
@@ -22,7 +22,7 @@ Class taxonomy (prefix-based) and retention policy:
|
|
|
22
22
|
Prefixes:
|
|
23
23
|
pre-update-*, pre-autoupdate-*, pre-backfill-owner-*,
|
|
24
24
|
pre-runtime-sync-*, pre-sleep-wrapper-*, pre-obs-clean-*,
|
|
25
|
-
pre-import-user-data-*, pre-backfill-*,
|
|
25
|
+
pre-import-user-data-*, pre-backfill-*, pre-heal-*, pre-recover-*,
|
|
26
26
|
code-tree-*, runtime-tree-*,
|
|
27
27
|
app-install-*, app-reinstall-*, desktop-local-install-*,
|
|
28
28
|
packaged-code-f06-conflicts-*, legacy-shim-conflicts-*,
|
|
@@ -82,6 +82,8 @@ TECHNICAL_PREFIXES = (
|
|
|
82
82
|
"pre-sleep-wrapper-",
|
|
83
83
|
"pre-obs-clean-",
|
|
84
84
|
"pre-import-user-data-",
|
|
85
|
+
"pre-heal-",
|
|
86
|
+
"pre-recover-",
|
|
85
87
|
"pre-freshinstall-",
|
|
86
88
|
"code-tree-",
|
|
87
89
|
"runtime-tree-",
|
|
@@ -108,8 +110,11 @@ TECHNICAL_PREFIXES = (
|
|
|
108
110
|
PROTECTED_NAMES = {"shopify-backups", "weekly"}
|
|
109
111
|
# Hourly DB dumps at the root of runtime/backups — managed by nexo-backup.sh.
|
|
110
112
|
HOURLY_DB_RE = re.compile(r"^nexo-\d{4}-\d{2}-\d{2}-\d{4}\.db$")
|
|
113
|
+
LOCAL_CONTEXT_DB_RE = re.compile(r"^local-context-\d{4}-\d{2}-\d{2}-\d{4}(\d{2})?\.db$")
|
|
114
|
+
TEMPORARY_RE = re.compile(r"(^|.*[.])tmp([.-].*)?$|.*\.tmp\..*|.*-journal$|.*\.db-(wal|shm)$")
|
|
111
115
|
# Big ad-hoc DB files at the root — rare, include for reporting but never auto-prune.
|
|
112
116
|
ROOT_DB_RE = re.compile(r"^(pre-obs-clean|pre-sleep-wrapper-apply|pre-.*)-\d{4}-\d{2}-\d{2}-\d{4}\.db$")
|
|
117
|
+
DEFAULT_MAX_BYTES = 50 * 1024 * 1024 * 1024
|
|
113
118
|
|
|
114
119
|
# Timestamp patterns embedded in directory names.
|
|
115
120
|
TS_PATTERNS = (
|
|
@@ -147,6 +152,10 @@ def classify(name: str) -> tuple[str, str] | None:
|
|
|
147
152
|
"""Return (class, family) or None if the entry should be ignored."""
|
|
148
153
|
if name in PROTECTED_NAMES:
|
|
149
154
|
return ("BUSINESS", name)
|
|
155
|
+
if TEMPORARY_RE.match(name):
|
|
156
|
+
return ("TEMPORARY", "temporary")
|
|
157
|
+
if LOCAL_CONTEXT_DB_RE.match(name):
|
|
158
|
+
return ("LOCAL_CONTEXT_DB", "local-context")
|
|
150
159
|
if HOURLY_DB_RE.match(name):
|
|
151
160
|
return ("HOURLY_DB", "nexo-db")
|
|
152
161
|
if ROOT_DB_RE.match(name):
|
|
@@ -181,6 +190,30 @@ def human_size(n: int) -> str:
|
|
|
181
190
|
return f"{n:.1f}P"
|
|
182
191
|
|
|
183
192
|
|
|
193
|
+
def parse_size_bytes(value: str | int | None, *, default: int = DEFAULT_MAX_BYTES) -> int:
|
|
194
|
+
if value is None or value == "":
|
|
195
|
+
return default
|
|
196
|
+
if isinstance(value, int):
|
|
197
|
+
return max(0, value)
|
|
198
|
+
raw = str(value).strip().lower()
|
|
199
|
+
if not raw:
|
|
200
|
+
return default
|
|
201
|
+
multiplier = 1
|
|
202
|
+
if raw[-1:] in {"k", "m", "g", "t"}:
|
|
203
|
+
unit = raw[-1]
|
|
204
|
+
raw = raw[:-1].strip()
|
|
205
|
+
multiplier = {
|
|
206
|
+
"k": 1024,
|
|
207
|
+
"m": 1024 ** 2,
|
|
208
|
+
"g": 1024 ** 3,
|
|
209
|
+
"t": 1024 ** 4,
|
|
210
|
+
}[unit]
|
|
211
|
+
try:
|
|
212
|
+
return max(0, int(float(raw) * multiplier))
|
|
213
|
+
except ValueError:
|
|
214
|
+
return default
|
|
215
|
+
|
|
216
|
+
|
|
184
217
|
def gather_entries(backups_root: Path) -> list[dict]:
|
|
185
218
|
items: list[dict] = []
|
|
186
219
|
for entry in backups_root.iterdir():
|
|
@@ -195,7 +228,10 @@ def gather_entries(backups_root: Path) -> list[dict]:
|
|
|
195
228
|
ts = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
|
|
196
229
|
except OSError:
|
|
197
230
|
ts = None
|
|
198
|
-
|
|
231
|
+
try:
|
|
232
|
+
size = dir_size_bytes(entry) if entry.is_dir() else entry.stat().st_size
|
|
233
|
+
except OSError:
|
|
234
|
+
continue
|
|
199
235
|
items.append({
|
|
200
236
|
"name": name,
|
|
201
237
|
"path": str(entry),
|
|
@@ -214,11 +250,16 @@ def plan_prunes(
|
|
|
214
250
|
n_recent: int,
|
|
215
251
|
window_days: int,
|
|
216
252
|
only: str | None,
|
|
253
|
+
max_bytes: int,
|
|
254
|
+
tmp_ttl_seconds: int,
|
|
255
|
+
local_context_keep: int,
|
|
256
|
+
hourly_keep: int,
|
|
217
257
|
) -> tuple[list[dict], list[dict]]:
|
|
218
|
-
"""Return (to_delete, to_keep)
|
|
258
|
+
"""Return (to_delete, to_keep) for product-generated backup artifacts."""
|
|
219
259
|
now = datetime.now(tz=timezone.utc)
|
|
220
260
|
to_delete: list[dict] = []
|
|
221
261
|
to_keep: list[dict] = []
|
|
262
|
+
delete_ids: set[int] = set()
|
|
222
263
|
by_family: dict[str, list[dict]] = {}
|
|
223
264
|
for it in items:
|
|
224
265
|
if it["class"] != "TECHNICAL":
|
|
@@ -245,8 +286,75 @@ def plan_prunes(
|
|
|
245
286
|
seen_months.add(ym)
|
|
246
287
|
to_keep.append(it)
|
|
247
288
|
continue
|
|
248
|
-
|
|
289
|
+
if id(it) not in delete_ids:
|
|
290
|
+
to_delete.append(it)
|
|
291
|
+
delete_ids.add(id(it))
|
|
249
292
|
to_keep.extend(keep_recent)
|
|
293
|
+
|
|
294
|
+
for it in items:
|
|
295
|
+
if it["class"] != "TEMPORARY":
|
|
296
|
+
continue
|
|
297
|
+
ts = it["ts"]
|
|
298
|
+
age_seconds = (now - ts).total_seconds() if ts else 10_000_000
|
|
299
|
+
if age_seconds >= tmp_ttl_seconds:
|
|
300
|
+
if id(it) not in delete_ids:
|
|
301
|
+
to_delete.append(it)
|
|
302
|
+
delete_ids.add(id(it))
|
|
303
|
+
else:
|
|
304
|
+
to_keep.append(it)
|
|
305
|
+
|
|
306
|
+
for klass, keep_count in (("LOCAL_CONTEXT_DB", local_context_keep), ("HOURLY_DB", hourly_keep)):
|
|
307
|
+
group = [it for it in items if it["class"] == klass]
|
|
308
|
+
group.sort(key=lambda x: (x["ts"] or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
|
|
309
|
+
for it in group[:max(0, keep_count)]:
|
|
310
|
+
to_keep.append(it)
|
|
311
|
+
for it in group[max(0, keep_count):]:
|
|
312
|
+
if id(it) not in delete_ids:
|
|
313
|
+
to_delete.append(it)
|
|
314
|
+
delete_ids.add(id(it))
|
|
315
|
+
|
|
316
|
+
if max_bytes > 0:
|
|
317
|
+
total_after_planned = sum(i["size"] for i in items if id(i) not in delete_ids)
|
|
318
|
+
if total_after_planned > max_bytes:
|
|
319
|
+
protected_keep_ids: set[int] = set()
|
|
320
|
+
by_budget_family: dict[tuple[str, str], list[dict]] = {}
|
|
321
|
+
for it in items:
|
|
322
|
+
by_budget_family.setdefault((it["class"], it["family"]), []).append(it)
|
|
323
|
+
for (klass, _family), group in by_budget_family.items():
|
|
324
|
+
if klass not in {"TECHNICAL", "LOCAL_CONTEXT_DB", "HOURLY_DB", "TEMPORARY"}:
|
|
325
|
+
continue
|
|
326
|
+
min_keep = 0
|
|
327
|
+
if klass == "HOURLY_DB":
|
|
328
|
+
min_keep = max(1, hourly_keep)
|
|
329
|
+
elif klass == "LOCAL_CONTEXT_DB":
|
|
330
|
+
min_keep = max(0, local_context_keep)
|
|
331
|
+
group.sort(key=lambda x: (x["ts"] or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
|
|
332
|
+
for it in group[:min_keep]:
|
|
333
|
+
protected_keep_ids.add(id(it))
|
|
334
|
+
|
|
335
|
+
budget_candidates = [
|
|
336
|
+
it for it in items
|
|
337
|
+
if id(it) not in delete_ids
|
|
338
|
+
and id(it) not in protected_keep_ids
|
|
339
|
+
and it["class"] in {"TECHNICAL", "LOCAL_CONTEXT_DB", "HOURLY_DB", "TEMPORARY"}
|
|
340
|
+
]
|
|
341
|
+
budget_candidates.sort(
|
|
342
|
+
key=lambda x: (
|
|
343
|
+
x["ts"] or datetime.min.replace(tzinfo=timezone.utc),
|
|
344
|
+
-int(x["size"] or 0),
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
for it in budget_candidates:
|
|
348
|
+
if total_after_planned <= max_bytes:
|
|
349
|
+
break
|
|
350
|
+
to_delete.append(it)
|
|
351
|
+
delete_ids.add(id(it))
|
|
352
|
+
total_after_planned -= int(it["size"] or 0)
|
|
353
|
+
|
|
354
|
+
keep_ids = {id(i) for i in to_keep}
|
|
355
|
+
for it in items:
|
|
356
|
+
if id(it) not in delete_ids and id(it) not in keep_ids:
|
|
357
|
+
to_keep.append(it)
|
|
250
358
|
return to_delete, to_keep
|
|
251
359
|
|
|
252
360
|
|
|
@@ -260,13 +368,20 @@ def run(args: argparse.Namespace) -> int:
|
|
|
260
368
|
biz_items = [i for i in items if i["class"] == "BUSINESS"]
|
|
261
369
|
hourly_items = [i for i in items if i["class"] == "HOURLY_DB"]
|
|
262
370
|
root_db_items = [i for i in items if i["class"] == "ROOT_DB"]
|
|
371
|
+
local_context_items = [i for i in items if i["class"] == "LOCAL_CONTEXT_DB"]
|
|
372
|
+
temporary_items = [i for i in items if i["class"] == "TEMPORARY"]
|
|
263
373
|
unknown_items = [i for i in items if i["class"] == "UNKNOWN"]
|
|
374
|
+
max_bytes = parse_size_bytes(args.max_bytes)
|
|
264
375
|
|
|
265
376
|
to_delete, to_keep = plan_prunes(
|
|
266
377
|
items,
|
|
267
378
|
n_recent=args.recent,
|
|
268
379
|
window_days=args.window_days,
|
|
269
380
|
only=args.only,
|
|
381
|
+
max_bytes=max_bytes,
|
|
382
|
+
tmp_ttl_seconds=max(0, args.tmp_ttl_minutes) * 60,
|
|
383
|
+
local_context_keep=max(0, args.local_context_keep),
|
|
384
|
+
hourly_keep=max(0, args.hourly_keep),
|
|
270
385
|
)
|
|
271
386
|
|
|
272
387
|
total_all = sum(i["size"] for i in items)
|
|
@@ -279,6 +394,11 @@ def run(args: argparse.Namespace) -> int:
|
|
|
279
394
|
"n_recent": args.recent,
|
|
280
395
|
"window_days": args.window_days,
|
|
281
396
|
"only": args.only,
|
|
397
|
+
"max_bytes": max_bytes,
|
|
398
|
+
"max_human": human_size(max_bytes),
|
|
399
|
+
"tmp_ttl_minutes": args.tmp_ttl_minutes,
|
|
400
|
+
"local_context_keep": args.local_context_keep,
|
|
401
|
+
"hourly_keep": args.hourly_keep,
|
|
282
402
|
},
|
|
283
403
|
"totals": {
|
|
284
404
|
"all_bytes": total_all,
|
|
@@ -291,6 +411,8 @@ def run(args: argparse.Namespace) -> int:
|
|
|
291
411
|
"technical": len(tech_items),
|
|
292
412
|
"business": len(biz_items),
|
|
293
413
|
"hourly_db": len(hourly_items),
|
|
414
|
+
"local_context_db": len(local_context_items),
|
|
415
|
+
"temporary": len(temporary_items),
|
|
294
416
|
"root_db": len(root_db_items),
|
|
295
417
|
"unknown": len(unknown_items),
|
|
296
418
|
},
|
|
@@ -315,9 +437,11 @@ def run(args: argparse.Namespace) -> int:
|
|
|
315
437
|
print(f" technical: {len(tech_items)}")
|
|
316
438
|
print(f" business: {len(biz_items)} (protected)")
|
|
317
439
|
print(f" hourly_db: {len(hourly_items)} (managed by nexo-backup.sh)")
|
|
440
|
+
print(f" local_context: {len(local_context_items)}")
|
|
441
|
+
print(f" temporary: {len(temporary_items)}")
|
|
318
442
|
print(f" root_db: {len(root_db_items)} (never auto-pruned)")
|
|
319
443
|
print(f" unknown: {len(unknown_items)}")
|
|
320
|
-
print(f" policy: keep {args.recent} most-recent + 1 per month within {args.window_days}d")
|
|
444
|
+
print(f" policy: keep {args.recent} most-recent + 1 per month within {args.window_days}d; hard-cap {human_size(max_bytes)}")
|
|
321
445
|
if args.only:
|
|
322
446
|
print(f" restricted to family: {args.only}")
|
|
323
447
|
print()
|
|
@@ -364,6 +488,10 @@ def main() -> int:
|
|
|
364
488
|
ap.add_argument("--recent", type=int, default=5, help="N most recent per family to always keep (default: 5)")
|
|
365
489
|
ap.add_argument("--window-days", type=int, default=90, help="month-spaced retention window (default: 90)")
|
|
366
490
|
ap.add_argument("--only", help="restrict to one technical family (e.g. 'pre-backfill-owner')")
|
|
491
|
+
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)")
|
|
492
|
+
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)")
|
|
493
|
+
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)")
|
|
494
|
+
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)")
|
|
367
495
|
args = ap.parse_args()
|
|
368
496
|
try:
|
|
369
497
|
return run(args)
|