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.
@@ -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({