main
py 341 lines 12 KB
Raw
1 """Retention for Time Travel shadow repositories.
2
3 Time Travel keeps one hidden git repository per workspace under
4 ``/a0/usr/.time_travel/workspaces/<workspace_id>/repo.git`` and snapshots it on every file
5 change. Without retention those repositories accumulate unboundedly: a removed chat or project
6 leaves its shadow repository orphaned forever (nothing cleans it up), and a workspace whose
7 ``git add`` ever exceeded ``GIT_TIMEOUT_SECONDS`` strands a ``repo.git/index.lock`` that makes
8 every later snapshot fail with "index.lock: File exists".
9
10 The sweep (driven from ``job_loop``, throttled by config) removes:
11
12 - ORPHANS — shadow directories whose id matches no live workspace path. Live paths are
13 forward-enumerated (project folders, the configured workdir, per-chat workdirs) and hashed
14 with the same ``workspace_id_for`` derivation; anything outside that set has no owner and can
15 never be shown in the UI again. Deleted once last activity is past a grace window.
16 - AGED repositories — no snapshot in ``retention_max_age_days`` (0 = keep forever, the
17 default).
18 - STALE LOCKS — ``repo.git/index.lock`` older than ``retention_stale_lock_minutes``; Time
19 Travel kills its git subprocesses at ``GIT_TIMEOUT_SECONDS``, so no legitimate lock lives
20 that long. Removing it un-wedges future snapshots.
21 - INVALID BACKUPS — ``repo.git.invalid*`` set-asides made for corrupt repositories, past the
22 same grace window.
23
24 Deleting a live workspace's shadow repository is always safe for the feature itself: the next
25 snapshot lazily re-initializes an empty history. Deletion is refused for any path outside the
26 shadow root.
27
28 Durable state next to the workspaces dir: ``retention.json`` (running totals + last sweep
29 stamp) and ``retention.log`` (one JSON line per sweep with the names of everything removed,
30 tail-capped).
31 """
32
33 from __future__ import annotations
34
35 import datetime
36 import json
37 import os
38 import shutil
39 import time
40 from typing import Any, Optional
41
42 PLUGIN_NAME = "_time_travel"
43
44 MARKER_FILE = "retention.json"
45 HISTORY_FILE = "retention.log"
46 HISTORY_MAX_LINES = 1000
47
48 DEFAULT_CONFIG: dict[str, Any] = {
49 "retention_enabled": True,
50 "retention_sweep_interval_hours": 6,
51 "retention_max_age_days": 0,
52 "retention_orphan_grace_hours": 24,
53 "retention_stale_lock_minutes": 30,
54 }
55
56
57 def _int_at_least(value: Any, minimum: int, fallback: int) -> int:
58 try:
59 return max(int(value), minimum)
60 except Exception:
61 return fallback
62
63
64 def effective_config(cfg: Optional[dict[str, Any]] = None) -> dict[str, Any]:
65 """Plugin config with defaults filled in and values clamped to sane minimums."""
66 if cfg is None:
67 try:
68 from helpers import plugins
69
70 cfg = plugins.get_plugin_config(PLUGIN_NAME) or {}
71 except Exception:
72 cfg = {}
73 merged = dict(DEFAULT_CONFIG)
74 merged.update({k: v for k, v in cfg.items() if k in DEFAULT_CONFIG and v is not None})
75 merged["retention_enabled"] = bool(merged["retention_enabled"])
76 merged["retention_sweep_interval_hours"] = _int_at_least(
77 merged["retention_sweep_interval_hours"], 1, 6
78 )
79 merged["retention_max_age_days"] = _int_at_least(merged["retention_max_age_days"], 0, 0)
80 merged["retention_orphan_grace_hours"] = _int_at_least(
81 merged["retention_orphan_grace_hours"], 1, 24
82 )
83 merged["retention_stale_lock_minutes"] = _int_at_least(
84 merged["retention_stale_lock_minutes"], 5, 30
85 )
86 return merged
87
88
89 def _state_dir() -> str:
90 from plugins._time_travel.helpers import time_travel
91
92 return str(time_travel.real_path_for_display("/a0/usr/.time_travel"))
93
94
95 def _shadow_root() -> str:
96 from plugins._time_travel.helpers import time_travel
97
98 return str(time_travel.real_path_for_display(time_travel.SHADOW_DISPLAY_ROOT))
99
100
101 def live_workspace_ids() -> set[str]:
102 """Every workspace id resolvable from a path that exists right now: project folders, the
103 configured workdir, and per-chat workdirs (custom projects resolvers may mint workspaces
104 there; including them only makes the sweep more conservative)."""
105 from plugins._time_travel.helpers import time_travel
106
107 ids: set[str] = set()
108
109 projects_root = time_travel.real_path_for_display("/a0/usr/projects")
110 try:
111 for name in os.listdir(projects_root):
112 if os.path.isdir(os.path.join(projects_root, name)):
113 ids.add(time_travel.workspace_id_for(f"/a0/usr/projects/{name}"))
114 except Exception:
115 pass
116
117 try:
118 ids.add(time_travel.workspace_id_for(time_travel.configured_workdir_display_path()))
119 except Exception:
120 ids.add(time_travel.workspace_id_for("/a0/usr/workdir"))
121
122 chats_root = time_travel.real_path_for_display("/a0/usr/chats")
123 try:
124 for name in os.listdir(chats_root):
125 if os.path.isdir(os.path.join(chats_root, name, "workdir")):
126 ids.add(time_travel.workspace_id_for(f"/a0/usr/chats/{name}/workdir"))
127 except Exception:
128 pass
129
130 return ids
131
132
133 def _read_json(path: str) -> dict[str, Any]:
134 try:
135 with open(path, "r", encoding="utf-8") as f:
136 return json.load(f)
137 except Exception:
138 return {}
139
140
141 def _write_marker(state_dir: str, sweep_stats: dict[str, int], stamp: str) -> None:
142 try:
143 os.makedirs(state_dir, exist_ok=True)
144 path = os.path.join(state_dir, MARKER_FILE)
145 payload = _read_json(path)
146 payload["sweeps"] = int(payload.get("sweeps", 0)) + 1
147 for key, value in sweep_stats.items():
148 payload[key] = int(payload.get(key, 0)) + int(value)
149 payload["last_sweep_at"] = stamp
150 tmp = path + ".tmp"
151 with open(tmp, "w", encoding="utf-8") as f:
152 json.dump(payload, f, indent=2, sort_keys=True)
153 os.replace(tmp, path)
154 except Exception:
155 pass
156
157
158 def _append_history(state_dir: str, entry: dict[str, Any]) -> None:
159 try:
160 os.makedirs(state_dir, exist_ok=True)
161 path = os.path.join(state_dir, HISTORY_FILE)
162 lines: list[str] = []
163 try:
164 with open(path, "r", encoding="utf-8") as f:
165 lines = [ln for ln in f.read().splitlines() if ln.strip()]
166 except Exception:
167 lines = []
168 lines.append(json.dumps(entry, sort_keys=True))
169 if len(lines) > HISTORY_MAX_LINES:
170 lines = lines[-HISTORY_MAX_LINES:]
171 tmp = path + ".tmp"
172 with open(tmp, "w", encoding="utf-8") as f:
173 f.write("\n".join(lines) + "\n")
174 os.replace(tmp, path)
175 except Exception:
176 pass
177
178
179 def read_history(limit: int = 50, state_dir: Optional[str] = None) -> list[dict[str, Any]]:
180 try:
181 base = state_dir if state_dir is not None else _state_dir()
182 with open(os.path.join(base, HISTORY_FILE), "r", encoding="utf-8") as f:
183 lines = [ln for ln in f.read().splitlines() if ln.strip()]
184 return [json.loads(ln) for ln in lines[-limit:]]
185 except Exception:
186 return []
187
188
189 def _last_activity(entry_path: str) -> float:
190 candidates = [
191 os.path.join(entry_path, "repo.git", "refs", "heads", "current"),
192 os.path.join(entry_path, "repo.git", "packed-refs"),
193 os.path.join(entry_path, "repo.git", "HEAD"),
194 os.path.join(entry_path, "repo.git"),
195 entry_path,
196 ]
197 newest = 0.0
198 for candidate in candidates:
199 try:
200 newest = max(newest, os.stat(candidate).st_mtime)
201 except Exception:
202 continue
203 return newest
204
205
206 def _tree_bytes(path: str) -> int:
207 total = 0
208 try:
209 for root, _dirs, names in os.walk(path):
210 for name in names:
211 try:
212 total += os.stat(os.path.join(root, name)).st_size
213 except Exception:
214 pass
215 except Exception:
216 pass
217 return total
218
219
220 def _remove_tree(path: str, shadow_root: str) -> int:
221 """rmtree guarded to the shadow root; returns bytes reclaimed (0 on refusal/failure)."""
222 real = os.path.realpath(path)
223 root = os.path.realpath(shadow_root)
224 if not real.startswith(root + os.sep):
225 return 0
226 size = _tree_bytes(real)
227 try:
228 shutil.rmtree(real)
229 return size
230 except Exception:
231 return 0
232
233
234 def sweep(
235 cfg: Optional[dict[str, Any]] = None,
236 shadow_root: Optional[str] = None,
237 live_ids: Optional[set[str]] = None,
238 now_ts: Optional[float] = None,
239 state_dir: Optional[str] = None,
240 ) -> dict[str, int]:
241 """One retention pass. All inputs are injectable for tests; production callers pass
242 nothing and everything resolves from the plugin runtime."""
243 stats = {
244 "orphans_removed": 0,
245 "aged_removed": 0,
246 "stale_locks_removed": 0,
247 "invalid_backups_removed": 0,
248 "bytes_reclaimed": 0,
249 }
250 config = effective_config(cfg)
251 if not config["retention_enabled"]:
252 return stats
253 root = shadow_root if shadow_root is not None else _shadow_root()
254 if not os.path.isdir(root):
255 return stats
256 ids = live_ids if live_ids is not None else live_workspace_ids()
257 base = state_dir if state_dir is not None else _state_dir()
258 now = time.time() if now_ts is None else now_ts
259
260 max_age_s = config["retention_max_age_days"] * 86400
261 grace_s = config["retention_orphan_grace_hours"] * 3600
262 lock_s = config["retention_stale_lock_minutes"] * 60
263
264 detail: dict[str, list[str]] = {"orphans": [], "aged": [], "locks": [], "invalid": []}
265
266 try:
267 entries = os.listdir(root)
268 except Exception:
269 return stats
270
271 for name in entries:
272 entry = os.path.join(root, name)
273 if not os.path.isdir(entry):
274 continue
275 last = _last_activity(entry)
276
277 if name not in ids:
278 if now - last > grace_s:
279 stats["bytes_reclaimed"] += _remove_tree(entry, root)
280 stats["orphans_removed"] += 1
281 detail["orphans"].append(name)
282 continue
283
284 if max_age_s and now - last > max_age_s:
285 stats["bytes_reclaimed"] += _remove_tree(entry, root)
286 stats["aged_removed"] += 1
287 detail["aged"].append(name)
288 continue
289
290 lock = os.path.join(entry, "repo.git", "index.lock")
291 try:
292 if os.path.isfile(lock) and now - os.stat(lock).st_mtime > lock_s:
293 os.remove(lock)
294 stats["stale_locks_removed"] += 1
295 detail["locks"].append(name)
296 except Exception:
297 pass
298
299 try:
300 for sub in os.listdir(entry):
301 if sub.startswith("repo.git.invalid"):
302 backup = os.path.join(entry, sub)
303 if now - os.stat(backup).st_mtime > grace_s:
304 stats["bytes_reclaimed"] += _remove_tree(backup, root)
305 stats["invalid_backups_removed"] += 1
306 detail["invalid"].append(f"{name}/{sub}")
307 except Exception:
308 pass
309
310 stamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
311 _write_marker(base, stats, stamp)
312 _append_history(base, {"at": stamp, **stats, "removed": detail})
313 return stats
314
315
316 def due(
317 cfg: Optional[dict[str, Any]] = None,
318 now_ts: Optional[float] = None,
319 state_dir: Optional[str] = None,
320 ) -> bool:
321 """True when retention is enabled and the configured interval has elapsed since the last
322 sweep (or no sweep ever ran)."""
323 config = effective_config(cfg)
324 if not config["retention_enabled"]:
325 return False
326 base = state_dir if state_dir is not None else _state_dir()
327 marker = _read_json(os.path.join(base, MARKER_FILE))
328 last = str(marker.get("last_sweep_at") or "")
329 if not last:
330 return True
331 try:
332 last_dt = datetime.datetime.fromisoformat(last)
333 now = (
334 datetime.datetime.now(datetime.timezone.utc)
335 if now_ts is None
336 else datetime.datetime.fromtimestamp(now_ts, datetime.timezone.utc)
337 )
338 interval_s = config["retention_sweep_interval_hours"] * 3600
339 return (now - last_dt).total_seconds() >= interval_s
340 except Exception:
341 return True