From c5ea6780523a83ef6f7ccf8bc2037e80f7a55920 Mon Sep 17 00:00:00 2001
From: Alessandro <155005371+3clyp50@users.noreply.github.com>
Date: Mon, 27 Apr 2026 01:27:20 +0200
Subject: [PATCH] Add Time Travel workspace history
Add the _time_travel core plugin with Agent Zero-owned shadow Git snapshots, history/diff/preview/travel/revert APIs, capture hooks, and canvas plus floating window UI surfaces for /a0/usr workspaces.
Wire generic file-browser mutation hooks for UI edits, update modal backdrop handling, remove the legacy _diff_viewer plugin, and replace Diff Viewer tests with focused Time Travel coverage.
Inspired by Space Agent :-)
---
api/delete_work_dir_file.py | 12 +-
api/edit_work_dir_file.py | 11 +-
api/rename_work_dir_file.py | 18 +-
api/upload_work_dir_files.py | 19 +-
plugins/_diff_viewer/api/diff.py | 29 -
.../refresh-diff-viewer.js | 9 -
.../diff-viewer-panel.html | 8 -
plugins/_diff_viewer/helpers/__init__.py | 1 -
plugins/_diff_viewer/helpers/diff.py | 347 ------
plugins/_diff_viewer/plugin.yaml | 8 -
.../_diff_viewer/webui/diff-viewer-panel.html | 572 ---------
.../_diff_viewer/webui/diff-viewer-store.js | 321 -----
plugins/_diff_viewer/webui/main.html | 14 -
plugins/_time_travel/api/history_diff.py | 25 +
plugins/_time_travel/api/history_list.py | 26 +
plugins/_time_travel/api/history_preview.py | 24 +
plugins/_time_travel/api/history_revert.py | 35 +
plugins/_time_travel/api/history_snapshot.py | 28 +
plugins/_time_travel/api/history_travel.py | 28 +
.../text_editor_patch_after/_50_snapshot.py | 18 +
.../text_editor_write_after/_50_snapshot.py | 17 +
.../_50_code_execution_snapshot.py | 39 +
.../_50_snapshot.py | 28 +
.../refresh-time-travel.js | 9 +
.../time-travel-panel.html | 8 +
.../register-time-travel.js} | 20 +-
plugins/_time_travel/helpers/__init__.py | 1 +
plugins/_time_travel/helpers/time_travel.py | 1087 +++++++++++++++++
plugins/_time_travel/plugin.yaml | 8 +
plugins/_time_travel/webui/main.html | 8 +
.../_time_travel/webui/time-travel-panel.html | 907 ++++++++++++++
.../_time_travel/webui/time-travel-store.js | 527 ++++++++
tests/test_diff_viewer.py | 192 ---
tests/test_time_travel.py | 262 ++++
webui/js/modals.js | 4 +-
35 files changed, 3152 insertions(+), 1518 deletions(-)
delete mode 100644 plugins/_diff_viewer/api/diff.py
delete mode 100644 plugins/_diff_viewer/extensions/webui/apply_snapshot_before/refresh-diff-viewer.js
delete mode 100644 plugins/_diff_viewer/extensions/webui/right-canvas-panels/diff-viewer-panel.html
delete mode 100644 plugins/_diff_viewer/helpers/__init__.py
delete mode 100644 plugins/_diff_viewer/helpers/diff.py
delete mode 100644 plugins/_diff_viewer/plugin.yaml
delete mode 100644 plugins/_diff_viewer/webui/diff-viewer-panel.html
delete mode 100644 plugins/_diff_viewer/webui/diff-viewer-store.js
delete mode 100644 plugins/_diff_viewer/webui/main.html
create mode 100644 plugins/_time_travel/api/history_diff.py
create mode 100644 plugins/_time_travel/api/history_list.py
create mode 100644 plugins/_time_travel/api/history_preview.py
create mode 100644 plugins/_time_travel/api/history_revert.py
create mode 100644 plugins/_time_travel/api/history_snapshot.py
create mode 100644 plugins/_time_travel/api/history_travel.py
create mode 100644 plugins/_time_travel/extensions/python/text_editor_patch_after/_50_snapshot.py
create mode 100644 plugins/_time_travel/extensions/python/text_editor_write_after/_50_snapshot.py
create mode 100644 plugins/_time_travel/extensions/python/tool_execute_after/_50_code_execution_snapshot.py
create mode 100644 plugins/_time_travel/extensions/python/workdir_file_mutation_after/_50_snapshot.py
create mode 100644 plugins/_time_travel/extensions/webui/apply_snapshot_before/refresh-time-travel.js
create mode 100644 plugins/_time_travel/extensions/webui/right-canvas-panels/time-travel-panel.html
rename plugins/{_diff_viewer/extensions/webui/right_canvas_register_surfaces/register-diff-viewer.js => _time_travel/extensions/webui/right_canvas_register_surfaces/register-time-travel.js} (62%)
create mode 100644 plugins/_time_travel/helpers/__init__.py
create mode 100644 plugins/_time_travel/helpers/time_travel.py
create mode 100644 plugins/_time_travel/plugin.yaml
create mode 100644 plugins/_time_travel/webui/main.html
create mode 100644 plugins/_time_travel/webui/time-travel-panel.html
create mode 100644 plugins/_time_travel/webui/time-travel-store.js
delete mode 100644 tests/test_diff_viewer.py
create mode 100644 tests/test_time_travel.py
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- files changed
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- folder_off
- Not a Git workspace
-
-
-
-
-
-
- check_circle
- No changes
- The selected context workspace is clean.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- data_object
- Binary file changed.
-
-
-
-
- text_snippet
- Diff is too large to render inline.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ lock
+ Unavailable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ hourglass_empty
+ No file changes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ data_object
+ Binary file changed
+
+
+
+
+
+ text_snippet
+ Diff exceeds the 1 MB render limit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Details
+
+
+
+
+
+
+
+
+
+
+
+
+
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");