nexo-brain 7.1.8 → 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 +5 -1
- package/package.json +2 -2
- package/src/auto_update.py +239 -8
- package/src/autonomy_mandate.py +62 -0
- package/src/checkpoint_policy.py +302 -0
- 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 +148 -2
- package/src/hooks/g1_enforcer.py +305 -0
- package/src/hooks/post-compact.sh +34 -0
- package/src/hooks/post_tool_use.py +32 -3
- package/src/hooks/pre-compact.sh +14 -0
- package/src/paths.py +10 -0
- package/src/plugins/adaptive_mode.py +26 -2
- package/src/plugins/protocol.py +24 -0
- package/src/plugins/recover.py +42 -10
- package/src/plugins/update.py +47 -17
- package/src/plugins/workflow.py +65 -0
- 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/skills/run-release-final-audit/guide.md +3 -1
- package/src/skills/run-release-final-audit/script.py +2 -0
- package/src/tools_sessions.py +64 -3
- package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -1
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Durable checkpoint policy for long-running multi-step work.
|
|
2
|
+
|
|
3
|
+
This module turns task/workflow milestones into a small persistent state file
|
|
4
|
+
and periodically flushes that state into ``session_checkpoints`` so compaction
|
|
5
|
+
and phase switches recover a richer next-step snapshot than the heartbeat-only
|
|
6
|
+
goal stub.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
19
|
+
STATE_PATH = NEXO_HOME / "runtime" / "data" / "durable_checkpoint_state.json"
|
|
20
|
+
DEFAULT_MILESTONE_INTERVAL = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _now_iso() -> str:
|
|
24
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_dir() -> None:
|
|
28
|
+
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_all() -> dict[str, dict[str, Any]]:
|
|
32
|
+
try:
|
|
33
|
+
raw = json.loads(STATE_PATH.read_text())
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
return {}
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
return {}
|
|
38
|
+
return raw if isinstance(raw, dict) else {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_all(payload: dict[str, dict[str, Any]]) -> None:
|
|
42
|
+
_ensure_dir()
|
|
43
|
+
STATE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _blank_session_state(session_id: str) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"session_id": session_id,
|
|
49
|
+
"milestone_count": 0,
|
|
50
|
+
"updated_at": _now_iso(),
|
|
51
|
+
"last_reason": "",
|
|
52
|
+
"task": "",
|
|
53
|
+
"task_status": "active",
|
|
54
|
+
"active_files": [],
|
|
55
|
+
"current_goal": "",
|
|
56
|
+
"decisions_summary": "",
|
|
57
|
+
"blockers": "",
|
|
58
|
+
"reasoning_thread": "",
|
|
59
|
+
"next_step": "",
|
|
60
|
+
"last_flushed_at": "",
|
|
61
|
+
"last_flush_reason": "",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _coalesce_text(new_value: str, old_value: str = "") -> str:
|
|
66
|
+
clean = str(new_value or "").strip()
|
|
67
|
+
return clean or str(old_value or "").strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_active_files(active_files: Any) -> list[str]:
|
|
71
|
+
if active_files is None:
|
|
72
|
+
return []
|
|
73
|
+
if isinstance(active_files, str):
|
|
74
|
+
stripped = active_files.strip()
|
|
75
|
+
if not stripped:
|
|
76
|
+
return []
|
|
77
|
+
if stripped.startswith("["):
|
|
78
|
+
try:
|
|
79
|
+
parsed = json.loads(stripped)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
parsed = [part.strip() for part in stripped.split(",") if part.strip()]
|
|
82
|
+
else:
|
|
83
|
+
parsed = [part.strip() for part in stripped.split(",") if part.strip()]
|
|
84
|
+
elif isinstance(active_files, (list, tuple, set)):
|
|
85
|
+
parsed = list(active_files)
|
|
86
|
+
else:
|
|
87
|
+
parsed = [str(active_files).strip()]
|
|
88
|
+
|
|
89
|
+
seen: list[str] = []
|
|
90
|
+
for item in parsed:
|
|
91
|
+
clean = str(item or "").strip()
|
|
92
|
+
if clean and clean not in seen:
|
|
93
|
+
seen.append(clean)
|
|
94
|
+
return seen
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _session_state(all_state: dict[str, dict[str, Any]], session_id: str) -> dict[str, Any]:
|
|
98
|
+
existing = all_state.get(session_id)
|
|
99
|
+
if not isinstance(existing, dict):
|
|
100
|
+
return _blank_session_state(session_id)
|
|
101
|
+
base = _blank_session_state(session_id)
|
|
102
|
+
base.update(existing)
|
|
103
|
+
base["session_id"] = session_id
|
|
104
|
+
base["active_files"] = _normalize_active_files(base.get("active_files"))
|
|
105
|
+
try:
|
|
106
|
+
base["milestone_count"] = max(0, int(base.get("milestone_count", 0)))
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
base["milestone_count"] = 0
|
|
109
|
+
return base
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_active_files_from_payload(payload: dict[str, Any] | None) -> list[str]:
|
|
113
|
+
if not isinstance(payload, dict):
|
|
114
|
+
return []
|
|
115
|
+
for key in ("active_files", "files", "tracked_files"):
|
|
116
|
+
files = _normalize_active_files(payload.get(key))
|
|
117
|
+
if files:
|
|
118
|
+
return files
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _flush_state(
|
|
123
|
+
all_state: dict[str, dict[str, Any]],
|
|
124
|
+
session_id: str,
|
|
125
|
+
state: dict[str, Any],
|
|
126
|
+
*,
|
|
127
|
+
reason: str,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
from db import save_checkpoint
|
|
130
|
+
|
|
131
|
+
decisions_summary = _coalesce_text(state.get("decisions_summary", ""))
|
|
132
|
+
if reason:
|
|
133
|
+
reason_note = f"checkpoint_reason={reason}"
|
|
134
|
+
decisions_summary = f"{decisions_summary} | {reason_note}".strip(" |")
|
|
135
|
+
|
|
136
|
+
active_files = _normalize_active_files(state.get("active_files"))
|
|
137
|
+
save_result = save_checkpoint(
|
|
138
|
+
sid=session_id,
|
|
139
|
+
task=_coalesce_text(state.get("task", ""), state.get("current_goal", "")),
|
|
140
|
+
task_status=_coalesce_text(state.get("task_status", ""), "active"),
|
|
141
|
+
active_files=json.dumps(active_files, ensure_ascii=False),
|
|
142
|
+
current_goal=_coalesce_text(state.get("current_goal", ""), state.get("task", "")),
|
|
143
|
+
decisions_summary=decisions_summary,
|
|
144
|
+
errors_found=_coalesce_text(state.get("blockers", "")),
|
|
145
|
+
reasoning_thread=_coalesce_text(state.get("reasoning_thread", "")),
|
|
146
|
+
next_step=_coalesce_text(state.get("next_step", "")),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
state["active_files"] = active_files
|
|
150
|
+
state["milestone_count"] = 0
|
|
151
|
+
state["last_flushed_at"] = _now_iso()
|
|
152
|
+
state["last_flush_reason"] = reason
|
|
153
|
+
state["updated_at"] = _now_iso()
|
|
154
|
+
all_state[session_id] = state
|
|
155
|
+
_save_all(all_state)
|
|
156
|
+
return {
|
|
157
|
+
"ok": True,
|
|
158
|
+
"checkpoint_written": True,
|
|
159
|
+
"session_id": session_id,
|
|
160
|
+
"milestone_count": 0,
|
|
161
|
+
"last_flush_reason": reason,
|
|
162
|
+
"compaction_count": save_result.get("compaction_count", 0),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def record_milestone(
|
|
167
|
+
session_id: str,
|
|
168
|
+
*,
|
|
169
|
+
reason: str,
|
|
170
|
+
task: str = "",
|
|
171
|
+
task_status: str = "active",
|
|
172
|
+
active_files: Any = None,
|
|
173
|
+
current_goal: str = "",
|
|
174
|
+
decisions_summary: str = "",
|
|
175
|
+
blockers: str = "",
|
|
176
|
+
reasoning_thread: str = "",
|
|
177
|
+
next_step: str = "",
|
|
178
|
+
interval: int = DEFAULT_MILESTONE_INTERVAL,
|
|
179
|
+
force_flush: bool = False,
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
clean_sid = str(session_id or "").strip()
|
|
182
|
+
if not clean_sid:
|
|
183
|
+
return {"ok": False, "error": "session_id is required"}
|
|
184
|
+
|
|
185
|
+
all_state = _load_all()
|
|
186
|
+
state = _session_state(all_state, clean_sid)
|
|
187
|
+
|
|
188
|
+
state["last_reason"] = str(reason or "").strip()
|
|
189
|
+
state["updated_at"] = _now_iso()
|
|
190
|
+
state["task"] = _coalesce_text(task, state.get("task", ""))
|
|
191
|
+
state["task_status"] = _coalesce_text(task_status, state.get("task_status", "active"))
|
|
192
|
+
state["current_goal"] = _coalesce_text(current_goal, state.get("current_goal", ""))
|
|
193
|
+
state["decisions_summary"] = _coalesce_text(decisions_summary, state.get("decisions_summary", ""))
|
|
194
|
+
state["blockers"] = _coalesce_text(blockers, state.get("blockers", ""))
|
|
195
|
+
state["reasoning_thread"] = _coalesce_text(reasoning_thread, state.get("reasoning_thread", ""))
|
|
196
|
+
state["next_step"] = _coalesce_text(next_step, state.get("next_step", ""))
|
|
197
|
+
|
|
198
|
+
files = _normalize_active_files(active_files)
|
|
199
|
+
if files:
|
|
200
|
+
state["active_files"] = files
|
|
201
|
+
|
|
202
|
+
state["milestone_count"] = max(0, int(state.get("milestone_count", 0))) + 1
|
|
203
|
+
all_state[clean_sid] = state
|
|
204
|
+
|
|
205
|
+
flush_every = max(1, int(interval or DEFAULT_MILESTONE_INTERVAL))
|
|
206
|
+
if force_flush or state["milestone_count"] >= flush_every:
|
|
207
|
+
return _flush_state(all_state, clean_sid, state, reason=str(reason or "").strip())
|
|
208
|
+
|
|
209
|
+
_save_all(all_state)
|
|
210
|
+
return {
|
|
211
|
+
"ok": True,
|
|
212
|
+
"checkpoint_written": False,
|
|
213
|
+
"session_id": clean_sid,
|
|
214
|
+
"milestone_count": state["milestone_count"],
|
|
215
|
+
"flush_interval": flush_every,
|
|
216
|
+
"pending_reason": state["last_reason"],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def force_runtime_checkpoint(session_id: str, *, reason: str = "pre-compact") -> dict[str, Any]:
|
|
221
|
+
clean_sid = str(session_id or "").strip()
|
|
222
|
+
if not clean_sid:
|
|
223
|
+
return {"ok": False, "error": "session_id is required"}
|
|
224
|
+
|
|
225
|
+
from db import get_db, read_checkpoint
|
|
226
|
+
|
|
227
|
+
all_state = _load_all()
|
|
228
|
+
state = _session_state(all_state, clean_sid)
|
|
229
|
+
conn = get_db()
|
|
230
|
+
|
|
231
|
+
session_row = conn.execute(
|
|
232
|
+
"SELECT task FROM sessions WHERE sid = ? LIMIT 1",
|
|
233
|
+
(clean_sid,),
|
|
234
|
+
).fetchone()
|
|
235
|
+
existing_checkpoint = read_checkpoint(clean_sid) or {}
|
|
236
|
+
workflow_row = conn.execute(
|
|
237
|
+
"""SELECT goal, status, current_step_key, next_action, shared_state
|
|
238
|
+
FROM workflow_runs
|
|
239
|
+
WHERE session_id = ?
|
|
240
|
+
ORDER BY updated_at DESC LIMIT 1""",
|
|
241
|
+
(clean_sid,),
|
|
242
|
+
).fetchone()
|
|
243
|
+
|
|
244
|
+
workflow_state: dict[str, Any] = {}
|
|
245
|
+
if workflow_row and workflow_row["shared_state"]:
|
|
246
|
+
try:
|
|
247
|
+
parsed = json.loads(workflow_row["shared_state"])
|
|
248
|
+
if isinstance(parsed, dict):
|
|
249
|
+
workflow_state = parsed
|
|
250
|
+
except json.JSONDecodeError:
|
|
251
|
+
workflow_state = {}
|
|
252
|
+
|
|
253
|
+
workflow_blocker = ""
|
|
254
|
+
if workflow_row and workflow_row["status"] in {"blocked", "waiting_approval"}:
|
|
255
|
+
workflow_blocker = (
|
|
256
|
+
f"Workflow {workflow_row['status']} at "
|
|
257
|
+
f"{workflow_row['current_step_key'] or 'current-step'}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
merged_files = (
|
|
261
|
+
_normalize_active_files(state.get("active_files"))
|
|
262
|
+
or _extract_active_files_from_payload(workflow_state)
|
|
263
|
+
or _normalize_active_files(existing_checkpoint.get("active_files"))
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
state["task"] = _coalesce_text(
|
|
267
|
+
state.get("task", ""),
|
|
268
|
+
(session_row["task"] if session_row else "") or existing_checkpoint.get("task", ""),
|
|
269
|
+
)
|
|
270
|
+
state["current_goal"] = _coalesce_text(
|
|
271
|
+
state.get("current_goal", ""),
|
|
272
|
+
(workflow_row["goal"] if workflow_row else "") or existing_checkpoint.get("current_goal", "") or state.get("task", ""),
|
|
273
|
+
)
|
|
274
|
+
state["decisions_summary"] = _coalesce_text(
|
|
275
|
+
state.get("decisions_summary", ""),
|
|
276
|
+
existing_checkpoint.get("decisions_summary", "") or f"Forced durable checkpoint before {reason}.",
|
|
277
|
+
)
|
|
278
|
+
current_blockers = _coalesce_text(
|
|
279
|
+
state.get("blockers", ""),
|
|
280
|
+
existing_checkpoint.get("errors_found", ""),
|
|
281
|
+
)
|
|
282
|
+
if workflow_blocker:
|
|
283
|
+
if workflow_blocker not in current_blockers:
|
|
284
|
+
current_blockers = f"{workflow_blocker} | {current_blockers}".strip(" |")
|
|
285
|
+
state["blockers"] = current_blockers
|
|
286
|
+
state["reasoning_thread"] = _coalesce_text(
|
|
287
|
+
state.get("reasoning_thread", ""),
|
|
288
|
+
existing_checkpoint.get("reasoning_thread", "") or f"Auto-flushed by checkpoint_policy ({reason}).",
|
|
289
|
+
)
|
|
290
|
+
state["next_step"] = _coalesce_text(
|
|
291
|
+
state.get("next_step", ""),
|
|
292
|
+
(workflow_row["next_action"] if workflow_row else "") or existing_checkpoint.get("next_step", ""),
|
|
293
|
+
)
|
|
294
|
+
state["task_status"] = _coalesce_text(
|
|
295
|
+
state.get("task_status", ""),
|
|
296
|
+
(workflow_row["status"] if workflow_row else "") or existing_checkpoint.get("task_status", "") or "active",
|
|
297
|
+
)
|
|
298
|
+
state["active_files"] = merged_files
|
|
299
|
+
state["updated_at"] = _now_iso()
|
|
300
|
+
state["last_reason"] = reason
|
|
301
|
+
all_state[clean_sid] = state
|
|
302
|
+
return _flush_state(all_state, clean_sid, state, reason=reason)
|
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({
|