| 1 | from __future__ import annotations |
| 2 | |
| 3 | import base64 |
| 4 | import fnmatch |
| 5 | import hashlib |
| 6 | import json |
| 7 | import os |
| 8 | import posixpath |
| 9 | import shutil |
| 10 | import subprocess |
| 11 | import threading |
| 12 | import time |
| 13 | from dataclasses import dataclass |
| 14 | from pathlib import Path |
| 15 | from typing import Any, Iterable |
| 16 | |
| 17 | from helpers import files |
| 18 | from helpers.localization import Localization |
| 19 | from helpers.print_style import PrintStyle |
| 20 | |
| 21 | |
| 22 | PLUGIN_NAME = "_time_travel" |
| 23 | USR_DISPLAY_ROOT = "/a0/usr" |
| 24 | SHADOW_DISPLAY_ROOT = "/a0/usr/.time_travel/workspaces" |
| 25 | CURRENT_REF = "refs/heads/current" |
| 26 | PRESERVED_REF_PREFIX = "refs/a0-time-travel/preserved" |
| 27 | METADATA_PREFIX = "A0-Time-Travel-Metadata:" |
| 28 | EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" |
| 29 | MAX_RENDERED_PATCH_BYTES = 1_000_000 |
| 30 | GIT_TIMEOUT_SECONDS = 20 |
| 31 | AUTO_SNAPSHOT_DEBOUNCE_SECONDS = 10.0 |
| 32 | WATCHDOG_ID = "time_travel_usr" |
| 33 | WATCHDOG_DEBOUNCE_SECONDS = 1.0 |
| 34 | SHADOW_REPO_BACKUP_PREFIX = "repo.git.invalid" |
| 35 | |
| 36 | _AUTO_SNAPSHOT_LOCK = threading.RLock() |
| 37 | _AUTO_SNAPSHOT_TIMERS: dict[str, threading.Timer] = {} |
| 38 | _AUTO_SNAPSHOT_PAYLOADS: dict[str, dict[str, Any]] = {} |
| 39 | |
| 40 | STATUS_LABELS = { |
| 41 | "A": "added", |
| 42 | "C": "copied", |
| 43 | "D": "deleted", |
| 44 | "M": "modified", |
| 45 | "R": "renamed", |
| 46 | "T": "type_changed", |
| 47 | "U": "unmerged", |
| 48 | "X": "unknown", |
| 49 | } |
| 50 | |
| 51 | EXCLUDED_DIR_NAMES = { |
| 52 | ".git", |
| 53 | ".time_travel", |
| 54 | "__pycache__", |
| 55 | ".pytest_cache", |
| 56 | ".mypy_cache", |
| 57 | ".ruff_cache", |
| 58 | ".cache", |
| 59 | ".tox", |
| 60 | ".nox", |
| 61 | ".venv", |
| 62 | "venv", |
| 63 | "env", |
| 64 | "node_modules", |
| 65 | "bower_components", |
| 66 | "dist", |
| 67 | "build", |
| 68 | ".next", |
| 69 | ".nuxt", |
| 70 | ".svelte-kit", |
| 71 | ".turbo", |
| 72 | "coverage", |
| 73 | "htmlcov", |
| 74 | ".parcel-cache", |
| 75 | } |
| 76 | |
| 77 | EXCLUDED_DIR_PATTERNS = { |
| 78 | "*.egg-info", |
| 79 | } |
| 80 | |
| 81 | EXCLUDED_FILE_PATTERNS = { |
| 82 | "*.pyc", |
| 83 | "*.pyo", |
| 84 | "*.pyd", |
| 85 | ".env", |
| 86 | ".env.*", |
| 87 | "*.class", |
| 88 | } |
| 89 | |
| 90 | USR_ROOT_EXCLUDED_DIR_NAMES = { |
| 91 | "plugins", |
| 92 | } |
| 93 | |
| 94 | SAFE_A0PROJ_FILES = { |
| 95 | ".a0proj/project.json", |
| 96 | ".a0proj/agents.json", |
| 97 | } |
| 98 | |
| 99 | SAFE_A0PROJ_DIRS = { |
| 100 | ".a0proj/instructions/", |
| 101 | ".a0proj/knowledge/", |
| 102 | ".a0proj/skills/", |
| 103 | } |
| 104 | |
| 105 | SAFE_PLUGIN_ASSET_NAMES = { |
| 106 | "config.json", |
| 107 | "presets.yaml", |
| 108 | ".toggle-0", |
| 109 | ".toggle-1", |
| 110 | } |
| 111 | |
| 112 | |
| 113 | class TimeTravelError(RuntimeError): |
| 114 | """Base error for user-visible Time Travel failures.""" |
| 115 | |
| 116 | |
| 117 | class WorkspaceRejectedError(TimeTravelError): |
| 118 | """Raised when a workspace is outside the /a0/usr kernel boundary.""" |
| 119 | |
| 120 | |
| 121 | class TimeTravelConflictError(TimeTravelError): |
| 122 | """Raised when an operation cannot safely mutate the workspace.""" |
| 123 | |
| 124 | |
| 125 | class GitCommandError(TimeTravelError): |
| 126 | def __init__(self, message: str, *, stdout: str = "", stderr: str = "") -> None: |
| 127 | super().__init__(message) |
| 128 | self.stdout = stdout |
| 129 | self.stderr = stderr |
| 130 | |
| 131 | |
| 132 | @dataclass(frozen=True) |
| 133 | class WorkspaceInfo: |
| 134 | id: str |
| 135 | display_path: str |
| 136 | real_path: Path |
| 137 | shadow_path: Path |
| 138 | repo_git_path: Path |
| 139 | context_id: str = "" |
| 140 | project_name: str = "" |
| 141 | |
| 142 | def public(self) -> dict[str, Any]: |
| 143 | return { |
| 144 | "id": self.id, |
| 145 | "path": self.display_path, |
| 146 | "display_path": self.display_path, |
| 147 | "real_path": str(self.real_path), |
| 148 | "shadow_path": normalize_display_path(str(self.shadow_path)), |
| 149 | "repo_git_path": normalize_display_path(str(self.repo_git_path)), |
| 150 | "context_id": self.context_id, |
| 151 | "project_name": self.project_name, |
| 152 | "available": True, |
| 153 | "locked": False, |
| 154 | } |
| 155 | |
| 156 | |
| 157 | @dataclass(frozen=True) |
| 158 | class SnapshotResult: |
| 159 | created: bool |
| 160 | hash: str |
| 161 | short_hash: str |
| 162 | tree_hash: str |
| 163 | message: str |
| 164 | files: list[dict[str, Any]] |
| 165 | metadata: dict[str, Any] |
| 166 | |
| 167 | |
| 168 | def now_iso() -> str: |
| 169 | return Localization.get().now_iso() |
| 170 | |
| 171 | |
| 172 | def normalize_display_path(path: str) -> str: |
| 173 | raw = str(path or "").strip() |
| 174 | if not raw: |
| 175 | return "" |
| 176 | if raw.startswith("/a0"): |
| 177 | normalized = posixpath.normpath(raw.replace("\\", "/")) |
| 178 | return "/" if normalized == "." else normalized |
| 179 | |
| 180 | resolved = Path(raw).expanduser().resolve(strict=False) |
| 181 | normalized = files.normalize_a0_path(str(resolved)) |
| 182 | if normalized.startswith("/a0"): |
| 183 | return posixpath.normpath(normalized.replace("\\", "/")) |
| 184 | return str(resolved) |
| 185 | |
| 186 | |
| 187 | def is_inside_usr_display(display_path: str) -> bool: |
| 188 | normalized = normalize_display_path(display_path) |
| 189 | return normalized == USR_DISPLAY_ROOT or normalized.startswith(USR_DISPLAY_ROOT + "/") |
| 190 | |
| 191 | |
| 192 | def workspace_id_for(display_path: str) -> str: |
| 193 | normalized = canonical_workspace_display_path(display_path).rstrip("/") |
| 194 | return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32] |
| 195 | |
| 196 | |
| 197 | def real_path_for_display(display_path: str) -> Path: |
| 198 | normalized = normalize_display_path(display_path) |
| 199 | if normalized == "/a0": |
| 200 | return Path(files.get_base_dir()).resolve(strict=False) |
| 201 | if normalized.startswith("/a0/"): |
| 202 | return Path(files.get_base_dir(), normalized.removeprefix("/a0/")).resolve(strict=False) |
| 203 | return Path(normalized).expanduser().resolve(strict=False) |
| 204 | |
| 205 | |
| 206 | def canonical_workspace_display_path(display_path: str) -> str: |
| 207 | normalized = normalize_display_path(display_path) |
| 208 | real_path = real_path_for_display(normalized) |
| 209 | canonical = normalize_display_path(str(real_path)) |
| 210 | return (canonical if canonical.startswith("/a0") else normalized).rstrip("/") or canonical |
| 211 | |
| 212 | |
| 213 | def configured_workdir_display_path() -> str: |
| 214 | from helpers import settings |
| 215 | |
| 216 | configured = str(settings.get_settings().get("workdir_path") or "") |
| 217 | fallback = files.normalize_a0_path(files.get_abs_path("usr/workdir")) |
| 218 | return canonical_workspace_display_path(configured or fallback) |
| 219 | |
| 220 | |
| 221 | def _workspace_option( |
| 222 | *, |
| 223 | kind: str, |
| 224 | display_path: str, |
| 225 | label: str, |
| 226 | name: str = "", |
| 227 | title: str = "", |
| 228 | project_name: str = "", |
| 229 | color: str = "", |
| 230 | ) -> dict[str, Any]: |
| 231 | normalized = canonical_workspace_display_path(display_path) |
| 232 | available = is_inside_usr_display(normalized) |
| 233 | error = ( |
| 234 | "" if available else "Time Travel is only available for workspaces inside /a0/usr." |
| 235 | ) |
| 236 | return { |
| 237 | "id": workspace_id_for(normalized), |
| 238 | "kind": kind, |
| 239 | "name": name, |
| 240 | "title": title or label, |
| 241 | "label": label, |
| 242 | "display_path": normalized.rstrip("/") or normalized, |
| 243 | "path": normalized.rstrip("/") or normalized, |
| 244 | "project_name": project_name, |
| 245 | "color": color, |
| 246 | "available": available, |
| 247 | "locked": not available, |
| 248 | "error": error, |
| 249 | } |
| 250 | |
| 251 | |
| 252 | def list_selectable_workspaces( |
| 253 | context_id: str = "", |
| 254 | *, |
| 255 | context_loader=None, |
| 256 | ) -> dict[str, Any]: |
| 257 | from helpers import projects |
| 258 | |
| 259 | context_id = str(context_id or "").strip() |
| 260 | workspaces: list[dict[str, Any]] = [] |
| 261 | seen_ids: set[str] = set() |
| 262 | |
| 263 | def add_workspace(option: dict[str, Any]) -> None: |
| 264 | option_id = str(option.get("id") or "") |
| 265 | if not option_id or option_id in seen_ids: |
| 266 | return |
| 267 | seen_ids.add(option_id) |
| 268 | workspaces.append(option) |
| 269 | |
| 270 | add_workspace( |
| 271 | _workspace_option( |
| 272 | kind="workdir", |
| 273 | display_path=configured_workdir_display_path(), |
| 274 | label="User working directory", |
| 275 | name="workdir", |
| 276 | title="User working directory", |
| 277 | ) |
| 278 | ) |
| 279 | |
| 280 | for project in projects.get_active_projects_list() or []: |
| 281 | project_name = str(project.get("name") or "").strip() |
| 282 | if not project_name: |
| 283 | continue |
| 284 | title = str(project.get("title") or project_name) |
| 285 | add_workspace( |
| 286 | _workspace_option( |
| 287 | kind="project", |
| 288 | display_path=files.normalize_a0_path( |
| 289 | projects.get_project_folder(project_name) |
| 290 | ), |
| 291 | label=title, |
| 292 | name=project_name, |
| 293 | title=title, |
| 294 | project_name=project_name, |
| 295 | color=str(project.get("color") or ""), |
| 296 | ) |
| 297 | ) |
| 298 | |
| 299 | default_workspace_id = "" |
| 300 | try: |
| 301 | default_workspace_id = _resolve_context_workspace( |
| 302 | context_id, |
| 303 | context_loader=context_loader, |
| 304 | ).id |
| 305 | except TimeTravelError: |
| 306 | default_workspace_id = "" |
| 307 | |
| 308 | if default_workspace_id not in seen_ids: |
| 309 | default_workspace_id = "" |
| 310 | if not default_workspace_id and workspaces: |
| 311 | default_workspace_id = str(workspaces[0].get("id") or "") |
| 312 | |
| 313 | return { |
| 314 | "context_id": context_id, |
| 315 | "workspaces": workspaces, |
| 316 | "default_workspace_id": default_workspace_id, |
| 317 | } |
| 318 | |
| 319 | |
| 320 | def _selectable_workspace_by_id( |
| 321 | workspace_id: str, |
| 322 | context_id: str = "", |
| 323 | *, |
| 324 | context_loader=None, |
| 325 | ) -> dict[str, Any] | None: |
| 326 | wanted = str(workspace_id or "").strip() |
| 327 | if not wanted: |
| 328 | return None |
| 329 | for workspace in list_selectable_workspaces( |
| 330 | context_id, |
| 331 | context_loader=context_loader, |
| 332 | )["workspaces"]: |
| 333 | if str(workspace.get("id") or "") == wanted: |
| 334 | return workspace |
| 335 | return None |
| 336 | |
| 337 | |
| 338 | def _resolve_context_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo: |
| 339 | from helpers import projects, settings |
| 340 | |
| 341 | context_id = str(context_id or "").strip() |
| 342 | project_name = "" |
| 343 | display_path = "" |
| 344 | |
| 345 | if context_id: |
| 346 | context = context_loader(context_id) if context_loader else None |
| 347 | if context is not None: |
| 348 | project_name = projects.get_context_project_name(context) or "" |
| 349 | if project_name: |
| 350 | display_path = files.normalize_a0_path(projects.get_project_folder(project_name)) |
| 351 | |
| 352 | if not display_path: |
| 353 | configured = str(settings.get_settings().get("workdir_path") or "") |
| 354 | display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")) |
| 355 | |
| 356 | normalized = canonical_workspace_display_path(display_path) |
| 357 | if not is_inside_usr_display(normalized): |
| 358 | raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.") |
| 359 | |
| 360 | workspace_id = workspace_id_for(normalized) |
| 361 | shadow_display = f"{SHADOW_DISPLAY_ROOT}/{workspace_id}" |
| 362 | shadow_path = real_path_for_display(shadow_display) |
| 363 | return WorkspaceInfo( |
| 364 | id=workspace_id, |
| 365 | display_path=normalized.rstrip("/") or normalized, |
| 366 | real_path=real_path_for_display(normalized), |
| 367 | shadow_path=shadow_path, |
| 368 | repo_git_path=shadow_path / "repo.git", |
| 369 | context_id=context_id, |
| 370 | project_name=project_name, |
| 371 | ) |
| 372 | |
| 373 | |
| 374 | def resolve_workspace( |
| 375 | context_id: str = "", |
| 376 | *, |
| 377 | workspace_id: str = "", |
| 378 | context_loader=None, |
| 379 | ) -> WorkspaceInfo: |
| 380 | workspace_id = str(workspace_id or "").strip() |
| 381 | if not workspace_id: |
| 382 | return _resolve_context_workspace(context_id, context_loader=context_loader) |
| 383 | |
| 384 | selected = _selectable_workspace_by_id( |
| 385 | workspace_id, |
| 386 | context_id, |
| 387 | context_loader=context_loader, |
| 388 | ) |
| 389 | if not selected: |
| 390 | raise WorkspaceRejectedError("Selected Time Travel workspace is not available.") |
| 391 | if selected.get("locked") or selected.get("available") is False: |
| 392 | raise WorkspaceRejectedError( |
| 393 | str(selected.get("error") or "Selected Time Travel workspace is not available.") |
| 394 | ) |
| 395 | |
| 396 | return _workspace_from_display( |
| 397 | str(selected.get("display_path") or selected.get("path") or ""), |
| 398 | project_name=str(selected.get("project_name") or ""), |
| 399 | context_id=str(context_id or "").strip(), |
| 400 | ) |
| 401 | |
| 402 | |
| 403 | def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None: |
| 404 | normalized = canonical_workspace_display_path(path_hint) |
| 405 | if not is_inside_usr_display(normalized): |
| 406 | return None |
| 407 | |
| 408 | parts = [part for part in normalized.split("/") if part] |
| 409 | if len(parts) >= 4 and parts[0] == "a0" and parts[1] == "usr" and parts[2] == "projects": |
| 410 | project_display = f"/a0/usr/projects/{parts[3]}" |
| 411 | return _workspace_from_display(project_display, project_name=parts[3]) |
| 412 | |
| 413 | workdir_display = configured_workdir_display_path() |
| 414 | if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"): |
| 415 | return _workspace_from_display(workdir_display) |
| 416 | |
| 417 | return None |
| 418 | |
| 419 | |
| 420 | def _workspace_from_display(display_path: str, *, project_name: str = "", context_id: str = "") -> WorkspaceInfo: |
| 421 | normalized = canonical_workspace_display_path(display_path) |
| 422 | if not is_inside_usr_display(normalized): |
| 423 | raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.") |
| 424 | workspace_id = workspace_id_for(normalized) |
| 425 | shadow_path = real_path_for_display(f"{SHADOW_DISPLAY_ROOT}/{workspace_id}") |
| 426 | return WorkspaceInfo( |
| 427 | id=workspace_id, |
| 428 | display_path=normalized.rstrip("/") or normalized, |
| 429 | real_path=real_path_for_display(normalized), |
| 430 | shadow_path=shadow_path, |
| 431 | repo_git_path=shadow_path / "repo.git", |
| 432 | context_id=context_id, |
| 433 | project_name=project_name, |
| 434 | ) |
| 435 | |
| 436 | |
| 437 | def unavailable_payload(context_id: str, error: str) -> dict[str, Any]: |
| 438 | return { |
| 439 | "ok": True, |
| 440 | "context_id": context_id, |
| 441 | "workspace": { |
| 442 | "available": False, |
| 443 | "locked": True, |
| 444 | "path": "", |
| 445 | "display_path": "", |
| 446 | "error": error, |
| 447 | }, |
| 448 | "current_hash": "", |
| 449 | "present": clean_summary(), |
| 450 | "commits": [], |
| 451 | "has_more": False, |
| 452 | } |
| 453 | |
| 454 | |
| 455 | def clean_summary() -> dict[str, Any]: |
| 456 | return { |
| 457 | "dirty": False, |
| 458 | "files_count": 0, |
| 459 | "additions": 0, |
| 460 | "deletions": 0, |
| 461 | "files": [], |
| 462 | } |
| 463 | |
| 464 | |
| 465 | def snapshot_for_agent( |
| 466 | agent: Any, |
| 467 | *, |
| 468 | trigger: str, |
| 469 | metadata: dict[str, Any] | None = None, |
| 470 | debounced: bool = True, |
| 471 | ) -> SnapshotResult | None: |
| 472 | if not agent: |
| 473 | return None |
| 474 | |
| 475 | context_id = str(getattr(getattr(agent, "context", None), "id", "") or "") |
| 476 | try: |
| 477 | workspace = resolve_workspace(context_id, context_loader=lambda _ctxid: agent.context) |
| 478 | full_metadata = _agent_metadata(agent, metadata) |
| 479 | if debounced: |
| 480 | schedule_debounced_snapshot( |
| 481 | workspace, |
| 482 | trigger=trigger, |
| 483 | metadata=full_metadata, |
| 484 | changed_path_hints=_extract_changed_path_hints(full_metadata), |
| 485 | ) |
| 486 | return None |
| 487 | return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=full_metadata) |
| 488 | except WorkspaceRejectedError: |
| 489 | return None |
| 490 | except Exception as exc: |
| 491 | PrintStyle.error(f"Time Travel snapshot failed: {exc}") |
| 492 | return None |
| 493 | |
| 494 | |
| 495 | def snapshot_for_path_hint( |
| 496 | path_hint: str, |
| 497 | *, |
| 498 | trigger: str, |
| 499 | metadata: dict[str, Any] | None = None, |
| 500 | debounced: bool = True, |
| 501 | ) -> SnapshotResult | None: |
| 502 | try: |
| 503 | workspace = resolve_workspace_for_path_hint(path_hint) |
| 504 | if workspace is None: |
| 505 | return None |
| 506 | full_metadata = metadata or {} |
| 507 | if debounced: |
| 508 | schedule_debounced_snapshot( |
| 509 | workspace, |
| 510 | trigger=trigger, |
| 511 | metadata=full_metadata, |
| 512 | changed_path_hints=_extract_changed_path_hints(full_metadata), |
| 513 | ) |
| 514 | return None |
| 515 | return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=full_metadata) |
| 516 | except Exception as exc: |
| 517 | PrintStyle.error(f"Time Travel file-browser snapshot failed: {exc}") |
| 518 | return None |
| 519 | |
| 520 | |
| 521 | def register_watchdogs() -> None: |
| 522 | from helpers import watchdog |
| 523 | |
| 524 | root = real_path_for_display(USR_DISPLAY_ROOT) |
| 525 | if not root.exists() or not root.is_dir(): |
| 526 | return |
| 527 | |
| 528 | watchdog.add_watchdog( |
| 529 | id=WATCHDOG_ID, |
| 530 | roots=[str(root)], |
| 531 | patterns=["**/*"], |
| 532 | ignore_patterns=[ |
| 533 | "**/.git", |
| 534 | "**/.git/**", |
| 535 | "**/.time_travel", |
| 536 | "**/.time_travel/**", |
| 537 | "**/__pycache__", |
| 538 | "**/__pycache__/**", |
| 539 | "**/*.pyc", |
| 540 | "**/.pytest_cache/**", |
| 541 | "**/.mypy_cache/**", |
| 542 | "**/.ruff_cache/**", |
| 543 | "**/.cache/**", |
| 544 | "**/node_modules/**", |
| 545 | "**/.venv/**", |
| 546 | "**/venv/**", |
| 547 | "**/dist/**", |
| 548 | "**/build/**", |
| 549 | ], |
| 550 | events=["create", "modify", "delete", "move"], |
| 551 | debounce=WATCHDOG_DEBOUNCE_SECONDS, |
| 552 | handler=_handle_usr_watchdog_events, |
| 553 | ) |
| 554 | |
| 555 | |
| 556 | def schedule_debounced_snapshot( |
| 557 | workspace: WorkspaceInfo, |
| 558 | *, |
| 559 | trigger: str, |
| 560 | metadata: dict[str, Any] | None = None, |
| 561 | changed_path_hints: list[str] | None = None, |
| 562 | delay: float | None = None, |
| 563 | ) -> None: |
| 564 | clean_metadata = dict(metadata or {}) |
| 565 | metadata_hints = _extract_changed_path_hints(clean_metadata) |
| 566 | clean_metadata.pop("changed_path_hints", None) |
| 567 | hints = _merge_hints(metadata_hints, changed_path_hints or []) |
| 568 | delay_seconds = AUTO_SNAPSHOT_DEBOUNCE_SECONDS if delay is None else max(0.0, float(delay)) |
| 569 | with _AUTO_SNAPSHOT_LOCK: |
| 570 | payload = _AUTO_SNAPSHOT_PAYLOADS.get(workspace.id) |
| 571 | if payload is None: |
| 572 | payload = { |
| 573 | "workspace": workspace, |
| 574 | "trigger": trigger, |
| 575 | "metadata": clean_metadata, |
| 576 | "changed_path_hints": hints, |
| 577 | } |
| 578 | _AUTO_SNAPSHOT_PAYLOADS[workspace.id] = payload |
| 579 | timer = threading.Timer(delay_seconds, _flush_debounced_snapshot, args=(workspace.id,)) |
| 580 | timer.daemon = True |
| 581 | _AUTO_SNAPSHOT_TIMERS[workspace.id] = timer |
| 582 | timer.start() |
| 583 | return |
| 584 | |
| 585 | payload["trigger"] = trigger |
| 586 | payload["metadata"] = {**payload.get("metadata", {}), **clean_metadata} |
| 587 | payload["changed_path_hints"] = _merge_hints( |
| 588 | payload.get("changed_path_hints", []), |
| 589 | hints, |
| 590 | ) |
| 591 | |
| 592 | |
| 593 | def flush_debounced_snapshots() -> None: |
| 594 | with _AUTO_SNAPSHOT_LOCK: |
| 595 | workspace_ids = list(_AUTO_SNAPSHOT_PAYLOADS) |
| 596 | for workspace_id in workspace_ids: |
| 597 | timer = _AUTO_SNAPSHOT_TIMERS.pop(workspace_id, None) |
| 598 | timer and timer.cancel() |
| 599 | for workspace_id in workspace_ids: |
| 600 | _flush_debounced_snapshot(workspace_id) |
| 601 | |
| 602 | |
| 603 | def clear_debounced_snapshots() -> None: |
| 604 | with _AUTO_SNAPSHOT_LOCK: |
| 605 | timers = list(_AUTO_SNAPSHOT_TIMERS.values()) |
| 606 | _AUTO_SNAPSHOT_TIMERS.clear() |
| 607 | _AUTO_SNAPSHOT_PAYLOADS.clear() |
| 608 | for timer in timers: |
| 609 | timer.cancel() |
| 610 | |
| 611 | |
| 612 | def _flush_debounced_snapshot(workspace_id: str) -> None: |
| 613 | with _AUTO_SNAPSHOT_LOCK: |
| 614 | _AUTO_SNAPSHOT_TIMERS.pop(workspace_id, None) |
| 615 | payload = _AUTO_SNAPSHOT_PAYLOADS.pop(workspace_id, None) |
| 616 | if not payload: |
| 617 | return |
| 618 | |
| 619 | try: |
| 620 | workspace = payload["workspace"] |
| 621 | if not workspace.real_path.is_dir(): |
| 622 | return |
| 623 | TimeTravelService(workspace).snapshot( |
| 624 | trigger=str(payload.get("trigger") or "watchdog"), |
| 625 | metadata=payload.get("metadata") or {}, |
| 626 | changed_path_hints=payload.get("changed_path_hints") or None, |
| 627 | ) |
| 628 | except WorkspaceRejectedError: |
| 629 | return |
| 630 | except Exception as exc: |
| 631 | PrintStyle.error(f"Time Travel debounced snapshot failed: {exc}") |
| 632 | |
| 633 | |
| 634 | def _handle_usr_watchdog_events(items: list[Any]) -> None: |
| 635 | by_workspace: dict[str, tuple[WorkspaceInfo, list[str]]] = {} |
| 636 | for path, _event in items: |
| 637 | display_path = normalize_display_path(str(path or "")) |
| 638 | if not _is_watchdog_snapshot_candidate(display_path): |
| 639 | continue |
| 640 | workspace = resolve_workspace_for_path_hint(display_path) |
| 641 | if workspace is None: |
| 642 | continue |
| 643 | hints = by_workspace.setdefault(workspace.id, (workspace, []))[1] |
| 644 | hints.append(display_path) |
| 645 | |
| 646 | for workspace, hints in by_workspace.values(): |
| 647 | schedule_debounced_snapshot( |
| 648 | workspace, |
| 649 | trigger="watchdog", |
| 650 | metadata={ |
| 651 | "source": "watchdog", |
| 652 | "changed_path_hints": _merge_hints(hints), |
| 653 | }, |
| 654 | changed_path_hints=hints, |
| 655 | ) |
| 656 | |
| 657 | |
| 658 | def _agent_metadata(agent: Any, metadata: dict[str, Any] | None = None) -> dict[str, Any]: |
| 659 | from helpers import projects |
| 660 | |
| 661 | result = dict(metadata or {}) |
| 662 | context = getattr(agent, "context", None) |
| 663 | if context is not None: |
| 664 | result.setdefault("context_id", str(getattr(context, "id", "") or "")) |
| 665 | project_name = projects.get_context_project_name(context) or "" |
| 666 | if project_name: |
| 667 | result.setdefault("project_name", project_name) |
| 668 | tool = getattr(getattr(agent, "loop_data", None), "current_tool", None) |
| 669 | if tool is not None: |
| 670 | result.setdefault("tool_name", str(getattr(tool, "name", "") or "")) |
| 671 | args = getattr(tool, "args", None) |
| 672 | if isinstance(args, dict): |
| 673 | result.setdefault("runtime", str(args.get("runtime") or "")) |
| 674 | log = getattr(tool, "log", None) |
| 675 | if log is not None: |
| 676 | result.setdefault("log_item_id", str(getattr(log, "id", "") or "")) |
| 677 | result.setdefault("log_item_no", getattr(log, "no", None)) |
| 678 | return {key: value for key, value in result.items() if value not in (None, "")} |
| 679 | |
| 680 | |
| 681 | def _extract_changed_path_hints(metadata: dict[str, Any]) -> list[str]: |
| 682 | hints = metadata.get("changed_path_hints") |
| 683 | if not isinstance(hints, list): |
| 684 | return [] |
| 685 | return [str(path) for path in hints if path] |
| 686 | |
| 687 | |
| 688 | def _merge_hints(*groups: list[str]) -> list[str]: |
| 689 | merged: list[str] = [] |
| 690 | seen: set[str] = set() |
| 691 | for group in groups: |
| 692 | for path in group: |
| 693 | normalized = normalize_display_path(str(path or "")) |
| 694 | if not normalized or normalized in seen: |
| 695 | continue |
| 696 | merged.append(normalized) |
| 697 | seen.add(normalized) |
| 698 | return merged |
| 699 | |
| 700 | |
| 701 | def _is_watchdog_snapshot_candidate(display_path: str) -> bool: |
| 702 | normalized = normalize_display_path(display_path) |
| 703 | if not is_inside_usr_display(normalized): |
| 704 | return False |
| 705 | if normalized == "/a0/usr/plugins" or normalized.startswith("/a0/usr/plugins/"): |
| 706 | return False |
| 707 | parts = [part for part in normalized.split("/") if part] |
| 708 | return ".git" not in parts and ".time_travel" not in parts |
| 709 | |
| 710 | |
| 711 | class TimeTravelService: |
| 712 | def __init__(self, workspace: WorkspaceInfo): |
| 713 | self.workspace = workspace |
| 714 | |
| 715 | def ensure_repo(self) -> None: |
| 716 | self.workspace.shadow_path.mkdir(parents=True, exist_ok=True) |
| 717 | if not self._shadow_repo_valid(): |
| 718 | self._repair_shadow_repo_head() |
| 719 | if not self._shadow_repo_valid(): |
| 720 | self._initialize_shadow_repo(quarantine_existing=True) |
| 721 | self._ensure_current_head_ref() |
| 722 | |
| 723 | self._git("config", "user.name", "Agent Zero Time Travel") |
| 724 | self._git("config", "user.email", "time-travel@agent-zero.local") |
| 725 | self._git("config", "core.autocrlf", "false") |
| 726 | self._git("config", "core.filemode", "true") |
| 727 | |
| 728 | def current_hash(self) -> str: |
| 729 | self.ensure_repo() |
| 730 | completed = self._git("rev-parse", "--verify", "HEAD", check=False) |
| 731 | return completed.stdout.strip() if completed.returncode == 0 else "" |
| 732 | |
| 733 | def current_short_hash(self) -> str: |
| 734 | current = self.current_hash() |
| 735 | return current[:12] if current else "" |
| 736 | |
| 737 | def snapshot( |
| 738 | self, |
| 739 | *, |
| 740 | trigger: str = "manual", |
| 741 | message: str = "", |
| 742 | metadata: dict[str, Any] | None = None, |
| 743 | changed_path_hints: list[str] | None = None, |
| 744 | ) -> SnapshotResult: |
| 745 | self._ensure_workspace_dir() |
| 746 | self.ensure_repo() |
| 747 | previous_hash = self.current_hash() |
| 748 | tree_hash, included_paths = self._stage_current_tree() |
| 749 | |
| 750 | if previous_hash and self._commit_tree(previous_hash) == tree_hash: |
| 751 | return SnapshotResult( |
| 752 | created=False, |
| 753 | hash=previous_hash, |
| 754 | short_hash=previous_hash[:12], |
| 755 | tree_hash=tree_hash, |
| 756 | message=message or self._default_snapshot_message(trigger), |
| 757 | files=[], |
| 758 | metadata=self._metadata(trigger, metadata, changed_path_hints), |
| 759 | ) |
| 760 | |
| 761 | if not previous_hash and not included_paths: |
| 762 | return SnapshotResult( |
| 763 | created=False, |
| 764 | hash="", |
| 765 | short_hash="", |
| 766 | tree_hash=tree_hash, |
| 767 | message=message or self._default_snapshot_message(trigger), |
| 768 | files=[], |
| 769 | metadata=self._metadata(trigger, metadata, changed_path_hints), |
| 770 | ) |
| 771 | |
| 772 | full_metadata = self._metadata(trigger, metadata, changed_path_hints) |
| 773 | commit_message = self._commit_message(message or self._default_snapshot_message(trigger), full_metadata) |
| 774 | args = ["commit-tree", tree_hash] |
| 775 | if previous_hash: |
| 776 | args.extend(["-p", previous_hash]) |
| 777 | args.extend(["-F", "-"]) |
| 778 | env = self._git_env() |
| 779 | if timestamp := str(full_metadata.get("timestamp") or ""): |
| 780 | env["GIT_AUTHOR_DATE"] = timestamp |
| 781 | env["GIT_COMMITTER_DATE"] = timestamp |
| 782 | commit = self._git(*args, input=commit_message, env=env).stdout.strip() |
| 783 | self._git("update-ref", "HEAD", commit) |
| 784 | diff_base = previous_hash or EMPTY_TREE |
| 785 | return SnapshotResult( |
| 786 | created=True, |
| 787 | hash=commit, |
| 788 | short_hash=commit[:12], |
| 789 | tree_hash=tree_hash, |
| 790 | message=message or self._default_snapshot_message(trigger), |
| 791 | files=self.diff_files(diff_base, commit), |
| 792 | metadata=full_metadata, |
| 793 | ) |
| 794 | |
| 795 | def history_list(self, *, limit: int = 100, offset: int = 0, file_filter: str = "") -> dict[str, Any]: |
| 796 | self._ensure_workspace_dir() |
| 797 | self.ensure_repo() |
| 798 | limit = min(max(int(limit or 100), 1), 200) |
| 799 | offset = max(int(offset or 0), 0) |
| 800 | file_filter = str(file_filter or "").strip().lower() |
| 801 | current = self.current_hash() |
| 802 | present = self.present_summary() |
| 803 | |
| 804 | all_hashes = self._rev_list_all() |
| 805 | if file_filter: |
| 806 | all_hashes = [ |
| 807 | commit_hash |
| 808 | for commit_hash in all_hashes |
| 809 | if any( |
| 810 | file_filter in str(item.get("path") or "").lower() |
| 811 | or file_filter in str(item.get("old_path") or "").lower() |
| 812 | for item in self.commit_files(commit_hash) |
| 813 | ) |
| 814 | ] |
| 815 | |
| 816 | window = all_hashes[offset : offset + limit + 1] |
| 817 | visible = window[:limit] |
| 818 | return { |
| 819 | "ok": True, |
| 820 | "context_id": self.workspace.context_id, |
| 821 | "workspace": self.workspace.public(), |
| 822 | "current_hash": current, |
| 823 | "present": present, |
| 824 | "commits": [self.commit_object(commit_hash, current_hash=current) for commit_hash in visible], |
| 825 | "has_more": len(window) > limit, |
| 826 | } |
| 827 | |
| 828 | def history_diff(self, *, commit_hash: str, path: str, mode: str = "commit") -> dict[str, Any]: |
| 829 | self.ensure_repo() |
| 830 | path = self._safe_rel_path(path) |
| 831 | mode = str(mode or "commit").strip().lower() |
| 832 | |
| 833 | if mode in {"present", "current"}: |
| 834 | base = self.current_hash() or EMPTY_TREE |
| 835 | target, _paths = self._current_tree() |
| 836 | else: |
| 837 | commit_hash = self._validate_commit(commit_hash) |
| 838 | base = self._first_parent(commit_hash) or EMPTY_TREE |
| 839 | target = commit_hash |
| 840 | |
| 841 | return self._patch_payload(base, target, path) |
| 842 | |
| 843 | def preview(self, *, operation: str, commit_hash: str) -> dict[str, Any]: |
| 844 | self.ensure_repo() |
| 845 | operation = str(operation or "").strip().lower() |
| 846 | commit_hash = self._validate_commit(commit_hash) |
| 847 | current = self.current_hash() or EMPTY_TREE |
| 848 | |
| 849 | if operation == "travel": |
| 850 | base = current |
| 851 | target = commit_hash |
| 852 | elif operation == "revert": |
| 853 | base = commit_hash |
| 854 | target = self._first_parent(commit_hash) or EMPTY_TREE |
| 855 | else: |
| 856 | raise TimeTravelError("Unsupported preview operation.") |
| 857 | |
| 858 | files_changed = self.diff_files(base, target) |
| 859 | previews = [] |
| 860 | for item in files_changed[:12]: |
| 861 | rel_path = str(item.get("path") or item.get("old_path") or "") |
| 862 | if not rel_path: |
| 863 | continue |
| 864 | previews.append(self._patch_payload(base, target, rel_path)) |
| 865 | |
| 866 | return { |
| 867 | "ok": True, |
| 868 | "operation": operation, |
| 869 | "commit_hash": commit_hash, |
| 870 | "short_hash": commit_hash[:12], |
| 871 | "files": files_changed, |
| 872 | "previews": previews, |
| 873 | } |
| 874 | |
| 875 | def travel(self, *, commit_hash: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: |
| 876 | self._ensure_workspace_dir() |
| 877 | self.ensure_repo() |
| 878 | target = self._validate_commit(commit_hash) |
| 879 | before = self.snapshot(trigger="before_travel", metadata=metadata or {}) |
| 880 | previous = self.current_hash() |
| 881 | if previous: |
| 882 | self._preserve_ref(previous, reason="travel") |
| 883 | affected = self.diff_files(previous or EMPTY_TREE, target) |
| 884 | self._apply_commit_tree(previous or EMPTY_TREE, target, affected) |
| 885 | self._git("update-ref", "HEAD", target) |
| 886 | return { |
| 887 | "ok": True, |
| 888 | "operation": "travel", |
| 889 | "current_hash": target, |
| 890 | "previous_hash": previous, |
| 891 | "preserved_hash": previous, |
| 892 | "auto_snapshot": _snapshot_public(before), |
| 893 | "affected_files": affected, |
| 894 | } |
| 895 | |
| 896 | def revert(self, *, commit_hash: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: |
| 897 | self._ensure_workspace_dir() |
| 898 | self.ensure_repo() |
| 899 | target = self._validate_commit(commit_hash) |
| 900 | before = self.snapshot(trigger="before_revert", metadata=metadata or {}) |
| 901 | parent = self._first_parent(target) or EMPTY_TREE |
| 902 | patch = self._git_bytes("diff", "--binary", parent, target).stdout |
| 903 | if patch: |
| 904 | checked = self._git_bytes("apply", "--reverse", "--check", "--binary", "--whitespace=nowarn", input=patch, check=False) |
| 905 | if checked.returncode != 0: |
| 906 | raise TimeTravelConflictError(_compact_git_error(checked.stderr.decode("utf-8", "replace"))) |
| 907 | applied = self._git_bytes("apply", "--reverse", "--binary", "--whitespace=nowarn", input=patch, check=False) |
| 908 | if applied.returncode != 0: |
| 909 | raise TimeTravelConflictError(_compact_git_error(applied.stderr.decode("utf-8", "replace"))) |
| 910 | |
| 911 | after = self.snapshot( |
| 912 | trigger="revert", |
| 913 | message=f"Revert {target[:12]}", |
| 914 | metadata={ |
| 915 | **(metadata or {}), |
| 916 | "reverted_commit": target, |
| 917 | }, |
| 918 | ) |
| 919 | return { |
| 920 | "ok": True, |
| 921 | "operation": "revert", |
| 922 | "current_hash": after.hash, |
| 923 | "auto_snapshot": _snapshot_public(before), |
| 924 | "snapshot": _snapshot_public(after), |
| 925 | "affected_files": after.files, |
| 926 | } |
| 927 | |
| 928 | def present_summary(self) -> dict[str, Any]: |
| 929 | self.ensure_repo() |
| 930 | current_tree, _paths = self._current_tree() |
| 931 | current = self.current_hash() |
| 932 | base = current or EMPTY_TREE |
| 933 | base_tree = self._commit_tree(current) if current else EMPTY_TREE |
| 934 | if base_tree == current_tree: |
| 935 | return clean_summary() |
| 936 | changed = self.diff_files(base, current_tree) |
| 937 | return { |
| 938 | "dirty": bool(changed), |
| 939 | "files_count": len(changed), |
| 940 | "additions": sum(int(item.get("additions") or 0) for item in changed), |
| 941 | "deletions": sum(int(item.get("deletions") or 0) for item in changed), |
| 942 | "files": changed, |
| 943 | } |
| 944 | |
| 945 | def commit_object(self, commit_hash: str, *, current_hash: str = "") -> dict[str, Any]: |
| 946 | commit_hash = self._validate_commit(commit_hash) |
| 947 | show = self._git("show", "-s", "--format=%H%x00%h%x00%cI%x00%s%x00%B", commit_hash).stdout |
| 948 | parts = show.split("\0", 4) |
| 949 | full_hash = parts[0].strip() |
| 950 | short_hash = parts[1].strip() if len(parts) > 1 else full_hash[:12] |
| 951 | timestamp = parts[2].strip() if len(parts) > 2 else "" |
| 952 | subject = parts[3].strip() if len(parts) > 3 else "" |
| 953 | body = parts[4] if len(parts) > 4 else "" |
| 954 | metadata = self._parse_metadata(body) |
| 955 | return { |
| 956 | "hash": full_hash, |
| 957 | "short_hash": short_hash, |
| 958 | "timestamp": timestamp, |
| 959 | "message": subject, |
| 960 | "is_current": bool(current_hash and full_hash == current_hash), |
| 961 | "metadata": metadata, |
| 962 | "files": self.commit_files(full_hash), |
| 963 | } |
| 964 | |
| 965 | def commit_files(self, commit_hash: str) -> list[dict[str, Any]]: |
| 966 | commit_hash = self._validate_commit(commit_hash) |
| 967 | parent = self._first_parent(commit_hash) or EMPTY_TREE |
| 968 | return self.diff_files(parent, commit_hash) |
| 969 | |
| 970 | def diff_files(self, base: str, target: str, *, path_filter: str = "") -> list[dict[str, Any]]: |
| 971 | args = ["diff", "--name-status", "-z", "--find-renames", base, target] |
| 972 | path_filter = str(path_filter or "").strip() |
| 973 | if path_filter: |
| 974 | args.extend(["--", path_filter]) |
| 975 | output = self._git(*args).stdout |
| 976 | entries = _parse_name_status(output) |
| 977 | result: list[dict[str, Any]] = [] |
| 978 | for entry in entries: |
| 979 | path = entry["path"] |
| 980 | old_path = entry.get("old_path", "") |
| 981 | additions, deletions, binary = self._numstat(base, target, [p for p in (old_path, path) if p]) |
| 982 | action = STATUS_LABELS.get(entry["status"], entry["status"].lower()) |
| 983 | result.append( |
| 984 | { |
| 985 | "path": path, |
| 986 | "old_path": old_path, |
| 987 | "status": action, |
| 988 | "action": action, |
| 989 | "additions": additions, |
| 990 | "deletions": deletions, |
| 991 | "binary": binary, |
| 992 | } |
| 993 | ) |
| 994 | return result |
| 995 | |
| 996 | def _current_tree(self) -> tuple[str, list[str]]: |
| 997 | return self._stage_current_tree() |
| 998 | |
| 999 | def _stage_current_tree(self) -> tuple[str, list[str]]: |
| 1000 | self.ensure_repo() |
| 1001 | self._git("read-tree", "--empty") |
| 1002 | paths = list(iter_snapshot_paths(self.workspace.real_path, display_path=self.workspace.display_path)) |
| 1003 | if paths: |
| 1004 | payload = "\0".join(paths).encode("utf-8") + b"\0" |
| 1005 | self._git_bytes( |
| 1006 | "add", |
| 1007 | "-f", |
| 1008 | "-A", |
| 1009 | "--pathspec-from-file=-", |
| 1010 | "--pathspec-file-nul", |
| 1011 | input=payload, |
| 1012 | ) |
| 1013 | tree_hash = self._git("write-tree").stdout.strip() |
| 1014 | return tree_hash, paths |
| 1015 | |
| 1016 | def _apply_commit_tree(self, base: str, target: str, affected: list[dict[str, Any]]) -> None: |
| 1017 | delete_paths: list[str] = [] |
| 1018 | write_paths: list[str] = [] |
| 1019 | for item in affected: |
| 1020 | action = str(item.get("action") or item.get("status") or "") |
| 1021 | old_path = str(item.get("old_path") or "") |
| 1022 | path = str(item.get("path") or "") |
| 1023 | if old_path and old_path != path: |
| 1024 | delete_paths.append(old_path) |
| 1025 | if action == "deleted": |
| 1026 | delete_paths.append(path) |
| 1027 | else: |
| 1028 | write_paths.append(path) |
| 1029 | |
| 1030 | for rel_path in sorted(set(delete_paths), key=lambda value: value.count("/"), reverse=True): |
| 1031 | self._delete_workspace_entry(rel_path) |
| 1032 | for rel_path in sorted(set(write_paths)): |
| 1033 | self._materialize_tree_path(target, rel_path) |
| 1034 | self._prune_empty_dirs() |
| 1035 | |
| 1036 | def _materialize_tree_path(self, commit_hash: str, rel_path: str) -> None: |
| 1037 | rel_path = self._safe_rel_path(rel_path) |
| 1038 | entry = self._tree_entry(commit_hash, rel_path) |
| 1039 | if entry is None: |
| 1040 | self._delete_workspace_entry(rel_path) |
| 1041 | return |
| 1042 | mode, obj_type, obj_hash = entry |
| 1043 | if obj_type != "blob": |
| 1044 | return |
| 1045 | target_path = self._workspace_child(rel_path) |
| 1046 | data = self._git_bytes("cat-file", "-p", obj_hash).stdout |
| 1047 | self._prepare_parent(target_path) |
| 1048 | if target_path.exists() or target_path.is_symlink(): |
| 1049 | self._remove_for_replacement(target_path) |
| 1050 | if mode == "120000": |
| 1051 | os.symlink(data.decode("utf-8", errors="replace"), target_path) |
| 1052 | else: |
| 1053 | target_path.write_bytes(data) |
| 1054 | if mode == "100755": |
| 1055 | target_path.chmod(0o755) |
| 1056 | |
| 1057 | def _delete_workspace_entry(self, rel_path: str) -> None: |
| 1058 | rel_path = self._safe_rel_path(rel_path) |
| 1059 | target_path = self._workspace_child(rel_path) |
| 1060 | if target_path.is_symlink() or target_path.is_file(): |
| 1061 | target_path.unlink() |
| 1062 | elif target_path.exists(): |
| 1063 | if target_path.is_dir() and not any(target_path.iterdir()): |
| 1064 | target_path.rmdir() |
| 1065 | else: |
| 1066 | raise TimeTravelConflictError( |
| 1067 | f"Cannot safely replace non-empty directory: {rel_path}" |
| 1068 | ) |
| 1069 | |
| 1070 | def _prepare_parent(self, target_path: Path) -> None: |
| 1071 | current = self.workspace.real_path |
| 1072 | rel_parts = target_path.relative_to(self.workspace.real_path).parts[:-1] |
| 1073 | for part in rel_parts: |
| 1074 | current = current / part |
| 1075 | if current.is_symlink() or current.is_file(): |
| 1076 | self._remove_for_replacement(current) |
| 1077 | current.mkdir(exist_ok=True) |
| 1078 | |
| 1079 | def _remove_for_replacement(self, target_path: Path) -> None: |
| 1080 | if target_path.is_symlink() or target_path.is_file(): |
| 1081 | target_path.unlink() |
| 1082 | return |
| 1083 | if target_path.is_dir(): |
| 1084 | if any(target_path.iterdir()): |
| 1085 | raise TimeTravelConflictError( |
| 1086 | f"Cannot safely replace non-empty directory: {self._rel_from_workspace(target_path)}" |
| 1087 | ) |
| 1088 | target_path.rmdir() |
| 1089 | |
| 1090 | def _prune_empty_dirs(self) -> None: |
| 1091 | for root, dirs, _filenames in os.walk(self.workspace.real_path, topdown=False, followlinks=False): |
| 1092 | root_path = Path(root) |
| 1093 | if root_path == self.workspace.real_path: |
| 1094 | continue |
| 1095 | if not is_snapshot_candidate(root_path.relative_to(self.workspace.real_path).as_posix(), is_dir=True): |
| 1096 | continue |
| 1097 | try: |
| 1098 | root_path.rmdir() |
| 1099 | except OSError: |
| 1100 | pass |
| 1101 | |
| 1102 | def _tree_entry(self, commit_hash: str, rel_path: str) -> tuple[str, str, str] | None: |
| 1103 | completed = self._git("ls-tree", "-z", commit_hash, "--", rel_path, check=False) |
| 1104 | if completed.returncode != 0 or not completed.stdout: |
| 1105 | return None |
| 1106 | record = completed.stdout.split("\0", 1)[0] |
| 1107 | meta, _sep, _name = record.partition("\t") |
| 1108 | parts = meta.split() |
| 1109 | if len(parts) < 3: |
| 1110 | return None |
| 1111 | return parts[0], parts[1], parts[2] |
| 1112 | |
| 1113 | def _patch_payload(self, base: str, target: str, path: str) -> dict[str, Any]: |
| 1114 | path = self._safe_rel_path(path) |
| 1115 | additions, deletions, binary = self._numstat(base, target, [path]) |
| 1116 | completed = self._git_bytes("diff", "--binary", "--patch", base, target, "--", path, check=False) |
| 1117 | data = completed.stdout or b"" |
| 1118 | too_large = len(data) > MAX_RENDERED_PATCH_BYTES |
| 1119 | rendered = data[:MAX_RENDERED_PATCH_BYTES].decode("utf-8", errors="replace") |
| 1120 | return { |
| 1121 | "ok": completed.returncode == 0, |
| 1122 | "path": path, |
| 1123 | "patch": "" if binary else rendered, |
| 1124 | "binary": binary, |
| 1125 | "too_large": too_large, |
| 1126 | "additions": additions, |
| 1127 | "deletions": deletions, |
| 1128 | "error": "" if completed.returncode == 0 else completed.stderr.decode("utf-8", errors="replace"), |
| 1129 | } |
| 1130 | |
| 1131 | def _numstat(self, base: str, target: str, paths: list[str]) -> tuple[int, int, bool]: |
| 1132 | if not paths: |
| 1133 | return 0, 0, False |
| 1134 | output = self._git("diff", "--numstat", "--find-renames", base, target, "--", *paths, check=False).stdout |
| 1135 | additions = 0 |
| 1136 | deletions = 0 |
| 1137 | binary = False |
| 1138 | for line in output.splitlines(): |
| 1139 | if not line.strip(): |
| 1140 | continue |
| 1141 | parts = line.split("\t") |
| 1142 | if len(parts) < 2: |
| 1143 | continue |
| 1144 | if parts[0] == "-" or parts[1] == "-": |
| 1145 | binary = True |
| 1146 | continue |
| 1147 | additions += _safe_int(parts[0]) |
| 1148 | deletions += _safe_int(parts[1]) |
| 1149 | return additions, deletions, binary |
| 1150 | |
| 1151 | def _rev_list_all(self) -> list[str]: |
| 1152 | completed = self._git("rev-list", "--date-order", "--all", check=False) |
| 1153 | if completed.returncode != 0: |
| 1154 | return [] |
| 1155 | seen: set[str] = set() |
| 1156 | result: list[str] = [] |
| 1157 | for line in completed.stdout.splitlines(): |
| 1158 | commit = line.strip() |
| 1159 | if commit and commit not in seen: |
| 1160 | result.append(commit) |
| 1161 | seen.add(commit) |
| 1162 | return result |
| 1163 | |
| 1164 | def _validate_commit(self, commit_hash: str) -> str: |
| 1165 | candidate = str(commit_hash or "").strip() |
| 1166 | if not candidate: |
| 1167 | raise TimeTravelError("Commit hash is required.") |
| 1168 | completed = self._git("rev-parse", "--verify", f"{candidate}^{{commit}}", check=False) |
| 1169 | if completed.returncode != 0: |
| 1170 | raise TimeTravelError("Unknown Time Travel commit.") |
| 1171 | return completed.stdout.strip() |
| 1172 | |
| 1173 | def _first_parent(self, commit_hash: str) -> str: |
| 1174 | completed = self._git("rev-list", "--parents", "-n", "1", commit_hash) |
| 1175 | parts = completed.stdout.strip().split() |
| 1176 | return parts[1] if len(parts) > 1 else "" |
| 1177 | |
| 1178 | def _commit_tree(self, commit_hash: str) -> str: |
| 1179 | return self._git("show", "-s", "--format=%T", commit_hash).stdout.strip() |
| 1180 | |
| 1181 | def _preserve_ref(self, commit_hash: str, *, reason: str) -> str: |
| 1182 | stamp = Localization.get().now().strftime("%Y%m%d%H%M%S") |
| 1183 | base_ref = f"{PRESERVED_REF_PREFIX}/{stamp}-{reason}-{commit_hash[:12]}" |
| 1184 | ref = base_ref |
| 1185 | counter = 2 |
| 1186 | while self._git("show-ref", "--verify", "--quiet", ref, check=False).returncode == 0: |
| 1187 | ref = f"{base_ref}-{counter}" |
| 1188 | counter += 1 |
| 1189 | self._git("update-ref", ref, commit_hash) |
| 1190 | return ref |
| 1191 | |
| 1192 | def _commit_message(self, message: str, metadata: dict[str, Any]) -> str: |
| 1193 | encoded = base64.b64encode(json.dumps(metadata, sort_keys=True).encode("utf-8")).decode("ascii") |
| 1194 | return f"{message.strip() or 'Snapshot'}\n\n{METADATA_PREFIX} {encoded}\n" |
| 1195 | |
| 1196 | def _parse_metadata(self, body: str) -> dict[str, Any]: |
| 1197 | for line in body.splitlines(): |
| 1198 | if line.startswith(METADATA_PREFIX): |
| 1199 | encoded = line.removeprefix(METADATA_PREFIX).strip() |
| 1200 | try: |
| 1201 | return json.loads(base64.b64decode(encoded).decode("utf-8")) |
| 1202 | except Exception: |
| 1203 | return {} |
| 1204 | return {} |
| 1205 | |
| 1206 | def _metadata( |
| 1207 | self, |
| 1208 | trigger: str, |
| 1209 | metadata: dict[str, Any] | None, |
| 1210 | changed_path_hints: list[str] | None, |
| 1211 | ) -> dict[str, Any]: |
| 1212 | result = dict(metadata or {}) |
| 1213 | result.setdefault("context_id", self.workspace.context_id) |
| 1214 | result.setdefault("project_name", self.workspace.project_name) |
| 1215 | result.setdefault("trigger", trigger) |
| 1216 | result.setdefault("timestamp", now_iso()) |
| 1217 | hints = [normalize_display_path(path) for path in (changed_path_hints or []) if path] |
| 1218 | if hints: |
| 1219 | result.setdefault("changed_path_hints", hints) |
| 1220 | return {key: value for key, value in result.items() if value not in (None, "")} |
| 1221 | |
| 1222 | def _default_snapshot_message(self, trigger: str) -> str: |
| 1223 | label = str(trigger or "snapshot").replace("_", " ").strip().title() |
| 1224 | return f"Snapshot: {label}" |
| 1225 | |
| 1226 | def _ensure_workspace_dir(self) -> None: |
| 1227 | if not self.workspace.real_path.exists(): |
| 1228 | raise TimeTravelError("Workspace path does not exist.") |
| 1229 | if not self.workspace.real_path.is_dir(): |
| 1230 | raise TimeTravelError("Workspace path is not a directory.") |
| 1231 | |
| 1232 | def _safe_rel_path(self, path: str) -> str: |
| 1233 | rel = str(path or "").replace("\\", "/").lstrip("/") |
| 1234 | normalized = posixpath.normpath(rel) |
| 1235 | if not normalized or normalized == "." or normalized.startswith("../") or normalized == "..": |
| 1236 | raise TimeTravelError("Invalid path.") |
| 1237 | return normalized |
| 1238 | |
| 1239 | def _workspace_child(self, rel_path: str) -> Path: |
| 1240 | rel = self._safe_rel_path(rel_path) |
| 1241 | path = self.workspace.real_path.joinpath(*rel.split("/")) |
| 1242 | try: |
| 1243 | path.relative_to(self.workspace.real_path) |
| 1244 | except ValueError: |
| 1245 | raise TimeTravelError("Invalid path.") |
| 1246 | return path |
| 1247 | |
| 1248 | def _rel_from_workspace(self, path: Path) -> str: |
| 1249 | try: |
| 1250 | return path.relative_to(self.workspace.real_path).as_posix() |
| 1251 | except ValueError: |
| 1252 | return str(path) |
| 1253 | |
| 1254 | def _git_env(self) -> dict[str, str]: |
| 1255 | env = os.environ.copy() |
| 1256 | env["GIT_TERMINAL_PROMPT"] = "0" |
| 1257 | env["GIT_OPTIONAL_LOCKS"] = "0" |
| 1258 | return env |
| 1259 | |
| 1260 | def _run_git_dir(self, *args: str, check: bool = False) -> subprocess.CompletedProcess[str]: |
| 1261 | return subprocess.run( |
| 1262 | ["git", f"--git-dir={self.workspace.repo_git_path}", *args], |
| 1263 | capture_output=True, |
| 1264 | text=True, |
| 1265 | encoding="utf-8", |
| 1266 | errors="replace", |
| 1267 | env=self._git_env(), |
| 1268 | timeout=GIT_TIMEOUT_SECONDS, |
| 1269 | check=check, |
| 1270 | ) |
| 1271 | |
| 1272 | def _shadow_repo_valid(self) -> bool: |
| 1273 | if not self.workspace.repo_git_path.is_dir(): |
| 1274 | return False |
| 1275 | completed = self._run_git_dir("rev-parse", "--git-dir") |
| 1276 | return completed.returncode == 0 |
| 1277 | |
| 1278 | def _repair_shadow_repo_head(self) -> None: |
| 1279 | if not self.workspace.repo_git_path.is_dir(): |
| 1280 | return |
| 1281 | if not (self.workspace.repo_git_path / "objects").is_dir() or not (self.workspace.repo_git_path / "refs").is_dir(): |
| 1282 | return |
| 1283 | target_ref = CURRENT_REF if self._loose_ref_exists(CURRENT_REF) else self._first_loose_head_ref() |
| 1284 | try: |
| 1285 | (self.workspace.repo_git_path / "HEAD").write_text(f"ref: {target_ref}\n", encoding="utf-8") |
| 1286 | except OSError: |
| 1287 | return |
| 1288 | |
| 1289 | def _initialize_shadow_repo(self, *, quarantine_existing: bool = False) -> None: |
| 1290 | if quarantine_existing and self.workspace.repo_git_path.exists(): |
| 1291 | backup_path = self._next_invalid_repo_backup_path() |
| 1292 | shutil.move(str(self.workspace.repo_git_path), str(backup_path)) |
| 1293 | completed = subprocess.run( |
| 1294 | ["git", "init", "--bare", str(self.workspace.repo_git_path)], |
| 1295 | capture_output=True, |
| 1296 | text=True, |
| 1297 | encoding="utf-8", |
| 1298 | errors="replace", |
| 1299 | env=self._git_env(), |
| 1300 | timeout=GIT_TIMEOUT_SECONDS, |
| 1301 | ) |
| 1302 | if completed.returncode != 0: |
| 1303 | raise GitCommandError( |
| 1304 | (completed.stderr or completed.stdout or "Could not initialize shadow Git repository.").strip(), |
| 1305 | stdout=completed.stdout, |
| 1306 | stderr=completed.stderr, |
| 1307 | ) |
| 1308 | updated = self._run_git_dir("symbolic-ref", "HEAD", CURRENT_REF) |
| 1309 | if updated.returncode != 0: |
| 1310 | raise GitCommandError( |
| 1311 | (updated.stderr or updated.stdout or "Could not initialize shadow Git HEAD.").strip(), |
| 1312 | stdout=updated.stdout, |
| 1313 | stderr=updated.stderr, |
| 1314 | ) |
| 1315 | |
| 1316 | def _loose_ref_exists(self, ref: str) -> bool: |
| 1317 | return self.workspace.repo_git_path.joinpath(*ref.split("/")).is_file() |
| 1318 | |
| 1319 | def _first_loose_head_ref(self) -> str: |
| 1320 | heads_dir = self.workspace.repo_git_path / "refs" / "heads" |
| 1321 | try: |
| 1322 | refs = sorted(path for path in heads_dir.rglob("*") if path.is_file()) |
| 1323 | except OSError: |
| 1324 | refs = [] |
| 1325 | if not refs: |
| 1326 | return CURRENT_REF |
| 1327 | return "refs/heads/" + refs[0].relative_to(heads_dir).as_posix() |
| 1328 | |
| 1329 | def _next_invalid_repo_backup_path(self) -> Path: |
| 1330 | stamp = Localization.get().now().strftime("%Y%m%d%H%M%S") |
| 1331 | base_path = self.workspace.shadow_path / f"{SHADOW_REPO_BACKUP_PREFIX}-{stamp}" |
| 1332 | backup_path = base_path |
| 1333 | counter = 2 |
| 1334 | while backup_path.exists(): |
| 1335 | backup_path = self.workspace.shadow_path / f"{base_path.name}-{counter}" |
| 1336 | counter += 1 |
| 1337 | return backup_path |
| 1338 | |
| 1339 | def _ensure_current_head_ref(self) -> None: |
| 1340 | current_ref = self._run_git_dir("symbolic-ref", "-q", "HEAD") |
| 1341 | if current_ref.returncode == 0 and current_ref.stdout.strip() == CURRENT_REF: |
| 1342 | return |
| 1343 | |
| 1344 | current_commit = self._run_git_dir("rev-parse", "--verify", "HEAD^{commit}") |
| 1345 | if current_commit.returncode == 0: |
| 1346 | self._run_git_dir("update-ref", CURRENT_REF, current_commit.stdout.strip()) |
| 1347 | |
| 1348 | updated = self._run_git_dir("symbolic-ref", "HEAD", CURRENT_REF) |
| 1349 | if updated.returncode != 0: |
| 1350 | raise GitCommandError( |
| 1351 | (updated.stderr or updated.stdout or "Could not repair shadow Git HEAD.").strip(), |
| 1352 | stdout=updated.stdout, |
| 1353 | stderr=updated.stderr, |
| 1354 | ) |
| 1355 | |
| 1356 | def _git(self, *args: str, input: str | None = None, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: |
| 1357 | self.workspace.shadow_path.mkdir(parents=True, exist_ok=True) |
| 1358 | completed = subprocess.run( |
| 1359 | [ |
| 1360 | "git", |
| 1361 | f"--git-dir={self.workspace.repo_git_path}", |
| 1362 | f"--work-tree={self.workspace.real_path}", |
| 1363 | "-c", |
| 1364 | "core.bare=false", |
| 1365 | *args, |
| 1366 | ], |
| 1367 | input=input, |
| 1368 | capture_output=True, |
| 1369 | text=True, |
| 1370 | encoding="utf-8", |
| 1371 | errors="replace", |
| 1372 | env=env or self._git_env(), |
| 1373 | cwd=str(self.workspace.real_path) if self.workspace.real_path.exists() else None, |
| 1374 | timeout=GIT_TIMEOUT_SECONDS, |
| 1375 | ) |
| 1376 | if check and completed.returncode != 0: |
| 1377 | raise GitCommandError( |
| 1378 | (completed.stderr or completed.stdout or "Git command failed.").strip(), |
| 1379 | stdout=completed.stdout, |
| 1380 | stderr=completed.stderr, |
| 1381 | ) |
| 1382 | return completed |
| 1383 | |
| 1384 | def _git_bytes(self, *args: str, input: bytes | None = None, check: bool = True) -> subprocess.CompletedProcess[bytes]: |
| 1385 | self.workspace.shadow_path.mkdir(parents=True, exist_ok=True) |
| 1386 | completed = subprocess.run( |
| 1387 | [ |
| 1388 | "git", |
| 1389 | f"--git-dir={self.workspace.repo_git_path}", |
| 1390 | f"--work-tree={self.workspace.real_path}", |
| 1391 | "-c", |
| 1392 | "core.bare=false", |
| 1393 | *args, |
| 1394 | ], |
| 1395 | input=input, |
| 1396 | capture_output=True, |
| 1397 | env=self._git_env(), |
| 1398 | cwd=str(self.workspace.real_path) if self.workspace.real_path.exists() else None, |
| 1399 | timeout=GIT_TIMEOUT_SECONDS, |
| 1400 | ) |
| 1401 | if check and completed.returncode != 0: |
| 1402 | stderr = completed.stderr.decode("utf-8", errors="replace") |
| 1403 | stdout = completed.stdout.decode("utf-8", errors="replace") |
| 1404 | raise GitCommandError((stderr or stdout or "Git command failed.").strip(), stdout=stdout, stderr=stderr) |
| 1405 | return completed |
| 1406 | |
| 1407 | |
| 1408 | def iter_snapshot_paths(workspace: Path, *, display_path: str = "") -> Iterable[str]: |
| 1409 | workspace = workspace.resolve(strict=False) |
| 1410 | if display_path: |
| 1411 | root_is_usr = normalize_display_path(display_path) == USR_DISPLAY_ROOT |
| 1412 | else: |
| 1413 | root_is_usr = workspace == real_path_for_display(USR_DISPLAY_ROOT) |
| 1414 | |
| 1415 | def walk(folder: Path, rel_prefix: str = "") -> Iterable[str]: |
| 1416 | try: |
| 1417 | with os.scandir(folder) as iterator: |
| 1418 | entries = sorted(iterator, key=lambda entry: entry.name) |
| 1419 | except OSError: |
| 1420 | return |
| 1421 | for entry in entries: |
| 1422 | rel = f"{rel_prefix}/{entry.name}" if rel_prefix else entry.name |
| 1423 | rel = rel.replace("\\", "/") |
| 1424 | try: |
| 1425 | is_dir = entry.is_dir(follow_symlinks=False) |
| 1426 | is_file = entry.is_file(follow_symlinks=False) |
| 1427 | is_link = entry.is_symlink() |
| 1428 | except OSError: |
| 1429 | continue |
| 1430 | if is_dir: |
| 1431 | if root_is_usr and not rel_prefix and entry.name in USR_ROOT_EXCLUDED_DIR_NAMES: |
| 1432 | continue |
| 1433 | if _is_nested_git_worktree_dir(Path(entry.path), workspace): |
| 1434 | continue |
| 1435 | if not is_snapshot_candidate(rel, is_dir=True): |
| 1436 | continue |
| 1437 | yield from walk(Path(entry.path), rel) |
| 1438 | elif (is_file or is_link) and is_snapshot_candidate(rel, is_dir=False): |
| 1439 | yield rel |
| 1440 | |
| 1441 | yield from walk(workspace) |
| 1442 | |
| 1443 | |
| 1444 | def _is_nested_git_worktree_dir(folder: Path, workspace: Path) -> bool: |
| 1445 | try: |
| 1446 | if folder.resolve(strict=False) == workspace.resolve(strict=False): |
| 1447 | return False |
| 1448 | except OSError: |
| 1449 | return False |
| 1450 | dot_git = folder / ".git" |
| 1451 | return dot_git.exists() or dot_git.is_symlink() |
| 1452 | |
| 1453 | |
| 1454 | def is_snapshot_candidate(rel_path: str, *, is_dir: bool) -> bool: |
| 1455 | rel = rel_path.replace("\\", "/").strip("/") |
| 1456 | if not rel: |
| 1457 | return False |
| 1458 | parts = rel.split("/") |
| 1459 | name = parts[-1] |
| 1460 | |
| 1461 | if name == ".git" or ".git" in parts: |
| 1462 | return False |
| 1463 | if name == ".time_travel" or ".time_travel" in parts: |
| 1464 | return False |
| 1465 | if name in {"secrets.env", "variables.env"}: |
| 1466 | return False |
| 1467 | |
| 1468 | if rel.startswith(".a0proj/"): |
| 1469 | return _is_safe_a0proj_candidate(rel, is_dir=is_dir) |
| 1470 | |
| 1471 | if is_dir: |
| 1472 | if name in EXCLUDED_DIR_NAMES: |
| 1473 | return False |
| 1474 | if any(fnmatch.fnmatch(name, pattern) for pattern in EXCLUDED_DIR_PATTERNS): |
| 1475 | return False |
| 1476 | return True |
| 1477 | |
| 1478 | if any(fnmatch.fnmatch(name, pattern) for pattern in EXCLUDED_FILE_PATTERNS): |
| 1479 | return False |
| 1480 | return True |
| 1481 | |
| 1482 | |
| 1483 | def _is_safe_a0proj_candidate(rel: str, *, is_dir: bool) -> bool: |
| 1484 | if rel in {".a0proj/secrets.env", ".a0proj/variables.env"}: |
| 1485 | return False |
| 1486 | if rel == ".a0proj/memory" or rel.startswith(".a0proj/memory/"): |
| 1487 | return False |
| 1488 | if is_dir: |
| 1489 | return ( |
| 1490 | rel == ".a0proj" |
| 1491 | or any(prefix.startswith(rel.rstrip("/") + "/") or rel.startswith(prefix) for prefix in SAFE_A0PROJ_DIRS) |
| 1492 | or rel.startswith(".a0proj/plugins") |
| 1493 | or rel.startswith(".a0proj/agents") |
| 1494 | ) |
| 1495 | if rel in SAFE_A0PROJ_FILES: |
| 1496 | return True |
| 1497 | if any(rel.startswith(prefix) for prefix in SAFE_A0PROJ_DIRS): |
| 1498 | return True |
| 1499 | return _is_safe_plugin_asset(rel) |
| 1500 | |
| 1501 | |
| 1502 | def _is_safe_plugin_asset(rel: str) -> bool: |
| 1503 | parts = rel.split("/") |
| 1504 | if len(parts) < 4: |
| 1505 | return False |
| 1506 | for index, part in enumerate(parts): |
| 1507 | if part != "plugins": |
| 1508 | continue |
| 1509 | tail = parts[index + 1 :] |
| 1510 | if len(tail) == 2 and tail[1] in SAFE_PLUGIN_ASSET_NAMES: |
| 1511 | return True |
| 1512 | return False |
| 1513 | |
| 1514 | |
| 1515 | def _parse_name_status(output: str) -> list[dict[str, str]]: |
| 1516 | parts = [part for part in output.split("\0") if part] |
| 1517 | entries: list[dict[str, str]] = [] |
| 1518 | index = 0 |
| 1519 | while index < len(parts): |
| 1520 | raw_status = parts[index] |
| 1521 | index += 1 |
| 1522 | status = raw_status[:1] |
| 1523 | if status in {"R", "C"} and index + 1 < len(parts): |
| 1524 | old_path = parts[index].replace("\\", "/") |
| 1525 | new_path = parts[index + 1].replace("\\", "/") |
| 1526 | index += 2 |
| 1527 | entries.append({"status": status, "old_path": old_path, "path": new_path}) |
| 1528 | continue |
| 1529 | if index < len(parts): |
| 1530 | path = parts[index].replace("\\", "/") |
| 1531 | index += 1 |
| 1532 | entries.append({"status": status, "old_path": "", "path": path}) |
| 1533 | entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "") |
| 1534 | return entries |
| 1535 | |
| 1536 | |
| 1537 | def _safe_int(value: str) -> int: |
| 1538 | try: |
| 1539 | return max(0, int(value)) |
| 1540 | except (TypeError, ValueError): |
| 1541 | return 0 |
| 1542 | |
| 1543 | |
| 1544 | def _snapshot_public(snapshot: SnapshotResult) -> dict[str, Any]: |
| 1545 | return { |
| 1546 | "created": snapshot.created, |
| 1547 | "hash": snapshot.hash, |
| 1548 | "short_hash": snapshot.short_hash, |
| 1549 | "message": snapshot.message, |
| 1550 | "files": snapshot.files, |
| 1551 | "metadata": snapshot.metadata, |
| 1552 | } |
| 1553 | |
| 1554 | |
| 1555 | def _compact_git_error(text: str) -> str: |
| 1556 | lines = [line.strip() for line in str(text or "").splitlines() if line.strip()] |
| 1557 | if not lines: |
| 1558 | return "The patch could not be applied cleanly." |
| 1559 | return "\n".join(lines[:8]) |