diff --git a/api/delete_work_dir_file.py b/api/delete_work_dir_file.py index a925dd40b..76792e5d6 100644 --- a/api/delete_work_dir_file.py +++ b/api/delete_work_dir_file.py @@ -2,7 +2,7 @@ from helpers.api import ApiHandler, Input, Output, Request, Response from helpers.file_browser import FileBrowser -from helpers import files, runtime +from helpers import files, runtime, extension from api import get_work_dir_files @@ -19,6 +19,16 @@ class DeleteWorkDirFile(ApiHandler): res = await runtime.call_development_function(delete_file, file_path) if res: + await extension.call_extensions_async( + "workdir_file_mutation_after", + agent=None, + data={ + "action": "delete", + "path": file_path, + "paths": [file_path], + "current_path": current_path, + }, + ) # Get updated file list # result = browser.get_files(current_path) result = await runtime.call_development_function(get_work_dir_files.get_files, current_path) diff --git a/api/edit_work_dir_file.py b/api/edit_work_dir_file.py index 2c043df62..439efbe01 100644 --- a/api/edit_work_dir_file.py +++ b/api/edit_work_dir_file.py @@ -3,7 +3,7 @@ import os from helpers.api import ApiHandler, Input, Output, Request from helpers.file_browser import FileBrowser -from helpers import runtime, files +from helpers import runtime, files, extension MAX_EDIT_FILE_SIZE = 1024 * 1024 BINARY_SAMPLE_SIZE = 10 * 1024 @@ -51,6 +51,15 @@ class EditWorkDirFile(ApiHandler): if not res: return {"error": "Failed to save file"} + await extension.call_extensions_async( + "workdir_file_mutation_after", + agent=None, + data={ + "action": "edit", + "path": file_path, + "paths": [file_path], + }, + ) return {"ok": True} except Exception as e: # Extract clean error message from exception diff --git a/api/rename_work_dir_file.py b/api/rename_work_dir_file.py index 5a05088db..6c92b113c 100644 --- a/api/rename_work_dir_file.py +++ b/api/rename_work_dir_file.py @@ -1,7 +1,8 @@ from helpers.api import ApiHandler, Input, Output, Request from helpers.file_browser import FileBrowser -from helpers import runtime +from helpers import runtime, extension from api import get_work_dir_files +import posixpath class RenameWorkDirFile(ApiHandler): @@ -21,6 +22,7 @@ class RenameWorkDirFile(ApiHandler): res = await runtime.call_development_function( create_folder, parent_path, new_name ) + changed_paths = [posixpath.join(str(parent_path).rstrip("/"), new_name)] else: file_path = input.get("path", "") if not file_path: @@ -30,8 +32,22 @@ class RenameWorkDirFile(ApiHandler): res = await runtime.call_development_function( rename_item, file_path, new_name ) + changed_paths = [ + file_path, + posixpath.join(posixpath.dirname(file_path), new_name), + ] if res: + await extension.call_extensions_async( + "workdir_file_mutation_after", + agent=None, + data={ + "action": action, + "path": changed_paths[-1], + "paths": changed_paths, + "current_path": current_path, + }, + ) result = await runtime.call_development_function( get_work_dir_files.get_files, current_path ) diff --git a/api/upload_work_dir_files.py b/api/upload_work_dir_files.py index 88a9ff080..5e1802024 100644 --- a/api/upload_work_dir_files.py +++ b/api/upload_work_dir_files.py @@ -2,9 +2,10 @@ import base64 from werkzeug.datastructures import FileStorage from helpers.api import ApiHandler, Request, Response from helpers.file_browser import FileBrowser -from helpers import files, runtime +from helpers import files, runtime, extension from api import get_work_dir_files import os +import posixpath class UploadWorkDirFiles(ApiHandler): @@ -23,6 +24,21 @@ class UploadWorkDirFiles(ApiHandler): if not successful and failed: raise Exception("All uploads failed") + if successful: + await extension.call_extensions_async( + "workdir_file_mutation_after", + agent=None, + data={ + "action": "upload", + "path": current_path, + "paths": [ + posixpath.join(str(current_path).rstrip("/"), name) + for name in successful + ], + "current_path": current_path, + }, + ) + # result = browser.get_files(current_path) result = await runtime.call_development_function(get_work_dir_files.get_files, current_path) @@ -61,4 +77,3 @@ async def upload_files(uploaded_files: list[FileStorage], current_path: str): async def upload_file(current_path: str, filename: str, base64_content: str): browser = FileBrowser() return browser.save_file_b64(current_path, filename, base64_content) - diff --git a/plugins/_diff_viewer/api/diff.py b/plugins/_diff_viewer/api/diff.py deleted file mode 100644 index ee5b42a07..000000000 --- a/plugins/_diff_viewer/api/diff.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from helpers import files, projects, settings -from helpers.api import ApiHandler, Request, Response -from plugins._diff_viewer.helpers.diff import collect_workspace_diff - - -class Diff(ApiHandler): - async def process(self, input: dict, request: Request) -> dict | Response: - context_id = str(input.get("context_id") or "").strip() - workspace_path, display_path = self._resolve_workspace(context_id) - return collect_workspace_diff( - workspace_path, - context_id=context_id, - display_path=display_path, - ) - - def _resolve_workspace(self, context_id: str) -> tuple[str, str]: - if context_id: - context = self.use_context(context_id) - project_name = projects.get_context_project_name(context) - if project_name: - project_path = projects.get_project_folder(project_name) - display_path = files.normalize_a0_path(project_path) - return files.fix_dev_path(display_path), display_path - - configured = str(settings.get_settings().get("workdir_path") or "") - display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")) - return files.fix_dev_path(display_path), display_path diff --git a/plugins/_diff_viewer/extensions/webui/apply_snapshot_before/refresh-diff-viewer.js b/plugins/_diff_viewer/extensions/webui/apply_snapshot_before/refresh-diff-viewer.js deleted file mode 100644 index 5afcccbfc..000000000 --- a/plugins/_diff_viewer/extensions/webui/apply_snapshot_before/refresh-diff-viewer.js +++ /dev/null @@ -1,9 +0,0 @@ -export default function refreshDiffViewerOnContextChange(ctx) { - const diffViewer = globalThis.Alpine?.store?.("diffViewer"); - const canvas = globalThis.Alpine?.store?.("rightCanvas"); - if (!diffViewer || !canvas?.isOpen || canvas.activeSurfaceId !== "diff") return; - const nextContextId = String(ctx?.snapshot?.context || ""); - if (nextContextId && nextContextId !== diffViewer.contextId) { - diffViewer.scheduleRefresh({ contextId: nextContextId, reason: "context-change" }); - } -} diff --git a/plugins/_diff_viewer/extensions/webui/right-canvas-panels/diff-viewer-panel.html b/plugins/_diff_viewer/extensions/webui/right-canvas-panels/diff-viewer-panel.html deleted file mode 100644 index 157089158..000000000 --- a/plugins/_diff_viewer/extensions/webui/right-canvas-panels/diff-viewer-panel.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/plugins/_diff_viewer/helpers/__init__.py b/plugins/_diff_viewer/helpers/__init__.py deleted file mode 100644 index 75da34a92..000000000 --- a/plugins/_diff_viewer/helpers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Helpers for the built-in diff viewer plugin.""" diff --git a/plugins/_diff_viewer/helpers/diff.py b/plugins/_diff_viewer/helpers/diff.py deleted file mode 100644 index 33a6c7aff..000000000 --- a/plugins/_diff_viewer/helpers/diff.py +++ /dev/null @@ -1,347 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -from pathlib import Path -from typing import Any - -from helpers import files - - -MAX_PATCH_LINES = 2500 -MAX_PATCH_BYTES = 240_000 -MAX_UNTRACKED_BYTES = 180_000 -GIT_TIMEOUT_SECONDS = 8 - -GROUP_ORDER = ("staged", "unstaged", "untracked") -STATUS_LABELS = { - "A": "added", - "C": "copied", - "D": "deleted", - "M": "modified", - "R": "renamed", - "T": "type_changed", - "U": "unmerged", - "?": "untracked", -} - - -class GitDiffError(RuntimeError): - pass - - -def collect_workspace_diff( - workspace_path: str, - *, - context_id: str = "", - display_path: str | None = None, -) -> dict[str, Any]: - workspace = Path(workspace_path).expanduser().resolve() - display = display_path or str(workspace) - - if not workspace.exists() or not workspace.is_dir(): - return { - "ok": False, - "context_id": context_id, - "workspace_path": display, - "is_git_repo": False, - "error": "Workspace path does not exist or is not a directory.", - "branch": "", - "totals": {"files": 0, "additions": 0, "deletions": 0}, - "groups": _empty_groups(), - } - - if not _is_git_repo(workspace): - return { - "ok": True, - "context_id": context_id, - "workspace_path": display, - "is_git_repo": False, - "branch": "", - "totals": {"files": 0, "additions": 0, "deletions": 0}, - "groups": _empty_groups(), - } - - groups = [ - {"kind": "staged", "files": _collect_diff_group(workspace, "staged")}, - {"kind": "unstaged", "files": _collect_diff_group(workspace, "unstaged")}, - {"kind": "untracked", "files": _collect_untracked_group(workspace)}, - ] - - seen_paths: set[str] = set() - additions = 0 - deletions = 0 - for group in groups: - for item in group["files"]: - seen_paths.add(str(item.get("path") or item.get("old_path") or "")) - additions += int(item.get("additions") or 0) - deletions += int(item.get("deletions") or 0) - - return { - "ok": True, - "context_id": context_id, - "workspace_path": display, - "is_git_repo": True, - "branch": _branch_name(workspace), - "totals": { - "files": len([path for path in seen_paths if path]), - "additions": additions, - "deletions": deletions, - }, - "groups": groups, - } - - -def _empty_groups() -> list[dict[str, Any]]: - return [{"kind": kind, "files": []} for kind in GROUP_ORDER] - - -def _git(workspace: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - env = os.environ.copy() - env["GIT_TERMINAL_PROMPT"] = "0" - env["GIT_OPTIONAL_LOCKS"] = "0" - completed = subprocess.run( - ["git", "-C", str(workspace), *args], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - env=env, - timeout=GIT_TIMEOUT_SECONDS, - ) - if check and completed.returncode != 0: - message = (completed.stderr or completed.stdout or "Git command failed.").strip() - raise GitDiffError(message) - return completed - - -def _is_git_repo(workspace: Path) -> bool: - return _git(workspace, "rev-parse", "--is-inside-work-tree", check=False).returncode == 0 - - -def _branch_name(workspace: Path) -> str: - branch = _git(workspace, "branch", "--show-current", check=False).stdout.strip() - if branch: - return branch - return _git(workspace, "rev-parse", "--short", "HEAD", check=False).stdout.strip() - - -def _collect_diff_group(workspace: Path, kind: str) -> list[dict[str, Any]]: - cached_args = ["--cached"] if kind == "staged" else [] - status_output = _git( - workspace, - "diff", - *cached_args, - "--name-status", - "-z", - "--find-renames", - "--relative", - "--", - ".", - ).stdout - entries = _parse_name_status(status_output) - result: list[dict[str, Any]] = [] - - for entry in entries: - path = entry["path"] - old_path = entry.get("old_path", "") - if _is_a0_metadata_path(path) and (not old_path or _is_a0_metadata_path(old_path)): - continue - - stat_paths = [candidate for candidate in (old_path, path) if candidate] - additions, deletions, binary = _diff_numstat(workspace, kind, stat_paths) - if _is_zero_line_gitkeep_change(path, old_path, additions, deletions): - continue - - too_large = additions + deletions > MAX_PATCH_LINES - patch = "" - if not binary and not too_large: - patch = _diff_patch(workspace, kind, stat_paths) - if len(patch.encode("utf-8", errors="replace")) > MAX_PATCH_BYTES: - patch = "" - too_large = True - - result.append( - { - "path": path, - "old_path": old_path, - "status": STATUS_LABELS.get(entry["status"], entry["status"].lower()), - "additions": additions, - "deletions": deletions, - "binary": binary, - "too_large": too_large, - "patch": patch, - } - ) - - return result - - -def _collect_untracked_group(workspace: Path) -> list[dict[str, Any]]: - output = _git( - workspace, - "ls-files", - "--others", - "--exclude-standard", - "-z", - "--", - ".", - ).stdout - result: list[dict[str, Any]] = [] - for path in [part for part in output.split("\0") if part]: - path = path.replace("\\", "/") - if _is_a0_metadata_path(path): - continue - item = _untracked_file_diff(workspace, path) - if _is_zero_line_gitkeep_change(item["path"], item.get("old_path", ""), item["additions"], item["deletions"]): - continue - result.append(item) - result.sort(key=lambda item: item["path"]) - return result - - -def _parse_name_status(output: str) -> list[dict[str, str]]: - parts = [part for part in output.split("\0") if part] - entries: list[dict[str, str]] = [] - index = 0 - while index < len(parts): - raw_status = parts[index] - index += 1 - status = raw_status[:1] - if status in {"R", "C"} and index + 1 < len(parts): - old_path = parts[index].replace("\\", "/") - new_path = parts[index + 1].replace("\\", "/") - index += 2 - entries.append({"status": status, "old_path": old_path, "path": new_path}) - continue - if index < len(parts): - path = parts[index].replace("\\", "/") - index += 1 - entries.append({"status": status, "path": path, "old_path": ""}) - entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "") - return entries - - -def _diff_numstat(workspace: Path, kind: str, paths: list[str]) -> tuple[int, int, bool]: - args = ["diff"] - if kind == "staged": - args.append("--cached") - output = _git( - workspace, - *args, - "--numstat", - "--find-renames", - "--relative", - "--", - *paths, - ).stdout - first = next((line for line in output.splitlines() if line.strip()), "") - if not first: - return 0, 0, False - parts = first.split("\t") - if len(parts) < 2: - return 0, 0, False - if parts[0] == "-" or parts[1] == "-": - return 0, 0, True - return _safe_int(parts[0]), _safe_int(parts[1]), False - - -def _diff_patch(workspace: Path, kind: str, paths: list[str]) -> str: - args = ["diff"] - if kind == "staged": - args.append("--cached") - return _git( - workspace, - *args, - "--patch", - "--find-renames", - "--relative", - "--", - *paths, - ).stdout - - -def _untracked_file_diff(workspace: Path, path: str) -> dict[str, Any]: - file_path = (workspace / path).resolve() - additions = 0 - binary = False - too_large = False - patch = "" - - try: - with open(file_path, "rb") as handle: - data = handle.read(MAX_UNTRACKED_BYTES + 1) - too_large = len(data) > MAX_UNTRACKED_BYTES - sample = data[: min(len(data), 10 * 1024)] - binary = files.is_probably_binary_bytes(sample) - if not binary and not too_large: - text = data.decode("utf-8", errors="replace") - lines = text.splitlines() - additions = len(lines) - if len(lines) > MAX_PATCH_LINES: - too_large = True - else: - patch = _synthetic_untracked_patch(path, lines, text.endswith("\n")) - elif not binary: - additions = _count_newlines(data[:MAX_UNTRACKED_BYTES]) - except OSError: - too_large = True - - return { - "path": path, - "old_path": "", - "status": "untracked", - "additions": additions, - "deletions": 0, - "binary": binary, - "too_large": too_large, - "patch": patch, - } - - -def _synthetic_untracked_patch(path: str, lines: list[str], has_trailing_newline: bool) -> str: - escaped = path.replace("\t", "\\t") - header = [ - f"diff --git a/{escaped} b/{escaped}", - "new file mode 100644", - "index 0000000..0000000", - "--- /dev/null", - f"+++ b/{escaped}", - ] - if not lines: - return "\n".join(header) + "\n" - body = [f"@@ -0,0 +1,{len(lines)} @@"] - body.extend(f"+{line}" for line in lines) - if not has_trailing_newline: - body.append("\\ No newline at end of file") - return "\n".join(header + body) + "\n" - - -def _count_newlines(data: bytes) -> int: - if not data: - return 0 - count = data.count(b"\n") - return count if data.endswith(b"\n") else count + 1 - - -def _safe_int(value: str) -> int: - try: - return max(0, int(value)) - except (TypeError, ValueError): - return 0 - - -def _is_a0_metadata_path(path: str) -> bool: - normalized = path.replace("\\", "/").lstrip("/") - return normalized == ".a0proj" or normalized.startswith(".a0proj/") - - -def _is_zero_line_gitkeep_change(path: str, old_path: str, additions: int, deletions: int) -> bool: - if additions != 0 or deletions != 0: - return False - candidates = [path, old_path] - return any( - candidate.replace("\\", "/").rstrip("/").split("/")[-1] == ".gitkeep" - for candidate in candidates - if candidate - ) diff --git a/plugins/_diff_viewer/plugin.yaml b/plugins/_diff_viewer/plugin.yaml deleted file mode 100644 index 7aa99f486..000000000 --- a/plugins/_diff_viewer/plugin.yaml +++ /dev/null @@ -1,8 +0,0 @@ -name: _diff_viewer -title: Diff Viewer -description: Right canvas Git working tree diff viewer for the active chat or task workspace. -version: 0.1.0 -always_enabled: false -settings_sections: [] -per_project_config: false -per_agent_config: false diff --git a/plugins/_diff_viewer/webui/diff-viewer-panel.html b/plugins/_diff_viewer/webui/diff-viewer-panel.html deleted file mode 100644 index 83e4d6a2f..000000000 --- a/plugins/_diff_viewer/webui/diff-viewer-panel.html +++ /dev/null @@ -1,572 +0,0 @@ - - - - - -
- -
- - - - diff --git a/plugins/_diff_viewer/webui/diff-viewer-store.js b/plugins/_diff_viewer/webui/diff-viewer-store.js deleted file mode 100644 index d13e619c2..000000000 --- a/plugins/_diff_viewer/webui/diff-viewer-store.js +++ /dev/null @@ -1,321 +0,0 @@ -import { createStore } from "/js/AlpineStore.js"; -import { callJsonApi } from "/js/api.js"; -import { getContext } from "/index.js"; -import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; - -const REFRESH_DEBOUNCE_MS = 180; - -function lineType(text) { - if (text.startsWith("@@")) return "hunk"; - if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) { - return "meta"; - } - if (text.startsWith("+")) return "add"; - if (text.startsWith("-")) return "del"; - if (text.startsWith("\\ No newline")) return "note"; - return "context"; -} - -function dirname(path) { - const clean = String(path || "").replace(/\/+$/, ""); - const index = clean.lastIndexOf("/"); - return index > 0 ? clean.slice(0, index) : ""; -} - -const model = { - loading: false, - error: "", - payload: null, - contextId: "", - workspacePath: "", - expanded: {}, - _root: null, - _mode: "canvas", - _refreshTimer: null, - _floatingCleanup: null, - _requestSeq: 0, - - async init(element = null) { - await this.onMount(element, { mode: "canvas" }); - }, - - async onMount(element = null, options = {}) { - if (element) this._root = element; - this._mode = options?.mode === "modal" ? "modal" : "canvas"; - if (this._mode === "modal") { - this.setupFloatingModal(element); - } else { - this.setupCanvasSurface(element); - } - this.contextId = this.resolveContextId(); - if (!this.payload && !this.loading) { - await this.refresh({ contextId: this.contextId }); - } - }, - - async onOpen(payload = {}) { - const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || ""); - await this.refresh({ contextId: nextContextId }); - }, - - cleanup() { - if (this._refreshTimer) { - clearTimeout(this._refreshTimer); - this._refreshTimer = null; - } - this._floatingCleanup?.(); - this._floatingCleanup = null; - }, - - setupFloatingModal(element = null) { - this._floatingCleanup?.(); - const root = element || globalThis.document?.querySelector(".diff-viewer-panel"); - const modal = root?.closest?.(".modal"); - const inner = modal?.querySelector?.(".modal-inner"); - const body = modal?.querySelector?.(".modal-bd"); - const header = modal?.querySelector?.(".modal-header"); - if (!modal || !inner || !header) return; - modal.classList.add("modal-floating"); - inner.classList.add("diff-viewer-modal", "modal-no-backdrop"); - body?.classList?.add("diff-viewer-modal-body"); - - const rect = inner.getBoundingClientRect(); - inner.style.left = `${Math.max(8, rect.left)}px`; - inner.style.top = `${Math.max(8, rect.top)}px`; - inner.style.transform = "none"; - - let drag = null; - let resizeObserver = null; - const viewportGap = 8; - const clampPosition = (left, top) => { - const bounds = inner.getBoundingClientRect(); - const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap); - const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap); - return { - left: Math.min(Math.max(viewportGap, left), maxLeft), - top: Math.min(Math.max(viewportGap, top), maxTop), - }; - }; - const clampGeometry = () => { - const bounds = inner.getBoundingClientRect(); - const left = Math.max(viewportGap, bounds.left); - const top = Math.max(viewportGap, bounds.top); - const maxWidth = Math.max(340, globalThis.innerWidth - viewportGap * 2); - const maxHeight = Math.max(360, globalThis.innerHeight - viewportGap * 2); - if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`; - if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`; - const next = clampPosition(left, top); - inner.style.left = `${next.left}px`; - inner.style.top = `${next.top}px`; - inner.style.maxWidth = `${Math.max(340, globalThis.innerWidth - next.left - viewportGap)}px`; - inner.style.maxHeight = `${Math.max(360, globalThis.innerHeight - next.top - viewportGap)}px`; - }; - clampGeometry(); - globalThis.addEventListener("resize", clampGeometry); - if (globalThis.ResizeObserver) { - resizeObserver = new ResizeObserver(clampGeometry); - resizeObserver.observe(inner); - } - - const onPointerMove = (event) => { - if (!drag) return; - const next = clampPosition( - drag.left + event.clientX - drag.x, - drag.top + event.clientY - drag.y, - ); - inner.style.left = `${next.left}px`; - inner.style.top = `${next.top}px`; - clampGeometry(); - }; - const onPointerUp = () => { - drag = null; - globalThis.removeEventListener("pointermove", onPointerMove); - globalThis.removeEventListener("pointerup", onPointerUp); - try { - header.releasePointerCapture?.(header.__diffViewerPanelPointerId || 0); - } catch {} - }; - const onPointerDown = (event) => { - if (event.button !== 0) return; - if (event.target?.closest?.("button, input, select, textarea, a")) return; - const current = inner.getBoundingClientRect(); - drag = { - x: event.clientX, - y: event.clientY, - left: current.left, - top: current.top, - }; - header.__diffViewerPanelPointerId = event.pointerId; - header.setPointerCapture?.(event.pointerId); - globalThis.addEventListener("pointermove", onPointerMove); - globalThis.addEventListener("pointerup", onPointerUp); - event.preventDefault(); - }; - header.addEventListener("pointerdown", onPointerDown); - - this._floatingCleanup = () => { - header.removeEventListener("pointerdown", onPointerDown); - globalThis.removeEventListener("pointermove", onPointerMove); - globalThis.removeEventListener("pointerup", onPointerUp); - globalThis.removeEventListener("resize", clampGeometry); - resizeObserver?.disconnect?.(); - }; - }, - - setupCanvasSurface(element = null) { - this._floatingCleanup?.(); - this._floatingCleanup = null; - if (element) this._root = element; - }, - - resolveContextId() { - const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid"); - return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || ""; - }, - - scheduleRefresh(options = {}) { - if (this._refreshTimer) clearTimeout(this._refreshTimer); - this._refreshTimer = setTimeout(() => { - this._refreshTimer = null; - this.refresh(options).catch((error) => { - console.error("Diff refresh failed", error); - }); - }, REFRESH_DEBOUNCE_MS); - }, - - async refresh(options = {}) { - const contextId = String(options.contextId || options.context_id || this.resolveContextId() || ""); - const seq = ++this._requestSeq; - this.loading = true; - this.error = ""; - try { - const response = await callJsonApi("/plugins/_diff_viewer/diff", { context_id: contextId }); - if (seq !== this._requestSeq) return; - if (!response?.ok) { - throw new Error(response?.error || "Could not load diff."); - } - this.payload = response; - this.contextId = String(response.context_id || contextId || ""); - this.workspacePath = String(response.workspace_path || ""); - this.reconcileExpanded(); - } catch (error) { - if (seq !== this._requestSeq) return; - this.error = error instanceof Error ? error.message : String(error); - } finally { - if (seq === this._requestSeq) this.loading = false; - } - }, - - reconcileExpanded() { - const next = {}; - let index = 0; - for (const group of this.visibleGroups()) { - for (const file of group.files || []) { - const key = this.fileKey(group, file); - next[key] = this.expanded[key] ?? index < 4; - index += 1; - } - } - this.expanded = next; - }, - - visibleGroups() { - return (this.payload?.groups || []).filter((group) => Array.isArray(group.files) && group.files.length > 0); - }, - - hasChanges() { - return this.visibleGroups().length > 0; - }, - - groupTitle(kind) { - const labels = { - staged: "Staged", - unstaged: "Unstaged", - untracked: "Untracked", - }; - return labels[kind] || kind; - }, - - statusLabel(file) { - return String(file?.status || "changed").replaceAll("_", " "); - }, - - fileKey(group, file) { - return `${group?.kind || "diff"}:${file?.old_path || ""}:${file?.path || ""}`; - }, - - isExpanded(group, file) { - return this.expanded[this.fileKey(group, file)] !== false; - }, - - toggleFile(group, file) { - const key = this.fileKey(group, file); - this.expanded[key] = !this.isExpanded(group, file); - }, - - expandAll() { - const next = {}; - for (const group of this.visibleGroups()) { - for (const file of group.files || []) { - next[this.fileKey(group, file)] = true; - } - } - this.expanded = next; - }, - - collapseAll() { - const next = {}; - for (const group of this.visibleGroups()) { - for (const file of group.files || []) { - next[this.fileKey(group, file)] = false; - } - } - this.expanded = next; - }, - - patchLines(file) { - const patch = String(file?.patch || ""); - if (!patch) return []; - const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n"); - return textLines.map((text, index) => ({ - id: `${index}-${text.slice(0, 20)}`, - text, - type: lineType(text), - })); - }, - - fileTitle(file) { - if (file?.old_path && file.old_path !== file.path) { - return `${file.old_path} -> ${file.path}`; - } - return file?.path || file?.old_path || ""; - }, - - formatSigned(value, sign) { - const number = Number(value) || 0; - return `${sign}${number.toLocaleString()}`; - }, - - fullPath(file) { - const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, ""); - const base = String(this.workspacePath || "").replace(/\/+$/, ""); - return relativePath ? `${base}/${relativePath}` : base; - }, - - async openContainingFolder(file) { - const parent = dirname(this.fullPath(file)); - await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR"); - }, - - async copyPath(file) { - const path = this.fullPath(file); - try { - await navigator.clipboard.writeText(path); - globalThis.justToast?.("Path copied", "success", 1200, "diff-viewer-copy"); - } catch (_error) { - globalThis.prompt?.("Copy path", path); - } - }, -}; - -export const store = createStore("diffViewer", model); diff --git a/plugins/_diff_viewer/webui/main.html b/plugins/_diff_viewer/webui/main.html deleted file mode 100644 index 3830d8c9b..000000000 --- a/plugins/_diff_viewer/webui/main.html +++ /dev/null @@ -1,14 +0,0 @@ - - - Diff - - - - - diff --git a/plugins/_time_travel/api/history_diff.py b/plugins/_time_travel/api/history_diff.py new file mode 100644 index 000000000..ca6217db7 --- /dev/null +++ b/plugins/_time_travel/api/history_diff.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request, Response +from plugins._time_travel.helpers.time_travel import ( + TimeTravelError, + TimeTravelService, + WorkspaceRejectedError, + resolve_workspace, +) + + +class HistoryDiff(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + context_id = str(input.get("context_id") or "").strip() + try: + workspace = resolve_workspace(context_id, context_loader=self.use_context) + return TimeTravelService(workspace).history_diff( + commit_hash=str(input.get("commit_hash") or ""), + path=str(input.get("path") or ""), + mode=str(input.get("mode") or "commit"), + ) + except WorkspaceRejectedError as exc: + return {"ok": False, "locked": True, "error": str(exc)} + except TimeTravelError as exc: + return {"ok": False, "error": str(exc)} diff --git a/plugins/_time_travel/api/history_list.py b/plugins/_time_travel/api/history_list.py new file mode 100644 index 000000000..cefcfdc9c --- /dev/null +++ b/plugins/_time_travel/api/history_list.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request, Response +from plugins._time_travel.helpers.time_travel import ( + TimeTravelError, + TimeTravelService, + WorkspaceRejectedError, + resolve_workspace, + unavailable_payload, +) + + +class HistoryList(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + context_id = str(input.get("context_id") or "").strip() + try: + workspace = resolve_workspace(context_id, context_loader=self.use_context) + return TimeTravelService(workspace).history_list( + limit=int(input.get("limit") or 100), + offset=int(input.get("offset") or 0), + file_filter=str(input.get("file_filter") or ""), + ) + except WorkspaceRejectedError as exc: + return unavailable_payload(context_id, str(exc)) + except TimeTravelError as exc: + return {"ok": False, "error": str(exc)} diff --git a/plugins/_time_travel/api/history_preview.py b/plugins/_time_travel/api/history_preview.py new file mode 100644 index 000000000..294505095 --- /dev/null +++ b/plugins/_time_travel/api/history_preview.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request, Response +from plugins._time_travel.helpers.time_travel import ( + TimeTravelError, + TimeTravelService, + WorkspaceRejectedError, + resolve_workspace, +) + + +class HistoryPreview(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + context_id = str(input.get("context_id") or "").strip() + try: + workspace = resolve_workspace(context_id, context_loader=self.use_context) + return TimeTravelService(workspace).preview( + operation=str(input.get("operation") or ""), + commit_hash=str(input.get("commit_hash") or ""), + ) + except WorkspaceRejectedError as exc: + return {"ok": False, "locked": True, "error": str(exc)} + except TimeTravelError as exc: + return {"ok": False, "error": str(exc), "technical_details": getattr(exc, "stderr", "")} diff --git a/plugins/_time_travel/api/history_revert.py b/plugins/_time_travel/api/history_revert.py new file mode 100644 index 000000000..6c64d9da6 --- /dev/null +++ b/plugins/_time_travel/api/history_revert.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request, Response +from plugins._time_travel.helpers.time_travel import ( + TimeTravelError, + TimeTravelService, + WorkspaceRejectedError, + resolve_workspace, +) + + +class HistoryRevert(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + context_id = str(input.get("context_id") or "").strip() + try: + workspace = resolve_workspace(context_id, context_loader=self.use_context) + return TimeTravelService(workspace).revert( + commit_hash=str(input.get("commit_hash") or ""), + metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {}, + ) + except WorkspaceRejectedError as exc: + return {"ok": False, "locked": True, "error": str(exc)} + except TimeTravelError as exc: + details = getattr(exc, "stderr", "") or str(exc) + return {"ok": False, "error": _human_conflict_summary(str(exc)), "technical_details": details} + + +def _human_conflict_summary(message: str) -> str: + text = str(message or "").strip() + if not text: + return "Revert could not be applied cleanly." + first = text.splitlines()[0] + if "does not match index" in text or "patch failed" in text.lower() or "error:" in text.lower(): + return "Revert could not be applied cleanly because the current workspace has conflicting changes." + return first diff --git a/plugins/_time_travel/api/history_snapshot.py b/plugins/_time_travel/api/history_snapshot.py new file mode 100644 index 000000000..2af4ec467 --- /dev/null +++ b/plugins/_time_travel/api/history_snapshot.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request, Response +from plugins._time_travel.helpers.time_travel import ( + TimeTravelError, + TimeTravelService, + WorkspaceRejectedError, + _snapshot_public, + resolve_workspace, +) + + +class HistorySnapshot(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + context_id = str(input.get("context_id") or "").strip() + try: + workspace = resolve_workspace(context_id, context_loader=self.use_context) + snapshot = TimeTravelService(workspace).snapshot( + trigger=str(input.get("trigger") or "manual"), + message=str(input.get("message") or ""), + metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {}, + changed_path_hints=input.get("changed_path_hints") if isinstance(input.get("changed_path_hints"), list) else None, + ) + return {"ok": True, "snapshot": _snapshot_public(snapshot)} + except WorkspaceRejectedError as exc: + return {"ok": False, "locked": True, "error": str(exc)} + except TimeTravelError as exc: + return {"ok": False, "error": str(exc), "technical_details": getattr(exc, "stderr", "")} diff --git a/plugins/_time_travel/api/history_travel.py b/plugins/_time_travel/api/history_travel.py new file mode 100644 index 000000000..c1ecac699 --- /dev/null +++ b/plugins/_time_travel/api/history_travel.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request, Response +from plugins._time_travel.helpers.time_travel import ( + TimeTravelError, + TimeTravelService, + WorkspaceRejectedError, + resolve_workspace, +) + + +class HistoryTravel(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + context_id = str(input.get("context_id") or "").strip() + try: + workspace = resolve_workspace(context_id, context_loader=self.use_context) + return TimeTravelService(workspace).travel( + commit_hash=str(input.get("commit_hash") or ""), + metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {}, + ) + except WorkspaceRejectedError as exc: + return {"ok": False, "locked": True, "error": str(exc)} + except TimeTravelError as exc: + return { + "ok": False, + "error": str(exc), + "technical_details": getattr(exc, "stderr", "") or str(exc), + } diff --git a/plugins/_time_travel/extensions/python/text_editor_patch_after/_50_snapshot.py b/plugins/_time_travel/extensions/python/text_editor_patch_after/_50_snapshot.py new file mode 100644 index 000000000..bd5b6a5eb --- /dev/null +++ b/plugins/_time_travel/extensions/python/text_editor_patch_after/_50_snapshot.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Any + +from helpers.extension import Extension +from plugins._time_travel.helpers.time_travel import snapshot_for_agent + + +class TimeTravelTextEditorPatchSnapshot(Extension): + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any): + snapshot_for_agent( + self.agent, + trigger="text_editor_patch", + metadata={ + "patch_mode": str((data or {}).get("mode") or "edits"), + "changed_path_hints": [str((data or {}).get("path") or "")], + }, + ) diff --git a/plugins/_time_travel/extensions/python/text_editor_write_after/_50_snapshot.py b/plugins/_time_travel/extensions/python/text_editor_write_after/_50_snapshot.py new file mode 100644 index 000000000..865992bb3 --- /dev/null +++ b/plugins/_time_travel/extensions/python/text_editor_write_after/_50_snapshot.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from helpers.extension import Extension +from plugins._time_travel.helpers.time_travel import snapshot_for_agent + + +class TimeTravelTextEditorWriteSnapshot(Extension): + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any): + snapshot_for_agent( + self.agent, + trigger="text_editor_write", + metadata={ + "changed_path_hints": [str((data or {}).get("path") or "")], + }, + ) diff --git a/plugins/_time_travel/extensions/python/tool_execute_after/_50_code_execution_snapshot.py b/plugins/_time_travel/extensions/python/tool_execute_after/_50_code_execution_snapshot.py new file mode 100644 index 000000000..c6ffaf81d --- /dev/null +++ b/plugins/_time_travel/extensions/python/tool_execute_after/_50_code_execution_snapshot.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import time +from typing import Any + +from helpers.extension import Extension +from plugins._time_travel.helpers.time_travel import snapshot_for_agent + + +DEBOUNCE_SECONDS = 2.0 +_LAST_SNAPSHOT_BY_CONTEXT: dict[str, float] = {} + + +class TimeTravelCodeExecutionSnapshot(Extension): + async def execute(self, tool_name: str = "", response: Any = None, **kwargs: Any): + if tool_name != "code_execution_tool" or not self.agent: + return + + context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "") + now = time.monotonic() + if context_id and now - _LAST_SNAPSHOT_BY_CONTEXT.get(context_id, 0.0) < DEBOUNCE_SECONDS: + return + if context_id: + _LAST_SNAPSHOT_BY_CONTEXT[context_id] = now + + tool = getattr(getattr(self.agent, "loop_data", None), "current_tool", None) + args = getattr(tool, "args", {}) if tool else {} + runtime = str(args.get("runtime") or "") if isinstance(args, dict) else "" + if runtime == "output": + return + + snapshot_for_agent( + self.agent, + trigger="code_execution", + metadata={ + "tool_name": tool_name, + "runtime": runtime, + }, + ) diff --git a/plugins/_time_travel/extensions/python/workdir_file_mutation_after/_50_snapshot.py b/plugins/_time_travel/extensions/python/workdir_file_mutation_after/_50_snapshot.py new file mode 100644 index 000000000..7a634fd2c --- /dev/null +++ b/plugins/_time_travel/extensions/python/workdir_file_mutation_after/_50_snapshot.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +from helpers.extension import Extension +from plugins._time_travel.helpers.time_travel import snapshot_for_path_hint + + +class TimeTravelWorkdirFileMutationSnapshot(Extension): + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any): + payload = data or {} + paths = payload.get("paths") + if not isinstance(paths, list): + paths = [payload.get("path") or payload.get("current_path") or payload.get("parent_path")] + + first_path = next((str(path) for path in paths if path), "") + if not first_path: + return + + snapshot_for_path_hint( + first_path, + trigger=f"file_browser_{payload.get('action') or 'mutation'}", + metadata={ + "source": "file_browser", + "action": payload.get("action") or "mutation", + "changed_path_hints": [str(path) for path in paths if path], + }, + ) diff --git a/plugins/_time_travel/extensions/webui/apply_snapshot_before/refresh-time-travel.js b/plugins/_time_travel/extensions/webui/apply_snapshot_before/refresh-time-travel.js new file mode 100644 index 000000000..cbbf30d7a --- /dev/null +++ b/plugins/_time_travel/extensions/webui/apply_snapshot_before/refresh-time-travel.js @@ -0,0 +1,9 @@ +export default function refreshTimeTravelOnContextChange(ctx) { + const store = globalThis.Alpine?.store?.("timeTravel"); + const canvas = globalThis.Alpine?.store?.("rightCanvas"); + if (!store || !canvas?.isOpen || canvas.activeSurfaceId !== "time-travel") return; + const nextContextId = String(ctx?.snapshot?.context || ""); + if (nextContextId && nextContextId !== store.contextId) { + store.scheduleRefresh({ contextId: nextContextId, reason: "context-change" }); + } +} diff --git a/plugins/_time_travel/extensions/webui/right-canvas-panels/time-travel-panel.html b/plugins/_time_travel/extensions/webui/right-canvas-panels/time-travel-panel.html new file mode 100644 index 000000000..fef7ceb49 --- /dev/null +++ b/plugins/_time_travel/extensions/webui/right-canvas-panels/time-travel-panel.html @@ -0,0 +1,8 @@ + diff --git a/plugins/_diff_viewer/extensions/webui/right_canvas_register_surfaces/register-diff-viewer.js b/plugins/_time_travel/extensions/webui/right_canvas_register_surfaces/register-time-travel.js similarity index 62% rename from plugins/_diff_viewer/extensions/webui/right_canvas_register_surfaces/register-diff-viewer.js rename to plugins/_time_travel/extensions/webui/right_canvas_register_surfaces/register-time-travel.js index 39ac86f7b..c2f4f64c8 100644 --- a/plugins/_diff_viewer/extensions/webui/right_canvas_register_surfaces/register-diff-viewer.js +++ b/plugins/_time_travel/extensions/webui/right_canvas_register_surfaces/register-time-travel.js @@ -17,21 +17,21 @@ function waitForElement(selector, timeoutMs = 3000) { }); } -export default async function registerDiffViewerSurface(canvas) { +export default async function registerTimeTravelSurface(canvas) { canvas.registerSurface({ - id: "diff", - title: "Diff", - icon: "difference", + id: "time-travel", + title: "Time Travel", + icon: "history", order: 30, - modalPath: "/plugins/_diff_viewer/webui/main.html", + modalPath: "/plugins/_time_travel/webui/main.html", async open(payload = {}) { - await waitForElement('[data-surface-id="diff"] .diff-viewer-panel'); - const diffViewer = globalThis.Alpine?.store?.("diffViewer"); - await diffViewer?.onOpen?.(payload); + await waitForElement('[data-surface-id="time-travel"] .time-travel-panel'); + const store = globalThis.Alpine?.store?.("timeTravel"); + await store?.onOpen?.(payload); }, async close() { - const diffViewer = globalThis.Alpine?.store?.("diffViewer"); - diffViewer?.cleanup?.(); + const store = globalThis.Alpine?.store?.("timeTravel"); + store?.cleanup?.(); }, }); } diff --git a/plugins/_time_travel/helpers/__init__.py b/plugins/_time_travel/helpers/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/plugins/_time_travel/helpers/__init__.py @@ -0,0 +1 @@ + diff --git a/plugins/_time_travel/helpers/time_travel.py b/plugins/_time_travel/helpers/time_travel.py new file mode 100644 index 000000000..8ffe644fb --- /dev/null +++ b/plugins/_time_travel/helpers/time_travel.py @@ -0,0 +1,1087 @@ +from __future__ import annotations + +import base64 +import fnmatch +import hashlib +import json +import os +import posixpath +import shutil +import subprocess +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from helpers import files +from helpers.print_style import PrintStyle + + +PLUGIN_NAME = "_time_travel" +USR_DISPLAY_ROOT = "/a0/usr" +SHADOW_DISPLAY_ROOT = "/a0/usr/.time_travel/workspaces" +CURRENT_REF = "refs/heads/current" +PRESERVED_REF_PREFIX = "refs/a0-time-travel/preserved" +METADATA_PREFIX = "A0-Time-Travel-Metadata:" +EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +MAX_RENDERED_PATCH_BYTES = 1_000_000 +GIT_TIMEOUT_SECONDS = 20 + +STATUS_LABELS = { + "A": "added", + "C": "copied", + "D": "deleted", + "M": "modified", + "R": "renamed", + "T": "type_changed", + "U": "unmerged", + "X": "unknown", +} + +EXCLUDED_DIR_NAMES = { + ".git", + ".time_travel", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".cache", + ".tox", + ".nox", + ".venv", + "venv", + "env", + "node_modules", + "bower_components", + "dist", + "build", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + "coverage", + "htmlcov", + ".parcel-cache", +} + +EXCLUDED_DIR_PATTERNS = { + "*.egg-info", +} + +EXCLUDED_FILE_PATTERNS = { + "*.pyc", + "*.pyo", + "*.pyd", + ".env", + ".env.*", + "*.class", +} + +SAFE_A0PROJ_FILES = { + ".a0proj/project.json", + ".a0proj/agents.json", +} + +SAFE_A0PROJ_DIRS = { + ".a0proj/instructions/", + ".a0proj/knowledge/", + ".a0proj/skills/", +} + +SAFE_PLUGIN_ASSET_NAMES = { + "config.json", + "presets.yaml", + ".toggle-0", + ".toggle-1", +} + + +class TimeTravelError(RuntimeError): + """Base error for user-visible Time Travel failures.""" + + +class WorkspaceRejectedError(TimeTravelError): + """Raised when a workspace is outside the /a0/usr kernel boundary.""" + + +class TimeTravelConflictError(TimeTravelError): + """Raised when an operation cannot safely mutate the workspace.""" + + +class GitCommandError(TimeTravelError): + def __init__(self, message: str, *, stdout: str = "", stderr: str = "") -> None: + super().__init__(message) + self.stdout = stdout + self.stderr = stderr + + +@dataclass(frozen=True) +class WorkspaceInfo: + id: str + display_path: str + real_path: Path + shadow_path: Path + repo_git_path: Path + context_id: str = "" + project_name: str = "" + + def public(self) -> dict[str, Any]: + return { + "id": self.id, + "path": self.display_path, + "display_path": self.display_path, + "real_path": str(self.real_path), + "shadow_path": normalize_display_path(str(self.shadow_path)), + "repo_git_path": normalize_display_path(str(self.repo_git_path)), + "context_id": self.context_id, + "project_name": self.project_name, + "available": True, + "locked": False, + } + + +@dataclass(frozen=True) +class SnapshotResult: + created: bool + hash: str + short_hash: str + tree_hash: str + message: str + files: list[dict[str, Any]] + metadata: dict[str, Any] + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def normalize_display_path(path: str) -> str: + raw = str(path or "").strip() + if not raw: + return "" + if raw.startswith("/a0"): + normalized = posixpath.normpath(raw.replace("\\", "/")) + return "/" if normalized == "." else normalized + + resolved = Path(raw).expanduser().resolve(strict=False) + normalized = files.normalize_a0_path(str(resolved)) + if normalized.startswith("/a0"): + return posixpath.normpath(normalized.replace("\\", "/")) + return str(resolved) + + +def is_inside_usr_display(display_path: str) -> bool: + normalized = normalize_display_path(display_path) + return normalized == USR_DISPLAY_ROOT or normalized.startswith(USR_DISPLAY_ROOT + "/") + + +def workspace_id_for(display_path: str) -> str: + normalized = normalize_display_path(display_path).rstrip("/") + return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32] + + +def real_path_for_display(display_path: str) -> Path: + normalized = normalize_display_path(display_path) + if normalized == "/a0": + return Path(files.get_base_dir()).resolve(strict=False) + if normalized.startswith("/a0/"): + return Path(files.get_base_dir(), normalized.removeprefix("/a0/")).resolve(strict=False) + return Path(normalized).expanduser().resolve(strict=False) + + +def resolve_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo: + from helpers import projects, settings + + context_id = str(context_id or "").strip() + project_name = "" + display_path = "" + + if context_id: + context = context_loader(context_id) if context_loader else None + if context is not None: + project_name = projects.get_context_project_name(context) or "" + if project_name: + display_path = files.normalize_a0_path(projects.get_project_folder(project_name)) + + if not display_path: + configured = str(settings.get_settings().get("workdir_path") or "") + display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")) + + normalized = normalize_display_path(display_path) + if not is_inside_usr_display(normalized): + raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.") + + workspace_id = workspace_id_for(normalized) + shadow_display = f"{SHADOW_DISPLAY_ROOT}/{workspace_id}" + shadow_path = real_path_for_display(shadow_display) + return WorkspaceInfo( + id=workspace_id, + display_path=normalized.rstrip("/") or normalized, + real_path=real_path_for_display(normalized), + shadow_path=shadow_path, + repo_git_path=shadow_path / "repo.git", + context_id=context_id, + project_name=project_name, + ) + + +def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None: + from helpers import settings + + normalized = normalize_display_path(path_hint) + if not is_inside_usr_display(normalized): + return None + + parts = [part for part in normalized.split("/") if part] + if len(parts) >= 4 and parts[0] == "a0" and parts[1] == "usr" and parts[2] == "projects": + project_display = f"/a0/usr/projects/{parts[3]}" + return _workspace_from_display(project_display, project_name=parts[3]) + + configured = str(settings.get_settings().get("workdir_path") or "") + workdir_display = normalize_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))) + if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"): + return _workspace_from_display(workdir_display) + + return None + + +def _workspace_from_display(display_path: str, *, project_name: str = "", context_id: str = "") -> WorkspaceInfo: + normalized = normalize_display_path(display_path) + if not is_inside_usr_display(normalized): + raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.") + workspace_id = workspace_id_for(normalized) + shadow_path = real_path_for_display(f"{SHADOW_DISPLAY_ROOT}/{workspace_id}") + return WorkspaceInfo( + id=workspace_id, + display_path=normalized.rstrip("/") or normalized, + real_path=real_path_for_display(normalized), + shadow_path=shadow_path, + repo_git_path=shadow_path / "repo.git", + context_id=context_id, + project_name=project_name, + ) + + +def unavailable_payload(context_id: str, error: str) -> dict[str, Any]: + return { + "ok": True, + "context_id": context_id, + "workspace": { + "available": False, + "locked": True, + "path": "", + "display_path": "", + "error": error, + }, + "current_hash": "", + "present": clean_summary(), + "commits": [], + "has_more": False, + } + + +def clean_summary() -> dict[str, Any]: + return { + "dirty": False, + "files_count": 0, + "additions": 0, + "deletions": 0, + "files": [], + } + + +def snapshot_for_agent(agent: Any, *, trigger: str, metadata: dict[str, Any] | None = None) -> SnapshotResult | None: + if not agent: + return None + + context_id = str(getattr(getattr(agent, "context", None), "id", "") or "") + try: + workspace = resolve_workspace(context_id, context_loader=lambda _ctxid: agent.context) + return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=_agent_metadata(agent, metadata)) + except WorkspaceRejectedError: + return None + except Exception as exc: + PrintStyle.error(f"Time Travel snapshot failed: {exc}") + return None + + +def snapshot_for_path_hint(path_hint: str, *, trigger: str, metadata: dict[str, Any] | None = None) -> SnapshotResult | None: + try: + workspace = resolve_workspace_for_path_hint(path_hint) + if workspace is None: + return None + return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=metadata or {}) + except Exception as exc: + PrintStyle.error(f"Time Travel file-browser snapshot failed: {exc}") + return None + + +def _agent_metadata(agent: Any, metadata: dict[str, Any] | None = None) -> dict[str, Any]: + from helpers import projects + + result = dict(metadata or {}) + context = getattr(agent, "context", None) + if context is not None: + result.setdefault("context_id", str(getattr(context, "id", "") or "")) + project_name = projects.get_context_project_name(context) or "" + if project_name: + result.setdefault("project_name", project_name) + tool = getattr(getattr(agent, "loop_data", None), "current_tool", None) + if tool is not None: + result.setdefault("tool_name", str(getattr(tool, "name", "") or "")) + args = getattr(tool, "args", None) + if isinstance(args, dict): + result.setdefault("runtime", str(args.get("runtime") or "")) + log = getattr(tool, "log", None) + if log is not None: + result.setdefault("log_item_id", str(getattr(log, "id", "") or "")) + result.setdefault("log_item_no", getattr(log, "no", None)) + return {key: value for key, value in result.items() if value not in (None, "")} + + +class TimeTravelService: + def __init__(self, workspace: WorkspaceInfo): + self.workspace = workspace + + def ensure_repo(self) -> None: + self.workspace.shadow_path.mkdir(parents=True, exist_ok=True) + if not self.workspace.repo_git_path.exists(): + completed = subprocess.run( + ["git", "init", "--bare", str(self.workspace.repo_git_path)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=GIT_TIMEOUT_SECONDS, + ) + if completed.returncode != 0: + raise GitCommandError( + (completed.stderr or completed.stdout or "Could not initialize shadow Git repository.").strip(), + stdout=completed.stdout, + stderr=completed.stderr, + ) + self._git("symbolic-ref", "HEAD", CURRENT_REF) + + self._git("config", "user.name", "Agent Zero Time Travel") + self._git("config", "user.email", "time-travel@agent-zero.local") + self._git("config", "core.autocrlf", "false") + self._git("config", "core.filemode", "true") + + def current_hash(self) -> str: + self.ensure_repo() + completed = self._git("rev-parse", "--verify", "HEAD", check=False) + return completed.stdout.strip() if completed.returncode == 0 else "" + + def current_short_hash(self) -> str: + current = self.current_hash() + return current[:12] if current else "" + + def snapshot( + self, + *, + trigger: str = "manual", + message: str = "", + metadata: dict[str, Any] | None = None, + changed_path_hints: list[str] | None = None, + ) -> SnapshotResult: + self._ensure_workspace_dir() + self.ensure_repo() + previous_hash = self.current_hash() + tree_hash, included_paths = self._stage_current_tree() + + if previous_hash and self._commit_tree(previous_hash) == tree_hash: + return SnapshotResult( + created=False, + hash=previous_hash, + short_hash=previous_hash[:12], + tree_hash=tree_hash, + message=message or self._default_snapshot_message(trigger), + files=[], + metadata=self._metadata(trigger, metadata, changed_path_hints), + ) + + if not previous_hash and not included_paths: + return SnapshotResult( + created=False, + hash="", + short_hash="", + tree_hash=tree_hash, + message=message or self._default_snapshot_message(trigger), + files=[], + metadata=self._metadata(trigger, metadata, changed_path_hints), + ) + + full_metadata = self._metadata(trigger, metadata, changed_path_hints) + commit_message = self._commit_message(message or self._default_snapshot_message(trigger), full_metadata) + args = ["commit-tree", tree_hash] + if previous_hash: + args.extend(["-p", previous_hash]) + args.extend(["-F", "-"]) + env = self._git_env() + if timestamp := str(full_metadata.get("timestamp") or ""): + env["GIT_AUTHOR_DATE"] = timestamp + env["GIT_COMMITTER_DATE"] = timestamp + commit = self._git(*args, input=commit_message, env=env).stdout.strip() + self._git("update-ref", "HEAD", commit) + diff_base = previous_hash or EMPTY_TREE + return SnapshotResult( + created=True, + hash=commit, + short_hash=commit[:12], + tree_hash=tree_hash, + message=message or self._default_snapshot_message(trigger), + files=self.diff_files(diff_base, commit), + metadata=full_metadata, + ) + + def history_list(self, *, limit: int = 100, offset: int = 0, file_filter: str = "") -> dict[str, Any]: + self._ensure_workspace_dir() + self.ensure_repo() + limit = min(max(int(limit or 100), 1), 200) + offset = max(int(offset or 0), 0) + file_filter = str(file_filter or "").strip().lower() + current = self.current_hash() + present = self.present_summary() + + all_hashes = self._rev_list_all() + if file_filter: + all_hashes = [ + commit_hash + for commit_hash in all_hashes + if any( + file_filter in str(item.get("path") or "").lower() + or file_filter in str(item.get("old_path") or "").lower() + for item in self.commit_files(commit_hash) + ) + ] + + window = all_hashes[offset : offset + limit + 1] + visible = window[:limit] + return { + "ok": True, + "context_id": self.workspace.context_id, + "workspace": self.workspace.public(), + "current_hash": current, + "present": present, + "commits": [self.commit_object(commit_hash, current_hash=current) for commit_hash in visible], + "has_more": len(window) > limit, + } + + def history_diff(self, *, commit_hash: str, path: str, mode: str = "commit") -> dict[str, Any]: + self.ensure_repo() + path = self._safe_rel_path(path) + mode = str(mode or "commit").strip().lower() + + if mode in {"present", "current"}: + base = self.current_hash() or EMPTY_TREE + target, _paths = self._current_tree() + else: + commit_hash = self._validate_commit(commit_hash) + base = self._first_parent(commit_hash) or EMPTY_TREE + target = commit_hash + + return self._patch_payload(base, target, path) + + def preview(self, *, operation: str, commit_hash: str) -> dict[str, Any]: + self.ensure_repo() + operation = str(operation or "").strip().lower() + commit_hash = self._validate_commit(commit_hash) + current = self.current_hash() or EMPTY_TREE + + if operation == "travel": + base = current + target = commit_hash + elif operation == "revert": + base = commit_hash + target = self._first_parent(commit_hash) or EMPTY_TREE + else: + raise TimeTravelError("Unsupported preview operation.") + + files_changed = self.diff_files(base, target) + previews = [] + for item in files_changed[:12]: + rel_path = str(item.get("path") or item.get("old_path") or "") + if not rel_path: + continue + previews.append(self._patch_payload(base, target, rel_path)) + + return { + "ok": True, + "operation": operation, + "commit_hash": commit_hash, + "short_hash": commit_hash[:12], + "files": files_changed, + "previews": previews, + } + + def travel(self, *, commit_hash: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: + self._ensure_workspace_dir() + self.ensure_repo() + target = self._validate_commit(commit_hash) + before = self.snapshot(trigger="before_travel", metadata=metadata or {}) + previous = self.current_hash() + if previous: + self._preserve_ref(previous, reason="travel") + affected = self.diff_files(previous or EMPTY_TREE, target) + self._apply_commit_tree(previous or EMPTY_TREE, target, affected) + self._git("update-ref", "HEAD", target) + return { + "ok": True, + "operation": "travel", + "current_hash": target, + "previous_hash": previous, + "preserved_hash": previous, + "auto_snapshot": _snapshot_public(before), + "affected_files": affected, + } + + def revert(self, *, commit_hash: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: + self._ensure_workspace_dir() + self.ensure_repo() + target = self._validate_commit(commit_hash) + before = self.snapshot(trigger="before_revert", metadata=metadata or {}) + parent = self._first_parent(target) or EMPTY_TREE + patch = self._git_bytes("diff", "--binary", parent, target).stdout + if patch: + checked = self._git_bytes("apply", "--reverse", "--check", "--binary", "--whitespace=nowarn", input=patch, check=False) + if checked.returncode != 0: + raise TimeTravelConflictError(_compact_git_error(checked.stderr.decode("utf-8", "replace"))) + applied = self._git_bytes("apply", "--reverse", "--binary", "--whitespace=nowarn", input=patch, check=False) + if applied.returncode != 0: + raise TimeTravelConflictError(_compact_git_error(applied.stderr.decode("utf-8", "replace"))) + + after = self.snapshot( + trigger="revert", + message=f"Revert {target[:12]}", + metadata={ + **(metadata or {}), + "reverted_commit": target, + }, + ) + return { + "ok": True, + "operation": "revert", + "current_hash": after.hash, + "auto_snapshot": _snapshot_public(before), + "snapshot": _snapshot_public(after), + "affected_files": after.files, + } + + def present_summary(self) -> dict[str, Any]: + self.ensure_repo() + current_tree, _paths = self._current_tree() + current = self.current_hash() + base = current or EMPTY_TREE + base_tree = self._commit_tree(current) if current else EMPTY_TREE + if base_tree == current_tree: + return clean_summary() + changed = self.diff_files(base, current_tree) + return { + "dirty": bool(changed), + "files_count": len(changed), + "additions": sum(int(item.get("additions") or 0) for item in changed), + "deletions": sum(int(item.get("deletions") or 0) for item in changed), + "files": changed, + } + + def commit_object(self, commit_hash: str, *, current_hash: str = "") -> dict[str, Any]: + commit_hash = self._validate_commit(commit_hash) + show = self._git("show", "-s", "--format=%H%x00%h%x00%cI%x00%s%x00%B", commit_hash).stdout + parts = show.split("\0", 4) + full_hash = parts[0].strip() + short_hash = parts[1].strip() if len(parts) > 1 else full_hash[:12] + timestamp = parts[2].strip() if len(parts) > 2 else "" + subject = parts[3].strip() if len(parts) > 3 else "" + body = parts[4] if len(parts) > 4 else "" + metadata = self._parse_metadata(body) + return { + "hash": full_hash, + "short_hash": short_hash, + "timestamp": timestamp, + "message": subject, + "is_current": bool(current_hash and full_hash == current_hash), + "metadata": metadata, + "files": self.commit_files(full_hash), + } + + def commit_files(self, commit_hash: str) -> list[dict[str, Any]]: + commit_hash = self._validate_commit(commit_hash) + parent = self._first_parent(commit_hash) or EMPTY_TREE + return self.diff_files(parent, commit_hash) + + def diff_files(self, base: str, target: str, *, path_filter: str = "") -> list[dict[str, Any]]: + args = ["diff", "--name-status", "-z", "--find-renames", base, target] + path_filter = str(path_filter or "").strip() + if path_filter: + args.extend(["--", path_filter]) + output = self._git(*args).stdout + entries = _parse_name_status(output) + result: list[dict[str, Any]] = [] + for entry in entries: + path = entry["path"] + old_path = entry.get("old_path", "") + additions, deletions, binary = self._numstat(base, target, [p for p in (old_path, path) if p]) + action = STATUS_LABELS.get(entry["status"], entry["status"].lower()) + result.append( + { + "path": path, + "old_path": old_path, + "status": action, + "action": action, + "additions": additions, + "deletions": deletions, + "binary": binary, + } + ) + return result + + def _current_tree(self) -> tuple[str, list[str]]: + return self._stage_current_tree() + + def _stage_current_tree(self) -> tuple[str, list[str]]: + self.ensure_repo() + self._git("read-tree", "--empty") + paths = list(iter_snapshot_paths(self.workspace.real_path)) + if paths: + payload = "\0".join(paths).encode("utf-8") + b"\0" + self._git_bytes( + "add", + "-A", + "--pathspec-from-file=-", + "--pathspec-file-nul", + input=payload, + ) + tree_hash = self._git("write-tree").stdout.strip() + return tree_hash, paths + + def _apply_commit_tree(self, base: str, target: str, affected: list[dict[str, Any]]) -> None: + delete_paths: list[str] = [] + write_paths: list[str] = [] + for item in affected: + action = str(item.get("action") or item.get("status") or "") + old_path = str(item.get("old_path") or "") + path = str(item.get("path") or "") + if old_path and old_path != path: + delete_paths.append(old_path) + if action == "deleted": + delete_paths.append(path) + else: + write_paths.append(path) + + for rel_path in sorted(set(delete_paths), key=lambda value: value.count("/"), reverse=True): + self._delete_workspace_entry(rel_path) + for rel_path in sorted(set(write_paths)): + self._materialize_tree_path(target, rel_path) + self._prune_empty_dirs() + + def _materialize_tree_path(self, commit_hash: str, rel_path: str) -> None: + rel_path = self._safe_rel_path(rel_path) + entry = self._tree_entry(commit_hash, rel_path) + if entry is None: + self._delete_workspace_entry(rel_path) + return + mode, obj_type, obj_hash = entry + if obj_type != "blob": + return + target_path = self._workspace_child(rel_path) + data = self._git_bytes("cat-file", "-p", obj_hash).stdout + self._prepare_parent(target_path) + if target_path.exists() or target_path.is_symlink(): + self._remove_for_replacement(target_path) + if mode == "120000": + os.symlink(data.decode("utf-8", errors="replace"), target_path) + else: + target_path.write_bytes(data) + if mode == "100755": + target_path.chmod(0o755) + + def _delete_workspace_entry(self, rel_path: str) -> None: + rel_path = self._safe_rel_path(rel_path) + target_path = self._workspace_child(rel_path) + if target_path.is_symlink() or target_path.is_file(): + target_path.unlink() + elif target_path.exists(): + if target_path.is_dir() and not any(target_path.iterdir()): + target_path.rmdir() + else: + raise TimeTravelConflictError( + f"Cannot safely replace non-empty directory: {rel_path}" + ) + + def _prepare_parent(self, target_path: Path) -> None: + current = self.workspace.real_path + rel_parts = target_path.relative_to(self.workspace.real_path).parts[:-1] + for part in rel_parts: + current = current / part + if current.is_symlink() or current.is_file(): + self._remove_for_replacement(current) + current.mkdir(exist_ok=True) + + def _remove_for_replacement(self, target_path: Path) -> None: + if target_path.is_symlink() or target_path.is_file(): + target_path.unlink() + return + if target_path.is_dir(): + if any(target_path.iterdir()): + raise TimeTravelConflictError( + f"Cannot safely replace non-empty directory: {self._rel_from_workspace(target_path)}" + ) + target_path.rmdir() + + def _prune_empty_dirs(self) -> None: + for root, dirs, _filenames in os.walk(self.workspace.real_path, topdown=False, followlinks=False): + root_path = Path(root) + if root_path == self.workspace.real_path: + continue + if not is_snapshot_candidate(root_path.relative_to(self.workspace.real_path).as_posix(), is_dir=True): + continue + try: + root_path.rmdir() + except OSError: + pass + + def _tree_entry(self, commit_hash: str, rel_path: str) -> tuple[str, str, str] | None: + completed = self._git("ls-tree", "-z", commit_hash, "--", rel_path, check=False) + if completed.returncode != 0 or not completed.stdout: + return None + record = completed.stdout.split("\0", 1)[0] + meta, _sep, _name = record.partition("\t") + parts = meta.split() + if len(parts) < 3: + return None + return parts[0], parts[1], parts[2] + + def _patch_payload(self, base: str, target: str, path: str) -> dict[str, Any]: + path = self._safe_rel_path(path) + additions, deletions, binary = self._numstat(base, target, [path]) + completed = self._git_bytes("diff", "--binary", "--patch", base, target, "--", path, check=False) + data = completed.stdout or b"" + too_large = len(data) > MAX_RENDERED_PATCH_BYTES + rendered = data[:MAX_RENDERED_PATCH_BYTES].decode("utf-8", errors="replace") + return { + "ok": completed.returncode == 0, + "path": path, + "patch": "" if binary else rendered, + "binary": binary, + "too_large": too_large, + "additions": additions, + "deletions": deletions, + "error": "" if completed.returncode == 0 else completed.stderr.decode("utf-8", errors="replace"), + } + + def _numstat(self, base: str, target: str, paths: list[str]) -> tuple[int, int, bool]: + if not paths: + return 0, 0, False + output = self._git("diff", "--numstat", "--find-renames", base, target, "--", *paths, check=False).stdout + additions = 0 + deletions = 0 + binary = False + for line in output.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + if len(parts) < 2: + continue + if parts[0] == "-" or parts[1] == "-": + binary = True + continue + additions += _safe_int(parts[0]) + deletions += _safe_int(parts[1]) + return additions, deletions, binary + + def _rev_list_all(self) -> list[str]: + completed = self._git("rev-list", "--date-order", "--all", check=False) + if completed.returncode != 0: + return [] + seen: set[str] = set() + result: list[str] = [] + for line in completed.stdout.splitlines(): + commit = line.strip() + if commit and commit not in seen: + result.append(commit) + seen.add(commit) + return result + + def _validate_commit(self, commit_hash: str) -> str: + candidate = str(commit_hash or "").strip() + if not candidate: + raise TimeTravelError("Commit hash is required.") + completed = self._git("rev-parse", "--verify", f"{candidate}^{{commit}}", check=False) + if completed.returncode != 0: + raise TimeTravelError("Unknown Time Travel commit.") + return completed.stdout.strip() + + def _first_parent(self, commit_hash: str) -> str: + completed = self._git("rev-list", "--parents", "-n", "1", commit_hash) + parts = completed.stdout.strip().split() + return parts[1] if len(parts) > 1 else "" + + def _commit_tree(self, commit_hash: str) -> str: + return self._git("show", "-s", "--format=%T", commit_hash).stdout.strip() + + def _preserve_ref(self, commit_hash: str, *, reason: str) -> str: + stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + base_ref = f"{PRESERVED_REF_PREFIX}/{stamp}-{reason}-{commit_hash[:12]}" + ref = base_ref + counter = 2 + while self._git("show-ref", "--verify", "--quiet", ref, check=False).returncode == 0: + ref = f"{base_ref}-{counter}" + counter += 1 + self._git("update-ref", ref, commit_hash) + return ref + + def _commit_message(self, message: str, metadata: dict[str, Any]) -> str: + encoded = base64.b64encode(json.dumps(metadata, sort_keys=True).encode("utf-8")).decode("ascii") + return f"{message.strip() or 'Snapshot'}\n\n{METADATA_PREFIX} {encoded}\n" + + def _parse_metadata(self, body: str) -> dict[str, Any]: + for line in body.splitlines(): + if line.startswith(METADATA_PREFIX): + encoded = line.removeprefix(METADATA_PREFIX).strip() + try: + return json.loads(base64.b64decode(encoded).decode("utf-8")) + except Exception: + return {} + return {} + + def _metadata( + self, + trigger: str, + metadata: dict[str, Any] | None, + changed_path_hints: list[str] | None, + ) -> dict[str, Any]: + result = dict(metadata or {}) + result.setdefault("context_id", self.workspace.context_id) + result.setdefault("project_name", self.workspace.project_name) + result.setdefault("trigger", trigger) + result.setdefault("timestamp", now_iso()) + hints = [normalize_display_path(path) for path in (changed_path_hints or []) if path] + if hints: + result.setdefault("changed_path_hints", hints) + return {key: value for key, value in result.items() if value not in (None, "")} + + def _default_snapshot_message(self, trigger: str) -> str: + label = str(trigger or "snapshot").replace("_", " ").strip().title() + return f"Snapshot: {label}" + + def _ensure_workspace_dir(self) -> None: + if not self.workspace.real_path.exists(): + raise TimeTravelError("Workspace path does not exist.") + if not self.workspace.real_path.is_dir(): + raise TimeTravelError("Workspace path is not a directory.") + + def _safe_rel_path(self, path: str) -> str: + rel = str(path or "").replace("\\", "/").lstrip("/") + normalized = posixpath.normpath(rel) + if not normalized or normalized == "." or normalized.startswith("../") or normalized == "..": + raise TimeTravelError("Invalid path.") + return normalized + + def _workspace_child(self, rel_path: str) -> Path: + rel = self._safe_rel_path(rel_path) + path = self.workspace.real_path.joinpath(*rel.split("/")) + try: + path.relative_to(self.workspace.real_path) + except ValueError: + raise TimeTravelError("Invalid path.") + return path + + def _rel_from_workspace(self, path: Path) -> str: + try: + return path.relative_to(self.workspace.real_path).as_posix() + except ValueError: + return str(path) + + def _git_env(self) -> dict[str, str]: + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + env["GIT_OPTIONAL_LOCKS"] = "0" + return env + + def _git(self, *args: str, input: str | None = None, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + self.workspace.shadow_path.mkdir(parents=True, exist_ok=True) + completed = subprocess.run( + [ + "git", + f"--git-dir={self.workspace.repo_git_path}", + f"--work-tree={self.workspace.real_path}", + "-c", + "core.bare=false", + *args, + ], + input=input, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env or self._git_env(), + cwd=str(self.workspace.real_path) if self.workspace.real_path.exists() else None, + timeout=GIT_TIMEOUT_SECONDS, + ) + if check and completed.returncode != 0: + raise GitCommandError( + (completed.stderr or completed.stdout or "Git command failed.").strip(), + stdout=completed.stdout, + stderr=completed.stderr, + ) + return completed + + def _git_bytes(self, *args: str, input: bytes | None = None, check: bool = True) -> subprocess.CompletedProcess[bytes]: + self.workspace.shadow_path.mkdir(parents=True, exist_ok=True) + completed = subprocess.run( + [ + "git", + f"--git-dir={self.workspace.repo_git_path}", + f"--work-tree={self.workspace.real_path}", + "-c", + "core.bare=false", + *args, + ], + input=input, + capture_output=True, + env=self._git_env(), + cwd=str(self.workspace.real_path) if self.workspace.real_path.exists() else None, + timeout=GIT_TIMEOUT_SECONDS, + ) + if check and completed.returncode != 0: + stderr = completed.stderr.decode("utf-8", errors="replace") + stdout = completed.stdout.decode("utf-8", errors="replace") + raise GitCommandError((stderr or stdout or "Git command failed.").strip(), stdout=stdout, stderr=stderr) + return completed + + +def iter_snapshot_paths(workspace: Path) -> Iterable[str]: + workspace = workspace.resolve(strict=False) + + def walk(folder: Path, rel_prefix: str = "") -> Iterable[str]: + try: + with os.scandir(folder) as iterator: + entries = sorted(iterator, key=lambda entry: entry.name) + except OSError: + return + for entry in entries: + rel = f"{rel_prefix}/{entry.name}" if rel_prefix else entry.name + rel = rel.replace("\\", "/") + try: + is_dir = entry.is_dir(follow_symlinks=False) + is_file = entry.is_file(follow_symlinks=False) + is_link = entry.is_symlink() + except OSError: + continue + if is_dir: + if not is_snapshot_candidate(rel, is_dir=True): + continue + yield from walk(Path(entry.path), rel) + elif (is_file or is_link) and is_snapshot_candidate(rel, is_dir=False): + yield rel + + yield from walk(workspace) + + +def is_snapshot_candidate(rel_path: str, *, is_dir: bool) -> bool: + rel = rel_path.replace("\\", "/").strip("/") + if not rel: + return False + parts = rel.split("/") + name = parts[-1] + + if name == ".git" or ".git" in parts: + return False + if name == ".time_travel" or ".time_travel" in parts: + return False + if name in {"secrets.env", "variables.env"}: + return False + + if rel.startswith(".a0proj/"): + return _is_safe_a0proj_candidate(rel, is_dir=is_dir) + + if is_dir: + if name in EXCLUDED_DIR_NAMES: + return False + if any(fnmatch.fnmatch(name, pattern) for pattern in EXCLUDED_DIR_PATTERNS): + return False + return True + + if any(fnmatch.fnmatch(name, pattern) for pattern in EXCLUDED_FILE_PATTERNS): + return False + return True + + +def _is_safe_a0proj_candidate(rel: str, *, is_dir: bool) -> bool: + if rel in {".a0proj/secrets.env", ".a0proj/variables.env"}: + return False + if rel == ".a0proj/memory" or rel.startswith(".a0proj/memory/"): + return False + if is_dir: + return ( + rel == ".a0proj" + or any(prefix.startswith(rel.rstrip("/") + "/") or rel.startswith(prefix) for prefix in SAFE_A0PROJ_DIRS) + or rel.startswith(".a0proj/plugins") + or rel.startswith(".a0proj/agents") + ) + if rel in SAFE_A0PROJ_FILES: + return True + if any(rel.startswith(prefix) for prefix in SAFE_A0PROJ_DIRS): + return True + return _is_safe_plugin_asset(rel) + + +def _is_safe_plugin_asset(rel: str) -> bool: + parts = rel.split("/") + if len(parts) < 4: + return False + for index, part in enumerate(parts): + if part != "plugins": + continue + tail = parts[index + 1 :] + if len(tail) == 2 and tail[1] in SAFE_PLUGIN_ASSET_NAMES: + return True + return False + + +def _parse_name_status(output: str) -> list[dict[str, str]]: + parts = [part for part in output.split("\0") if part] + entries: list[dict[str, str]] = [] + index = 0 + while index < len(parts): + raw_status = parts[index] + index += 1 + status = raw_status[:1] + if status in {"R", "C"} and index + 1 < len(parts): + old_path = parts[index].replace("\\", "/") + new_path = parts[index + 1].replace("\\", "/") + index += 2 + entries.append({"status": status, "old_path": old_path, "path": new_path}) + continue + if index < len(parts): + path = parts[index].replace("\\", "/") + index += 1 + entries.append({"status": status, "old_path": "", "path": path}) + entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "") + return entries + + +def _safe_int(value: str) -> int: + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 + + +def _snapshot_public(snapshot: SnapshotResult) -> dict[str, Any]: + return { + "created": snapshot.created, + "hash": snapshot.hash, + "short_hash": snapshot.short_hash, + "message": snapshot.message, + "files": snapshot.files, + "metadata": snapshot.metadata, + } + + +def _compact_git_error(text: str) -> str: + lines = [line.strip() for line in str(text or "").splitlines() if line.strip()] + if not lines: + return "The patch could not be applied cleanly." + return "\n".join(lines[:8]) diff --git a/plugins/_time_travel/plugin.yaml b/plugins/_time_travel/plugin.yaml new file mode 100644 index 000000000..3afeda0ec --- /dev/null +++ b/plugins/_time_travel/plugin.yaml @@ -0,0 +1,8 @@ +name: _time_travel +title: Time Travel +description: Agent Zero-owned workspace history, diff inspection, travel, and revert for active /a0/usr workspaces. +version: 0.1.0 +always_enabled: false +settings_sections: [] +per_project_config: false +per_agent_config: false diff --git a/plugins/_time_travel/webui/main.html b/plugins/_time_travel/webui/main.html new file mode 100644 index 000000000..90d5a92bf --- /dev/null +++ b/plugins/_time_travel/webui/main.html @@ -0,0 +1,8 @@ + + + Time Travel + + + + + diff --git a/plugins/_time_travel/webui/time-travel-panel.html b/plugins/_time_travel/webui/time-travel-panel.html new file mode 100644 index 000000000..d04028a76 --- /dev/null +++ b/plugins/_time_travel/webui/time-travel-panel.html @@ -0,0 +1,907 @@ + + + + + +
+ +
+ + + + diff --git a/plugins/_time_travel/webui/time-travel-store.js b/plugins/_time_travel/webui/time-travel-store.js new file mode 100644 index 000000000..54f02557f --- /dev/null +++ b/plugins/_time_travel/webui/time-travel-store.js @@ -0,0 +1,527 @@ +import { createStore } from "/js/AlpineStore.js"; +import { callJsonApi } from "/js/api.js"; +import { getContext } from "/index.js"; +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; + +const REFRESH_DEBOUNCE_MS = 180; + +function lineType(text) { + if (text.startsWith("@@")) return "hunk"; + if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) { + return "meta"; + } + if (text.startsWith("+")) return "add"; + if (text.startsWith("-")) return "del"; + if (text.startsWith("\\ No newline")) return "note"; + return "context"; +} + +function dirname(path) { + const clean = String(path || "").replace(/\/+$/, ""); + const index = clean.lastIndexOf("/"); + return index > 0 ? clean.slice(0, index) : ""; +} + +function apiPath(name) { + return `/plugins/_time_travel/${name}`; +} + +const model = { + loading: false, + busy: false, + error: "", + payload: null, + contextId: "", + workspacePath: "", + fileFilter: "", + selectedHash: "", + selectedPath: "", + selectedDiff: null, + diffLoading: false, + diffError: "", + previewOpen: false, + previewLoading: false, + previewError: "", + previewTechnicalDetails: "", + previewDetailsOpen: false, + preview: null, + _root: null, + _mode: "canvas", + _refreshTimer: null, + _filterTimer: null, + _floatingCleanup: null, + _requestSeq: 0, + _diffSeq: 0, + + async init(element = null) { + await this.onMount(element, { mode: "canvas" }); + }, + + async onMount(element = null, options = {}) { + if (element) this._root = element; + this._mode = options?.mode === "modal" ? "modal" : "canvas"; + if (this._mode === "modal") { + this.setupFloatingModal(element); + } else { + this.setupCanvasSurface(element); + } + this.contextId = this.resolveContextId(); + if (!this.payload && !this.loading) { + await this.refresh({ contextId: this.contextId }); + } + }, + + async onOpen(payload = {}) { + const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || ""); + await this.refresh({ contextId: nextContextId }); + }, + + cleanup() { + if (this._refreshTimer) { + clearTimeout(this._refreshTimer); + this._refreshTimer = null; + } + if (this._filterTimer) { + clearTimeout(this._filterTimer); + this._filterTimer = null; + } + this._floatingCleanup?.(); + this._floatingCleanup = null; + }, + + setupFloatingModal(element = null) { + this._floatingCleanup?.(); + const root = element || globalThis.document?.querySelector(".time-travel-panel"); + const modal = root?.closest?.(".modal"); + const inner = modal?.querySelector?.(".modal-inner"); + const body = modal?.querySelector?.(".modal-bd"); + const header = modal?.querySelector?.(".modal-header"); + if (!modal || !inner || !header) return; + modal.classList.add("modal-floating"); + inner.classList.add("time-travel-modal", "modal-no-backdrop"); + body?.classList?.add("time-travel-modal-body"); + + const rect = inner.getBoundingClientRect(); + inner.style.left = `${Math.max(8, rect.left)}px`; + inner.style.top = `${Math.max(8, rect.top)}px`; + inner.style.transform = "none"; + + let drag = null; + let resizeObserver = null; + const viewportGap = 8; + const clampPosition = (left, top) => { + const bounds = inner.getBoundingClientRect(); + const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap); + const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap); + return { + left: Math.min(Math.max(viewportGap, left), maxLeft), + top: Math.min(Math.max(viewportGap, top), maxTop), + }; + }; + const clampGeometry = () => { + const bounds = inner.getBoundingClientRect(); + const maxWidth = Math.max(360, globalThis.innerWidth - viewportGap * 2); + const maxHeight = Math.max(420, globalThis.innerHeight - viewportGap * 2); + if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`; + if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`; + const next = clampPosition(bounds.left, bounds.top); + inner.style.left = `${next.left}px`; + inner.style.top = `${next.top}px`; + inner.style.maxWidth = `${Math.max(360, globalThis.innerWidth - next.left - viewportGap)}px`; + inner.style.maxHeight = `${Math.max(420, globalThis.innerHeight - next.top - viewportGap)}px`; + }; + clampGeometry(); + globalThis.addEventListener("resize", clampGeometry); + if (globalThis.ResizeObserver) { + resizeObserver = new ResizeObserver(clampGeometry); + resizeObserver.observe(inner); + } + + const onPointerMove = (event) => { + if (!drag) return; + const next = clampPosition(drag.left + event.clientX - drag.x, drag.top + event.clientY - drag.y); + inner.style.left = `${next.left}px`; + inner.style.top = `${next.top}px`; + clampGeometry(); + }; + const onPointerUp = () => { + drag = null; + globalThis.removeEventListener("pointermove", onPointerMove); + globalThis.removeEventListener("pointerup", onPointerUp); + try { + header.releasePointerCapture?.(header.__timeTravelPanelPointerId || 0); + } catch {} + }; + const onPointerDown = (event) => { + if (event.button !== 0) return; + if (event.target?.closest?.("button, input, select, textarea, a")) return; + const current = inner.getBoundingClientRect(); + drag = { + x: event.clientX, + y: event.clientY, + left: current.left, + top: current.top, + }; + header.__timeTravelPanelPointerId = event.pointerId; + header.setPointerCapture?.(event.pointerId); + globalThis.addEventListener("pointermove", onPointerMove); + globalThis.addEventListener("pointerup", onPointerUp); + event.preventDefault(); + }; + header.addEventListener("pointerdown", onPointerDown); + + this._floatingCleanup = () => { + header.removeEventListener("pointerdown", onPointerDown); + globalThis.removeEventListener("pointermove", onPointerMove); + globalThis.removeEventListener("pointerup", onPointerUp); + globalThis.removeEventListener("resize", clampGeometry); + resizeObserver?.disconnect?.(); + }; + }, + + setupCanvasSurface(element = null) { + this._floatingCleanup?.(); + this._floatingCleanup = null; + if (element) this._root = element; + }, + + resolveContextId() { + const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid"); + return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || ""; + }, + + scheduleRefresh(options = {}) { + if (this._refreshTimer) clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => { + this._refreshTimer = null; + this.refresh(options).catch((error) => console.error("Time Travel refresh failed", error)); + }, REFRESH_DEBOUNCE_MS); + }, + + scheduleFilterRefresh() { + if (this._filterTimer) clearTimeout(this._filterTimer); + this._filterTimer = setTimeout(() => { + this._filterTimer = null; + this.refresh({ keepSelection: false }); + }, 240); + }, + + async refresh(options = {}) { + const contextId = String(options.contextId || options.context_id || this.resolveContextId() || ""); + const seq = ++this._requestSeq; + this.loading = true; + this.error = ""; + try { + const response = await callJsonApi(apiPath("history_list"), { + context_id: contextId, + limit: 100, + offset: 0, + file_filter: this.fileFilter, + }); + if (seq !== this._requestSeq) return; + if (!response?.ok) throw new Error(response?.error || "Could not load history."); + this.payload = response; + this.contextId = String(response.context_id || contextId || ""); + this.workspacePath = String(response.workspace?.display_path || response.workspace?.path || ""); + this.reconcileSelection(Boolean(options.keepSelection)); + } catch (error) { + if (seq !== this._requestSeq) return; + this.error = error instanceof Error ? error.message : String(error); + } finally { + if (seq === this._requestSeq) this.loading = false; + } + }, + + async loadMore() { + if (this.loading || !this.payload?.has_more || this.isLocked()) return; + const seq = ++this._requestSeq; + this.loading = true; + try { + const response = await callJsonApi(apiPath("history_list"), { + context_id: this.contextId, + limit: 100, + offset: this.commits().length, + file_filter: this.fileFilter, + }); + if (seq !== this._requestSeq) return; + if (!response?.ok) throw new Error(response?.error || "Could not load history."); + this.payload.commits = [...this.commits(), ...(response.commits || [])]; + this.payload.has_more = Boolean(response.has_more); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + if (seq === this._requestSeq) this.loading = false; + } + }, + + reconcileSelection(keepSelection = false) { + if (this.isLocked()) { + this.selectedHash = ""; + this.selectedPath = ""; + this.selectedDiff = null; + return; + } + + const rows = this.timelineRows(); + let selected = keepSelection ? rows.find((row) => row.key === this.selectedHash) : null; + if (!selected) selected = rows[0] || null; + this.selectedHash = selected?.key || ""; + + const files = this.selectedFiles(); + if (!files.some((file) => this.fileKey(file) === this.selectedPath)) { + this.selectedPath = files[0] ? this.fileKey(files[0]) : ""; + } + void this.loadSelectedDiff(); + }, + + commits() { + return Array.isArray(this.payload?.commits) ? this.payload.commits : []; + }, + + present() { + return this.payload?.present || {}; + }, + + isLocked() { + return Boolean(this.payload?.workspace?.locked || this.payload?.workspace?.available === false); + }, + + hasHistory() { + return this.commits().length > 0; + }, + + hasPresentChanges() { + return Boolean(this.present()?.dirty); + }, + + timelineRows() { + const rows = []; + rows.push({ + key: "present", + kind: "present", + hash: this.payload?.current_hash || "", + short_hash: "present", + message: this.hasPresentChanges() ? "Present changes" : "Present clean", + timestamp: "", + files: this.present()?.files || [], + is_current: false, + dirty: this.hasPresentChanges(), + }); + for (const commit of this.commits()) { + rows.push({ key: commit.hash, kind: "commit", ...commit }); + } + return rows; + }, + + selectedRow() { + return this.timelineRows().find((row) => row.key === this.selectedHash) || null; + }, + + selectedCommit() { + const row = this.selectedRow(); + return row?.kind === "commit" ? row : null; + }, + + selectedFiles() { + const row = this.selectedRow(); + return Array.isArray(row?.files) ? row.files : []; + }, + + selectedFile() { + return this.selectedFiles().find((file) => this.fileKey(file) === this.selectedPath) || null; + }, + + selectRow(row) { + this.selectedHash = row?.key || ""; + const files = this.selectedFiles(); + this.selectedPath = files[0] ? this.fileKey(files[0]) : ""; + void this.loadSelectedDiff(); + }, + + selectFile(file) { + this.selectedPath = this.fileKey(file); + void this.loadSelectedDiff(); + }, + + fileKey(file) { + return `${file?.old_path || ""}:${file?.path || ""}`; + }, + + async loadSelectedDiff() { + const file = this.selectedFile(); + const row = this.selectedRow(); + this.selectedDiff = null; + this.diffError = ""; + if (!file || !row || this.isLocked()) return; + const seq = ++this._diffSeq; + this.diffLoading = true; + try { + const response = await callJsonApi(apiPath("history_diff"), { + context_id: this.contextId, + commit_hash: row.kind === "present" ? this.payload?.current_hash || "" : row.hash, + path: file.path || file.old_path, + mode: row.kind === "present" ? "present" : "commit", + }); + if (seq !== this._diffSeq) return; + if (!response?.ok) throw new Error(response?.error || "Could not load diff."); + this.selectedDiff = response; + } catch (error) { + if (seq !== this._diffSeq) return; + this.diffError = error instanceof Error ? error.message : String(error); + } finally { + if (seq === this._diffSeq) this.diffLoading = false; + } + }, + + async manualSnapshot() { + if (this.busy || this.isLocked()) return; + this.busy = true; + this.error = ""; + try { + const response = await callJsonApi(apiPath("history_snapshot"), { + context_id: this.contextId, + trigger: "manual", + }); + if (!response?.ok) throw new Error(response?.error || "Snapshot failed."); + globalThis.justToast?.(response.snapshot?.created ? "Snapshot captured" : "No changes to snapshot", "success", 1400, "time-travel-snapshot"); + await this.refresh({ keepSelection: true }); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + this.busy = false; + } + }, + + async openPreview(operation, commit = null) { + const target = commit || this.selectedCommit(); + if (!target || this.busy || this.isLocked()) return; + if (operation === "travel" && target.is_current) return; + this.previewOpen = true; + this.previewLoading = true; + this.previewError = ""; + this.previewTechnicalDetails = ""; + this.previewDetailsOpen = false; + this.preview = { operation, commit_hash: target.hash, short_hash: target.short_hash, files: [], previews: [] }; + try { + const response = await callJsonApi(apiPath("history_preview"), { + context_id: this.contextId, + operation, + commit_hash: target.hash, + }); + if (!response?.ok) throw new Error(response?.error || "Preview failed."); + this.preview = response; + } catch (error) { + this.previewError = error instanceof Error ? error.message : String(error); + } finally { + this.previewLoading = false; + } + }, + + closePreview() { + if (this.busy) return; + this.previewOpen = false; + this.preview = null; + this.previewError = ""; + this.previewTechnicalDetails = ""; + this.previewDetailsOpen = false; + }, + + async confirmPreview() { + if (!this.preview || this.busy || this.previewLoading) return; + const operation = this.preview.operation; + const endpoint = operation === "travel" ? "history_travel" : "history_revert"; + this.busy = true; + this.previewError = ""; + this.previewDetailsOpen = false; + try { + const response = await callJsonApi(apiPath(endpoint), { + context_id: this.contextId, + commit_hash: this.preview.commit_hash, + metadata: { source: "time_travel_ui" }, + }); + if (!response?.ok) { + const error = new Error(response?.error || `${operation} failed.`); + error.technicalDetails = response?.technical_details || ""; + throw error; + } + globalThis.justToast?.(operation === "travel" ? "Workspace traveled" : "Revert applied", "success", 1500, "time-travel-apply"); + this.previewOpen = false; + this.preview = null; + await this.refresh({ keepSelection: false }); + } catch (error) { + this.previewError = error instanceof Error ? error.message : String(error); + this.previewTechnicalDetails = error?.technicalDetails || ""; + } finally { + this.busy = false; + } + }, + + patchLines(diff = null) { + const patch = String((diff || this.selectedDiff)?.patch || ""); + if (!patch) return []; + const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n"); + return textLines.map((text, index) => ({ + id: `${index}-${text.slice(0, 20)}`, + text, + type: lineType(text), + })); + }, + + fileTitle(file) { + if (file?.old_path && file.old_path !== file.path) { + return `${file.old_path} -> ${file.path}`; + } + return file?.path || file?.old_path || ""; + }, + + statusLabel(file) { + return String(file?.action || file?.status || "changed").replaceAll("_", " "); + }, + + rowMeta(row) { + if (!row) return ""; + const files = Array.isArray(row.files) ? row.files.length : 0; + if (row.kind === "present") return files ? `${files} file${files === 1 ? "" : "s"}` : "clean"; + return `${row.short_hash || ""} ยท ${this.formatTime(row.timestamp)}`; + }, + + formatTime(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }, + + formatSigned(value, sign) { + const number = Number(value) || 0; + return `${sign}${number.toLocaleString()}`; + }, + + fullPath(file) { + const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, ""); + const base = String(this.workspacePath || "").replace(/\/+$/, ""); + return relativePath ? `${base}/${relativePath}` : base; + }, + + async openContainingFolder(file) { + const parent = dirname(this.fullPath(file)); + await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR"); + }, + + async copyPath(file) { + const path = this.fullPath(file); + try { + await navigator.clipboard.writeText(path); + globalThis.justToast?.("Path copied", "success", 1200, "time-travel-copy"); + } catch (_error) { + globalThis.prompt?.("Copy path", path); + } + }, +}; + +export const store = createStore("timeTravel", model); diff --git a/tests/test_diff_viewer.py b/tests/test_diff_viewer.py deleted file mode 100644 index f8fed240d..000000000 --- a/tests/test_diff_viewer.py +++ /dev/null @@ -1,192 +0,0 @@ -from __future__ import annotations - -import asyncio -import subprocess -import sys -import threading -from pathlib import Path -from types import SimpleNamespace - -import pytest -from flask import Flask - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from plugins._diff_viewer.helpers import diff as diff_helper -from plugins._diff_viewer.helpers.diff import collect_workspace_diff - - -def run_git(repo_dir: Path, *args: str) -> str: - completed = subprocess.run( - ["git", "-C", str(repo_dir), *args], - check=True, - text=True, - capture_output=True, - ) - return completed.stdout.strip() - - -def init_repo(repo_dir: Path) -> None: - run_git(repo_dir, "init") - run_git(repo_dir, "config", "user.name", "Test User") - run_git(repo_dir, "config", "user.email", "test@example.com") - (repo_dir / "tracked.txt").write_text("one\n", encoding="utf-8") - run_git(repo_dir, "add", "tracked.txt") - run_git(repo_dir, "commit", "-m", "initial") - - -def files_for_group(payload: dict, kind: str) -> list[dict]: - return next(group["files"] for group in payload["groups"] if group["kind"] == kind) - - -def test_collect_workspace_diff_returns_non_git_state(tmp_path: Path) -> None: - payload = collect_workspace_diff(str(tmp_path), context_id="ctx") - - assert payload["ok"] is True - assert payload["context_id"] == "ctx" - assert payload["is_git_repo"] is False - assert payload["totals"] == {"files": 0, "additions": 0, "deletions": 0} - - -def test_collect_workspace_diff_groups_staged_unstaged_and_untracked(tmp_path: Path) -> None: - init_repo(tmp_path) - (tmp_path / "tracked.txt").write_text("one\nstaged\n", encoding="utf-8") - run_git(tmp_path, "add", "tracked.txt") - (tmp_path / "tracked.txt").write_text("one\nstaged\nunstaged\n", encoding="utf-8") - (tmp_path / "new.txt").write_text("hello\n", encoding="utf-8") - - payload = collect_workspace_diff(str(tmp_path)) - - staged = files_for_group(payload, "staged") - unstaged = files_for_group(payload, "unstaged") - untracked = files_for_group(payload, "untracked") - assert staged[0]["path"] == "tracked.txt" - assert staged[0]["status"] == "modified" - assert "+staged" in staged[0]["patch"] - assert unstaged[0]["path"] == "tracked.txt" - assert "+unstaged" in unstaged[0]["patch"] - assert untracked[0]["path"] == "new.txt" - assert untracked[0]["status"] == "untracked" - assert "+hello" in untracked[0]["patch"] - assert payload["totals"]["files"] == 2 - assert payload["totals"]["additions"] == 3 - - -def test_collect_workspace_diff_ignores_zero_line_gitkeep(tmp_path: Path) -> None: - init_repo(tmp_path) - (tmp_path / ".gitkeep").write_text("", encoding="utf-8") - (tmp_path / "nested").mkdir() - (tmp_path / "nested" / ".gitkeep").write_text("", encoding="utf-8") - (tmp_path / "real.txt").write_text("real\n", encoding="utf-8") - run_git(tmp_path, "add", ".gitkeep") - - payload = collect_workspace_diff(str(tmp_path)) - paths = [ - item["path"] - for group in payload["groups"] - for item in group["files"] - ] - - assert ".gitkeep" not in paths - assert "nested/.gitkeep" not in paths - assert paths == ["real.txt"] - assert payload["totals"] == {"files": 1, "additions": 1, "deletions": 0} - - -def test_collect_workspace_diff_deleted_renamed_binary_large_and_a0_exclusion( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - init_repo(tmp_path) - (tmp_path / "rename_me.txt").write_text("move\n", encoding="utf-8") - (tmp_path / "delete_me.txt").write_text("delete\n", encoding="utf-8") - run_git(tmp_path, "add", "rename_me.txt", "delete_me.txt") - run_git(tmp_path, "commit", "-m", "fixtures") - - run_git(tmp_path, "mv", "rename_me.txt", "renamed.txt") - (tmp_path / "delete_me.txt").unlink() - (tmp_path / "binary.bin").write_bytes(b"\x00\x01data") - (tmp_path / ".a0proj").mkdir() - (tmp_path / ".a0proj" / "project.json").write_text("{}", encoding="utf-8") - monkeypatch.setattr(diff_helper, "MAX_UNTRACKED_BYTES", 10) - (tmp_path / "large.txt").write_text("0123456789\n" * 5, encoding="utf-8") - - payload = collect_workspace_diff(str(tmp_path)) - staged = files_for_group(payload, "staged") - unstaged = files_for_group(payload, "unstaged") - untracked = files_for_group(payload, "untracked") - - git_changes = staged + unstaged - statuses = {(item["path"], item["status"]) for item in git_changes} - assert ("delete_me.txt", "deleted") in statuses - assert ("renamed.txt", "renamed") in statuses - renamed = next(item for item in git_changes if item["path"] == "renamed.txt") - assert renamed["old_path"] == "rename_me.txt" - assert renamed["additions"] == 0 - assert renamed["deletions"] == 0 - binary = next(item for item in untracked if item["path"] == "binary.bin") - assert binary["binary"] is True - large = next(item for item in untracked if item["path"] == "large.txt") - assert large["too_large"] is True - assert all(not item["path"].startswith(".a0proj") for item in untracked) - - -def test_collect_workspace_diff_limits_nested_workspace_to_pathspec(tmp_path: Path) -> None: - init_repo(tmp_path) - (tmp_path / "outside.txt").write_text("outside\n", encoding="utf-8") - (tmp_path / "nested").mkdir() - (tmp_path / "nested" / "inside.txt").write_text("inside\n", encoding="utf-8") - - payload = collect_workspace_diff(str(tmp_path / "nested")) - untracked = files_for_group(payload, "untracked") - - assert [item["path"] for item in untracked] == ["inside.txt"] - - -def test_diff_api_resolves_project_context_workspace( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - pytest.importorskip("whisper") - pytest.importorskip("langchain_core") - from plugins._diff_viewer.api import diff as diff_api - - init_repo(tmp_path) - (tmp_path / "changed.txt").write_text("changed\n", encoding="utf-8") - handler = diff_api.Diff(Flask("diff-test"), threading.RLock()) - monkeypatch.setattr(handler, "use_context", lambda context_id: SimpleNamespace(id=context_id)) - monkeypatch.setattr(diff_api.projects, "get_context_project_name", lambda _context: "demo") - monkeypatch.setattr(diff_api.projects, "get_project_folder", lambda _name: str(tmp_path)) - monkeypatch.setattr(diff_api.files, "normalize_a0_path", lambda path: path) - monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path) - - payload = asyncio.run(handler.process({"context_id": "ctx-project"}, None)) - - assert isinstance(payload, dict) - assert payload["ok"] is True - assert payload["context_id"] == "ctx-project" - assert payload["workspace_path"] == str(tmp_path) - assert files_for_group(payload, "untracked")[0]["path"] == "changed.txt" - - -def test_diff_api_falls_back_to_default_workdir( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - pytest.importorskip("whisper") - pytest.importorskip("langchain_core") - from plugins._diff_viewer.api import diff as diff_api - - init_repo(tmp_path) - (tmp_path / "workdir.txt").write_text("workdir\n", encoding="utf-8") - handler = diff_api.Diff(Flask("diff-test-default"), threading.RLock()) - monkeypatch.setattr(diff_api.settings, "get_settings", lambda: {"workdir_path": str(tmp_path)}) - monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path) - - payload = asyncio.run(handler.process({}, None)) - - assert isinstance(payload, dict) - assert payload["workspace_path"] == str(tmp_path) - assert files_for_group(payload, "untracked")[0]["path"] == "workdir.txt" diff --git a/tests/test_time_travel.py b/tests/test_time_travel.py new file mode 100644 index 000000000..2a46b2d85 --- /dev/null +++ b/tests/test_time_travel.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import threading +import uuid +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from plugins._time_travel.helpers import time_travel as tt +from plugins._time_travel.helpers.time_travel import ( + TimeTravelConflictError, + TimeTravelError, + TimeTravelService, + WorkspaceRejectedError, + _workspace_from_display, + resolve_workspace, +) + + +def run_git(repo_dir: Path, *args: str, check: bool = True) -> str: + completed = subprocess.run( + ["git", "-C", str(repo_dir), *args], + check=check, + text=True, + capture_output=True, + ) + return completed.stdout.strip() + + +@pytest.fixture +def workspace(): + name = f"tt-{uuid.uuid4().hex}" + root = PROJECT_ROOT / "usr" / "time-travel-tests" / name + root.mkdir(parents=True) + service = TimeTravelService(_workspace_from_display(f"/a0/usr/time-travel-tests/{name}")) + try: + yield root, service + finally: + shutil.rmtree(root, ignore_errors=True) + shutil.rmtree(service.workspace.shadow_path, ignore_errors=True) + + +def tracked_paths(service: TimeTravelService, commit_hash: str = "HEAD") -> set[str]: + output = service._git("ls-tree", "-r", "--name-only", commit_hash).stdout + return {line.strip() for line in output.splitlines() if line.strip()} + + +def test_shadow_history_snapshot_diff_travel_preserve_refs_and_root_revert(workspace): + root, service = workspace + (root / "a.txt").write_text("one\n", encoding="utf-8") + + initial = service.snapshot(trigger="manual", metadata={"context_id": "ctx"}) + duplicate = service.snapshot(trigger="manual") + + assert initial.created is True + assert duplicate.created is False + assert duplicate.hash == initial.hash + assert (service.workspace.repo_git_path / "objects").is_dir() + + (root / "a.txt").write_text("one\ntwo\n", encoding="utf-8") + present = service.present_summary() + assert present["dirty"] is True + assert present["files"][0]["path"] == "a.txt" + assert "+two" in service.history_diff(commit_hash=initial.hash, path="a.txt", mode="present")["patch"] + + second = service.snapshot(trigger="tool", metadata={"tool_name": "code_execution_tool"}) + history = service.history_list(limit=10) + assert [commit["hash"] for commit in history["commits"][:2]] == [second.hash, initial.hash] + assert history["commits"][0]["metadata"]["tool_name"] == "code_execution_tool" + assert "+two" in service.history_diff(commit_hash=second.hash, path="a.txt", mode="commit")["patch"] + + service.travel(commit_hash=initial.hash) + assert (root / "a.txt").read_text(encoding="utf-8") == "one\n" + preserved = service._git( + "for-each-ref", + "--format=%(objectname)", + "refs/a0-time-travel/preserved", + ).stdout + assert second.hash in preserved + assert second.hash in [commit["hash"] for commit in service.history_list(limit=10)["commits"]] + + reverted = service.revert(commit_hash=initial.hash) + assert reverted["ok"] is True + assert not (root / "a.txt").exists() + assert reverted["snapshot"]["created"] is True + + +def test_revert_conflict_auto_snapshots_present_without_losing_changes(workspace): + root, service = workspace + (root / "a.txt").write_text("one\n", encoding="utf-8") + first = service.snapshot(trigger="manual") + (root / "a.txt").write_text("one\ntwo\n", encoding="utf-8") + second = service.snapshot(trigger="manual") + (root / "a.txt").write_text("custom\n", encoding="utf-8") + + with pytest.raises(TimeTravelConflictError): + service.revert(commit_hash=second.hash) + + assert (root / "a.txt").read_text(encoding="utf-8") == "custom\n" + assert service.current_hash() not in {first.hash, second.hash} + assert "custom" in service.history_diff(commit_hash=service.current_hash(), path="a.txt", mode="commit")["patch"] + + +def test_kernel_boundary_real_git_repo_and_git_dir_exclusion(workspace): + root, service = workspace + with pytest.raises(WorkspaceRejectedError): + _workspace_from_display("/tmp/outside") + + run_git(root, "init") + run_git(root, "config", "user.name", "Test User") + run_git(root, "config", "user.email", "test@example.com") + (root / "tracked.txt").write_text("tracked\n", encoding="utf-8") + run_git(root, "add", "tracked.txt") + run_git(root, "commit", "-m", "real initial") + real_head = run_git(root, "rev-parse", "HEAD") + (root / "untracked.txt").write_text("shadow only\n", encoding="utf-8") + real_status_before = run_git(root, "status", "--short") + + snapshot = service.snapshot(trigger="manual") + + assert snapshot.created is True + assert run_git(root, "rev-parse", "HEAD") == real_head + assert run_git(root, "status", "--short") == real_status_before + assert all(not path.startswith(".git/") and path != ".git" for path in tracked_paths(service)) + + +def test_metadata_policy_tracks_safe_project_files_and_preserves_exclusions(workspace): + root, service = workspace + (root / "src").mkdir() + (root / "src" / "app.py").write_text("print('one')\n", encoding="utf-8") + (root / ".a0proj" / "instructions").mkdir(parents=True) + (root / ".a0proj" / "knowledge").mkdir(parents=True) + (root / ".a0proj" / "skills" / "demo").mkdir(parents=True) + (root / ".a0proj" / "plugins" / "demo").mkdir(parents=True) + (root / ".a0proj" / "memory").mkdir(parents=True) + (root / "node_modules").mkdir() + (root / "dist").mkdir() + (root / "__pycache__").mkdir() + (root / ".a0proj" / "project.json").write_text("{}", encoding="utf-8") + (root / ".a0proj" / "agents.json").write_text("{}", encoding="utf-8") + (root / ".a0proj" / "instructions" / "one.md").write_text("i\n", encoding="utf-8") + (root / ".a0proj" / "knowledge" / "one.md").write_text("k\n", encoding="utf-8") + (root / ".a0proj" / "skills" / "demo" / "SKILL.md").write_text("s\n", encoding="utf-8") + (root / ".a0proj" / "plugins" / "demo" / "config.json").write_text("{}", encoding="utf-8") + (root / ".a0proj" / "plugins" / "demo" / "presets.yaml").write_text("[]\n", encoding="utf-8") + (root / ".a0proj" / "plugins" / "demo" / "state.json").write_text('{"state": true}\n', encoding="utf-8") + (root / ".a0proj" / "secrets.env").write_text("SECRET=one\n", encoding="utf-8") + (root / ".a0proj" / "variables.env").write_text("VAR=one\n", encoding="utf-8") + (root / ".a0proj" / "memory" / "index.faiss").write_bytes(b"memory") + (root / ".env").write_text("TOKEN=one\n", encoding="utf-8") + (root / "node_modules" / "pkg.js").write_text("pkg\n", encoding="utf-8") + (root / "dist" / "bundle.js").write_text("dist\n", encoding="utf-8") + (root / "__pycache__" / "app.pyc").write_bytes(b"pyc") + + first = service.snapshot(trigger="manual") + paths = tracked_paths(service, first.hash) + + assert "src/app.py" in paths + assert ".a0proj/project.json" in paths + assert ".a0proj/agents.json" in paths + assert ".a0proj/instructions/one.md" in paths + assert ".a0proj/knowledge/one.md" in paths + assert ".a0proj/skills/demo/SKILL.md" in paths + assert ".a0proj/plugins/demo/config.json" in paths + assert ".a0proj/plugins/demo/presets.yaml" in paths + assert ".a0proj/plugins/demo/state.json" not in paths + assert ".a0proj/secrets.env" not in paths + assert ".a0proj/variables.env" not in paths + assert ".a0proj/memory/index.faiss" not in paths + assert ".env" not in paths + assert "node_modules/pkg.js" not in paths + assert "dist/bundle.js" not in paths + assert "__pycache__/app.pyc" not in paths + + (root / "src" / "app.py").write_text("print('two')\n", encoding="utf-8") + (root / ".a0proj" / "secrets.env").write_text("SECRET=two\n", encoding="utf-8") + service.snapshot(trigger="manual") + service.travel(commit_hash=first.hash) + + assert (root / "src" / "app.py").read_text(encoding="utf-8") == "print('one')\n" + assert (root / ".a0proj" / "secrets.env").read_text(encoding="utf-8") == "SECRET=two\n" + + +def test_symlink_entries_are_snapshotted_and_deleted_without_following_targets(workspace, tmp_path: Path): + root, service = workspace + outside = tmp_path / "outside.txt" + outside.write_text("outside\n", encoding="utf-8") + os.symlink(outside, root / "outside-link") + + first = service.snapshot(trigger="manual") + assert "outside-link" in tracked_paths(service, first.hash) + assert service._git("ls-tree", "HEAD", "outside-link").stdout.startswith("120000") + + (root / "outside-link").unlink() + second = service.snapshot(trigger="manual") + assert outside.exists() + + service.travel(commit_hash=first.hash) + assert (root / "outside-link").is_symlink() + assert outside.exists() + + service.travel(commit_hash=second.hash) + assert not (root / "outside-link").exists() + assert outside.exists() + + +def test_pagination_large_diff_and_invalid_inputs(workspace, monkeypatch: pytest.MonkeyPatch): + root, service = workspace + (root / "file.txt").write_text("0\n", encoding="utf-8") + hashes = [service.snapshot(trigger="manual").hash] + for index in range(1, 4): + (root / "file.txt").write_text(("x\n" * index), encoding="utf-8") + hashes.append(service.snapshot(trigger="manual").hash) + + page = service.history_list(limit=2) + assert len(page["commits"]) == 2 + assert page["has_more"] is True + page2 = service.history_list(limit=2, offset=2) + assert page2["commits"][0]["hash"] == hashes[1] + + monkeypatch.setattr(tt, "MAX_RENDERED_PATCH_BYTES", 30) + diff = service.history_diff(commit_hash=hashes[-1], path="file.txt", mode="commit") + assert diff["too_large"] is True + assert len(diff["patch"].encode("utf-8")) <= 30 + + with pytest.raises(TimeTravelError): + service.history_diff(commit_hash="not-a-commit", path="file.txt", mode="commit") + with pytest.raises(TimeTravelError): + service.history_diff(commit_hash=hashes[-1], path="../file.txt", mode="commit") + + +def test_workspace_resolution_prefers_project_and_rejects_external_paths(monkeypatch: pytest.MonkeyPatch, workspace): + root, _service = workspace + projects_mod = ModuleType("helpers.projects") + projects_mod.get_context_project_name = lambda _context: "demo" + projects_mod.get_project_folder = lambda _name: str(root) + settings_mod = ModuleType("helpers.settings") + settings_mod.get_settings = lambda: {"workdir_path": "/tmp/not-a0"} + + import helpers + + monkeypatch.setitem(sys.modules, "helpers.projects", projects_mod) + monkeypatch.setitem(sys.modules, "helpers.settings", settings_mod) + monkeypatch.setattr(helpers, "projects", projects_mod, raising=False) + monkeypatch.setattr(helpers, "settings", settings_mod, raising=False) + + resolved = resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx")) + assert resolved.project_name == "demo" + assert resolved.display_path.startswith("/a0/usr/time-travel-tests/") + + projects_mod.get_context_project_name = lambda _context: "" + with pytest.raises(WorkspaceRejectedError): + resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx")) diff --git a/webui/js/modals.js b/webui/js/modals.js index 764faa429..dddbe969d 100644 --- a/webui/js/modals.js +++ b/webui/js/modals.js @@ -58,8 +58,8 @@ function modalSuppressesBackdrop(modal) { || path === "plugins/_browser/webui/main.html" || path === "/plugins/_office/webui/main.html" || path === "plugins/_office/webui/main.html" - || path === "/plugins/_diff_viewer/webui/main.html" - || path === "plugins/_diff_viewer/webui/main.html" + || path === "/plugins/_time_travel/webui/main.html" + || path === "plugins/_time_travel/webui/main.html" || modal?.element?.classList?.contains("modal-floating") || modal?.element?.classList?.contains("modal-no-backdrop") || modal?.inner?.classList?.contains("modal-no-backdrop");