diff --git a/agent_tools/VERSION b/agent_tools/VERSION new file mode 100644 index 00000000..227cea21 --- /dev/null +++ b/agent_tools/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/agent_tools/agent_workspace/components/gtk_desktop/src/gtk_ui.py b/agent_tools/agent_workspace/components/gtk_desktop/src/gtk_ui.py index b95a86dc..b2568b6c 100644 --- a/agent_tools/agent_workspace/components/gtk_desktop/src/gtk_ui.py +++ b/agent_tools/agent_workspace/components/gtk_desktop/src/gtk_ui.py @@ -151,8 +151,12 @@ from ...task_sessions.api import reconcile_task_agent_run_session from ...task_sessions.api import resolve_task_agent_sessions from ...task_sessions.api import reset_task_agent_session +from ...settings.api import run_agent_workspace_update_check from ...settings.api import run_agent_workspace_update from ...settings.api import save_agent_workspace_settings +from ...settings.api import remember_agent_workspace +from ...workspace_config.api import ensure_agent_workspace +from ...workspace_config.api import resolve_agent_workspace_startup from ...task_sessions.api import save_task_active_agent_run from ...task_sessions.api import save_task_agent from ...task_sessions.api import save_task_agent_session @@ -344,6 +348,11 @@ def __init__(self, workspace: Path) -> None: self.main_split_ratio = settings.main_split_ratio self.details_split_ratio = settings.details_split_ratio self.actions_split_ratio = settings.actions_split_ratio + raw_settings = load_agent_workspace_settings() + recent_workspaces = raw_settings.get("recent_workspaces") + self.recent_workspaces = [ + item for item in recent_workspaces if isinstance(item, str) + ] if isinstance(recent_workspaces, list) else [str(self.workspace)] self._updating_pane_positions = False self._pane_layout_ready = False self._initial_pane_layout_source_id: int | None = None @@ -431,6 +440,8 @@ def _build_ui(self) -> None: root.pack_start(toolbar, False, False, 0) self._profile_widget("toolbar", toolbar) toolbar.pack_start(self._button("settings", self.open_settings), False, False, 0) + toolbar.pack_start(self._button("open_workspace_dialog", self.open_workspace_dialog), False, False, 0) + toolbar.pack_start(self._button("create_workspace_dialog", self.create_workspace_dialog), False, False, 0) self.summary_label = Gtk.Label(label="") self.summary_label.set_xalign(0) toolbar.pack_start(self.summary_label, False, False, 6) @@ -1713,6 +1724,68 @@ def open_task_dev(self, *_args: object) -> None: if self.selected_task is not None: open_path(self.selected_task.path / "dev") + def open_workspace_dialog(self, *_args: object) -> None: + workspace = self._choose_workspace_folder("open_workspace_dialog") + if workspace is not None: + self._switch_workspace(workspace) + + def create_workspace_dialog(self, *_args: object) -> None: + workspace = self._choose_workspace_folder("create_workspace_dialog") + if workspace is not None: + self._switch_workspace(workspace) + + def _choose_workspace_folder(self, title_key: str) -> Path | None: + dialog = Gtk.FileChooserDialog( + title=self._tr(title_key), + parent=self.window, + action=Gtk.FileChooserAction.SELECT_FOLDER, + ) + dialog.add_button(self._tr("cancel"), Gtk.ResponseType.CANCEL) + dialog.add_button(self._tr("ok"), Gtk.ResponseType.OK) + dialog.set_current_folder(str(self.workspace)) + response = dialog.run() + filename = dialog.get_filename() if response == Gtk.ResponseType.OK else None + dialog.destroy() + return Path(filename).resolve() if filename else None + + def _switch_workspace(self, workspace: Path) -> bool: + new_workspace = workspace.resolve() + if new_workspace == self.workspace: + return True + if not self._confirm_close_with_running_agents(): + return False + self._close_all_terminal_sessions() + if self.task_actions_monitor is not None: + self.task_actions_monitor.cancel() + self.task_actions_monitor = None + self.task_actions_monitor_path = None + workspace_ipc_server = getattr(self, "workspace_ipc_server", None) + if workspace_ipc_server is not None: + workspace_ipc_server.close() + self.workspace_ipc_server = None + try: + ensure_agent_workspace(new_workspace) + except (OSError, ValueError) as exc: + self._show_error(self._tr("workspace_switch_failed"), str(exc)) + self.workspace_ipc_server = start_workspace_ipc_server(self.workspace, self._on_workspace_ipc_event) + return False + updated_settings = remember_agent_workspace(new_workspace) + recent_workspaces = updated_settings.get("recent_workspaces") + self.recent_workspaces = [ + item for item in recent_workspaces if isinstance(item, str) + ] if isinstance(recent_workspaces, list) else [str(new_workspace)] + self.workspace = new_workspace + self.workspace_ipc_server = start_workspace_ipc_server(self.workspace, self._on_workspace_ipc_event) + self.task_agent_session_marker_cache.clear() + self.harness_debug_snapshot_signature = None + self.harness_debug_latest_by_task = {} + self.last_active_terminal_by_task.clear() + self.last_active_console_page_by_task.clear() + self._clear_selected_task_view() + self._apply_labels() + self.refresh_tasks() + return True + def add_task(self, *_args: object) -> None: request = self._prompt_task_name() if request is None: @@ -2310,10 +2383,6 @@ def open_settings(self, *_args: object) -> None: profiling_box.pack_start(profiling_controls, False, False, 0) profiling_box.pack_start(profiling_note, False, False, 0) profiling_box.pack_start(profiling_output_scrolled, True, True, 0) - notebook.append_page(general_scrolled, Gtk.Label(label=self._tr("settings_dictionary_general"))) - notebook.append_page(dictionary_scrolled, Gtk.Label(label=self._tr("settings_dictionary_dictionary"))) - notebook.append_page(profiling_box, Gtk.Label(label=self._tr("settings_profiling"))) - text_size = Gtk.SpinButton.new_with_range(8, 28, 1) text_size.set_value(self.text_font_size) button_size = Gtk.SpinButton.new_with_range(8, 28, 1) @@ -2333,15 +2402,74 @@ def open_settings(self, *_args: object) -> None: system_prompt_scrolled.set_hexpand(True) system_prompt_scrolled.set_vexpand(False) system_prompt_scrolled.set_min_content_height(96) - settings_update_button = Gtk.Button(label=self._tr("settings_check_updates")) + settings_update_check_button = Gtk.Button(label=self._tr("settings_check_updates")) + settings_update_button = Gtk.Button(label=self._tr("settings_apply_update")) + settings_update_button.set_no_show_all(True) + settings_update_button.set_visible(False) settings_update_status = Gtk.Label(label=self._tr("settings_update_idle")) settings_update_status.set_xalign(0) settings_update_status.set_line_wrap(True) + settings_update_controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) settings_update_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + settings_update_box.set_border_width(12) settings_update_box.set_hexpand(True) - settings_update_box.pack_start(settings_update_button, False, False, 0) + settings_update_box.set_vexpand(True) + settings_update_controls.pack_start(settings_update_check_button, False, False, 0) + settings_update_controls.pack_start(settings_update_button, False, False, 0) + settings_update_box.pack_start(settings_update_controls, False, False, 0) settings_update_box.pack_start(settings_update_status, False, False, 0) settings_open = {"value": True} + notebook.append_page(general_scrolled, Gtk.Label(label=self._tr("settings_dictionary_general"))) + notebook.append_page(dictionary_scrolled, Gtk.Label(label=self._tr("settings_dictionary_dictionary"))) + notebook.append_page(settings_update_box, Gtk.Label(label=self._tr("settings_updates"))) + notebook.append_page(profiling_box, Gtk.Label(label=self._tr("settings_profiling"))) + + def run_settings_update_check(*_ignored: object) -> None: + if self.settings_update_running: + return + self.settings_update_running = True + settings_update_check_button.set_sensitive(False) + settings_update_button.set_sensitive(False) + settings_update_button.set_visible(False) + settings_update_status.set_text(self._tr("settings_update_check_running")) + + def worker() -> None: + try: + result = run_agent_workspace_update_check() + if result.update_available: + status_text = self._tr("settings_update_available").format( + current=result.current_version, + latest=result.latest_version, + ) + elif result.ok: + status_text = self._tr("settings_update_none") + else: + status_text = ( + self._tr("settings_update_check_failed").format(code=result.returncode) + + "\n" + + _tail_text(result.output, 2_000) + ) + update_available = result.update_available + except Exception as error: + status_text = ( + self._tr("settings_update_check_failed").format(code=1) + + f"\n{type(error).__name__}: {error}" + ) + update_available = False + + def apply_result() -> bool: + self.settings_update_running = False + if not settings_open["value"]: + return False + settings_update_check_button.set_sensitive(True) + settings_update_button.set_sensitive(update_available) + settings_update_button.set_visible(update_available) + settings_update_status.set_text(status_text) + return False + + GLib.idle_add(apply_result) + + threading.Thread(target=worker, daemon=True).start() def run_settings_update(*_ignored: object) -> None: if self.settings_update_running: @@ -2355,19 +2483,20 @@ def run_settings_update(*_ignored: object) -> None: ) confirm.format_secondary_text(self._tr("settings_update_confirm_body")) confirm.add_button(self._tr("cancel"), Gtk.ResponseType.CANCEL) - confirm.add_button(self._tr("settings_check_updates"), Gtk.ResponseType.OK) + confirm.add_button(self._tr("settings_apply_update"), Gtk.ResponseType.OK) response = confirm.run() confirm.destroy() if response != Gtk.ResponseType.OK: return self.settings_update_running = True + settings_update_check_button.set_sensitive(False) settings_update_button.set_sensitive(False) settings_update_status.set_text(self._tr("settings_update_running")) def worker() -> None: update_ok = False try: - result = run_agent_workspace_update(self.workspace) + result = run_agent_workspace_update() update_ok = result.ok status_text = ( self._tr("settings_update_done") @@ -2383,6 +2512,7 @@ def apply_result() -> bool: self.settings_update_running = False if not settings_open["value"]: return False + settings_update_check_button.set_sensitive(True) settings_update_button.set_sensitive(True) settings_update_status.set_text(status_text) if update_ok: @@ -2395,6 +2525,7 @@ def apply_result() -> bool: threading.Thread(target=worker, daemon=True).start() + settings_update_check_button.connect("clicked", run_settings_update_check) settings_update_button.connect("clicked", run_settings_update) claude_model_combo = Gtk.ComboBoxText() claude_effort_combo = Gtk.ComboBoxText() @@ -2519,7 +2650,6 @@ def update_dictionary_preview(*_ignored: object) -> None: (self._tr("language"), language_combo), (self._tr("default_agent"), default_agent_combo), (self._tr("system_prompt"), system_prompt_scrolled), - (self._tr("settings_updates"), settings_update_box), (agent_label("codex"), None), ] if codex_models is not None: @@ -5828,6 +5958,8 @@ def _save_settings(self) -> None: "main_split_ratio": self.main_split_ratio, "details_split_ratio": self.details_split_ratio, "actions_split_ratio": self.actions_split_ratio, + "last_workspace": str(self.workspace), + "recent_workspaces": self.recent_workspaces, } ) @@ -6219,11 +6351,11 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Open the local workspace task dashboard.") parser.add_argument( "--workspace", - default=".", - help="Workspace root. Default: current directory.", + default=None, + help="Workspace root. Default: last opened workspace or current directory.", ) args = parser.parse_args(argv) - workspace = Path(args.workspace) + workspace = resolve_agent_workspace_startup(Path(args.workspace) if args.workspace else None) install_agent_workspace_exception_logger(workspace, "gtk") workspace_lock = acquire_agent_workspace_lock(workspace) if workspace_lock is None: diff --git a/agent_tools/agent_workspace/components/gtk_desktop/tests/test_gtk_desktop.py b/agent_tools/agent_workspace/components/gtk_desktop/tests/test_gtk_desktop.py index 2e09ac2a..6d3ab8e8 100644 --- a/agent_tools/agent_workspace/components/gtk_desktop/tests/test_gtk_desktop.py +++ b/agent_tools/agent_workspace/components/gtk_desktop/tests/test_gtk_desktop.py @@ -2756,7 +2756,8 @@ def test_gtk_translates_agent_and_manual_labels() -> None: assert GTK_TRANSLATIONS["ru"]["default_codex_model"] == "Модель Codex" assert GTK_TRANSLATIONS["ru"]["system_prompt"] == "Системный промпт" assert GTK_TRANSLATIONS["ru"]["settings_updates"] == "Обновления" - assert GTK_TRANSLATIONS["ru"]["settings_check_updates"] == "Проверить обновления" + assert GTK_TRANSLATIONS["ru"]["settings_check_updates"] == "Проверить доступные обновления" + assert GTK_TRANSLATIONS["ru"]["settings_apply_update"] == "Обновить" assert GTK_TRANSLATIONS["ru"]["settings_update_running"] == "Обновление Agent Workspace..." assert GTK_TRANSLATIONS["ru"]["settings_update_confirm_title"] == "Обновить Agent Workspace?" assert "закроется" in GTK_TRANSLATIONS["ru"]["settings_update_confirm_body"] @@ -2777,6 +2778,9 @@ def test_gtk_translates_agent_and_manual_labels() -> None: assert "Предлагаемая команда установки" in GTK_TRANSLATIONS["ru"]["install_agent_body"] assert GTK_TRANSLATIONS["ru"]["delete_artifacts"] == "Удалить артефакты" assert GTK_TRANSLATIONS["ru"]["open_containing_folder"] == "Открыть содержащую папку" + assert GTK_TRANSLATIONS["ru"]["open_workspace_dialog"] == "Открыть workspace" + assert GTK_TRANSLATIONS["ru"]["create_workspace_dialog"] == "Создать workspace" + assert GTK_TRANSLATIONS["ru"]["workspace_switch_failed"] == "Не удалось переключить workspace" assert GTK_TRANSLATIONS["ru"]["other_artifacts"] == "Другие артефакты" assert GTK_TRANSLATIONS["ru"]["updated"] == "Обновлено" assert GTK_TRANSLATIONS["uk"]["manual_usage_section"] == "Основи" diff --git a/agent_tools/agent_workspace/components/localization/src/gtk_translation_catalog.json b/agent_tools/agent_workspace/components/localization/src/gtk_translation_catalog.json index 728d7d45..885fbd1d 100644 --- a/agent_tools/agent_workspace/components/localization/src/gtk_translation_catalog.json +++ b/agent_tools/agent_workspace/components/localization/src/gtk_translation_catalog.json @@ -57,6 +57,7 @@ "cancel": "Cancel", "close": "Close", "copy": "Copy", + "create_workspace_dialog": "Create workspace", "confirm_close_console_body": "The console session will be closed.", "confirm_close_console_title": "Close console?", "confirm_close_running_agents_body": "There are running AI agent terminals.\n\n{sessions}\n\nClosing Agent Workspace will stop the local agent processes. Resumable conversations can be restored on the next launch. Continue?", @@ -94,7 +95,12 @@ "default_agent": "Default AI agent", "system_prompt": "System prompt", "settings_updates": "Updates", - "settings_check_updates": "Check updates", + "settings_check_updates": "Check available updates", + "settings_apply_update": "Update", + "settings_update_available": "Update available: {latest} (current: {current}).", + "settings_update_check_failed": "Update check failed with exit code {code}.", + "settings_update_check_running": "Checking for updates...", + "settings_update_none": "No updates available.", "settings_update_idle": "Ready to check for updates.", "settings_update_running": "Updating Agent Workspace...", "settings_update_done": "Agent Workspace is up to date. The app will close now.", @@ -159,6 +165,7 @@ "open_dev": "Open dev folder", "open_task": "Open task folder", "open_workspace": "Open Workspace", + "open_workspace_dialog": "Open workspace", "other_artifacts": "Other artifacts", "paste": "Paste", "refresh": "Refresh", @@ -214,7 +221,8 @@ "text_font_size": "Text font size", "theme": "Theme", "updated": "Updated", - "window_title": "Agent Workspace" + "window_title": "Agent Workspace", + "workspace_switch_failed": "Could not switch workspace" }, "ru": { "actions": "Действия", @@ -274,6 +282,7 @@ "cancel": "Отмена", "close": "Закрыть", "copy": "Копировать", + "create_workspace_dialog": "Создать workspace", "confirm_close_console_body": "Консольная сессия будет закрыта.", "confirm_close_console_title": "Закрыть консоль?", "confirm_close_running_agents_body": "Есть запущенные терминалы ИИ агентов.\n\n{sessions}\n\nЗакрытие Agent Workspace остановит локальные процессы агентов. Восстанавливаемые диалоги можно будет открыть при следующем запуске. Продолжить?", @@ -311,7 +320,12 @@ "default_agent": "ИИ агент по умолчанию", "system_prompt": "Системный промпт", "settings_updates": "Обновления", - "settings_check_updates": "Проверить обновления", + "settings_check_updates": "Проверить доступные обновления", + "settings_apply_update": "Обновить", + "settings_update_available": "Доступно обновление: {latest} (текущая: {current}).", + "settings_update_check_failed": "Проверка обновлений завершилась с кодом {code}.", + "settings_update_check_running": "Проверка обновлений...", + "settings_update_none": "Доступных обновлений нет.", "settings_update_idle": "Готово к проверке обновлений.", "settings_update_running": "Обновление Agent Workspace...", "settings_update_done": "Agent Workspace обновлён. Приложение сейчас закроется.", @@ -376,6 +390,7 @@ "open_dev": "Открыть dev", "open_task": "Открыть папку задачи", "open_workspace": "Открыть workspace", + "open_workspace_dialog": "Открыть workspace", "other_artifacts": "Другие артефакты", "paste": "Вставить", "refresh": "Обновить", @@ -431,7 +446,8 @@ "text_font_size": "Размер шрифта текста", "theme": "Тема", "updated": "Обновлено", - "window_title": "Agent Workspace" + "window_title": "Agent Workspace", + "workspace_switch_failed": "Не удалось переключить workspace" }, "uk": { "actions": "Дії", @@ -491,6 +507,7 @@ "cancel": "Скасувати", "close": "Закрити", "copy": "Копіювати", + "create_workspace_dialog": "Створити workspace", "confirm_close_console_body": "Консольну сесію буде закрито.", "confirm_close_console_title": "Закрити консоль?", "confirm_close_running_agents_body": "Є запущені термінали ШІ агентів.\n\n{sessions}\n\nЗакриття Agent Workspace зупинить локальні процеси агентів. Відновлювані діалоги можна буде відкрити під час наступного запуску. Продовжити?", @@ -528,7 +545,12 @@ "default_agent": "Типовий ШІ агент", "system_prompt": "Системний промпт", "settings_updates": "Оновлення", - "settings_check_updates": "Перевірити оновлення", + "settings_check_updates": "Перевірити доступні оновлення", + "settings_apply_update": "Оновити", + "settings_update_available": "Доступне оновлення: {latest} (поточна: {current}).", + "settings_update_check_failed": "Перевірка оновлень завершилася з кодом {code}.", + "settings_update_check_running": "Перевірка оновлень...", + "settings_update_none": "Доступних оновлень немає.", "settings_update_idle": "Готово до перевірки оновлень.", "settings_update_running": "Оновлення Agent Workspace...", "settings_update_done": "Agent Workspace оновлено. Застосунок зараз закриється.", @@ -593,6 +615,7 @@ "open_dev": "Відкрити dev", "open_task": "Відкрити папку задачі", "open_workspace": "Відкрити workspace", + "open_workspace_dialog": "Відкрити workspace", "other_artifacts": "Інші артефакти", "paste": "Вставити", "refresh": "Оновити", @@ -648,6 +671,7 @@ "text_font_size": "Розмір шрифту тексту", "theme": "Тема", "updated": "Оновлено", - "window_title": "Agent Workspace" + "window_title": "Agent Workspace", + "workspace_switch_failed": "Не вдалося перемкнути workspace" } } diff --git a/agent_tools/agent_workspace/components/settings/api/__init__.py b/agent_tools/agent_workspace/components/settings/api/__init__.py index 0786d550..47306e50 100644 --- a/agent_tools/agent_workspace/components/settings/api/__init__.py +++ b/agent_tools/agent_workspace/components/settings/api/__init__.py @@ -17,18 +17,22 @@ from ..src.settings import AGENT_WORKSPACE_DEFAULT_LIMITED_BASH_OUTPUT_TOKENS from ..src.settings import AGENT_WORKSPACE_GEOMETRY_RE from ..src.settings import AGENT_WORKSPACE_LANGUAGES +from ..src.settings import AGENT_WORKSPACE_RELEASES_API from ..src.settings import AGENT_WORKSPACE_REASONING_EFFORTS from ..src.settings import AGENT_WORKSPACE_SETTINGS_FILE from ..src.settings import AGENT_WORKSPACE_THEMES from ..src.settings import TASK_CONTEXT_PROMPT_INJECTION_DEFAULT from ..src.settings import AgentModelChoices from ..src.settings import AgentModelSettings +from ..src.settings import AgentWorkspaceSettingValue +from ..src.settings import AgentWorkspaceUpdateCheckResult from ..src.settings import AgentWorkspaceUpdateResult from ..src.settings import AgentWorkspaceRuntimeSettings from ..src.settings import agent_command_name from ..src.settings import agent_executable from ..src.settings import agent_install_command from ..src.settings import agent_label +from ..src.settings import agent_workspace_install_root from ..src.settings import agent_workspace_root from ..src.settings import agent_workspace_runtime_settings from ..src.settings import agent_workspace_setting_or_default @@ -42,8 +46,10 @@ from ..src.settings import load_agent_workspace_settings from ..src.settings import model_choices_with_current from ..src.settings import normalize_agent +from ..src.settings import run_agent_workspace_update_check from ..src.settings import run_agent_workspace_update from ..src.settings import save_agent_workspace_settings +from ..src.settings import remember_agent_workspace from ..src.settings import task_dictionary_policy_from_runtime_settings __all__ = [ @@ -64,18 +70,22 @@ "AGENT_WORKSPACE_DEFAULT_LIMITED_BASH_OUTPUT_TOKENS", "AGENT_WORKSPACE_GEOMETRY_RE", "AGENT_WORKSPACE_LANGUAGES", + "AGENT_WORKSPACE_RELEASES_API", "AGENT_WORKSPACE_REASONING_EFFORTS", "AGENT_WORKSPACE_SETTINGS_FILE", "AGENT_WORKSPACE_THEMES", "TASK_CONTEXT_PROMPT_INJECTION_DEFAULT", "AgentModelChoices", "AgentModelSettings", + "AgentWorkspaceSettingValue", + "AgentWorkspaceUpdateCheckResult", "AgentWorkspaceRuntimeSettings", "AgentWorkspaceUpdateResult", "agent_command_name", "agent_executable", "agent_install_command", "agent_label", + "agent_workspace_install_root", "agent_workspace_root", "agent_workspace_runtime_settings", "agent_workspace_setting_or_default", @@ -89,7 +99,9 @@ "load_agent_workspace_settings", "model_choices_with_current", "normalize_agent", + "run_agent_workspace_update_check", "run_agent_workspace_update", "save_agent_workspace_settings", + "remember_agent_workspace", "task_dictionary_policy_from_runtime_settings", ] diff --git a/agent_tools/agent_workspace/components/settings/src/settings.py b/agent_tools/agent_workspace/components/settings/src/settings.py index 0f02abbf..7d21eeac 100644 --- a/agent_tools/agent_workspace/components/settings/src/settings.py +++ b/agent_tools/agent_workspace/components/settings/src/settings.py @@ -8,6 +8,9 @@ import shutil import subprocess import sys +import tarfile +import tempfile +import urllib.request from agent_tools.tools.task_context import DICTIONARY_AUTO_DISCOVERY_DEFAULT from agent_tools.tools.task_context import DICTIONARY_MAX_TERM_WORDS @@ -59,6 +62,8 @@ } AGENT_WORKSPACE_GEOMETRY_RE = re.compile(r"^\d+x\d+(?:[+-]\d+[+-]\d+)?$") TASK_CONTEXT_PROMPT_INJECTION_DEFAULT = True +AgentWorkspaceSettingValue = int | float | str | bool | list[str] +AGENT_WORKSPACE_RELEASES_API = "https://api.github.com/repos/svlad-90/agent_tools/releases/latest" @dataclass(frozen=True) @@ -113,6 +118,25 @@ def ok(self) -> bool: return self.returncode == 0 +@dataclass(frozen=True) +class AgentWorkspaceUpdateCheckResult: + commands: tuple[tuple[str, ...], ...] + returncode: int + output: str + current_version: str = "" + latest_version: str = "" + release_url: str = "" + tarball_url: str = "" + + @property + def ok(self) -> bool: + return self.returncode == 0 + + @property + def update_available(self) -> bool: + return self.ok and _version_key(self.latest_version) > _version_key(self.current_version) + + def agent_workspace_settings_path() -> Path: config_root = os.environ.get("XDG_CONFIG_HOME") if config_root: @@ -128,16 +152,18 @@ def agent_workspace_root(start: Path | None = None) -> Path: raise RuntimeError(f"cannot resolve Agent Workspace root from {path}") +def agent_workspace_install_root(start: Path | None = None) -> Path: + return agent_workspace_root(start) + + def agent_workspace_update_commands( workspace: Path | None = None, *, python_executable: str | None = None, ) -> tuple[tuple[str, ...], ...]: - root = agent_workspace_root(workspace) if workspace is not None else agent_workspace_root() + root = agent_workspace_install_root(workspace) if workspace is not None else agent_workspace_install_root() python = python_executable or sys.executable - git = shutil.which("git") or "git" return ( - (git, "-C", str(root), "pull", "--ff-only"), ( python, str(root / "install-agent-tools.py"), @@ -148,15 +174,68 @@ def agent_workspace_update_commands( ) +def run_agent_workspace_update_check( + install_root: Path | None = None, + *, + timeout: float | None = None, +) -> AgentWorkspaceUpdateCheckResult: + root = agent_workspace_install_root(install_root) if install_root is not None else agent_workspace_install_root() + current_version = _agent_workspace_current_version(root) + commands = (("GET", AGENT_WORKSPACE_RELEASES_API),) + output_parts: list[str] = [] + try: + payload = _read_release_json(timeout=timeout) + except TimeoutError: + output_parts.append(f"Timed out after {timeout} seconds.") + return AgentWorkspaceUpdateCheckResult(commands, 124, "\n".join(output_parts).rstrip() + "\n", current_version) + except (OSError, ValueError, json.JSONDecodeError) as error: + output_parts.append(f"{type(error).__name__}: {error}") + return AgentWorkspaceUpdateCheckResult(commands, 1, "\n".join(output_parts).rstrip() + "\n", current_version) + latest_version = _release_version(payload.get("tag_name")) + release_url = _string_value(payload.get("html_url")) + tarball_url = _string_value(payload.get("tarball_url")) + if not latest_version or not tarball_url: + output_parts.append("Latest GitHub release does not contain tag_name and tarball_url.") + return AgentWorkspaceUpdateCheckResult(commands, 1, "\n".join(output_parts).rstrip() + "\n", current_version) + output_parts.append(f"Current version: {current_version}") + output_parts.append(f"Latest release: {latest_version}") + return AgentWorkspaceUpdateCheckResult( + commands, + 0, + "\n".join(output_parts).rstrip() + "\n", + current_version, + latest_version, + release_url, + tarball_url, + ) + + def run_agent_workspace_update( - workspace: Path | None = None, + install_root: Path | None = None, *, python_executable: str | None = None, timeout: float | None = None, ) -> AgentWorkspaceUpdateResult: - root = agent_workspace_root(workspace) if workspace is not None else agent_workspace_root() + root = agent_workspace_install_root(install_root) if install_root is not None else agent_workspace_install_root() + check = run_agent_workspace_update_check(root, timeout=timeout) + if not check.ok: + return AgentWorkspaceUpdateResult(check.commands, check.returncode, check.output) + if not check.update_available: + return AgentWorkspaceUpdateResult(check.commands, 0, check.output + "No release update available.\n") commands = agent_workspace_update_commands(root, python_executable=python_executable) output_parts: list[str] = [] + output_parts.append(check.output.rstrip()) + try: + with tempfile.TemporaryDirectory(prefix="agent-workspace-update-") as temp_dir: + release_source = _download_and_extract_release(check.tarball_url, Path(temp_dir), timeout=timeout) + _copy_release_tree(release_source, root) + except (OSError, tarfile.TarError, ValueError) as error: + output_parts.append(f"{type(error).__name__}: {error}") + return AgentWorkspaceUpdateResult( + (("download", check.tarball_url), *commands), + 1, + "\n".join(output_parts).rstrip() + "\n", + ) env = os.environ.copy() current_pythonpath = env.get("PYTHONPATH") env["PYTHONPATH"] = str(root) if not current_pythonpath else f"{root}{os.pathsep}{current_pythonpath}" @@ -183,7 +262,102 @@ def run_agent_workspace_update( output_parts.append(completed.stderr.rstrip()) if completed.returncode != 0: return AgentWorkspaceUpdateResult(commands, completed.returncode, "\n".join(output_parts).rstrip() + "\n") - return AgentWorkspaceUpdateResult(commands, 0, "\n".join(output_parts).rstrip() + "\n") + return AgentWorkspaceUpdateResult((("download", check.tarball_url), *commands), 0, "\n".join(output_parts).rstrip() + "\n") + + +def _read_release_json(*, timeout: float | None) -> dict[str, object]: + request = urllib.request.Request( + AGENT_WORKSPACE_RELEASES_API, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "agent-workspace-updater", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + data = json.loads(response.read().decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("GitHub release response is not a JSON object") + return data + + +def _agent_workspace_current_version(root: Path) -> str: + version_file = root / "agent_tools" / "VERSION" + if version_file.is_file(): + version = version_file.read_text(encoding="utf-8").strip() + if version: + return _release_version(version) + git = shutil.which("git") + if git is not None and (root / ".git").exists(): + completed = subprocess.run( + (git, "-C", str(root), "describe", "--tags", "--abbrev=0"), + text=True, + capture_output=True, + timeout=5, + ) + if completed.returncode == 0 and completed.stdout.strip(): + return _release_version(completed.stdout.strip()) + return "0.0.0" + + +def _download_and_extract_release(tarball_url: str, temp_dir: Path, *, timeout: float | None) -> Path: + archive_path = temp_dir / "release.tar.gz" + request = urllib.request.Request(tarball_url, headers={"User-Agent": "agent-workspace-updater"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + archive_path.write_bytes(response.read()) + extract_dir = temp_dir / "extract" + extract_dir.mkdir() + with tarfile.open(archive_path, "r:*") as archive: + _safe_extract_tar(archive, extract_dir) + children = [path for path in extract_dir.iterdir() if path.is_dir()] + if len(children) != 1: + raise ValueError("release archive must contain one top-level directory") + release_source = children[0] + if not (release_source / "install-agent-tools.py").is_file() or not (release_source / "agent_tools").is_dir(): + raise ValueError("release archive does not look like Agent Workspace") + return release_source + + +def _safe_extract_tar(archive: tarfile.TarFile, target: Path) -> None: + target_root = target.resolve() + for member in archive.getmembers(): + if member.issym() or member.islnk(): + raise ValueError(f"links are not allowed in release archive: {member.name}") + member_target = (target / member.name).resolve() + try: + member_target.relative_to(target_root) + except ValueError as error: + raise ValueError(f"unsafe path in release archive: {member.name}") from error + archive.extractall(target) + + +def _copy_release_tree(source: Path, target: Path) -> None: + for item in source.iterdir(): + if item.name == ".git": + continue + destination = target / item.name + if item.is_dir(): + shutil.copytree(item, destination, dirs_exist_ok=True) + else: + shutil.copy2(item, destination) + + +def _release_version(value: object) -> str: + if not isinstance(value, str): + return "" + return value.strip().removeprefix("v") + + +def _string_value(value: object) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _version_key(version: str) -> tuple[int, ...]: + numbers = [] + for part in version.split("."): + if not part.isdigit(): + break + numbers.append(int(part)) + return tuple(numbers) def normalize_agent(agent: object) -> str: @@ -400,7 +574,7 @@ def ai_agent_model_settings( ) -def load_agent_workspace_settings(path: Path | None = None) -> dict[str, int | float | str | bool]: +def load_agent_workspace_settings(path: Path | None = None) -> dict[str, AgentWorkspaceSettingValue]: settings_path = path or agent_workspace_settings_path() if not settings_path.is_file(): return {} @@ -410,7 +584,7 @@ def load_agent_workspace_settings(path: Path | None = None) -> dict[str, int | f return {} if not isinstance(data, dict): return {} - settings: dict[str, int | float | str | bool] = {} + settings: dict[str, AgentWorkspaceSettingValue] = {} text_font_size = data.get("text_font_size", data.get("font_size")) button_font_size = data.get("button_font_size") theme = data.get("theme") @@ -440,6 +614,8 @@ def load_agent_workspace_settings(path: Path | None = None) -> dict[str, int | f main_split_ratio = data.get("main_split_ratio") details_split_ratio = data.get("details_split_ratio") actions_split_ratio = data.get("actions_split_ratio") + last_workspace = data.get("last_workspace") + recent_workspaces = data.get("recent_workspaces") if isinstance(text_font_size, int): settings["text_font_size"] = max(8, min(28, text_font_size)) if isinstance(button_font_size, int): @@ -490,10 +666,21 @@ def load_agent_workspace_settings(path: Path | None = None) -> dict[str, int | f ): if isinstance(value, int | float) and 0.05 <= float(value) <= 0.95: settings[key] = float(value) + if isinstance(last_workspace, str) and last_workspace.strip(): + settings["last_workspace"] = last_workspace.strip() + if isinstance(recent_workspaces, list): + normalized_recent = [] + seen = set() + for item in recent_workspaces: + if isinstance(item, str) and item.strip() and item not in seen: + normalized_recent.append(item.strip()) + seen.add(item) + if normalized_recent: + settings["recent_workspaces"] = normalized_recent[:10] return settings -def save_agent_workspace_settings(settings: dict[str, int | float | str | bool], path: Path | None = None) -> None: +def save_agent_workspace_settings(settings: dict[str, AgentWorkspaceSettingValue], path: Path | None = None) -> None: settings_path = path or agent_workspace_settings_path() try: settings_path.parent.mkdir(parents=True, exist_ok=True) @@ -502,6 +689,30 @@ def save_agent_workspace_settings(settings: dict[str, int | float | str | bool], return +def remember_agent_workspace(workspace: Path, path: Path | None = None) -> dict[str, AgentWorkspaceSettingValue]: + root = str(workspace.resolve()) + settings = load_agent_workspace_settings(path) + recent = [root] + existing_recent = settings.get("recent_workspaces") + if isinstance(existing_recent, list): + for item in existing_recent: + if isinstance(item, str) and item != root: + recent.append(item) + previous_last = settings.get("last_workspace") + if isinstance(previous_last, str) and previous_last != root: + recent.append(previous_last) + normalized_recent = [] + seen = set() + for item in recent: + if item and item not in seen: + normalized_recent.append(item) + seen.add(item) + settings["last_workspace"] = root + settings["recent_workspaces"] = normalized_recent[:10] + save_agent_workspace_settings(settings, path) + return settings + + def _codex_model_choices_from_cli(*, timeout: float) -> tuple[str, ...] | None: executable = agent_executable("codex") if executable is None: diff --git a/agent_tools/agent_workspace/components/settings/tests/test_settings.py b/agent_tools/agent_workspace/components/settings/tests/test_settings.py index f8543d84..2290bedb 100644 --- a/agent_tools/agent_workspace/components/settings/tests/test_settings.py +++ b/agent_tools/agent_workspace/components/settings/tests/test_settings.py @@ -1,6 +1,8 @@ from __future__ import annotations +import io import subprocess +import tarfile from agent_tools.agent_workspace.components.test_support.src.helpers import * @@ -35,6 +37,8 @@ def test_agent_workspace_settings_persist_font_size(tmp_path: Path) -> None: "main_split_ratio": 0.3, "details_split_ratio": 0.7, "actions_split_ratio": 0.4, + "last_workspace": str(tmp_path / "workspace"), + "recent_workspaces": [str(tmp_path / "workspace"), str(tmp_path / "other")], }, settings_path, ) @@ -65,9 +69,31 @@ def test_agent_workspace_settings_persist_font_size(tmp_path: Path) -> None: "main_split_ratio": 0.3, "details_split_ratio": 0.7, "actions_split_ratio": 0.4, + "last_workspace": str(tmp_path / "workspace"), + "recent_workspaces": [str(tmp_path / "workspace"), str(tmp_path / "other")], } +def test_remember_agent_workspace_updates_last_and_recent(tmp_path: Path) -> None: + settings_path = tmp_path / "settings.json" + old_workspace = tmp_path / "old" + new_workspace = tmp_path / "new" + save_agent_workspace_settings( + { + "theme": "dark", + "last_workspace": str(old_workspace), + "recent_workspaces": [str(old_workspace)], + }, + settings_path, + ) + + settings = remember_agent_workspace(new_workspace, settings_path) + + assert settings["theme"] == "dark" + assert settings["last_workspace"] == str(new_workspace.resolve()) + assert settings["recent_workspaces"] == [str(new_workspace.resolve()), str(old_workspace)] + + def test_agent_workspace_settings_migrate_old_font_size(tmp_path: Path) -> None: settings_path = tmp_path / "settings.json" settings_path.write_text('{"font_size": 17}', encoding="utf-8") @@ -413,27 +439,31 @@ def test_agent_workspace_root_resolves_from_workspace(tmp_path: Path) -> None: assert agent_workspace_root(workspace / "agent_tools") == workspace -def test_agent_workspace_update_commands_pull_then_install(tmp_path: Path) -> None: +def test_agent_workspace_update_commands_install_only(tmp_path: Path) -> None: workspace = tmp_path / "workspace" (workspace / "agent_tools").mkdir(parents=True) (workspace / "install-agent-tools.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") commands = agent_workspace_update_commands(workspace, python_executable="/python") - assert commands[0][1:] == ("-C", str(workspace), "pull", "--ff-only") - assert commands[1] == ( + assert commands == (( "/python", str(workspace / "install-agent-tools.py"), "--non-interactive", "--skip-system-deps", "--recreate-venv-if-broken", - ) + ),) -def test_run_agent_workspace_update_returns_failed_command_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_run_agent_workspace_update_returns_failed_installer_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: workspace = tmp_path / "workspace" (workspace / "agent_tools").mkdir(parents=True) + (workspace / "agent_tools" / "VERSION").write_text("2.0.0\n", encoding="utf-8") (workspace / "install-agent-tools.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + tarball = _agent_workspace_release_tarball() def fake_run( command: tuple[str, ...], @@ -441,11 +471,107 @@ def fake_run( ) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(command, 7, stdout="stdout text\n", stderr="stderr text\n") + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.urllib.request.urlopen", + _fake_urlopen_factory( + { + AGENT_WORKSPACE_RELEASES_API: b'{"tag_name":"v2.1.0","html_url":"https://example/release","tarball_url":"https://example/tarball"}', + "https://example/tarball": tarball, + } + ), + ) monkeypatch.setattr("agent_tools.agent_workspace.components.settings.src.settings.subprocess.run", fake_run) result = run_agent_workspace_update(workspace, python_executable="/missing-python") assert result.ok is False - assert "pull --ff-only" in result.output + assert "Latest release: 2.1.0" in result.output assert "stdout text" in result.output assert "stderr text" in result.output + assert (workspace / "agent_tools" / "VERSION").read_text(encoding="utf-8") == "2.1.0\n" + + +def test_run_agent_workspace_update_check_reports_available_updates( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + (workspace / "agent_tools").mkdir(parents=True) + (workspace / "agent_tools" / "VERSION").write_text("2.0.0\n", encoding="utf-8") + (workspace / "install-agent-tools.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.urllib.request.urlopen", + _fake_urlopen_factory( + { + AGENT_WORKSPACE_RELEASES_API: b'{"tag_name":"v2.1.0","html_url":"https://example/release","tarball_url":"https://example/tarball"}', + } + ), + ) + + result = run_agent_workspace_update_check(workspace) + + assert result.ok is True + assert result.update_available is True + assert result.current_version == "2.0.0" + assert result.latest_version == "2.1.0" + assert result.release_url == "https://example/release" + assert result.tarball_url == "https://example/tarball" + + +def test_run_agent_workspace_update_check_returns_failed_release_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + (workspace / "agent_tools").mkdir(parents=True) + (workspace / "install-agent-tools.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + + def fake_urlopen(_request: object, **_kwargs: object) -> object: + raise OSError("network down") + + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.urllib.request.urlopen", + fake_urlopen, + ) + + result = run_agent_workspace_update_check(workspace) + + assert result.ok is False + assert result.update_available is False + assert "network down" in result.output + + +class _FakeResponse: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + def __enter__(self) -> "_FakeResponse": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return self.payload + + +def _fake_urlopen_factory(payloads: dict[str, bytes]) -> object: + def fake_urlopen(request: object, **_kwargs: object) -> _FakeResponse: + url = getattr(request, "full_url", request) + return _FakeResponse(payloads[str(url)]) + + return fake_urlopen + + +def _agent_workspace_release_tarball() -> bytes: + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz") as archive: + for name, payload in ( + ("release/install-agent-tools.py", b"#!/usr/bin/env python3\n"), + ("release/agent_tools/VERSION", b"2.1.0\n"), + ): + info = tarfile.TarInfo(name) + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return output.getvalue() diff --git a/agent_tools/agent_workspace/components/test_support/src/helpers.py b/agent_tools/agent_workspace/components/test_support/src/helpers.py index f4784622..5c02bedf 100644 --- a/agent_tools/agent_workspace/components/test_support/src/helpers.py +++ b/agent_tools/agent_workspace/components/test_support/src/helpers.py @@ -79,10 +79,12 @@ from agent_tools.agent_workspace.components.settings.api import agent_executable from agent_tools.agent_workspace.components.settings.api import agent_install_command from agent_tools.agent_workspace.components.settings.api import agent_workspace_root +from agent_tools.agent_workspace.components.settings.api import agent_workspace_install_root from agent_tools.agent_workspace.components.settings.api import agent_workspace_runtime_settings from agent_tools.agent_workspace.components.settings.api import agent_workspace_setting_or_default from agent_tools.agent_workspace.components.settings.api import agent_workspace_update_commands from agent_tools.agent_workspace.components.settings.api import AGENT_WORKSPACE_DEFAULT_LIMITED_BASH_OUTPUT_TOKENS +from agent_tools.agent_workspace.components.settings.api import AGENT_WORKSPACE_RELEASES_API from agent_tools.agent_workspace.components.settings.api import ai_agent_model_settings from agent_tools.agent_workspace.components.settings.api import claude_model_choices_info from agent_tools.agent_workspace.components.settings.api import codex_model_choices @@ -92,9 +94,15 @@ from agent_tools.agent_workspace.components.task_catalog.api import discover_tasks from agent_tools.agent_workspace.components.task_catalog.api import run_task_check from agent_tools.agent_workspace.components.settings.api import load_agent_workspace_settings +from agent_tools.agent_workspace.components.settings.api import run_agent_workspace_update_check from agent_tools.agent_workspace.components.settings.api import run_agent_workspace_update from agent_tools.agent_workspace.components.settings.api import save_agent_workspace_settings +from agent_tools.agent_workspace.components.settings.api import remember_agent_workspace from agent_tools.agent_workspace.components.settings.api import task_dictionary_policy_from_runtime_settings +from agent_tools.agent_workspace.components.workspace_config.api import AGENT_WORKSPACE_SCHEMA_VERSION +from agent_tools.agent_workspace.components.workspace_config.api import create_agent_workspace +from agent_tools.agent_workspace.components.workspace_config.api import load_agent_workspace_manifest +from agent_tools.agent_workspace.components.workspace_config.api import resolve_agent_workspace_startup from agent_tools.agent_workspace.components.task_context.api import context_entry_cards_markdown from agent_tools.agent_workspace.components.task_context.api import encoded_context_entries_markdown from agent_tools.agent_workspace.components.task_context.api import task_context_details_markdown diff --git a/agent_tools/agent_workspace/components/tk_frontend/src/ui.py b/agent_tools/agent_workspace/components/tk_frontend/src/ui.py index 20a49284..a51b5892 100644 --- a/agent_tools/agent_workspace/components/tk_frontend/src/ui.py +++ b/agent_tools/agent_workspace/components/tk_frontend/src/ui.py @@ -94,6 +94,7 @@ from ...task_context.api import load_task_context_slots as _load_task_context_slots from ...task_context.api import render_task_context_slots as _render_task_context_slots from ...task_context.api import task_goal_slot_markdown as _task_goal_slot_markdown +from ...workspace_config.api import resolve_agent_workspace_startup def _codex_executable() -> str: @@ -2614,11 +2615,11 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Open the local workspace task dashboard.") parser.add_argument( "--workspace", - default=os.getcwd(), - help="Workspace root. Default: current directory.", + default=None, + help="Workspace root. Default: last opened workspace or current directory.", ) args = parser.parse_args(argv) - workspace = Path(args.workspace) + workspace = resolve_agent_workspace_startup(Path(args.workspace) if args.workspace else None) install_agent_workspace_exception_logger(workspace, "tk") root = tk.Tk() diff --git a/agent_tools/agent_workspace/components/workspace_config/__init__.py b/agent_tools/agent_workspace/components/workspace_config/__init__.py new file mode 100644 index 00000000..f0f277d7 --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/__init__.py @@ -0,0 +1 @@ +"""Agent Workspace workspace configuration component.""" diff --git a/agent_tools/agent_workspace/components/workspace_config/api/__init__.py b/agent_tools/agent_workspace/components/workspace_config/api/__init__.py new file mode 100644 index 00000000..862f8282 --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/api/__init__.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from ..src.workspace import AGENT_WORKSPACE_MANIFEST_DIR +from ..src.workspace import AGENT_WORKSPACE_MANIFEST_FILE +from ..src.workspace import AGENT_WORKSPACE_SCHEMA_VERSION +from ..src.workspace import DEFAULT_TASKS_DIR +from ..src.workspace import AgentWorkspaceManifest +from ..src.workspace import agent_workspace_manifest_path +from ..src.workspace import create_agent_workspace +from ..src.workspace import ensure_agent_workspace +from ..src.workspace import is_agent_workspace +from ..src.workspace import load_agent_workspace_manifest +from ..src.workspace import resolve_agent_workspace_startup + +__all__ = [ + "AGENT_WORKSPACE_MANIFEST_DIR", + "AGENT_WORKSPACE_MANIFEST_FILE", + "AGENT_WORKSPACE_SCHEMA_VERSION", + "DEFAULT_TASKS_DIR", + "AgentWorkspaceManifest", + "agent_workspace_manifest_path", + "create_agent_workspace", + "ensure_agent_workspace", + "is_agent_workspace", + "load_agent_workspace_manifest", + "resolve_agent_workspace_startup", +] diff --git a/agent_tools/agent_workspace/components/workspace_config/src/__init__.py b/agent_tools/agent_workspace/components/workspace_config/src/__init__.py new file mode 100644 index 00000000..f1bf2b7d --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/src/__init__.py @@ -0,0 +1 @@ +"""Workspace manifest implementation.""" diff --git a/agent_tools/agent_workspace/components/workspace_config/src/workspace.py b/agent_tools/agent_workspace/components/workspace_config/src/workspace.py new file mode 100644 index 00000000..e8409f56 --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/src/workspace.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import json +from pathlib import Path +from uuid import uuid4 + +from ...settings.api import load_agent_workspace_settings +from ...settings.api import remember_agent_workspace + + +AGENT_WORKSPACE_MANIFEST_DIR = ".agent-workspace" +AGENT_WORKSPACE_MANIFEST_FILE = "workspace.json" +AGENT_WORKSPACE_SCHEMA_VERSION = 1 +AGENT_WORKSPACE_MIN_VERSION = "2.1.0" +DEFAULT_TASKS_DIR = "tasks" + + +@dataclass(frozen=True) +class AgentWorkspaceManifest: + schema_version: int + workspace_id: str + name: str + created_at: str + agent_workspace_min_version: str + tasks_dir: str = DEFAULT_TASKS_DIR + + +def agent_workspace_manifest_path(workspace: Path) -> Path: + return workspace / AGENT_WORKSPACE_MANIFEST_DIR / AGENT_WORKSPACE_MANIFEST_FILE + + +def is_agent_workspace(workspace: Path) -> bool: + path = workspace.resolve() + return agent_workspace_manifest_path(path).is_file() or (path / DEFAULT_TASKS_DIR).is_dir() + + +def create_agent_workspace(workspace: Path, *, name: str | None = None) -> AgentWorkspaceManifest: + root = workspace.resolve() + root.mkdir(parents=True, exist_ok=True) + (root / DEFAULT_TASKS_DIR).mkdir(exist_ok=True) + manifest = AgentWorkspaceManifest( + schema_version=AGENT_WORKSPACE_SCHEMA_VERSION, + workspace_id=str(uuid4()), + name=(name or root.name or "Agent Workspace").strip(), + created_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + agent_workspace_min_version=AGENT_WORKSPACE_MIN_VERSION, + tasks_dir=DEFAULT_TASKS_DIR, + ) + _write_manifest(root, manifest) + return manifest + + +def ensure_agent_workspace(workspace: Path) -> AgentWorkspaceManifest: + root = workspace.resolve() + manifest_path = agent_workspace_manifest_path(root) + if manifest_path.is_file(): + manifest = load_agent_workspace_manifest(root) + (root / manifest.tasks_dir).mkdir(parents=True, exist_ok=True) + return manifest + return create_agent_workspace(root) + + +def load_agent_workspace_manifest(workspace: Path) -> AgentWorkspaceManifest: + root = workspace.resolve() + manifest_path = agent_workspace_manifest_path(root) + data = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"workspace manifest is not an object: {manifest_path}") + schema_version = data.get("schema_version") + workspace_id = data.get("workspace_id") + name = data.get("name") + created_at = data.get("created_at") + min_version = data.get("agent_workspace_min_version") + tasks_dir = data.get("tasks_dir", DEFAULT_TASKS_DIR) + if schema_version != AGENT_WORKSPACE_SCHEMA_VERSION: + raise ValueError(f"unsupported workspace schema version in {manifest_path}: {schema_version!r}") + if not isinstance(workspace_id, str) or not workspace_id.strip(): + raise ValueError(f"workspace manifest has no workspace_id: {manifest_path}") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"workspace manifest has no name: {manifest_path}") + if not isinstance(created_at, str) or not created_at.strip(): + raise ValueError(f"workspace manifest has no created_at: {manifest_path}") + if not isinstance(min_version, str) or not min_version.strip(): + raise ValueError(f"workspace manifest has no agent_workspace_min_version: {manifest_path}") + if not isinstance(tasks_dir, str) or not tasks_dir.strip() or Path(tasks_dir).is_absolute(): + raise ValueError(f"workspace manifest has invalid tasks_dir: {manifest_path}") + return AgentWorkspaceManifest( + schema_version=schema_version, + workspace_id=workspace_id.strip(), + name=name.strip(), + created_at=created_at.strip(), + agent_workspace_min_version=min_version.strip(), + tasks_dir=tasks_dir.strip(), + ) + + +def resolve_agent_workspace_startup( + explicit_workspace: Path | None = None, + *, + cwd: Path | None = None, +) -> Path: + candidates: list[Path] = [] + if explicit_workspace is not None: + candidates.append(explicit_workspace) + else: + settings = load_agent_workspace_settings() + last_workspace = settings.get("last_workspace") + if isinstance(last_workspace, str) and last_workspace: + candidates.append(Path(last_workspace)) + recent_workspaces = settings.get("recent_workspaces") + if isinstance(recent_workspaces, list): + for item in recent_workspaces: + if isinstance(item, str) and item: + candidates.append(Path(item)) + candidates.append(cwd or Path.cwd()) + + for candidate in candidates: + root = candidate.expanduser().resolve() + if is_agent_workspace(root) or explicit_workspace is not None: + ensure_agent_workspace(root) + remember_agent_workspace(root) + return root + + root = (cwd or Path.cwd()).expanduser().resolve() + ensure_agent_workspace(root) + remember_agent_workspace(root) + return root + + +def _write_manifest(workspace: Path, manifest: AgentWorkspaceManifest) -> None: + manifest_path = agent_workspace_manifest_path(workspace) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema_version": manifest.schema_version, + "workspace_id": manifest.workspace_id, + "name": manifest.name, + "created_at": manifest.created_at, + "agent_workspace_min_version": manifest.agent_workspace_min_version, + "tasks_dir": manifest.tasks_dir, + } + manifest_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/agent_tools/agent_workspace/components/workspace_config/tests/__init__.py b/agent_tools/agent_workspace/components/workspace_config/tests/__init__.py new file mode 100644 index 00000000..442900c7 --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/tests/__init__.py @@ -0,0 +1 @@ +"""Workspace configuration tests.""" diff --git a/agent_tools/agent_workspace/components/workspace_config/tests/test_workspace_config.py b/agent_tools/agent_workspace/components/workspace_config/tests/test_workspace_config.py new file mode 100644 index 00000000..d2c2e33a --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/tests/test_workspace_config.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from agent_tools.agent_workspace.components.test_support.src.helpers import * + + +def test_create_agent_workspace_writes_manifest_and_tasks_dir(tmp_path: Path) -> None: + workspace = tmp_path / "work" + + manifest = create_agent_workspace(workspace, name="Main Work") + + assert manifest.name == "Main Work" + assert manifest.schema_version == AGENT_WORKSPACE_SCHEMA_VERSION + assert (workspace / ".agent-workspace" / "workspace.json").is_file() + assert (workspace / "tasks").is_dir() + assert load_agent_workspace_manifest(workspace).workspace_id == manifest.workspace_id + + +def test_load_agent_workspace_manifest_rejects_absolute_tasks_dir(tmp_path: Path) -> None: + workspace = tmp_path / "work" + manifest_path = workspace / ".agent-workspace" / "workspace.json" + manifest_path.parent.mkdir(parents=True) + manifest_path.write_text( + json.dumps( + { + "schema_version": 1, + "workspace_id": "id", + "name": "work", + "created_at": "2026-08-28T00:00:00Z", + "agent_workspace_min_version": "2.1.0", + "tasks_dir": "/tmp/tasks", + } + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="invalid tasks_dir"): + load_agent_workspace_manifest(workspace) + + +def test_resolve_agent_workspace_startup_prefers_last_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings_path = tmp_path / "settings.json" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + last_workspace = tmp_path / "last" + cwd_workspace = tmp_path / "cwd" + create_agent_workspace(last_workspace) + create_agent_workspace(cwd_workspace) + save_agent_workspace_settings({"last_workspace": str(last_workspace)}, settings_path) + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.agent_workspace_settings_path", + lambda: settings_path, + ) + + assert resolve_agent_workspace_startup(cwd=cwd_workspace) == last_workspace.resolve() + + +def test_resolve_agent_workspace_startup_creates_explicit_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings_path = tmp_path / "settings.json" + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.agent_workspace_settings_path", + lambda: settings_path, + ) + workspace = tmp_path / "new-work" + + assert resolve_agent_workspace_startup(workspace, cwd=tmp_path) == workspace.resolve() + assert (workspace / ".agent-workspace" / "workspace.json").is_file() + assert load_agent_workspace_settings(settings_path)["last_workspace"] == str(workspace.resolve()) diff --git a/agent_tools/paf_workspace/task_check/__init__.py b/agent_tools/paf_workspace/task_check/__init__.py index dffa0ab9..fbf4e69a 100644 --- a/agent_tools/paf_workspace/task_check/__init__.py +++ b/agent_tools/paf_workspace/task_check/__init__.py @@ -210,8 +210,7 @@ def check_task( manifests = _find_artifact_manifests(task_dir) harness_profiles = _find_xen_zephyr_harness_profiles(task_dir) - has_runtime_hints = _has_runtime_hints(context_text) - should_check_manifest = require_runtime_product or has_runtime_hints or bool(manifests) or bool(harness_profiles) + should_check_manifest = require_runtime_product or bool(manifests) or bool(harness_profiles) should_check_scenarios = require_xen_runtime or bool(harness_profiles) or _has_xen_hints(context_text) if should_check_manifest: diff --git a/agent_tools/paf_workspace/task_check/warning-policy.yaml b/agent_tools/paf_workspace/task_check/warning-policy.yaml index 65ee6b15..43f61479 100644 --- a/agent_tools/paf_workspace/task_check/warning-policy.yaml +++ b/agent_tools/paf_workspace/task_check/warning-policy.yaml @@ -6,10 +6,10 @@ non_strict_warnings: artifact-manifest-missing: reason: > - Runtime/product artifact manifests are required for build/runtime product - validation. When this check is enabled only because task context mentions - Xen/QEMU/Moulin, the missing manifest is a readiness reminder rather than - a blocker for analysis-only work. + Runtime/product artifact manifests are required only for explicit + build/runtime product validation. Missing manifests are not inferred from + task context wording because analysis-only tasks can mention runtime + systems without owning deployable product artifacts. strict_when_flags: - runtime_product - init_runtime_product diff --git a/agent_tools/paf_workspace/tests/test_task_check.py b/agent_tools/paf_workspace/tests/test_task_check.py index 0376e510..028980cd 100644 --- a/agent_tools/paf_workspace/tests/test_task_check.py +++ b/agent_tools/paf_workspace/tests/test_task_check.py @@ -141,6 +141,20 @@ def test_strict_warnings_ignores_auto_runtime_readiness_warnings(tmp_path: Path) assert result == 0 +def test_runtime_hints_do_not_require_product_artifact_manifest(tmp_path: Path) -> None: + task_dir = tmp_path / "tasks" / "sample-task" + initialize_task_layout(task_dir, workspace=tmp_path) + set_slot(task_dir, "goal", "Inspect QEMU runtime logs.") + set_slot(task_dir, "operational-memory", "Current: analysis-only task.") + set_slot(task_dir, "env", "Use local files.") + set_slot(task_dir, "validation", "Run smoke.") + + checks = check_task(task_dir, workspace=tmp_path) + + assert _has_check(checks, "PASS", "artifact-manifest-not-required") + assert not _has_check(checks, "WARN", "artifact-manifest-missing") + + def test_strict_warnings_fails_for_missing_required_slot(tmp_path: Path) -> None: task_dir = tmp_path / "tasks" / "sample-task" initialize_task_layout(task_dir, workspace=tmp_path) diff --git a/agent_tools/tools/commit_msg/__init__.py b/agent_tools/tools/commit_msg/__init__.py index 84baa4d1..bbf36a64 100644 --- a/agent_tools/tools/commit_msg/__init__.py +++ b/agent_tools/tools/commit_msg/__init__.py @@ -314,14 +314,31 @@ def _split_trailers(lines: list[str]) -> tuple[list[str], list[str]]: return [], [] index = len(stripped) - while index > 0 and _is_trailer_line(stripped[index - 1]): - index -= 1 + trailer_start = index + has_trailer = False + while index > 0: + line = stripped[index - 1] + if _is_trailer_line(line): + has_trailer = True + trailer_start = index - 1 + index -= 1 + continue + if ( + has_trailer + and line == "" + and index > 2 + and _is_trailer_line(stripped[index - 2]) + ): + index -= 1 + continue + break - if index == len(stripped): + if not has_trailer: return stripped, [] if index > 0 and stripped[index - 1] != "": return stripped, [] - return _strip_blank_edges(stripped[:index]), stripped[index:] + trailer_lines = [line for line in stripped[trailer_start:] if _is_trailer_line(line)] + return _strip_blank_edges(stripped[:index]), trailer_lines def _is_trailer_line(line: str) -> bool: diff --git a/agent_tools/tools/commit_msg/tests/test_commit_msg.py b/agent_tools/tools/commit_msg/tests/test_commit_msg.py index 30bbb0b2..86a8ed12 100644 --- a/agent_tools/tools/commit_msg/tests/test_commit_msg.py +++ b/agent_tools/tools/commit_msg/tests/test_commit_msg.py @@ -9,7 +9,7 @@ from contextlib import redirect_stdout from pathlib import Path -from agent_tools.tools.commit_msg import format_commit_message, main as commit_msg_main +from agent_tools.tools.commit_msg import GitIdentity, format_commit_message, main as commit_msg_main from agent_tools.tools.commit_msg.workflow import ( check_commits, check_repo_identity, @@ -141,6 +141,25 @@ def test_format_existing_message_keeps_assisted_by_last(self) -> None: message.rstrip().endswith("Assisted-by: Codex:gpt-5 cpp-code-map") ) + def test_format_existing_message_deduplicates_signoff_with_blank_trailer_gap(self) -> None: + message = format_commit_message( + textwrap.dedent( + """\ + tools: normalize spaced trailers + + Body. + + Signed-off-by: Example Author + + Assisted-by: Codex:gpt-5 + """ + ), + identity=GitIdentity("Example Author", "author@example.com"), + ) + + self.assertEqual(1, message.count("Signed-off-by: Example Author ")) + self.assertTrue(message.rstrip().endswith("Assisted-by: Codex:gpt-5")) + def test_zephyr_assisted_by_check(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: repo = Path(temp_dir) diff --git a/agent_tools/tools/diff_report/assets_story.py b/agent_tools/tools/diff_report/assets_story.py index f02f9278..e5249b7c 100644 --- a/agent_tools/tools/diff_report/assets_story.py +++ b/agent_tools/tools/diff_report/assets_story.py @@ -26,6 +26,7 @@ def story_script() -> str: let activeScrollEndTimer = 0; let activeFlashClearTimer = 0; let navigationToken = 0; + let userScrollIntent = false; let topStateRaf = 0; let storyOffsetRaf = 0; const storySentinel = story ? document.createElement("div") : null; @@ -845,6 +846,9 @@ def story_script() -> str: story.style.minHeight = ""; const storyTop = Math.max(0, Math.ceil(story.getBoundingClientRect().top)); const currentHeight = Math.ceil(story.getBoundingClientRect().height); + if (storySentinel && document.body.classList.contains("has-pinned-story")) { + storySentinel.style.height = currentHeight + "px"; + } document.documentElement.style.setProperty("--story-offset", (storyTop + currentHeight) + "px"); } @@ -869,10 +873,23 @@ def story_script() -> str: hasLeftTop && storySentinel && storySentinel.getBoundingClientRect().top <= 0 ); document.body.classList.toggle("has-left-top", hasLeftTop); - document.body.classList.toggle("has-pinned-story", storyPinned); + setStoryPinned(storyPinned); }); } + function setStoryPinned(storyPinned) { + if (!story || !storySentinel) { + return; + } + if (storyPinned) { + storySentinel.style.height = Math.ceil(story.getBoundingClientRect().height) + "px"; + document.body.classList.add("has-pinned-story"); + return; + } + document.body.classList.remove("has-pinned-story"); + storySentinel.style.height = "0px"; + } + function setActive(index) { if (!steps.length) { return; @@ -1235,6 +1252,9 @@ def story_script() -> str: } function resetPageScrollOnLoad() { + if (userScrollIntent || window.scrollY > 0) { + return; + } const nav = document.getElementById("review-comments"); window.scrollTo(0, 0); if (nav) { @@ -1245,6 +1265,22 @@ def story_script() -> str: document.body.classList.remove("has-pinned-story"); } + function cancelProgrammaticScrollForUserIntent() { + userScrollIntent = true; + navigationToken += 1; + window.clearTimeout(activeScrollTimer); + window.clearTimeout(activeScrollEndTimer); + } + + function handleScrollKeyIntent(event) { + if (event.altKey || event.ctrlKey || event.metaKey) { + return; + } + if (["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(event.key)) { + cancelProgrammaticScrollForUserIntent(); + } + } + function animateWindowScrollToElement(element, durationMs) { window.clearTimeout(activeScrollTimer); window.clearTimeout(activeScrollEndTimer); @@ -1743,6 +1779,9 @@ def story_script() -> str: updateTopButtonState(); scheduleStoryOffsetUpdate(); }, { passive: true }); + window.addEventListener("wheel", cancelProgrammaticScrollForUserIntent, { passive: true }); + window.addEventListener("touchmove", cancelProgrammaticScrollForUserIntent, { passive: true }); + window.addEventListener("keydown", handleScrollKeyIntent, { capture: true }); window.addEventListener("resize", function () { scheduleStoryOffsetUpdate(); scheduleStoryPagerResize(); diff --git a/agent_tools/tools/diff_report/assets_styles.py b/agent_tools/tools/diff_report/assets_styles.py index 7e500dd0..6093fb55 100644 --- a/agent_tools/tools/diff_report/assets_styles.py +++ b/agent_tools/tools/diff_report/assets_styles.py @@ -8,6 +8,7 @@ def stylesheet() -> str: color-scheme: light; --bg: #f3f3f3; --panel: #fbfbfc; + --card-bg: var(--panel); --panel-subtle: #f1f3f6; --meta-panel: #f7f8fa; --meta-border: #b8c0ca; @@ -23,6 +24,7 @@ def stylesheet() -> str: --border: #b8c0ca; --text: #1f1f1f; --muted: #616161; + --font-stack: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --link: #007acc; --button-bg: #ffffff; --button-hover-bg: #e5f1fb; @@ -125,6 +127,7 @@ def stylesheet() -> str: color-scheme: dark; --bg: #1e1e1e; --panel: #252526; + --card-bg: var(--panel); --panel-subtle: #2d2d30; --meta-panel: #1b1b1d; --meta-border: #52525a; @@ -140,6 +143,7 @@ def stylesheet() -> str: --border: #3c3c3c; --text: #d4d4d4; --muted: #858585; + --font-stack: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --link: #3794ff; --button-bg: #2d2d30; --button-hover-bg: #094771; @@ -364,7 +368,7 @@ def stylesheet() -> str: .report-metric-cell-part.status-not-run, .report-metric-cell-part.status-not-done, .report-metric-cell-part.status-unknown, .report-metric-cell-part.status-needs-evidence { color: var(--muted); } .report-table-filter { display: grid; gap: 5px; max-width: 460px; margin-bottom: 10px; } .report-table-filter input { min-height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--button-bg); color: var(--text); font: inherit; } - .report-table-wrap { max-width: 100%; overflow: visible; border: 1px solid var(--border); border-radius: 8px; } + .report-table-wrap { max-width: 100%; overflow-x: auto; overflow-y: visible; border: 1px solid var(--border); border-radius: 8px; } .report-table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; table-layout: auto; } .report-table th, .report-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); text-align: left; vertical-align: top; overflow-wrap: anywhere; } .report-table th:last-child, .report-table td:last-child { border-right: 0; } @@ -458,7 +462,7 @@ def stylesheet() -> str: .relationship-page-controls button { min-width: 24px; min-height: 22px; padding: 0 6px; } .relationship-page-controls span { min-width: 86px; text-align: center; } .relationship-page-controls.is-single-page { color: var(--muted); } - .relationship-control-bar { position: sticky; top: 0; z-index: 4; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 0; padding: 4px 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 4px 10px color-mix(in srgb, var(--shadow) 26%, transparent); } + .relationship-control-bar { position: sticky; top: 0; z-index: 4; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 8px; align-items: center; min-width: 0; padding: 4px 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 4px 10px color-mix(in srgb, var(--shadow) 26%, transparent); } .relationship-filters { flex-basis: 100%; min-width: 0; } .relationship-filters > summary { display: inline-flex; align-items: center; gap: 6px; width: fit-content; min-height: 26px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: var(--button-bg); color: var(--meta-text); font: 800 12px/1.1 var(--font-stack); cursor: pointer; list-style: none; } .relationship-filters > summary::-webkit-details-marker { display: none; } @@ -485,11 +489,19 @@ def stylesheet() -> str: .relationship-subfilter-popover .status-not-run, .relationship-subfilter-popover .status-not-done, .relationship-subfilter-popover .status-unknown { color: var(--graph-status-neutral-border); font-weight: 800; } .relationship-subfilter-popover p { margin: 0; color: var(--meta-text); } .relationship-nav-controls { display: flex; flex-wrap: nowrap; gap: 6px; align-items: center; } + .relationship-page-controls { justify-self: end; } .relationship-toolbar button { min-height: 28px; padding: 0 9px; border: 1px solid var(--border); border-radius: 999px; background: var(--button-bg); color: var(--text); font: 750 .86em/1.1 var(--font-stack); cursor: pointer; } .relationship-toolbar button:hover, .relationship-toolbar button.is-active { border-color: var(--link); background: var(--button-hover-bg); color: var(--link); } .relationship-toolbar button:disabled { cursor: default; color: var(--muted); border-color: var(--border); background: var(--button-bg); } .relationship-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(360px, .8fr); gap: 12px; align-items: start; min-width: 0; } .relationship-canvas-wrap, .relationship-detail { min-width: 0; height: min(760px, calc(100vh - 180px)); min-height: 560px; max-height: 760px; border: 1px solid var(--border); border-radius: 8px; background: var(--meta-panel); } + .relationship-browser.is-graph-ready .relationship-canvas-wrap { border-color: color-mix(in srgb, var(--meta-border) 72%, var(--border)); } + .relationship-browser.is-graph-active .relationship-toolbar, .relationship-browser.is-graph-active .relationship-control-bar, .relationship-browser.is-graph-active .relationship-selection-panel, .relationship-browser.is-graph-active .relationship-detail { border-color: color-mix(in srgb, var(--link) 36%, var(--border)); } + .relationship-browser.is-graph-active .relationship-canvas-wrap { border-color: color-mix(in srgb, var(--link) 50%, var(--border)); } + .relationship-browser.is-graph-focused .relationship-canvas-wrap { border-color: color-mix(in srgb, var(--link) 78%, var(--border)); outline: 2px solid color-mix(in srgb, var(--link) 78%, var(--border)); outline-offset: -2px; box-shadow: 0 0 0 3px color-mix(in srgb, var(--link) 18%, transparent); } + .relationship-focus-badge { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border: 1px solid color-mix(in srgb, var(--link) 62%, var(--border)); border-radius: 999px; background: color-mix(in srgb, var(--button-hover-bg) 72%, var(--panel)); color: var(--link); cursor: pointer; font: 850 12px/1 var(--font-stack); text-transform: uppercase; letter-spacing: .04em; } + .relationship-focus-badge:hover { border-color: var(--link); background: var(--button-hover-bg); } + .relationship-focus-badge[hidden] { display: none !important; } .relationship-modal .relationship-browser { min-height: 0; } .relationship-modal .relationship-layout { grid-template-columns: minmax(0, 2fr) minmax(380px, .72fr); align-items: stretch; min-height: 0; height: 100%; overflow: hidden; } .relationship-modal .relationship-canvas-wrap { height: clamp(520px, 58vh, 900px); min-height: 520px; max-height: none; } @@ -503,7 +515,7 @@ def stylesheet() -> str: .relationship-control-bar button:hover { border-color: var(--link); background: var(--button-hover-bg); color: var(--link); } .relationship-control-bar button:disabled { cursor: default; color: var(--muted); border-color: var(--border); background: var(--button-bg); } .relationship-canvas { position: relative; display: block; width: 100%; height: 100%; cursor: grab; } - .relationship-canvas[data-graph-interactive="false"] { cursor: default; pointer-events: none; } + .relationship-canvas[data-graph-interactive="false"] { cursor: default; } .relationship-canvas[data-graph-message]::after { content: attr(data-graph-message); position: absolute; left: 16px; right: 16px; bottom: 14px; z-index: 2; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: color-mix(in srgb, var(--panel) 88%, transparent); color: var(--meta-text); font-size: .9em; pointer-events: none; } .relationship-canvas[data-empty-graph="true"]::after { top: 50%; bottom: auto; transform: translateY(-50%); text-align: center; } .relationship-canvas[data-graph-hint="filtered"]::after { top: 14px; bottom: auto; border-color: color-mix(in srgb, var(--comment-border) 62%, var(--border)); color: var(--comment-border); text-align: center; } @@ -853,7 +865,7 @@ def stylesheet() -> str: .relationship-selection-table { min-height: 0; } .relationship-nav-controls { flex-wrap: wrap; } .to-top-button { right: 14px; bottom: calc(var(--story-nav-height) + 24px + var(--floating-control-size) + 12px); } - .review-nav { position: static; width: calc(100% - 16px); max-height: 38vh; margin: 8px auto 16px; } + .report-toc, .review-nav { position: static; width: calc(100% - 16px); max-height: 38vh; margin: 8px auto 16px; } .review-nav-resizer { display: none; } .story { top: 0; } body.has-pinned-story .story, body.has-diagram-open .story { left: 8px; right: 8px; top: 0; max-height: 35vh; } diff --git a/agent_tools/tools/diff_report/cli.py b/agent_tools/tools/diff_report/cli.py index d1d0c524..4f0454e3 100644 --- a/agent_tools/tools/diff_report/cli.py +++ b/agent_tools/tools/diff_report/cli.py @@ -31,6 +31,11 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--compose-report", help="Write findings compose diagnostics JSON.") parser.add_argument("--output", help="HTML report output path.") parser.add_argument("--report-json", help="Render a generic non-diff report from JSON.") + parser.add_argument( + "--report-test-mode", + action="store_true", + help="Inject report self-test automation hooks when rendering --report-json.", + ) parser.add_argument("--title", default="PR Diff Review", help="Report title.") parser.add_argument("--context", type=int, default=80, help="Git diff context lines.") parser.add_argument( @@ -79,6 +84,7 @@ def main(argv: list[str] | None = None) -> int: report_file=report_json, output_path=output, title=args.title if args.title != "PR Diff Review" else None, + test_mode=args.report_test_mode, ) print(str(output)) return 0 diff --git a/agent_tools/tools/diff_report/core.py b/agent_tools/tools/diff_report/core.py index aa7c43d4..c93950c6 100644 --- a/agent_tools/tools/diff_report/core.py +++ b/agent_tools/tools/diff_report/core.py @@ -76,9 +76,10 @@ def generate_report_json( report_file: Path, output_path: Path, title: str | None = None, + test_mode: bool = False, ) -> None: report = load_report_json(report_file) if title is not None: report = replace(report, title=title) output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(render_report_json_html(report), encoding="utf-8") + output_path.write_text(render_report_json_html(report, test_mode=test_mode), encoding="utf-8") diff --git a/agent_tools/tools/diff_report/report_json.py b/agent_tools/tools/diff_report/report_json.py index 77887c0a..d91a41b2 100644 --- a/agent_tools/tools/diff_report/report_json.py +++ b/agent_tools/tools/diff_report/report_json.py @@ -168,7 +168,7 @@ def report_from_payload( ) -def render_report_json_html(report: GenericReport) -> str: +def render_report_json_html(report: GenericReport, test_mode: bool = False) -> str: comments = report.comments parts: list[str] = [] parts.append(html_header(report.title)) @@ -220,6 +220,8 @@ def render_report_json_html(report: GenericReport) -> str: if report.relationship_graph: parts.append(_cytoscape_vendor_script()) parts.append(_relationship_graph_script()) + if test_mode: + parts.append(_report_self_test_script()) parts.append(story_script()) parts.append(theme_script()) parts.append("\n\n\n") @@ -901,6 +903,7 @@ def _render_relationship_graph_section(graph: RelationshipGraph) -> str: +
Page 1 @@ -1384,6 +1387,11 @@ def _relationship_graph_script() -> str: state.scopedAdjacencyCache = null; } + function isolatedIdsForRoot(state, rootId) { + const id = String(rootId || ""); + return id ? directedReachableIds(id, state) : null; + } + function nodeInScope(state, node) { if (!node) return false; return !state.isolatedNodeIds || state.isolatedNodeIds.has(node.id); @@ -1618,6 +1626,14 @@ def _relationship_graph_script() -> str: const neighbors = (nodeId) => adjacency.get(nodeId) || []; const distances = new Map([[selectedId, 0]]); const traversedEdgeKeys = new Set(); + const traversedEdgesByKey = new Map(); + const markTraversedEdge = (edge) => { + const key = edgeKey(edge); + traversedEdgeKeys.add(key); + if (!traversedEdgesByKey.has(key)) { + traversedEdgesByKey.set(key, edge); + } + }; const selectedRank = rankOf(selected); const sortCandidate = (left, right) => { const leftNode = nodesById.get(left.id); @@ -1655,7 +1671,7 @@ def _relationship_graph_script() -> str: ? regularCandidates : rawCandidates.filter((item) => isFallbackTraversalEdge(item.edge, traversal)); for (const parent of candidates) { - traversedEdgeKeys.add(edgeKey(parent.edge)); + markTraversedEdge(parent.edge); if (seen.has(parent.id)) continue; seen.add(parent.id); ancestry.add(parent.id); @@ -1691,7 +1707,10 @@ def _relationship_graph_script() -> str: if (!rankedChild && !fallbackChild) continue; (edgeFallback ? fallbackItems : regularItems).push({item, toId, toNode, edgeSecondary}); } - const items = regularItems.length ? regularItems : fallbackItems; + const visibleFallbackItems = fallbackItems.filter((candidate) => nodeVisible(candidate.toNode)); + const items = regularItems.length + ? regularItems.concat(visibleFallbackItems) + : fallbackItems; for (const candidate of items) { const {item, toId, toNode, edgeSecondary} = candidate; downSeen.add(toId); @@ -1709,10 +1728,10 @@ def _relationship_graph_script() -> str: } } } - if (visibleDescendants.length) { + if (visibleDescendants.length || secondaryDescendants.length || fallbackDescendants.length) { // Draw the first visible child rank only. Hidden intermediate nodes are still traversed, // and direct shortcut edges to deeper ranks do not mix tests with requirements. - const candidateDescendants = visibleDescendants.concat(secondaryDescendants); + const candidateDescendants = visibleDescendants.concat(secondaryDescendants, fallbackDescendants); const visibleChildRank = Math.min(...candidateDescendants.map((id) => rankOf(nodesById.get(id)))); const shownDescendants = candidateDescendants.filter((id) => rankOf(nodesById.get(id)) === visibleChildRank); for (const targetId of shownDescendants) { @@ -1728,17 +1747,10 @@ def _relationship_graph_script() -> str: path.reverse(); for (let index = 0; index < path.length; index += 1) { const item = path[index]; - traversedEdgeKeys.add(edgeKey(item.edge)); + markTraversedEdge(item.edge); distances.set(item.id, Math.min(distances.get(item.id) ?? index + 1, index + 1)); } } - } else if (fallbackDescendants.length) { - for (const targetId of fallbackDescendants) { - const parent = downParents.get(targetId); - if (!parent) continue; - traversedEdgeKeys.add(edgeKey(parent.edge)); - distances.set(targetId, Math.min(distances.get(targetId) ?? 1, 1)); - } } const selectedVisible = nodeVisible(selected); const hasVisibleNonFocusNode = Array.from(distances.keys()).some((id) => { @@ -1751,8 +1763,7 @@ def _relationship_graph_script() -> str: const node = nodesById.get(id); return nodeVisible(node) || (ancestry.has(id) && contextNodeVisible(node)); })); - const visibleEdges = edges.filter((edge) => { - if (!traversedEdgeKeys.has(edgeKey(edge))) return false; + const visibleEdges = Array.from(traversedEdgesByKey.values()).filter((edge) => { const sourceDistance = visibleIds.has(edge.source) ? distances.get(edge.source) : null; const targetDistance = visibleIds.has(edge.target) ? distances.get(edge.target) : null; if (sourceDistance == null || targetDistance == null) return false; @@ -2092,7 +2103,14 @@ def _relationship_graph_script() -> str: return value || fallback; } + let cachedCytoscapeStyleTheme = ""; + let cachedCytoscapeStyle = null; + function cytoscapeStyle() { + const theme = document.documentElement.dataset.theme || ""; + if (cachedCytoscapeStyle && cachedCytoscapeStyleTheme === theme) { + return cachedCytoscapeStyle; + } const text = cssValue("--graph-node-text", "#111827"); const panel = cssValue("--graph-node-bg", "#ffffff"); const metaPanel = cssValue("--graph-artifact-bg", "#f1f5f9"); @@ -2113,7 +2131,8 @@ def _relationship_graph_script() -> str: const autoPassBg = cssValue("--graph-status-auto-pass-bg", "#eff6ff"); const neutral = cssValue("--graph-status-neutral-border", "#64748b"); const neutralBg = cssValue("--graph-status-neutral-bg", "#f8fafc"); - return [ + cachedCytoscapeStyleTheme = theme; + cachedCytoscapeStyle = [ { selector: "node", style: { @@ -2156,6 +2175,7 @@ def _relationship_graph_script() -> str: {selector: ".is-active", style: {"border-color": active, "border-width": 4}}, {selector: "node:selected", style: {"border-color": link, "border-width": 4}} ]; + return cachedCytoscapeStyle; } function listNodePosition(index, graph) { @@ -2285,31 +2305,36 @@ def _relationship_graph_script() -> str: function setGraphInteractive(browser, state, interactive) { state.graphInteractive = Boolean(interactive); + state.graphFocusActive = state.graphInteractive; syncGraphViewport(state); const canvas = browser.querySelector("[data-relationship-canvas]"); if (canvas) { canvas.setAttribute("data-graph-interactive", state.graphInteractive ? "true" : "false"); } + refreshGraphFocusState(browser, state); if (!state.cy) return; state.cy.userZoomingEnabled(state.graphInteractive); state.cy.userPanningEnabled(state.graphInteractive); state.cy.autoungrabify(!state.graphInteractive); } + function activateGraphFocus(browser, state) { + syncGraphViewport(state); + if (!state.graphInteractive) { + setGraphInteractive(browser, state, true); + } + state.graphFocusActive = true; + refreshGraphFocusState(browser, state); + } + function releaseGraphFocus(browser, state) { - const hasActiveGraphFocus = state.graphInteractive || Boolean(state.activeId); + const hasActiveGraphFocus = state.graphInteractive || state.graphFocusActive; if (!hasActiveGraphFocus) return; const active = document.activeElement; if (active && typeof active.blur === "function") { active.blur(); } setGraphInteractive(browser, state, false); - state.activeId = ""; - if (state.cy) { - state.cy.elements().unselect(); - state.cy.nodes().removeClass("is-active"); - } - updateActiveTableRow(browser, state); } function updateGraphPageControls(browser, state, pagination) { @@ -2384,6 +2409,9 @@ def _relationship_graph_script() -> str: function renderGraph(browser, state) { const canvas = browser.querySelector("[data-relationship-canvas]"); if (!canvas || !state.selectedId) return; + browser.classList.add("is-graph-ready"); + browser.setAttribute("data-relationship-graph-ready", "true"); + refreshGraphActivityState(browser, state); if (typeof cytoscape !== "function") { canvas.textContent = "Cytoscape.js is not available."; return; @@ -2480,7 +2508,7 @@ def _relationship_graph_script() -> str: state.canvasViewportListenersAttached = true; } state.cy.on("tap", "node", (event) => { - syncGraphViewport(state); + activateGraphFocus(browser, state); const nodeId = event.target.id(); selectNode(browser, state, nodeId, true); }); @@ -2789,7 +2817,7 @@ def _relationship_graph_script() -> str: function applyProjectionSelections(browser, state) { state.enabledTypes = projectionTypesFromSelections(state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); } function ensureFocusFilters(browser, state, nodeId) { @@ -2809,7 +2837,7 @@ def _relationship_graph_script() -> str: : encodeProjectionChoice(group.types.filter((candidate) => selected.has(candidate))); } } - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); return true; } @@ -2840,7 +2868,7 @@ def _relationship_graph_script() -> str: state.enabledTypes = focusViewTypes(state, nodeId); state.projectionMode = "auto"; state.projectionSelections = defaultProjectionSelections(state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); } function enableAllEntityTypes(browser, state) { @@ -2848,7 +2876,15 @@ def _relationship_graph_script() -> str: state.enabledTypes = new Set(types); state.projectionMode = "custom"; state.projectionSelections = defaultProjectionSelections(state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, types); + refreshGraphControlState(browser, state, types); + } + + function relationshipControls(browser) { + return browser.querySelector("[data-relationship-projection-controls]") || browser; + } + + function refreshGraphControlState(browser, state, types) { + refreshTypeFilterState(relationshipControls(browser), state, types || stateGraphTypes(state)); } function resetGraphFilters(browser, state) { @@ -2859,7 +2895,7 @@ def _relationship_graph_script() -> str: state.activeSubfilterType = ""; state.activeSubfilterTypes = []; state.typeSearchType = ""; - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); } function resetGraphFiltersToAll(browser, state) { @@ -2873,7 +2909,7 @@ def _relationship_graph_script() -> str: state.activeSubfilterTypes = []; state.typeSearchType = ""; state.graphPage = 0; - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); } function childLayerSubfilterTypes(state) { @@ -2912,6 +2948,48 @@ def _relationship_graph_script() -> str: return types.length > 0 && types.every((type) => state.enabledTypes.has(type)); } + function allStatusesEnabled(state) { + const filter = state.statusFilter; + return Boolean(filter && filter.values && filter.values.length) + && filter.values.every((value) => filter.enabled && filter.enabled.has(value)); + } + + function allProjectionLevelsSelected(state) { + const selections = Object.values(state.projectionSelections || {}); + return selections.length > 0 && selections.every((value) => value === "__all__"); + } + + function graphViewIsActive(state) { + return Boolean( + state.activeViewPinned || + state.selectedId !== state.rootId || + state.activeId !== state.selectedId || + state.isolatedRootId || + state.searchQuery || + state.typeSearchType || + state.showSecondaryLinks || + state.graphPage > 0 || + !allEntityTypesEnabled(state) || + !allStatusesEnabled(state) || + !allProjectionLevelsSelected(state) + ); + } + + function refreshGraphActivityState(browser, state) { + const active = graphViewIsActive(state); + browser.classList.toggle("is-graph-active", active); + browser.setAttribute("data-relationship-active-view", active ? "true" : "false"); + } + + function refreshGraphFocusState(browser, state) { + const focused = Boolean(state.graphFocusActive); + browser.classList.toggle("is-graph-focused", focused); + browser.setAttribute("data-relationship-graph-focused", focused ? "true" : "false"); + browser.querySelectorAll("[data-relationship-focus-badge]").forEach((badge) => { + badge.hidden = !focused; + }); + } + function projectionSelectionsSnapshot(state) { return Object.assign({}, state.projectionSelections || {}); } @@ -2929,7 +3007,8 @@ def _relationship_graph_script() -> str: showSecondaryLinks: state.showSecondaryLinks, searchQuery: state.searchQuery, typeSearchType: state.typeSearchType, - isolatedRootId: state.isolatedRootId || "" + isolatedRootId: state.isolatedRootId || "", + activeViewPinned: Boolean(state.activeViewPinned) }; } @@ -2961,10 +3040,9 @@ def _relationship_graph_script() -> str: state.showSecondaryLinks = Boolean(snapshot.showSecondaryLinks); state.searchQuery = snapshot.searchQuery || ""; state.typeSearchType = snapshot.typeSearchType || ""; - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); - renderGraph(browser, state); - renderNodeSelect(browser, state); - renderDetail(browser, state); + state.activeViewPinned = Boolean(snapshot.activeViewPinned); + refreshGraphControlState(browser, state); + renderRelationshipState(browser, state); } function serializeStatusFilter(state) { @@ -3092,18 +3170,45 @@ def _relationship_graph_script() -> str: detail.innerHTML = html; } - function selectNode(browser, state, nodeId, recordHistory, options) { + function renderRelationshipState(browser, state) { + const startedAt = performance.now(); + refreshGraphActivityState(browser, state); + renderGraph(browser, state); + renderNodeSelect(browser, state); + renderDetail(browser, state); + const durationMs = performance.now() - startedAt; + state.renderVersion = Number(state.renderVersion || 0) + 1; + state.lastRenderDurationMs = durationMs; + if (!Array.isArray(state.renderSamples)) state.renderSamples = []; + state.renderSamples.push({ + version: state.renderVersion, + durationMs, + selectedId: state.selectedId, + nodes: state.visibleGraph && state.visibleGraph.nodes ? state.visibleGraph.nodes.length : 0, + edges: state.visibleGraph && state.visibleGraph.edges ? state.visibleGraph.edges.length : 0 + }); + if (state.renderSamples.length > 80) { + state.renderSamples.splice(0, state.renderSamples.length - 80); + } + } + + function updateSelectedNodeState(browser, state, nodeId, recordHistory, options) { if (!state.nodesById.has(nodeId)) return; if (state.isolatedNodeIds && !state.isolatedNodeIds.has(nodeId)) return; const resetTypes = Boolean(options && options.resetTypes); - const preserveTypes = options && Object.prototype.hasOwnProperty.call(options, "preserveTypes") + const preserveFilters = Boolean(options && options.preserveFilters); + const entityChanged = Boolean(state.selectedId && state.selectedId !== nodeId); + const resetFiltersToAll = entityChanged && !preserveFilters; + let preserveTypes = options && Object.prototype.hasOwnProperty.call(options, "preserveTypes") ? Boolean(options.preserveTypes) : (state.projectionMode || "auto") === "custom"; if (recordHistory && state.selectedId && state.selectedId !== nodeId) { state.backStack.push(navigationSnapshot(state)); state.forwardStack.length = 0; } - if (resetTypes) { + if (resetFiltersToAll) { + preserveTypes = true; + } else if (resetTypes) { enableAllEntityTypes(browser, state); } state.selectedId = nodeId; @@ -3116,9 +3221,16 @@ def _relationship_graph_script() -> str: } else { ensureFocusFilters(browser, state, nodeId); } - renderGraph(browser, state); - renderNodeSelect(browser, state); - renderDetail(browser, state); + if (resetFiltersToAll) { + resetGraphFiltersToAll(browser, state); + ensureFocusFilters(browser, state, nodeId); + } + return true; + } + + function selectNode(browser, state, nodeId, recordHistory, options) { + if (!updateSelectedNodeState(browser, state, nodeId, recordHistory, options)) return; + renderRelationshipState(browser, state); } function selectTableNode(browser, state, nodeId) { @@ -3126,8 +3238,8 @@ def _relationship_graph_script() -> str: if (state.selectedId !== nodeId || !allEntityTypesEnabled(state)) { pushNavigationSnapshot(state); } - resetGraphFilters(browser, state); - selectNode(browser, state, nodeId, false); + resetGraphFiltersToAll(browser, state); + selectNode(browser, state, nodeId, false, {preserveTypes: true, preserveFilters: true}); } function updateActiveGraphNode(state) { @@ -3139,6 +3251,8 @@ def _relationship_graph_script() -> str: function refreshGraphTheme(state) { if (!state.cy) return; + cachedCytoscapeStyle = null; + cachedCytoscapeStyleTheme = ""; state.cy.style(cytoscapeStyle()); state.cy.resize(); } @@ -3156,9 +3270,10 @@ def _relationship_graph_script() -> str: syncActiveSubfilterType(state); updateActiveGraphNode(state); updateActiveTableRow(browser, state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); renderNodeSelect(browser, state); renderDetail(browser, state); + refreshGraphActivityState(browser, state); } function graphTypes(nodes) { @@ -3557,7 +3672,7 @@ def _relationship_graph_script() -> str: const rerender = () => { state.graphPage = 0; ensureSelectableFocus(state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); renderGraph(browser, state); renderNodeSelect(browser, state); renderDetail(browser, state); @@ -3968,6 +4083,7 @@ def _relationship_graph_script() -> str: activeSubfilterType: "", selectedId: preferred.id, activeId: preferred.id, + rootId: preferred.id, cy: null, backStack: [], forwardStack: [], @@ -3976,6 +4092,7 @@ def _relationship_graph_script() -> str: searchUpdateTimer: 0, typeSearchType: "", graphInteractive: false, + graphFocusActive: false, graphRenderSignature: "", focusGraph: null, focusContent: null, @@ -3986,6 +4103,7 @@ def _relationship_graph_script() -> str: scopedAdjacencyCache: null, canvasViewportListenersAttached: false, showSecondaryLinks: false, + activeViewPinned: false, graphPage: 0, traversal: traversalConfig(graph.traversal), projectionMode: "auto", @@ -4031,7 +4149,7 @@ def _relationship_graph_script() -> str: state.typeSearchType = focusType.value || ""; state.graphPage = 0; renderNodeSelect(browser, state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); }); } const focusScope = browser.querySelector("[data-relationship-focus-scope]"); @@ -4076,24 +4194,32 @@ def _relationship_graph_script() -> str: const fit = browser.querySelector("[data-relationship-fit]"); if (fit) { fit.addEventListener("click", () => { - setGraphInteractive(browser, state, true); + activateGraphFocus(browser, state); fitGraph(state, 40); }); } const canvas = browser.querySelector("[data-relationship-canvas]"); if (canvas) { - const canvasWrap = canvas.closest(".relationship-canvas-wrap"); - const activateFromCanvasWrap = (event) => { - syncGraphViewport(state); - if (!state.graphInteractive) { - setGraphInteractive(browser, state, true); - } - }; - canvas.addEventListener("pointerdown", activateFromCanvasWrap, {capture: true}); - if (canvasWrap) { - canvasWrap.addEventListener("pointerdown", activateFromCanvasWrap, {capture: true}); - } + canvas.addEventListener("pointerenter", () => syncGraphViewport(state), {passive: true}); } + const routeGraphFocusEvent = (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest("[data-relationship-focus-badge]")) { + return; + } + if (target.closest(".relationship-canvas-wrap")) { + activateGraphFocus(browser, state); + } else { + releaseGraphFocus(browser, state); + } + }; + ["pointerdown", "mousedown", "touchstart", "click"].forEach((eventName) => { + browser.addEventListener(eventName, routeGraphFocusEvent, {capture: true}); + }); + browser.querySelectorAll("[data-relationship-focus-badge]").forEach((badge) => { + badge.addEventListener("click", () => releaseGraphFocus(browser, state)); + }); const detail = browser.querySelector("[data-relationship-detail]"); if (detail) { const releaseFromDetail = () => { @@ -4118,7 +4244,6 @@ def _relationship_graph_script() -> str: if (explorer) { const handleExplorerScroll = () => { syncGraphViewport(state); - releaseGraphFocus(browser, state); }; explorer.addEventListener("scroll", handleExplorerScroll, {passive: true}); explorer.addEventListener("wheel", () => syncGraphViewport(state), {passive: true}); @@ -4231,10 +4356,13 @@ def _relationship_graph_script() -> str: function applyRelationshipView(browser, state, view) { const focusId = view && view.focus; if (!focusId || !state.nodesById.has(focusId)) return; + const isolateRoot = view.isolate_root || ""; + const isolatedNodeIds = isolatedIdsForRoot(state, isolateRoot); + if (isolatedNodeIds && !isolatedNodeIds.has(focusId)) return; pushNavigationSnapshot(state); - setIsolatedRoot(state, view.isolate_root || ""); - if (state.isolatedNodeIds && !state.isolatedNodeIds.has(focusId)) return; + setIsolatedRoot(state, isolateRoot); resetGraphFilters(browser, state); + state.activeViewPinned = true; const types = stateGraphTypes(state); const requested = Array.isArray(view.types) ? view.types.filter((type) => types.includes(type)) : []; if (requested.length) { @@ -4249,17 +4377,15 @@ def _relationship_graph_script() -> str: cancelSearchUpdate(state); const search = browser.querySelector("[data-relationship-search]"); if (search) search.value = ""; - selectNode(browser, state, focusId, false, {preserveTypes: true}); + updateSelectedNodeState(browser, state, focusId, false, {preserveTypes: true, preserveFilters: true}); const targetType = view.target_type || ""; if (targetType && state.enabledTypes.has(targetType)) { state.activeSubfilterType = targetType; state.activeSubfilterTypes = [targetType]; state.typeSearchType = targetType; } - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, types); - renderGraph(browser, state); - renderNodeSelect(browser, state); - renderDetail(browser, state); + refreshGraphControlState(browser, state, types); + renderRelationshipState(browser, state); } function openRelationshipView(trigger, view) { @@ -4288,6 +4414,7 @@ def _relationship_graph_script() -> str: pushNavigationSnapshot(state); setIsolatedRoot(state, ""); resetGraphFiltersToAll(browser, state); + state.activeViewPinned = false; ensureSelectableFocus(state); renderGraph(browser, state); renderNodeSelect(browser, state); @@ -4327,3 +4454,628 @@ def _relationship_graph_script() -> str: })(); """ + + +def _report_self_test_script() -> str: + return r""" + +""" diff --git a/agent_tools/tools/diff_report/tests/test_assets_contract.py b/agent_tools/tools/diff_report/tests/test_assets_contract.py index 26db8f90..c211570d 100644 --- a/agent_tools/tools/diff_report/tests/test_assets_contract.py +++ b/agent_tools/tools/diff_report/tests/test_assets_contract.py @@ -75,7 +75,7 @@ def test_stylesheet_exposes_layout_theme_and_search_contracts(self) -> None: ".report-table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; table-layout: auto; }", ".report-table thead { position: sticky; top: 0; z-index: 5; }", ".report-table th { position: sticky; top: 0; z-index: 5;", - '.relationship-canvas[data-graph-interactive="false"] { cursor: default; pointer-events: none; }', + '.relationship-canvas[data-graph-interactive="false"] { cursor: default; }', "background-clip: padding-box;", ".report-toc a.is-current", "border-left-color: var(--comment-border);", @@ -466,6 +466,19 @@ def test_story_script_exposes_target_and_navigation_contracts(self) -> None: "function fileHeaderStickyTop()", "function settleFileHeaderScroll(element, onDone)", "element.getBoundingClientRect().top - fileHeaderStickyTop()", + "function setStoryPinned(storyPinned)", + 'storySentinel.style.height = Math.ceil(story.getBoundingClientRect().height) + "px";', + 'storySentinel.style.height = "0px";', + "let userScrollIntent = false;", + "function cancelProgrammaticScrollForUserIntent()", + "userScrollIntent = true;", + "window.clearTimeout(activeScrollTimer);", + "window.clearTimeout(activeScrollEndTimer);", + "function handleScrollKeyIntent(event)", + 'if (userScrollIntent || window.scrollY > 0)', + 'window.addEventListener("wheel", cancelProgrammaticScrollForUserIntent, { passive: true });', + 'window.addEventListener("touchmove", cancelProgrammaticScrollForUserIntent, { passive: true });', + 'window.addEventListener("keydown", handleScrollKeyIntent, { capture: true });', "const isFileJump = element && element.classList && element.classList.contains(\"file-header\");", "const isCommentJump = element && element.classList && element.classList.contains(\"review-comment\");", "codex-review-file-jump-start", diff --git a/agent_tools/tools/diff_report/tests/test_browser_smoke.py b/agent_tools/tools/diff_report/tests/test_browser_smoke.py new file mode 100644 index 00000000..c7e4f1d9 --- /dev/null +++ b/agent_tools/tools/diff_report/tests/test_browser_smoke.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import base64 +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.request +from pathlib import Path + +from agent_tools.tools.diff_report.cli import main +from agent_tools.tools.diff_report.core import generate_report + + +def _browser() -> str | None: + configured = os.environ.get("DIFF_REPORT_BROWSER") + if configured: + return configured + for candidate in ("google-chrome", "chromium", "chromium-browser"): + path = shutil.which(candidate) + if path: + return path + return None + + +@unittest.skipUnless(_browser(), "Chrome-compatible browser is not available") +class BrowserSmokeTests(unittest.TestCase): + def test_report_json_self_test_runs_in_browser(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + report_json = root / "report.json" + output = root / "report.html" + report_json.write_text(json.dumps(_generic_report_payload()), encoding="utf-8") + + status = main( + [ + "--report-json", + str(report_json), + "--output", + str(output), + "--report-test-mode", + ] + ) + + self.assertEqual(0, status) + result = _evaluate_in_browser( + output, + "window.__reportSelfTest.runAll()", + await_promise=True, + ) + + self.assertIsInstance(result, dict) + self.assertTrue(result.get("pass"), result) + self.assertGreaterEqual(int(result.get("total", 0)), 1) + + def test_story_bar_pinning_does_not_jump_during_manual_scroll(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + diff_path = root / "change.patch" + comments_path = root / "comments.json" + output = root / "report.html" + diff_path.write_text(_long_diff(), encoding="utf-8") + comments_path.write_text(json.dumps(_story_comments()), encoding="utf-8") + generate_report( + output_path=output, + title="Synthetic scroll report", + diff_file=diff_path, + comments_file=comments_path, + ) + + result = _evaluate_in_browser( + output, + r"""(async () => { + window.scrollTo(0, 1400); + window.dispatchEvent(new WheelEvent("wheel", {deltaY: 1400, bubbles: true})); + await new Promise(resolve => setTimeout(resolve, 180)); + const afterWait = window.scrollY; + const sentinel = document.querySelector(".story-sentinel"); + const sentinelHeight = sentinel ? sentinel.getBoundingClientRect().height : 0; + const pinned = document.body.classList.contains("has-pinned-story"); + window.scrollTo(0, 900); + await new Promise(resolve => setTimeout(resolve, 80)); + return { + afterWait, + afterManual: window.scrollY, + pinned, + sentinelHeight + }; + })()""", + await_promise=True, + ) + + self.assertIsInstance(result, dict) + self.assertGreaterEqual(result.get("afterWait", 0), 1000, result) + self.assertGreaterEqual(result.get("afterManual", 0), 850, result) + self.assertTrue(result.get("pinned"), result) + self.assertGreater(result.get("sentinelHeight", 0), 0, result) + + +def _generic_report_payload() -> dict[str, object]: + rows = [{"id": f"row-{index:03d}", "status": "not_failed"} for index in range(1, 80)] + return { + "title": "Synthetic dashboard", + "summary_blocks": [{"type": "text", "body": "Synthetic browser smoke report."}], + "metric_tables": [ + { + "title": "Metrics", + "columns": [ + {"key": "name", "label": "Name"}, + {"key": "passed", "label": "Passed"}, + ], + "rows": [ + { + "cells": { + "name": { + "text": "Components", + "graph_view": { + "focus": "product:test", + "types": ["product", "component"], + "target_type": "component", + }, + }, + "passed": { + "text": "3", + "status": "not_failed", + "graph_view": { + "focus": "product:test", + "types": ["product", "component"], + "target_type": "component", + "filters": {"component": {"status": ["not_failed"]}}, + }, + }, + } + } + ], + } + ], + "tables": [{"title": "Synthetic rows", "columns": ["id", "status"], "rows": rows}], + "relationship_graph": { + "title": "Synthetic graph", + "nodes": [ + {"id": "product:test", "type": "product", "label": "Test product", "status": "not_failed"}, + {"id": "component:1", "type": "component", "label": "Component 1", "status": "not_failed"}, + {"id": "component:2", "type": "component", "label": "Component 2", "status": "not_failed"}, + {"id": "component:3", "type": "component", "label": "Component 3", "status": "not_failed"}, + ], + "edges": [ + {"source": "product:test", "target": "component:1", "relation": "contains"}, + {"source": "product:test", "target": "component:2", "relation": "contains"}, + {"source": "product:test", "target": "component:3", "relation": "contains"}, + ], + }, + } + + +def _story_comments() -> dict[str, object]: + return { + "summary": "Synthetic story scroll report.", + "inline": [ + { + "file": "src/demo.c", + "line": 120, + "title": "Late line", + "body": "This comment forces a long page.", + } + ], + "story": [ + {"title": "Top", "body": "Start at the file.", "file": "src/demo.c"}, + { + "title": "Late comment", + "body": "Jump near the end.", + "comment": {"file": "src/demo.c", "line": 120}, + }, + ], + } + + +def _long_diff() -> str: + added_lines = "\n".join(f"+int demo_{index:03d}(void) {{ return {index}; }}" for index in range(1, 181)) + return ( + "diff --git a/src/demo.c b/src/demo.c\n" + "new file mode 100644\n" + "index 0000000..1111111\n" + "--- /dev/null\n" + "+++ b/src/demo.c\n" + "@@ -0,0 +1,180 @@\n" + f"{added_lines}\n" + ) + + +def _evaluate_in_browser(html: Path, expression: str, *, await_promise: bool = False) -> object: + browser = _browser() + if not browser: + raise unittest.SkipTest("Chrome-compatible browser is not available") + profile_dir = Path(tempfile.mkdtemp(prefix="diff-report-browser-test-")) + process = subprocess.Popen( + [ + browser, + "--headless", + "--disable-gpu", + "--no-sandbox", + "--disable-crash-reporter", + "--disable-crashpad", + f"--user-data-dir={profile_dir}", + "--remote-debugging-port=0", + "about:blank", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + try: + port = _wait_for_debug_port(profile_dir) + targets = _browser_json(port, "/json/list") + target = next(item for item in targets if isinstance(item, dict) and item.get("type") == "page") + with _CdpConnection(target["webSocketDebuggerUrl"]) as cdp: + cdp.call("Page.enable") + cdp.call("Runtime.enable") + cdp.call( + "Emulation.setDeviceMetricsOverride", + {"width": 1440, "height": 1000, "deviceScaleFactor": 1, "mobile": False}, + ) + cdp.call("Page.navigate", {"url": html.resolve().as_uri()}) + _wait_for_page_ready(cdp) + result = cdp.call( + "Runtime.evaluate", + {"expression": expression, "awaitPromise": await_promise, "returnByValue": True}, + ) + value = result.get("result", {}).get("result", {}) + if value.get("subtype") == "error": + raise AssertionError(value.get("description") or value) + return value.get("value") + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + shutil.rmtree(profile_dir, ignore_errors=True) + + +def _wait_for_page_ready(cdp: "_CdpConnection") -> None: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + response = cdp.call("Runtime.evaluate", {"expression": "document.readyState", "returnByValue": True}) + if response.get("result", {}).get("result", {}).get("value") == "complete": + return + time.sleep(0.05) + raise RuntimeError("page did not finish loading") + + +def _wait_for_debug_port(profile_dir: Path) -> int: + port_file = profile_dir / "DevToolsActivePort" + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if port_file.is_file(): + return int(port_file.read_text(encoding="utf-8").splitlines()[0]) + time.sleep(0.05) + raise RuntimeError("Chrome DevTools port file was not created") + + +def _browser_json(port: int, path: str) -> object: + with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + +class _CdpConnection: + def __init__(self, websocket_url: str) -> None: + prefix = "ws://" + if not websocket_url.startswith(prefix): + raise ValueError(f"unsupported DevTools websocket URL: {websocket_url}") + host_port, self.path = websocket_url[len(prefix) :].split("/", 1) + self.path = "/" + self.path + self.host, port = host_port.split(":", 1) + self.port = int(port) + self.sock: socket.socket | None = None + self.next_id = 1 + + def __enter__(self) -> "_CdpConnection": + raw = socket.create_connection((self.host, self.port), timeout=5) + key = base64.b64encode(os.urandom(16)).decode("ascii") + request = ( + f"GET {self.path} HTTP/1.1\r\n" + f"Host: {self.host}:{self.port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ) + raw.sendall(request.encode("ascii")) + response = raw.recv(4096) + if b" 101 " not in response.split(b"\r\n", 1)[0]: + raw.close() + raise RuntimeError("DevTools websocket handshake failed") + self.sock = raw + return self + + def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None: + if self.sock: + self.sock.close() + self.sock = None + + def call(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + message_id = self.next_id + self.next_id += 1 + self._send_json({"id": message_id, "method": method, "params": params or {}}) + while True: + message = self._recv_json() + if message.get("id") == message_id: + if "error" in message: + raise RuntimeError(f"CDP {method} failed: {message['error']}") + return message + + def _send_json(self, payload: dict[str, object]) -> None: + self._send_frame(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + + def _recv_json(self) -> dict[str, object]: + while True: + opcode, payload = self._recv_frame() + if opcode == 1: + return json.loads(payload.decode("utf-8")) + if opcode == 9: + self._send_frame(payload, opcode=10) + + def _send_frame(self, payload: bytes, opcode: int = 1) -> None: + if self.sock is None: + raise RuntimeError("websocket is not connected") + header = bytearray([0x80 | opcode]) + length = len(payload) + if length < 126: + header.append(0x80 | length) + elif length < 65536: + header.extend([0x80 | 126]) + header.extend(struct.pack("!H", length)) + else: + header.extend([0x80 | 127]) + header.extend(struct.pack("!Q", length)) + mask = os.urandom(4) + masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + self.sock.sendall(bytes(header) + mask + masked) + + def _recv_frame(self) -> tuple[int, bytes]: + if self.sock is None: + raise RuntimeError("websocket is not connected") + first, second = self._read_exact(2) + opcode = first & 0x0F + masked = bool(second & 0x80) + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", self._read_exact(2))[0] + elif length == 127: + length = struct.unpack("!Q", self._read_exact(8))[0] + mask = self._read_exact(4) if masked else b"" + payload = self._read_exact(length) + if masked: + payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + return opcode, payload + + def _read_exact(self, size: int) -> bytes: + if self.sock is None: + raise RuntimeError("websocket is not connected") + chunks = bytearray() + while len(chunks) < size: + chunk = self.sock.recv(size - len(chunks)) + if not chunk: + raise RuntimeError("websocket closed") + chunks.extend(chunk) + return bytes(chunks) diff --git a/agent_tools/tools/diff_report/tests/test_report_json.py b/agent_tools/tools/diff_report/tests/test_report_json.py index cd467884..ec59ff29 100644 --- a/agent_tools/tools/diff_report/tests/test_report_json.py +++ b/agent_tools/tools/diff_report/tests/test_report_json.py @@ -332,7 +332,7 @@ def test_renders_dashboard_widgets_and_shared_artifacts(self) -> None: "state.enabledTypes = new Set(stateGraphTypes(state));", "graphInteractive: false", 'canvas.addEventListener("pointerdown"', - 'canvasWrap.addEventListener("pointerdown"', + 'browser.addEventListener(eventName, routeGraphFocusEvent, {capture: true});', "function releaseGraphFocus(browser, state)", 'detail.addEventListener("pointerdown", releaseFromDetail, {capture: true, passive: true});', 'detail.addEventListener("scroll", releaseFromDetail, {passive: true});', @@ -404,7 +404,8 @@ def test_renders_dashboard_widgets_and_shared_artifacts(self) -> None: "hasSearch ? 0 : 50", "const visibleGraph = state.visibleGraph || {edges: []};", "const traversedEdgeKeys = new Set();", - "if (!traversedEdgeKeys.has(edgeKey(edge))) return false;", + "const traversedEdgesByKey = new Map();", + "const visibleEdges = Array.from(traversedEdgesByKey.values()).filter((edge) => {", "data-relationship-jump", "data-relationship-jump-visible", "focus to show", @@ -575,7 +576,7 @@ def test_renders_metric_tables_with_graph_views(self) -> None: "Custom scopes", "function applyRelationshipViewFilters(state, filters)", "function applyRelationshipView(browser, state, view)", - "selectNode(browser, state, focusId, false, {preserveTypes: true});", + "updateSelectedNodeState(browser, state, focusId, false, {preserveTypes: true, preserveFilters: true});", "function openRelationshipView(trigger, view)", 'document.querySelectorAll("[data-relationship-open-view]")', "state.typeSearchType = targetType;",