nexo-brain 7.1.10 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/auto_update.py +239 -8
- package/src/cli.py +229 -0
- package/src/core_schedule_controls.py +66 -0
- package/src/doctor/providers/boot.py +190 -0
- package/src/evolution_cycle.py +4 -0
- package/src/guardian_runtime_config.py +98 -0
- package/src/hook_guardrails.py +142 -2
- package/src/hooks/g1_enforcer.py +305 -0
- package/src/hooks/post_tool_use.py +10 -1
- package/src/paths.py +10 -0
- package/src/plugins/adaptive_mode.py +26 -2
- package/src/plugins/recover.py +42 -10
- package/src/plugins/update.py +47 -17
- package/src/public_contribution.py +51 -5
- package/src/r34_identity_coherence.py +31 -8
- package/src/script_registry.py +14 -6
- package/src/scripts/nexo-watchdog.sh +7 -1
- package/src/scripts/prune_runtime_backups.py +376 -0
- package/src/tools_sessions.py +33 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
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/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.
|
|
21
|
+
Version `7.2.0` is the current packaged-runtime line. It is a minor release that consolidates three parallel workstreams into a single Guardian-active-by-default train. Block K roadmap closure: G1 enforcer active (PostToolUse nudge when the latest `nexo_task_open` returned `mode ∈ {defer, ask, verify}` and the operator has not executed the paired `next_action`), G3 SSH remote-write detector (catches `ssh host "cat > ..."`, `scp upload`, `rsync upload`, `sftp -b batch` and 10+ other patterns the local destructive gate never saw), new `src/guardian_runtime_config.py` resolver (env > `~/.nexo/personal/config/guardian-runtime-overrides.json` > default), and `_persist_guardian_hard_defaults` during `nexo update` so every non-ephemeral install gets Guardian in `hard` without manual env vars. F0.6 hardening wave: `nexo rollback f06` CLI with two-stage safe swap, `src/scripts/prune_runtime_backups.py` promoted from personal to core, the authoritative `docs/f06-layout-contract.md`, three new doctor boot-tier checks (`check_core_dev_packaged_install`, `check_dashboard_desktop_contract`, `check_f06_migration_consistency`), `scripts/nexo-migrate-nora.sh` + `scripts/f0-safe-apply-remote.sh` idempotent migration workflow, and v6.x→F0.6 rollback hardening tests. Adaptive weights: flips from "14-day calendar wait" to "14 days OR (≥200 samples AND ≥2 days)" + `_maybe_promote_adaptive_weights_empirically` auto-promotes shadow→learned during `nexo update` so mature installs feel the Cortex calibration immediately. Small-fixes batch: R34 `bool("unknown")==True` fix, `classify_scripts_dir` dedup by realpath, B10 module-level path constants lazy-evaluated in `public_contribution.py` / `tools_sessions.py` / `plugins/recover.py` / `plugins/update.py`, schedule override audit log at `runtime/logs/core-schedule-overrides.log`, `scripts/pre-release-verify.sh` + `docs/release-discipline.md`, and a pre-commit hook that blocks commits when `tool-enforcement-map.json` drifts from `src/plugins/`.
|
|
22
|
+
|
|
23
|
+
Previously in `7.1.10`: follow-up over v7.1.8 that shipped two rescue batches of WIP stashed aside during the v7.1.8 release window. First rescue: `src/autonomy_mandate.py` expanded the mandate-detection vocabulary (hazlo todo / no pares / estás al mando / te dejo al mando / sigue sin parar / haz el plan completo), added three honest flags on `MandateState` (`execute_until_blocker`, `suppress_mid_task_menus`, `revalidate_after_compaction`) with session filtering, wired post/pre-compact hooks that read those flags, surfaced them through protocol/workflow handlers and session payload, and introduced the new `src/checkpoint_policy.py` module with tests. Second rescue: `scripts/verify_release_readiness.py` gained a smoke-artifact contract pass that validates `release-contracts/smoke/v<version>.json` before any tag push, the release-final audit skill references the new contract, `src/hook_guardrails.py` + `src/hooks/post_tool_use.py` refine the post-tool protocol reminder path with a new contract test, and a couple of core prompts (task-close evidence, r14 correction learning) got wording polish.
|
|
22
24
|
|
|
23
25
|
Previously in `7.1.8`: batch release over v7.1.7 consolidating the Block K Guardian/Enforcer roadmap (auto-drain of stale `protocol_debt` rows, destructive-command pre-tool gate, `guard_check`-required gate, inline guard ack on `nexo_task_open`, Guardian Health in the morning briefing) with Block D hardcode cleanup (classifier-backed `backfill_task_owner`, migration v50 supersedes the duplicate NEXO-product learning pair, new semantic-hardcodes audit) and Block E product guards (LaunchAgent plist protection, agent-name fallbacks no longer leak the product identity, `francisco_emails` removed from the email-config dict export, `runner-health-check.py` + `nexo_personal_automation.py` promoted from personal to core).
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
5
|
+
"description": "NEXO Brain \u2014 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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"nexo-brain": "./bin/nexo-brain.js",
|
package/src/auto_update.py
CHANGED
|
@@ -389,25 +389,65 @@ def _write_last_check(data: dict):
|
|
|
389
389
|
|
|
390
390
|
|
|
391
391
|
def _sync_watchdog_hash_registry():
|
|
392
|
-
"""Keep the immutable-hash registry aligned with the installed watchdog script.
|
|
392
|
+
"""Keep the immutable-hash registry aligned with the installed watchdog script.
|
|
393
|
+
|
|
394
|
+
Folds any pre-F0.6 legacy registry at ``paths.legacy_watchdog_hashes_path()``
|
|
395
|
+
into the canonical F0.6 file and then drops the legacy artifact so a later
|
|
396
|
+
``ls ~/.nexo/scripts/.watchdog-hashes`` is empty (unless ``~/.nexo/scripts``
|
|
397
|
+
is itself a symlink to the canonical dir, in which case both paths already
|
|
398
|
+
point at the same inode).
|
|
399
|
+
"""
|
|
393
400
|
try:
|
|
394
401
|
watchdog_file = paths.core_scripts_dir() / "nexo-watchdog.sh"
|
|
395
402
|
if not watchdog_file.exists():
|
|
396
403
|
return
|
|
397
404
|
registry_file = paths.core_scripts_dir() / ".watchdog-hashes"
|
|
405
|
+
legacy_registry_file = paths.legacy_watchdog_hashes_path()
|
|
398
406
|
entries: dict[str, str] = {}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
407
|
+
|
|
408
|
+
def _load(path: Path) -> None:
|
|
409
|
+
if not path.exists():
|
|
410
|
+
return
|
|
411
|
+
try:
|
|
412
|
+
for line in path.read_text().splitlines():
|
|
413
|
+
if "|" not in line:
|
|
414
|
+
continue
|
|
415
|
+
filepath, expected = line.split("|", 1)
|
|
416
|
+
if filepath:
|
|
417
|
+
entries[filepath] = expected
|
|
418
|
+
except Exception as load_error:
|
|
419
|
+
_log(f"watchdog hash registry load error at {path}: {load_error}")
|
|
420
|
+
|
|
421
|
+
_load(registry_file)
|
|
422
|
+
|
|
423
|
+
legacy_is_canonical_alias = False
|
|
424
|
+
if legacy_registry_file.exists():
|
|
425
|
+
try:
|
|
426
|
+
legacy_is_canonical_alias = (
|
|
427
|
+
registry_file.exists()
|
|
428
|
+
and legacy_registry_file.resolve() == registry_file.resolve()
|
|
429
|
+
)
|
|
430
|
+
except Exception:
|
|
431
|
+
legacy_is_canonical_alias = False
|
|
432
|
+
if not legacy_is_canonical_alias:
|
|
433
|
+
_load(legacy_registry_file)
|
|
434
|
+
|
|
406
435
|
actual_hash = hashlib.sha256(watchdog_file.read_bytes()).hexdigest()
|
|
407
436
|
entries[str(watchdog_file)] = actual_hash
|
|
408
437
|
registry_file.write_text(
|
|
409
438
|
"\n".join(f"{filepath}|{digest}" for filepath, digest in sorted(entries.items())) + "\n"
|
|
410
439
|
)
|
|
440
|
+
|
|
441
|
+
if (
|
|
442
|
+
legacy_registry_file.exists()
|
|
443
|
+
and not legacy_is_canonical_alias
|
|
444
|
+
and legacy_registry_file != registry_file
|
|
445
|
+
):
|
|
446
|
+
try:
|
|
447
|
+
legacy_registry_file.unlink()
|
|
448
|
+
_log(f"watchdog hash registry migrated from legacy path: {legacy_registry_file}")
|
|
449
|
+
except Exception as unlink_error:
|
|
450
|
+
_log(f"watchdog hash registry legacy cleanup skipped: {unlink_error}")
|
|
411
451
|
except Exception as e:
|
|
412
452
|
_log(f"watchdog hash registry sync error: {e}")
|
|
413
453
|
|
|
@@ -4608,6 +4648,26 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
|
|
|
4608
4648
|
except Exception as exc:
|
|
4609
4649
|
actions.append(f"classifier-install-warning:{exc.__class__.__name__}")
|
|
4610
4650
|
|
|
4651
|
+
try:
|
|
4652
|
+
_emit_progress(progress_fn, "Persisting Guardian enforcement defaults...")
|
|
4653
|
+
persisted, persist_message = _persist_guardian_hard_defaults(dest)
|
|
4654
|
+
if persisted:
|
|
4655
|
+
actions.append("guardian-hard-persisted")
|
|
4656
|
+
if persist_message:
|
|
4657
|
+
actions.append(persist_message)
|
|
4658
|
+
except Exception as exc:
|
|
4659
|
+
actions.append(f"guardian-hard-persist-warning:{exc.__class__.__name__}")
|
|
4660
|
+
|
|
4661
|
+
try:
|
|
4662
|
+
_emit_progress(progress_fn, "Promoting adaptive weights if enough data...")
|
|
4663
|
+
promoted, promote_message = _maybe_promote_adaptive_weights_empirically(dest)
|
|
4664
|
+
if promoted:
|
|
4665
|
+
actions.append("adaptive-weights-promoted")
|
|
4666
|
+
if promote_message:
|
|
4667
|
+
actions.append(promote_message)
|
|
4668
|
+
except Exception as exc:
|
|
4669
|
+
actions.append(f"adaptive-weights-promote-warning:{exc.__class__.__name__}")
|
|
4670
|
+
|
|
4611
4671
|
_emit_progress(progress_fn, "Verifying runtime imports...")
|
|
4612
4672
|
verify = subprocess.run(
|
|
4613
4673
|
[sys.executable, "-c", "import server"],
|
|
@@ -4655,6 +4715,177 @@ def _emit_progress(progress_fn, message: str) -> None:
|
|
|
4655
4715
|
pass
|
|
4656
4716
|
|
|
4657
4717
|
|
|
4718
|
+
_GUARDIAN_PERSIST_HARD_DEFAULTS = {
|
|
4719
|
+
"G1_ENFORCER_ACTIVE": "hard",
|
|
4720
|
+
"G3_ENFORCE_DESTRUCTIVE": "hard",
|
|
4721
|
+
"G3_SSH_ENFORCE_REMOTE_WRITE": "hard",
|
|
4722
|
+
"G4_ENFORCE_GUARD_CHECK": "hard",
|
|
4723
|
+
}
|
|
4724
|
+
|
|
4725
|
+
|
|
4726
|
+
def _persist_guardian_hard_defaults(dest: Path) -> tuple[bool, str | None]:
|
|
4727
|
+
"""Write ``~/.nexo/config/guardian-runtime-overrides.json`` with the
|
|
4728
|
+
v7.2.0 "Guardian-on by default" matrix so operators get the active
|
|
4729
|
+
enforcer experience without setting env vars every shell.
|
|
4730
|
+
|
|
4731
|
+
Returns (persisted, message). ``persisted`` is True when the file was
|
|
4732
|
+
written or updated during this call; False when we left it alone
|
|
4733
|
+
(operator opt-out, or file already matches the defaults).
|
|
4734
|
+
|
|
4735
|
+
Contract:
|
|
4736
|
+
- Operator opt-out with ``NEXO_GUARDIAN_PERSIST_HARD=off`` in env
|
|
4737
|
+
at update time: leave the file untouched.
|
|
4738
|
+
- Ephemeral runtimes: skip.
|
|
4739
|
+
- Never overwrite a key the operator already customized to a
|
|
4740
|
+
non-default value (``shadow`` / ``off`` / anything else). Only
|
|
4741
|
+
fills missing keys or upgrades explicit defaults.
|
|
4742
|
+
"""
|
|
4743
|
+
if _is_ephemeral_runtime_install(dest):
|
|
4744
|
+
return False, "guardian-hard-persist-skipped:ephemeral"
|
|
4745
|
+
if os.environ.get("NEXO_GUARDIAN_PERSIST_HARD", "").strip().lower() == "off":
|
|
4746
|
+
return False, "guardian-hard-persist-skipped:operator-opt-out"
|
|
4747
|
+
|
|
4748
|
+
config_path = dest / "personal" / "config" / "guardian-runtime-overrides.json"
|
|
4749
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
4750
|
+
|
|
4751
|
+
current: dict[str, str] = {}
|
|
4752
|
+
if config_path.is_file():
|
|
4753
|
+
try:
|
|
4754
|
+
raw = json.loads(config_path.read_text())
|
|
4755
|
+
if isinstance(raw, dict):
|
|
4756
|
+
current = {str(k): str(v) for k, v in raw.items()}
|
|
4757
|
+
except Exception:
|
|
4758
|
+
# Malformed file — treat as empty. We write the fresh defaults
|
|
4759
|
+
# and keep the malformed copy for the operator to inspect.
|
|
4760
|
+
try:
|
|
4761
|
+
config_path.rename(
|
|
4762
|
+
config_path.with_suffix(
|
|
4763
|
+
config_path.suffix + f".broken-{int(time.time())}"
|
|
4764
|
+
)
|
|
4765
|
+
)
|
|
4766
|
+
except Exception:
|
|
4767
|
+
pass
|
|
4768
|
+
current = {}
|
|
4769
|
+
|
|
4770
|
+
original = dict(current)
|
|
4771
|
+
for key, default in _GUARDIAN_PERSIST_HARD_DEFAULTS.items():
|
|
4772
|
+
if key not in current:
|
|
4773
|
+
current[key] = default
|
|
4774
|
+
|
|
4775
|
+
if current == original and config_path.is_file():
|
|
4776
|
+
return False, None
|
|
4777
|
+
|
|
4778
|
+
try:
|
|
4779
|
+
tmp_path = config_path.with_suffix(config_path.suffix + ".tmp")
|
|
4780
|
+
tmp_path.write_text(json.dumps(current, indent=2, ensure_ascii=False) + "\n")
|
|
4781
|
+
tmp_path.replace(config_path)
|
|
4782
|
+
except Exception as exc: # pragma: no cover - filesystem failures
|
|
4783
|
+
return False, f"guardian-hard-persist-error:{exc.__class__.__name__}"
|
|
4784
|
+
|
|
4785
|
+
return True, None
|
|
4786
|
+
|
|
4787
|
+
|
|
4788
|
+
from datetime import datetime as _adaptive_datetime # isolated alias; other modules use ``time.time()``
|
|
4789
|
+
|
|
4790
|
+
_ADAPTIVE_PROMOTE_MIN_SAMPLES = 200
|
|
4791
|
+
_ADAPTIVE_PROMOTE_MIN_DAYS = 2
|
|
4792
|
+
|
|
4793
|
+
|
|
4794
|
+
def _maybe_promote_adaptive_weights_empirically(dest: Path) -> tuple[bool, str | None]:
|
|
4795
|
+
"""Promote ``shadow_weights`` to ``learned_weights`` during ``nexo update``
|
|
4796
|
+
when the install already has enough empirical signal.
|
|
4797
|
+
|
|
4798
|
+
Mirrors the empirical path in ``adaptive_mode.learn_weights`` (the
|
|
4799
|
+
background learner cron). Without this, operators upgrading to v7.2.0
|
|
4800
|
+
with a mature shadow dataset would still have to wait for the next
|
|
4801
|
+
learner tick to feel the calibration — which can be hours or days.
|
|
4802
|
+
|
|
4803
|
+
Conditions to promote:
|
|
4804
|
+
- adaptive_state.json exists and parses.
|
|
4805
|
+
- ``shadow_weights`` dict is present.
|
|
4806
|
+
- ``shadow_weights_samples >= 200`` AND the learner has been
|
|
4807
|
+
running for at least 2 calendar days
|
|
4808
|
+
(``learned_weights_first_date`` ≥ 2 days ago).
|
|
4809
|
+
- ``learned_weights`` is absent or empty (never overwrite an
|
|
4810
|
+
already-active set of weights).
|
|
4811
|
+
- Operator opt-out flag ``NEXO_ADAPTIVE_EMPIRICAL_PROMOTION=off``
|
|
4812
|
+
is NOT set. Same flag the learner already honours, so
|
|
4813
|
+
operators who opted out stay opted out everywhere.
|
|
4814
|
+
|
|
4815
|
+
Returns ``(promoted, message)``:
|
|
4816
|
+
- ``(True, None)``: weights were promoted, audit trail written.
|
|
4817
|
+
- ``(False, None)``: no-op (no shadow data, not enough samples,
|
|
4818
|
+
already promoted, or ephemeral install).
|
|
4819
|
+
- ``(False, "adaptive-promote-skipped:<reason>")``: explicit skip
|
|
4820
|
+
for the install log.
|
|
4821
|
+
"""
|
|
4822
|
+
if _is_ephemeral_runtime_install(dest):
|
|
4823
|
+
return False, "adaptive-promote-skipped:ephemeral"
|
|
4824
|
+
if os.environ.get("NEXO_ADAPTIVE_EMPIRICAL_PROMOTION", "").strip().lower() == "off":
|
|
4825
|
+
return False, "adaptive-promote-skipped:operator-opt-out"
|
|
4826
|
+
|
|
4827
|
+
state_path = dest / "personal" / "brain" / "adaptive_state.json"
|
|
4828
|
+
if not state_path.is_file():
|
|
4829
|
+
# Fall back to the legacy pre-F0.6 location for half-migrated
|
|
4830
|
+
# installs; the migrator will eventually move it.
|
|
4831
|
+
legacy = dest / "brain" / "adaptive_state.json"
|
|
4832
|
+
if legacy.is_file():
|
|
4833
|
+
state_path = legacy
|
|
4834
|
+
else:
|
|
4835
|
+
return False, None # no data yet — nothing to promote
|
|
4836
|
+
|
|
4837
|
+
try:
|
|
4838
|
+
raw = json.loads(state_path.read_text())
|
|
4839
|
+
except Exception:
|
|
4840
|
+
return False, "adaptive-promote-skipped:state-parse-error"
|
|
4841
|
+
if not isinstance(raw, dict):
|
|
4842
|
+
return False, "adaptive-promote-skipped:state-not-dict"
|
|
4843
|
+
|
|
4844
|
+
learned = raw.get("learned_weights")
|
|
4845
|
+
if isinstance(learned, dict) and learned:
|
|
4846
|
+
return False, None # already active — nothing to do
|
|
4847
|
+
|
|
4848
|
+
shadow = raw.get("shadow_weights")
|
|
4849
|
+
if not isinstance(shadow, dict) or not shadow:
|
|
4850
|
+
return False, None # no shadow data yet
|
|
4851
|
+
|
|
4852
|
+
samples = 0
|
|
4853
|
+
try:
|
|
4854
|
+
samples = int(raw.get("shadow_weights_samples", 0) or 0)
|
|
4855
|
+
except (TypeError, ValueError):
|
|
4856
|
+
samples = 0
|
|
4857
|
+
if samples < _ADAPTIVE_PROMOTE_MIN_SAMPLES:
|
|
4858
|
+
return False, None
|
|
4859
|
+
|
|
4860
|
+
first_date_raw = str(raw.get("learned_weights_first_date") or "").strip()
|
|
4861
|
+
if not first_date_raw:
|
|
4862
|
+
return False, None
|
|
4863
|
+
try:
|
|
4864
|
+
first_dt = _adaptive_datetime.strptime(first_date_raw[:19], "%Y-%m-%dT%H:%M:%S")
|
|
4865
|
+
except ValueError:
|
|
4866
|
+
return False, "adaptive-promote-skipped:first-date-parse-error"
|
|
4867
|
+
days_since_first = (_adaptive_datetime.utcnow() - first_dt).days
|
|
4868
|
+
if days_since_first < _ADAPTIVE_PROMOTE_MIN_DAYS:
|
|
4869
|
+
return False, None
|
|
4870
|
+
|
|
4871
|
+
# Promote — deep-copy so callers holding references to the original
|
|
4872
|
+
# dict don't see phantom mutations mid-update.
|
|
4873
|
+
raw["learned_weights"] = {str(k): v for k, v in shadow.items()}
|
|
4874
|
+
raw["learned_weights_date"] = _adaptive_datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
|
|
4875
|
+
raw["learned_weights_samples"] = samples
|
|
4876
|
+
raw["learned_weights_promoted_by"] = "nexo_update_empirical_v7_2_0"
|
|
4877
|
+
raw["learned_weights_promoted_at"] = _adaptive_datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
|
|
4878
|
+
|
|
4879
|
+
try:
|
|
4880
|
+
tmp_path = state_path.with_suffix(state_path.suffix + ".tmp")
|
|
4881
|
+
tmp_path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n")
|
|
4882
|
+
tmp_path.replace(state_path)
|
|
4883
|
+
except Exception as exc: # pragma: no cover - filesystem failures
|
|
4884
|
+
return False, f"adaptive-promote-error:{exc.__class__.__name__}"
|
|
4885
|
+
|
|
4886
|
+
return True, None
|
|
4887
|
+
|
|
4888
|
+
|
|
4658
4889
|
def _parse_runtime_init_payload(stdout: str) -> dict:
|
|
4659
4890
|
"""Extract the JSON payload emitted by the runtime init helper."""
|
|
4660
4891
|
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
package/src/cli.py
CHANGED
|
@@ -1168,6 +1168,199 @@ def _recover(args):
|
|
|
1168
1168
|
return _recover_cli_main(argv)
|
|
1169
1169
|
|
|
1170
1170
|
|
|
1171
|
+
def _rollback_f06(args):
|
|
1172
|
+
"""Revert the F0.6 layout migration using ``~/.nexo-pre-f06-snapshot``.
|
|
1173
|
+
|
|
1174
|
+
Safe two-stage swap: the current ``~/.nexo`` tree is renamed to a dated
|
|
1175
|
+
backup (``~/.nexo-rollback-backup-YYYYMMDDHHMMSS``) BEFORE the snapshot is
|
|
1176
|
+
restored in its place, so the operation never destroys state if it is
|
|
1177
|
+
interrupted mid-way. LaunchAgents are booted out before the swap and
|
|
1178
|
+
reloaded after (skip via ``--keep-agents-running``).
|
|
1179
|
+
"""
|
|
1180
|
+
import json as _json
|
|
1181
|
+
import shutil as _shutil
|
|
1182
|
+
import subprocess as _subprocess
|
|
1183
|
+
from datetime import datetime as _datetime
|
|
1184
|
+
from pathlib import Path as _Path
|
|
1185
|
+
|
|
1186
|
+
nexo_home = _Path(os.environ.get("NEXO_HOME", str(_Path.home() / ".nexo")))
|
|
1187
|
+
snapshot = _Path(str(nexo_home) + "-pre-f06-snapshot")
|
|
1188
|
+
now_stamp = _datetime.now().strftime("%Y%m%d%H%M%S")
|
|
1189
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
1190
|
+
emit_json = bool(getattr(args, "json", False))
|
|
1191
|
+
assume_yes = bool(getattr(args, "yes", False))
|
|
1192
|
+
keep_agents = bool(getattr(args, "keep_agents_running", False))
|
|
1193
|
+
|
|
1194
|
+
report: dict[str, object] = {
|
|
1195
|
+
"nexo_home": str(nexo_home),
|
|
1196
|
+
"snapshot": str(snapshot),
|
|
1197
|
+
"snapshot_exists": snapshot.exists(),
|
|
1198
|
+
"snapshot_is_dir": snapshot.is_dir() if snapshot.exists() else False,
|
|
1199
|
+
"dry_run": dry_run,
|
|
1200
|
+
"steps": [],
|
|
1201
|
+
"status": "planned",
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if not snapshot.exists() or not snapshot.is_dir():
|
|
1205
|
+
report["status"] = "error_no_snapshot"
|
|
1206
|
+
report["error"] = f"Pre-F0.6 snapshot not found at {snapshot}"
|
|
1207
|
+
if emit_json:
|
|
1208
|
+
print(_json.dumps(report, indent=2))
|
|
1209
|
+
else:
|
|
1210
|
+
print(f"ERROR: no pre-F0.6 snapshot at {snapshot}", file=sys.stderr)
|
|
1211
|
+
print(" Rollback is only available immediately after a migration.", file=sys.stderr)
|
|
1212
|
+
return 1
|
|
1213
|
+
|
|
1214
|
+
if nexo_home.exists():
|
|
1215
|
+
backup_target = _Path(str(nexo_home) + f"-rollback-backup-{now_stamp}")
|
|
1216
|
+
# Avoid collision if the operator retries in the same second.
|
|
1217
|
+
collision_suffix = 0
|
|
1218
|
+
while backup_target.exists():
|
|
1219
|
+
collision_suffix += 1
|
|
1220
|
+
backup_target = _Path(str(nexo_home) + f"-rollback-backup-{now_stamp}-{collision_suffix}")
|
|
1221
|
+
else:
|
|
1222
|
+
backup_target = None
|
|
1223
|
+
|
|
1224
|
+
agents_to_restart: list[_Path] = []
|
|
1225
|
+
if not keep_agents:
|
|
1226
|
+
agents_dir = _Path.home() / "Library" / "LaunchAgents"
|
|
1227
|
+
if agents_dir.is_dir():
|
|
1228
|
+
agents_to_restart = sorted(agents_dir.glob("com.nexo.*.plist"))
|
|
1229
|
+
|
|
1230
|
+
plan_steps: list[dict] = []
|
|
1231
|
+
if agents_to_restart:
|
|
1232
|
+
plan_steps.append({
|
|
1233
|
+
"step": "bootout_launchagents",
|
|
1234
|
+
"count": len(agents_to_restart),
|
|
1235
|
+
"samples": [p.name for p in agents_to_restart[:5]],
|
|
1236
|
+
})
|
|
1237
|
+
if backup_target is not None:
|
|
1238
|
+
plan_steps.append({
|
|
1239
|
+
"step": "move_current_nexo_home_to_backup",
|
|
1240
|
+
"from": str(nexo_home),
|
|
1241
|
+
"to": str(backup_target),
|
|
1242
|
+
})
|
|
1243
|
+
plan_steps.append({
|
|
1244
|
+
"step": "move_snapshot_to_nexo_home",
|
|
1245
|
+
"from": str(snapshot),
|
|
1246
|
+
"to": str(nexo_home),
|
|
1247
|
+
})
|
|
1248
|
+
if agents_to_restart:
|
|
1249
|
+
plan_steps.append({
|
|
1250
|
+
"step": "reload_launchagents",
|
|
1251
|
+
"count": len(agents_to_restart),
|
|
1252
|
+
})
|
|
1253
|
+
report["steps"] = plan_steps
|
|
1254
|
+
|
|
1255
|
+
if dry_run:
|
|
1256
|
+
report["status"] = "dry_run"
|
|
1257
|
+
if emit_json:
|
|
1258
|
+
print(_json.dumps(report, indent=2))
|
|
1259
|
+
else:
|
|
1260
|
+
print(f"nexo rollback f06 (DRY-RUN)")
|
|
1261
|
+
print(f" NEXO_HOME: {nexo_home}")
|
|
1262
|
+
print(f" snapshot: {snapshot}")
|
|
1263
|
+
if backup_target is not None:
|
|
1264
|
+
print(f" backup → {backup_target}")
|
|
1265
|
+
if agents_to_restart:
|
|
1266
|
+
print(f" LaunchAgents to restart: {len(agents_to_restart)}")
|
|
1267
|
+
print(" (no filesystem or launchctl changes were made)")
|
|
1268
|
+
return 0
|
|
1269
|
+
|
|
1270
|
+
if not assume_yes:
|
|
1271
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
1272
|
+
print("ERROR: interactive confirmation required. Pass --yes to proceed non-interactively.", file=sys.stderr)
|
|
1273
|
+
return 1
|
|
1274
|
+
print("This will replace the current NEXO_HOME with the pre-F0.6 snapshot.")
|
|
1275
|
+
print(f" Current ~/.nexo → {backup_target if backup_target else '(nothing to back up, current missing)'}")
|
|
1276
|
+
print(f" Restored from snapshot → {nexo_home}")
|
|
1277
|
+
if agents_to_restart:
|
|
1278
|
+
print(f" LaunchAgents affected: {len(agents_to_restart)}")
|
|
1279
|
+
answer = input("Type 'ROLLBACK' to proceed: ").strip()
|
|
1280
|
+
if answer != "ROLLBACK":
|
|
1281
|
+
print("Aborted — exact token not typed.")
|
|
1282
|
+
return 1
|
|
1283
|
+
|
|
1284
|
+
def _run_launchctl(cmd: list[str]) -> tuple[int, str]:
|
|
1285
|
+
try:
|
|
1286
|
+
proc = _subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
1287
|
+
return proc.returncode, (proc.stderr or "").strip()
|
|
1288
|
+
except _subprocess.TimeoutExpired as exc:
|
|
1289
|
+
return 124, f"timeout: {exc}"
|
|
1290
|
+
except FileNotFoundError:
|
|
1291
|
+
return 127, "launchctl not found"
|
|
1292
|
+
except Exception as exc: # noqa: BLE001 — launchctl errors are varied
|
|
1293
|
+
return 1, str(exc)
|
|
1294
|
+
|
|
1295
|
+
executed: list[dict] = []
|
|
1296
|
+
|
|
1297
|
+
if agents_to_restart and not keep_agents:
|
|
1298
|
+
for plist in agents_to_restart:
|
|
1299
|
+
rc, err = _run_launchctl(["launchctl", "unload", str(plist)])
|
|
1300
|
+
executed.append({"op": "unload", "plist": str(plist), "rc": rc, "err": err})
|
|
1301
|
+
|
|
1302
|
+
if backup_target is not None:
|
|
1303
|
+
try:
|
|
1304
|
+
nexo_home.rename(backup_target)
|
|
1305
|
+
executed.append({"op": "rename_home", "to": str(backup_target), "rc": 0})
|
|
1306
|
+
except OSError as exc:
|
|
1307
|
+
executed.append({"op": "rename_home", "to": str(backup_target), "rc": 1, "err": str(exc)})
|
|
1308
|
+
report["status"] = "error_rename_home"
|
|
1309
|
+
report["executed"] = executed
|
|
1310
|
+
if emit_json:
|
|
1311
|
+
print(_json.dumps(report, indent=2))
|
|
1312
|
+
else:
|
|
1313
|
+
print(f"ERROR: failed to move {nexo_home} → {backup_target}: {exc}", file=sys.stderr)
|
|
1314
|
+
print(" No changes to snapshot. Current NEXO_HOME is intact.", file=sys.stderr)
|
|
1315
|
+
return 1
|
|
1316
|
+
|
|
1317
|
+
try:
|
|
1318
|
+
snapshot.rename(nexo_home)
|
|
1319
|
+
executed.append({"op": "rename_snapshot", "to": str(nexo_home), "rc": 0})
|
|
1320
|
+
except OSError as exc:
|
|
1321
|
+
executed.append({"op": "rename_snapshot", "to": str(nexo_home), "rc": 1, "err": str(exc)})
|
|
1322
|
+
# Best-effort rollback of the backup rename so the user isn't left without NEXO_HOME.
|
|
1323
|
+
if backup_target is not None and backup_target.exists() and not nexo_home.exists():
|
|
1324
|
+
try:
|
|
1325
|
+
backup_target.rename(nexo_home)
|
|
1326
|
+
executed.append({"op": "rollback_rename_home", "rc": 0})
|
|
1327
|
+
except OSError as rexc:
|
|
1328
|
+
executed.append({"op": "rollback_rename_home", "rc": 1, "err": str(rexc)})
|
|
1329
|
+
report["status"] = "error_rename_snapshot"
|
|
1330
|
+
report["executed"] = executed
|
|
1331
|
+
if emit_json:
|
|
1332
|
+
print(_json.dumps(report, indent=2))
|
|
1333
|
+
else:
|
|
1334
|
+
print(f"ERROR: failed to move snapshot → NEXO_HOME: {exc}", file=sys.stderr)
|
|
1335
|
+
return 1
|
|
1336
|
+
|
|
1337
|
+
if agents_to_restart and not keep_agents:
|
|
1338
|
+
for plist in agents_to_restart:
|
|
1339
|
+
if not plist.exists():
|
|
1340
|
+
# The snapshot may not have the same plist set; skip silently.
|
|
1341
|
+
executed.append({"op": "load_skip_missing", "plist": str(plist), "rc": 0})
|
|
1342
|
+
continue
|
|
1343
|
+
rc, err = _run_launchctl(["launchctl", "load", str(plist)])
|
|
1344
|
+
executed.append({"op": "load", "plist": str(plist), "rc": rc, "err": err})
|
|
1345
|
+
|
|
1346
|
+
report["status"] = "done"
|
|
1347
|
+
report["executed"] = executed
|
|
1348
|
+
if backup_target is not None:
|
|
1349
|
+
report["backup_target"] = str(backup_target)
|
|
1350
|
+
|
|
1351
|
+
if emit_json:
|
|
1352
|
+
print(_json.dumps(report, indent=2))
|
|
1353
|
+
else:
|
|
1354
|
+
print("nexo rollback f06: done")
|
|
1355
|
+
print(f" restored: {nexo_home}")
|
|
1356
|
+
if backup_target is not None:
|
|
1357
|
+
print(f" prior home saved at: {backup_target}")
|
|
1358
|
+
print(f" review and rm -rf {backup_target} when you are sure.")
|
|
1359
|
+
if agents_to_restart:
|
|
1360
|
+
print(f" LaunchAgents reloaded: {len(agents_to_restart)}")
|
|
1361
|
+
return 0
|
|
1362
|
+
|
|
1363
|
+
|
|
1171
1364
|
def _update(args):
|
|
1172
1365
|
"""Update the installed runtime.
|
|
1173
1366
|
|
|
@@ -2699,6 +2892,37 @@ def main():
|
|
|
2699
2892
|
help="Skip the interactive confirmation prompt")
|
|
2700
2893
|
recover_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2701
2894
|
|
|
2895
|
+
# -- rollback --
|
|
2896
|
+
rollback_parser = sub.add_parser(
|
|
2897
|
+
"rollback",
|
|
2898
|
+
help="Reverse a structural migration using a pre-change snapshot",
|
|
2899
|
+
)
|
|
2900
|
+
rollback_sub = rollback_parser.add_subparsers(dest="rollback_command")
|
|
2901
|
+
rollback_f06_p = rollback_sub.add_parser(
|
|
2902
|
+
"f06",
|
|
2903
|
+
help="Revert the F0.6 layout migration using ~/.nexo-pre-f06-snapshot",
|
|
2904
|
+
)
|
|
2905
|
+
rollback_f06_p.add_argument(
|
|
2906
|
+
"--dry-run",
|
|
2907
|
+
action="store_true",
|
|
2908
|
+
help="Show what would happen, do not mutate filesystem or LaunchAgents.",
|
|
2909
|
+
)
|
|
2910
|
+
rollback_f06_p.add_argument(
|
|
2911
|
+
"--yes",
|
|
2912
|
+
action="store_true",
|
|
2913
|
+
help="Skip the interactive confirmation prompt.",
|
|
2914
|
+
)
|
|
2915
|
+
rollback_f06_p.add_argument(
|
|
2916
|
+
"--json",
|
|
2917
|
+
action="store_true",
|
|
2918
|
+
help="Emit machine-readable JSON instead of text output.",
|
|
2919
|
+
)
|
|
2920
|
+
rollback_f06_p.add_argument(
|
|
2921
|
+
"--keep-agents-running",
|
|
2922
|
+
action="store_true",
|
|
2923
|
+
help="Do not bootout / reload LaunchAgents. Advanced use; leaves stale services.",
|
|
2924
|
+
)
|
|
2925
|
+
|
|
2702
2926
|
# -- clients --
|
|
2703
2927
|
clients_parser = sub.add_parser("clients", help="Shared client config management")
|
|
2704
2928
|
clients_sub = clients_parser.add_subparsers(dest="clients_command")
|
|
@@ -2998,6 +3222,11 @@ def main():
|
|
|
2998
3222
|
return _update(args)
|
|
2999
3223
|
elif args.command == "recover":
|
|
3000
3224
|
return _recover(args)
|
|
3225
|
+
elif args.command == "rollback":
|
|
3226
|
+
if args.rollback_command == "f06":
|
|
3227
|
+
return _rollback_f06(args)
|
|
3228
|
+
rollback_parser.print_help()
|
|
3229
|
+
return 0
|
|
3001
3230
|
elif args.command == "clients":
|
|
3002
3231
|
if args.clients_command == "sync":
|
|
3003
3232
|
return _clients_sync(args)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import platform
|
|
8
|
+
from datetime import datetime, timezone
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -209,6 +210,51 @@ def _save_core_schedule_overrides(overrides: dict[str, dict[str, Any]]) -> Path:
|
|
|
209
210
|
return path
|
|
210
211
|
|
|
211
212
|
|
|
213
|
+
def _audit_log_path() -> Path:
|
|
214
|
+
home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
215
|
+
# Prefer the F0.6 runtime/logs location with a legacy fallback so audit
|
|
216
|
+
# entries remain contiguous across installs that have not yet migrated.
|
|
217
|
+
new = home / "runtime" / "logs" / "core-schedule-overrides.log"
|
|
218
|
+
legacy = home / "logs" / "core-schedule-overrides.log"
|
|
219
|
+
if new.parent.is_dir() or not legacy.parent.is_dir():
|
|
220
|
+
return new
|
|
221
|
+
return legacy
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _append_override_audit(
|
|
225
|
+
*,
|
|
226
|
+
name: str,
|
|
227
|
+
action: str,
|
|
228
|
+
previous: dict[str, Any],
|
|
229
|
+
current: dict[str, Any],
|
|
230
|
+
warning: str,
|
|
231
|
+
actor: str,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Append a single-line JSON audit record for a schedule override change.
|
|
234
|
+
|
|
235
|
+
Writes to ``~/.nexo/runtime/logs/core-schedule-overrides.log`` (or the
|
|
236
|
+
legacy location on pre-F0.6 installs). Best-effort only: a failed log
|
|
237
|
+
write never blocks the override itself.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
log_path = _audit_log_path()
|
|
241
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
record = {
|
|
243
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
244
|
+
"name": name,
|
|
245
|
+
"action": action,
|
|
246
|
+
"previous": previous,
|
|
247
|
+
"current": current,
|
|
248
|
+
"warning": warning or "",
|
|
249
|
+
"actor": actor or "cli",
|
|
250
|
+
}
|
|
251
|
+
with log_path.open("a", encoding="utf-8") as fh:
|
|
252
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
253
|
+
except Exception:
|
|
254
|
+
# Audit logging is best-effort — never fail the operator action.
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
|
|
212
258
|
def _apply_calendar_override(base_cron: dict[str, Any], start_hour: str) -> dict[str, Any]:
|
|
213
259
|
parsed = _parse_daily_at(start_hour)
|
|
214
260
|
schedule = base_cron.get("schedule")
|
|
@@ -417,6 +463,7 @@ def set_core_schedule(
|
|
|
417
463
|
interval_seconds: int | None = None,
|
|
418
464
|
daily_at: str | None = None,
|
|
419
465
|
clear: bool = False,
|
|
466
|
+
actor: str = "cli",
|
|
420
467
|
) -> dict[str, Any]:
|
|
421
468
|
clean_name = _normalize_name(name)
|
|
422
469
|
if clean_name in _TOGGLEABLE_AUTOMATIONS:
|
|
@@ -437,6 +484,7 @@ def set_core_schedule(
|
|
|
437
484
|
}
|
|
438
485
|
|
|
439
486
|
overrides = load_core_schedule_overrides()
|
|
487
|
+
previous_snapshot = dict(overrides.get(clean_name) or {})
|
|
440
488
|
changed = False
|
|
441
489
|
warning = ""
|
|
442
490
|
if clear:
|
|
@@ -482,6 +530,24 @@ def set_core_schedule(
|
|
|
482
530
|
}
|
|
483
531
|
|
|
484
532
|
config_path = _save_core_schedule_overrides(overrides)
|
|
533
|
+
|
|
534
|
+
if changed:
|
|
535
|
+
current_snapshot = dict(overrides.get(clean_name) or {})
|
|
536
|
+
if clear:
|
|
537
|
+
audit_action = "clear"
|
|
538
|
+
elif not previous_snapshot:
|
|
539
|
+
audit_action = "set"
|
|
540
|
+
else:
|
|
541
|
+
audit_action = "update"
|
|
542
|
+
_append_override_audit(
|
|
543
|
+
name=clean_name,
|
|
544
|
+
action=audit_action,
|
|
545
|
+
previous=previous_snapshot,
|
|
546
|
+
current=current_snapshot,
|
|
547
|
+
warning=warning,
|
|
548
|
+
actor=actor,
|
|
549
|
+
)
|
|
550
|
+
|
|
485
551
|
sync_result = _sync_core_crons_runtime()
|
|
486
552
|
refreshed = get_core_schedule_status(clean_name)
|
|
487
553
|
refreshed.update({
|