Coverage for core / prompts_backup.py: 88.5%
96 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2core.prompts_backup - periodic local snapshot of the prompts/ directory.
4Closes the "user accidentally wipes data dir / corruption / bad
5shutdown" recovery gap. Cloud sync (core.recipe_sync) handles
6cross-device propagation, but it only helps when the central is
7reachable AND the user previously pushed. Local snapshots are the
8zero-network fallback: the user's last N agent states sit in a
9sibling directory and can be restored in one cp -r.
11When called:
12 Once per HARTOS boot (best-effort, non-blocking) - cheap because
13 the prompts/ dir is typically <10MB even with hundreds of agents.
14 No file watcher / continuous backup - the boot snapshot bounds
15 data loss to "since last reboot" which matches user mental
16 model for most desktop applications.
18Where stored:
19 ``<data>/prompts_snapshots/YYYYMMDD_HHMMSS/`` - one dir per snapshot,
20 each containing a flat copy of all .json files from prompts/ at
21 that moment. See core.platform_paths.get_data_dir for the data
22 root resolution.
24Retention:
25 Keep the latest ``MAX_DAILY_SNAPSHOTS`` (default 7) - one per
26 day if HARTOS reboots daily; old snapshots beyond that count
27 get pruned automatically on each new snapshot.
29Restore:
30 Manual for now - user copies files back. A future feature could
31 expose a /prompts/restore endpoint.
33Tests in tests/unit/test_prompts_backup.py.
34"""
36import logging
37import os
38import shutil
39import time
40from typing import List, Optional
42logger = logging.getLogger('hevolve.prompts_backup')
44#: How many recent snapshots to keep. Override via env.
45MAX_DAILY_SNAPSHOTS: int = int(os.environ.get(
46 'HEVOLVE_PROMPTS_SNAPSHOT_KEEP', '7'))
48#: Subdirectory under data/ where snapshot dirs live. Sibling of
49#: prompts/ so the user can find it without a hunt.
50SNAPSHOTS_SUBDIR: str = 'prompts_snapshots'
53def _snapshots_root(prompts_dir: str) -> str:
54 """Sibling directory of prompts_dir for snapshot storage."""
55 return os.path.join(os.path.dirname(prompts_dir), SNAPSHOTS_SUBDIR)
58def list_snapshots(prompts_dir: str) -> List[str]:
59 """Return existing snapshot directory names, oldest-first.
61 Snapshot names are YYYYMMDD_HHMMSS so lexical sort = chronological.
62 Returns ``[]`` when the snapshots root doesn't exist.
63 """
64 root = _snapshots_root(prompts_dir)
65 if not os.path.isdir(root):
66 return []
67 names = []
68 for name in os.listdir(root):
69 full = os.path.join(root, name)
70 if os.path.isdir(full) and name[:1].isdigit():
71 names.append(name)
72 return sorted(names)
75def snapshot_prompts(prompts_dir: str,
76 max_keep: Optional[int] = None) -> Optional[str]:
77 """Take one snapshot of prompts_dir. Returns the snapshot dir
78 name on success, None on failure or no-op.
80 Best-effort: any IOError / permission issue is logged at debug
81 and returns None so the boot path never crashes on a backup
82 failure.
84 Idempotent within the same second: if a snapshot for the current
85 YYYYMMDD_HHMMSS already exists, returns that name without copying
86 again. (Multiple HARTOS boots within one second is rare but the
87 guard avoids a partial-overwrite race.)
88 """
89 if not os.path.isdir(prompts_dir):
90 logger.debug(f'snapshot_prompts: source missing: {prompts_dir}')
91 return None
92 # Don't snapshot empty prompts/. Avoids creating misleading
93 # "everything was wiped" snapshots when the user starts a fresh
94 # install before any agent exists.
95 try:
96 entries = [f for f in os.listdir(prompts_dir) if f.endswith('.json')]
97 except OSError as e:
98 logger.debug(f'snapshot_prompts: cannot list {prompts_dir}: {e}')
99 return None
100 if not entries:
101 logger.debug('snapshot_prompts: prompts dir empty, skipping')
102 return None
104 snap_name = time.strftime('%Y%m%d_%H%M%S')
105 root = _snapshots_root(prompts_dir)
106 snap_path = os.path.join(root, snap_name)
107 if os.path.isdir(snap_path):
108 logger.debug(f'snapshot_prompts: {snap_name} already exists')
109 return snap_name
111 try:
112 os.makedirs(snap_path, exist_ok=True)
113 except OSError as e:
114 logger.debug(f'snapshot_prompts: cannot create {snap_path}: {e}')
115 return None
117 # Belt + suspenders for M3 (post-shipment review): even though
118 # the canonical recipe writers were converted to atomic temp+rename,
119 # third-party writers / older HARTOS versions / hand-edits could
120 # still leave torn JSON. Validate each file's JSON before copying;
121 # skip + log on parse failure so the snapshot only contains valid
122 # restorable state.
123 import json as _json
124 copied = 0
125 skipped_corrupt = 0
126 for fname in entries:
127 src = os.path.join(prompts_dir, fname)
128 dst = os.path.join(snap_path, fname)
129 try:
130 with open(src, 'r', encoding='utf-8') as _src_f:
131 _content = _src_f.read()
132 try:
133 _json.loads(_content)
134 except _json.JSONDecodeError as je:
135 logger.warning(
136 f'snapshot_prompts: skipping torn/corrupt {fname} '
137 f'({je}) - snapshot stays restore-safe')
138 skipped_corrupt += 1
139 continue
140 shutil.copy2(src, dst)
141 copied += 1
142 except (IOError, OSError) as e:
143 logger.debug(f'snapshot_prompts: copy {fname} failed: {e}')
144 if skipped_corrupt:
145 logger.warning(
146 f'snapshot_prompts: {skipped_corrupt} torn/corrupt files '
147 f'skipped in snapshot {snap_name}')
148 if copied == 0:
149 # Empty snapshot is worse than no snapshot.
150 try:
151 os.rmdir(snap_path)
152 except OSError:
153 pass
154 return None
156 logger.info(
157 f'prompts_backup: snapshot {snap_name} saved '
158 f'({copied}/{len(entries)} files)')
160 # Retention: prune old snapshots beyond max_keep.
161 keep = max_keep if max_keep is not None else MAX_DAILY_SNAPSHOTS
162 _prune_old_snapshots(prompts_dir, keep=keep)
163 return snap_name
166def _prune_old_snapshots(prompts_dir: str, keep: int) -> int:
167 """Delete snapshots beyond the most-recent *keep*.
169 Returns count of pruned snapshots.
170 """
171 if keep < 1:
172 return 0
173 snapshots = list_snapshots(prompts_dir)
174 excess = len(snapshots) - keep
175 if excess <= 0:
176 return 0
177 pruned = 0
178 root = _snapshots_root(prompts_dir)
179 for name in snapshots[:excess]: # oldest first
180 full = os.path.join(root, name)
181 try:
182 shutil.rmtree(full)
183 pruned += 1
184 logger.info(f'prompts_backup: pruned old snapshot {name}')
185 except (IOError, OSError) as e:
186 logger.debug(f'prompts_backup: prune {name} failed: {e}')
187 return pruned
190def snapshot_at_boot() -> Optional[str]:
191 """Convenience entry-point for HARTOS boot. Resolves the
192 prompts directory via core.platform_paths and snapshots it.
194 Returns the snapshot name on success, None on no-op or failure.
195 """
196 try:
197 from core.platform_paths import get_prompts_dir
198 prompts_dir = get_prompts_dir()
199 except Exception as e:
200 logger.debug(f'snapshot_at_boot: cannot resolve prompts_dir: {e}')
201 return None
202 return snapshot_prompts(prompts_dir)