From 4e023f5bc990ac430d38a220c74162bbe92293f9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH] feat(gateway): build authoritative project tree --- hermes_cli/web_server.py | 3 + tests/test_tui_gateway_server.py | 35 +- tests/tui_gateway/test_project_tree.py | 352 ++++++++++++ tests/tui_gateway/test_projects_rpc.py | 237 ++++++++ tui_gateway/git_probe.py | 187 +++++++ tui_gateway/project_tree.py | 558 ++++++++++++++++++ tui_gateway/server.py | 747 +++++++++++++++++++++++-- 7 files changed, 2073 insertions(+), 46 deletions(-) create mode 100644 tests/tui_gateway/test_project_tree.py create mode 100644 tests/tui_gateway/test_projects_rpc.py create mode 100644 tui_gateway/git_probe.py create mode 100644 tui_gateway/project_tree.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b88c6a20475..75ae10bf50a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2977,6 +2977,7 @@ async def get_sessions( order: str = "created", source: str = None, exclude_sources: str = None, + cwd_prefix: str = None, profile: Optional[str] = None, ): """List sessions. @@ -3018,6 +3019,7 @@ async def get_sessions( sessions = db.list_sessions_rich( source=source or None, exclude_sources=exclude_list or None, + cwd_prefix=(cwd_prefix or None), limit=limit, offset=offset, min_message_count=min_message_count, @@ -3027,6 +3029,7 @@ async def get_sessions( ) total = db.session_count( source=source or None, + cwd_prefix=(cwd_prefix or None), exclude_sources=exclude_list or None, min_message_count=min_message_count, include_archived=include_archived, diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index e761e46158c..fe42ebcc232 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -703,6 +703,19 @@ def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch): assert server._load_enabled_toolsets() == ["plugin_demo"] +def test_load_enabled_toolsets_folds_project_into_focus_posture(monkeypatch): + # Focus-mode coding posture returns before the config fallback, but it's + # still a GUI-only resolver — `project` must come along so the desktop keeps + # the project tools while sitting in a repo. + monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False) + + import agent.coding_context as cc + + monkeypatch.setattr(cc, "coding_selection", lambda **_: ["coding", "figma"]) + + assert server._load_enabled_toolsets() == ["coding", "figma", "project"] + + def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys): monkeypatch.setenv("HERMES_TUI_TOOLSETS", "mcp-off") monkeypatch.setitem( @@ -722,10 +735,10 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys): config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}} ) - # Sorted: ["kanban", "memory"]. `kanban` is auto-recovered by - # _get_platform_tools because it's a non-configurable platform toolset - # whose tools live in hermes-cli's universe (see toolsets.py). - assert server._load_enabled_toolsets() == ["kanban", "memory"] + # Sorted: ["kanban", "memory", "project"]. `kanban` is auto-recovered by + # _get_platform_tools (a non-configurable platform toolset in hermes-cli's + # universe); `project` is GUI-only, folded in by _load_enabled_toolsets. + assert server._load_enabled_toolsets() == ["kanban", "memory", "project"] err = capsys.readouterr().err assert "ignoring disabled MCP servers" in err assert "mcp-off" in err @@ -746,7 +759,7 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}} ) - assert server._load_enabled_toolsets() == ["kanban", "memory"] + assert server._load_enabled_toolsets() == ["kanban", "memory", "project"] assert "using configured CLI toolsets" in capsys.readouterr().err @@ -1172,7 +1185,7 @@ def test_session_cwd_set_profile_session_updates_profile_db(monkeypatch, tmp_pat captured = {} class ProfileDB: - def update_session_cwd(self, session_id, cwd): + def update_session_cwd(self, session_id, cwd, git_branch=None, git_repo_root=None): captured["profile_update"] = (session_id, cwd) def close(self): @@ -2009,7 +2022,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -2028,7 +2041,7 @@ def test_ensure_session_db_row_persists_session_source(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -2049,7 +2062,7 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -2076,7 +2089,7 @@ def test_ensure_session_db_row_persists_session_model_override(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append( {"key": key, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -2108,7 +2121,7 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): created.append({"model": model, "model_config": model_config}) monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py new file mode 100644 index 00000000000..0958a769688 --- /dev/null +++ b/tests/tui_gateway/test_project_tree.py @@ -0,0 +1,352 @@ +"""Invariants for the authoritative project-tree builder (tui_gateway.project_tree). + +These assert structural contracts (worktree folding, kanban collapse, lane id +scheme, membership union) rather than snapshots, so routine data changes don't +break them. +""" + +from __future__ import annotations + +from tui_gateway import project_tree as pt + +_SID = 0 + + +def _session(cwd, *, branch="", repo_root="", **over): + global _SID + _SID += 1 + row = { + "id": f"s{_SID}", + "cwd": cwd, + "git_branch": branch, + "git_repo_root": repo_root, + "started_at": 1000, + "last_active": 1000, + "title": None, + "preview": None, + "source": "cli", + } + row.update(over) + return row + + +def _project(pid, name, folders, **over): + row = { + "id": pid, + "name": name, + "primary_path": folders[0] if folders else None, + "archived": False, + "folders": [{"path": p, "is_primary": i == 0} for i, p in enumerate(folders)], + } + row.update(over) + return row + + +def _resolver(mapping): + """Build a resolve() from {cwd: (repo_root, worktree_root)}.""" + + def resolve(cwd): + hit = mapping.get(cwd) + if not hit: + return None + return {"repo_root": hit[0], "worktree_root": hit[1]} + + return resolve + + +def _lane_ids(project): + return [g["id"] for repo in project["repos"] for g in repo["groups"]] + + +# --------------------------------------------------------------------------- + + +def test_main_checkout_groups_by_recorded_branch_with_stable_lane_ids(): + resolve = _resolver({"/repo": ("/repo", "/repo")}) + sessions = [ + _session("/repo", branch="main"), + _session("/repo", branch="feature"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = next(p for p in tree["projects"] if p["id"] == "/repo") + + assert project["isAuto"] is True + assert _lane_ids(project) == ["/repo::branch::main", "/repo::branch::feature"] + # Trunk sorts ahead of the feature branch; both live in the main checkout. + assert [g["label"] for repo in project["repos"] for g in repo["groups"]] == ["main", "feature"] + assert all(g["isMain"] for repo in project["repos"] for g in repo["groups"]) + + +def test_linked_worktrees_fold_under_their_common_repo_root(): + # The linked worktree's own toplevel is /elsewhere/wt, but its COMMON root is + # /repo, so it must group under /repo (not as a separate project). + resolve = _resolver( + { + "/repo": ("/repo", "/repo"), + "/elsewhere/wt": ("/repo", "/elsewhere/wt"), + } + ) + sessions = [ + _session("/repo", branch="main"), + _session("/elsewhere/wt", branch="feature"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["/repo"] + project = tree["projects"][0] + assert project["repos"][0]["id"] == "/repo" + lane_ids = _lane_ids(project) + assert "/repo::branch::main" in lane_ids + # Linked worktree lane is keyed by the worktree path and is not main. + linked = next(g for repo in project["repos"] for g in repo["groups"] if not g["isMain"]) + assert linked["id"] == "/elsewhere/wt" + assert linked["path"] == "/elsewhere/wt" + + +def test_kanban_task_worktrees_collapse_into_one_bucket(): + resolve = _resolver( + { + "/repo": ("/repo", "/repo"), + "/repo/.worktrees/t_aaaaaaaa": ("/repo", "/repo/.worktrees/t_aaaaaaaa"), + "/repo/.worktrees/t_bbbbbbbb": ("/repo", "/repo/.worktrees/t_bbbbbbbb"), + } + ) + sessions = [ + _session("/repo", branch="main"), + _session("/repo/.worktrees/t_aaaaaaaa"), + _session("/repo/.worktrees/t_bbbbbbbb"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = tree["projects"][0] + kanban = [g for repo in project["repos"] for g in repo["groups"] if g.get("isKanban")] + + assert len(kanban) == 1 + assert kanban[0]["id"] == "/repo::kanban" + assert kanban[0]["path"] == "/repo/.worktrees" + assert len(kanban[0]["sessions"]) == 2 + # The bucket sorts below the real main branch. + assert _lane_ids(project)[-1] == "/repo::kanban" + + +def test_user_worktree_under_dotworktrees_is_its_own_lane_not_kanban(): + # A user "New worktree" lives at /.worktrees/ (no t_ id), so it + # must NOT collapse into the kanban bucket — it gets its own linked lane. + resolve = _resolver( + { + "/repo": ("/repo", "/repo"), + "/repo/.worktrees/test-gui-stuff": ("/repo", "/repo/.worktrees/test-gui-stuff"), + } + ) + sessions = [ + _session("/repo", branch="main"), + _session("/repo/.worktrees/test-gui-stuff", branch="hermes/test-gui-stuff"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = tree["projects"][0] + lanes = {g["id"]: g for repo in project["repos"] for g in repo["groups"]} + + assert "/repo/.worktrees/test-gui-stuff" in lanes + assert not lanes["/repo/.worktrees/test-gui-stuff"].get("isKanban") + assert "/repo::kanban" not in lanes + + +def test_unrecorded_and_recorded_main_share_one_lane(): + # Empty git_branch (historical sessions) folds into the same trunk lane as + # sessions that recorded branch "main" — no duplicate "main". + resolve = _resolver({"/repo": ("/repo", "/repo")}) + sessions = [_session("/repo", branch=""), _session("/repo", branch="main")] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = tree["projects"][0] + main_lanes = [g for repo in project["repos"] for g in repo["groups"] if g["label"] == "main"] + + assert len(main_lanes) == 1 + assert main_lanes[0]["id"] == "/repo::branch::main" + assert len(main_lanes[0]["sessions"]) == 2 + + +def test_persisted_repo_root_used_when_no_live_probe(): + # No resolver (remote backend): fall back to the persisted git_repo_root and + # split the main checkout by the session's recorded branch. + sessions = [_session("/repo/src", branch="main", repo_root="/repo")] + + tree = pt.build_tree([], sessions, [], resolve=None, hydrate=True) + project = next(p for p in tree["projects"] if p["id"] == "/repo") + + assert _lane_ids(project) == ["/repo::branch::main"] + + +def test_explicit_project_claims_sessions_and_beats_auto(): + project = _project("p_app", "App", ["/www/app"]) + resolve = _resolver( + { + "/www/app": ("/www/app", "/www/app"), + "/www/other": ("/www/other", "/www/other"), + } + ) + sessions = [ + _session("/www/app", branch="main"), + _session("/www/other", branch="main"), + ] + + tree = pt.build_tree([project], sessions, [], resolve, hydrate=True) + + explicit = next(p for p in tree["projects"] if p["id"] == "p_app") + assert explicit["isAuto"] is False + assert explicit["sessionCount"] == 1 + # The unowned /www/other session becomes its own auto project. + assert any(p["id"] == "/www/other" and p["isAuto"] for p in tree["projects"]) + + +def test_scoped_session_ids_is_union_of_placed_sessions(): + project = _project("p_app", "App", ["/www/app"]) + resolve = _resolver( + { + "/www/app": ("/www/app", "/www/app"), + "/www/repo": ("/www/repo", "/www/repo"), + } + ) + owned = _session("/www/app", branch="main") + auto = _session("/www/repo", branch="main") + homeless = _session(None) # no cwd -> belongs to no project + + tree = pt.build_tree([project], [owned, auto, homeless], [], resolve, hydrate=True) + + assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"]} + assert homeless["id"] not in tree["scoped_session_ids"] + + +def test_overview_drops_session_rows_but_keeps_counts_and_previews(): + resolve = _resolver({"/repo": ("/repo", "/repo")}) + sessions = [_session("/repo", branch="main") for _ in range(4)] + + tree = pt.build_tree([], sessions, [], resolve, preview_limit=3, hydrate=False) + project = tree["projects"][0] + + assert project["sessionCount"] == 4 + assert len(project["previewSessions"]) == 3 + # Lanes carry structure + counts but no rows in overview mode. + assert all(g["sessions"] == [] for repo in project["repos"] for g in repo["groups"]) + assert project["repos"][0]["sessionCount"] == 4 + + +def test_discovered_repo_with_no_sessions_becomes_zero_session_project(): + discovered = [{"root": "/www/fresh", "label": "fresh", "sessions": 0, "last_active": 5}] + + tree = pt.build_tree([], [], discovered, resolve=None, hydrate=False) + + fresh = next(p for p in tree["projects"] if p["id"] == "/www/fresh") + assert fresh["isAuto"] is True + assert fresh["sessionCount"] == 0 + assert fresh["repos"][0]["groups"] == [] + + +def test_explicit_project_with_no_sessions_seeds_its_folders_as_repos(): + # A brand-new (or unloaded) project must still expose its declared folders as + # repos so the entered view renders and the desktop's optimistic overlay has a + # lane to place a freshly-created session into (otherwise it only shows after a + # full tree refresh). + project = _project("p_new", "New", ["/work/blank"]) + + tree = pt.build_tree([project], [], [], resolve=None, hydrate=True) + + node = next(p for p in tree["projects"] if p["id"] == "p_new") + assert node["sessionCount"] == 0 + assert [r["path"] for r in node["repos"]] == ["/work/blank"] + assert node["repos"][0]["groups"] == [] + + +def test_seeded_folder_repo_does_not_duplicate_a_session_derived_repo(): + # When a folder already has sessions (same git root), seeding must not add a + # second repo for the same path. + project = _project("p_app", "App", ["/www/app"]) + resolve = _resolver({"/www/app": ("/www/app", "/www/app")}) + sessions = [_session("/www/app", branch="main")] + + tree = pt.build_tree([project], sessions, [], resolve, hydrate=True) + + node = next(p for p in tree["projects"] if p["id"] == "p_app") + assert [r["path"] for r in node["repos"]] == ["/www/app"] + + +def test_discovered_repo_owned_by_explicit_project_is_not_duplicated(): + project = _project("p_app", "App", ["/www/app"]) + discovered = [{"root": "/www/app", "label": "app", "sessions": 2, "last_active": 1}] + + tree = pt.build_tree([project], [], discovered, resolve=None, hydrate=False) + + assert [p["id"] for p in tree["projects"] if p["path"] == "/www/app"] == ["p_app"] + + +def test_nested_project_folders_pick_the_deepest_match(): + # The folder index must resolve a session to its most-specific (deepest) + # project folder, not just any ancestor. + outer = _project("p_outer", "Outer", ["/work"]) + inner = _project("p_inner", "Inner", ["/work/app"]) + resolve = _resolver( + { + "/work/app": ("/work/app", "/work/app"), + "/work/other": ("/work/other", "/work/other"), + } + ) + + tree = pt.build_tree( + [outer, inner], + [_session("/work/app", branch="main"), _session("/work/other", branch="main")], + [], + resolve, + hydrate=True, + ) + by_id = {p["id"]: p for p in tree["projects"]} + + assert by_id["p_inner"]["sessionCount"] == 1 # /work/app → deepest folder wins + assert by_id["p_outer"]["sessionCount"] == 1 # /work/other → only the outer project + + +def test_junk_root_never_becomes_an_auto_project(): + # A session whose git root is HERMES_HOME (config/state) must not spawn a + # phantom project; it falls through to flat Recents (unscoped). A real repo + # alongside it still groups normally. + resolve = _resolver( + { + "/home/me/.hermes": ("/home/me/.hermes", "/home/me/.hermes"), + "/www/app": ("/www/app", "/www/app"), + } + ) + junk = _session("/home/me/.hermes", branch="main") + real = _session("/www/app", branch="main") + is_junk = lambda root: root == "/home/me/.hermes" + + tree = pt.build_tree([], [junk, real], [], resolve, hydrate=True, is_junk_root=is_junk) + + ids = {p["id"] for p in tree["projects"]} + assert ids == {"/www/app"} + assert junk["id"] not in tree["scoped_session_ids"] + assert real["id"] in tree["scoped_session_ids"] + + +def test_junk_root_is_dropped_from_the_discovered_tier(): + discovered = [{"root": "/home/me/.hermes", "label": ".hermes", "sessions": 0, "last_active": 9}] + + tree = pt.build_tree([], [], discovered, resolve=None, is_junk_root=lambda r: r == "/home/me/.hermes") + + assert tree["projects"] == [] + + +def test_colliding_repo_basenames_disambiguate_labels(): + resolve = _resolver( + { + "/x/proj": ("/x/proj", "/x/proj"), + "/y/proj": ("/y/proj", "/y/proj"), + } + ) + sessions = [_session("/x/proj", branch="main"), _session("/y/proj", branch="main")] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + labels = sorted(p["label"] for p in tree["projects"]) + + assert labels == ["x/proj", "y/proj"] diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py new file mode 100644 index 00000000000..ca65803d5ae --- /dev/null +++ b/tests/tui_gateway/test_projects_rpc.py @@ -0,0 +1,237 @@ +"""Tests for the projects.* JSON-RPC methods on the tui_gateway server.""" + +from __future__ import annotations + +import os +import subprocess + +import pytest + +import tui_gateway.server as server + + +def _call(method, params=None): + handler = server._methods[method] + resp = handler(1, params or {}) + assert "error" not in resp, resp.get("error") + return resp["result"] + + +def test_methods_registered(): + for m in ( + "projects.list", + "projects.create", + "projects.get", + "projects.update", + "projects.add_folder", + "projects.remove_folder", + "projects.set_primary", + "projects.archive", + "projects.set_active", + "projects.for_cwd", + ): + assert m in server._methods + + +def test_for_cwd_is_a_long_handler(): + # git-probe handler must run off the dispatch thread. + assert "projects.for_cwd" in server._LONG_HANDLERS + + +def test_repo_root_cache_does_not_freeze_a_not_yet_repo(monkeypatch): + # We `git init` a new project's folder on first worktree; the cache must not + # have frozen the pre-init "" result, or the main lane mislabels by basename. + # Negative results are TTL-cached; TTL=0 here makes them expire immediately so + # this verifies the "never permanently frozen" contract directly. + from tui_gateway import git_probe + + monkeypatch.setattr(git_probe, "_NEG_TTL", 0) + cwd = "/tmp/baby pics" + git_probe.invalidate() + state = {"root": ""} # flips once the folder becomes a repo + monkeypatch.setattr(git_probe, "run_git", lambda c, *a: state["root"] if c == cwd else "") + + assert git_probe.repo_root(cwd) == "" # pre-init: not a repo (expires at once) + + state["root"] = cwd # `git init` happened + assert git_probe.repo_root(cwd) == cwd # re-probed, not frozen + assert git_probe.repo_root(cwd) == cwd # now cached + + +def test_negative_results_are_ttl_cached_then_re_probed(monkeypatch): + # A non-repo cwd is re-derived on every session in a project-tree build, so a + # "not a repo" answer must be cached briefly to avoid re-spawning git dozens + # of times — but only until the TTL elapses, so a folder that later becomes a + # repo is still picked up. + from tui_gateway import git_probe + + git_probe.invalidate() + calls = {"n": 0} + + def probe(_cwd, *_a): + calls["n"] += 1 + return "" # never a repo + + monkeypatch.setattr(git_probe, "run_git", probe) + monkeypatch.setattr(git_probe, "_NEG_TTL", 1000) # effectively no expiry here + + cwd = "/not/a/repo" + assert git_probe.repo_root(cwd) == "" + for _ in range(10): + assert git_probe.repo_root(cwd) == "" + assert calls["n"] == 1 # cached: probed once, not 11 times + + # Once the TTL lapses, the next lookup re-probes (a `git init` may have run). + monkeypatch.setattr(git_probe, "_NEG_TTL", 0) + git_probe._cache._neg[cwd] = 0.0 # force-expire the cached negative + assert git_probe.repo_root(cwd) == "" + assert calls["n"] == 2 + + +def test_repo_root_cache_is_single_flight(monkeypatch): + # Concurrent identical probes share one git invocation (gateway long handlers + # run on worker threads). + import threading + + from tui_gateway import git_probe + + git_probe.invalidate() + calls = {"n": 0} + started = threading.Event() + + def slow(_cwd, *_a): + calls["n"] += 1 + started.set() + time = __import__("time") + time.sleep(0.05) + return "/repo" + + monkeypatch.setattr(git_probe, "run_git", slow) + out: list[str] = [] + threads = [threading.Thread(target=lambda: out.append(git_probe.repo_root("/repo/x"))) for _ in range(6)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert out == ["/repo"] * 6 + assert calls["n"] == 1 + + +def test_warm_roots_probes_in_parallel_and_fills_the_cache(monkeypatch): + # Cold first paint must not serialize one git subprocess per cwd. + import threading + import time + + from tui_gateway import git_probe + + git_probe.invalidate() + lock = threading.Lock() + live = {"now": 0, "peak": 0, "calls": 0} + + def slow(cwd, *_a): + with lock: + live["now"] += 1 + live["calls"] += 1 + live["peak"] = max(live["peak"], live["now"]) + time.sleep(0.02) + with lock: + live["now"] -= 1 + return cwd # show-toplevel → cwd is its own root + + monkeypatch.setattr(git_probe, "run_git", slow) + cwds = [f"/repo{i}" for i in range(8)] + git_probe.warm_roots(cwds, max_workers=8) + + assert live["peak"] > 1 # ran concurrently, not serialized + # Cache is warm: resolving again triggers no further probes. + before = live["calls"] + assert git_probe.repo_root("/repo0") == "/repo0" + assert live["calls"] == before + + +def test_create_list_roundtrip(tmp_path): + created = _call("projects.create", {"name": "Demo", "folders": [str(tmp_path)], "use": True}) + assert created["project"]["slug"] == "demo" + + listing = _call("projects.list") + assert [p["slug"] for p in listing["projects"]] == ["demo"] + assert listing["active_id"] == created["project"]["id"] + + +def test_add_folder_and_for_cwd(tmp_path): + folder = tmp_path / "repo" + folder.mkdir() + pid = _call("projects.create", {"name": "Repo", "folders": [str(folder)]})["project"]["id"] + + nested = folder / "src" + nested.mkdir() + resolved = _call("projects.for_cwd", {"cwd": str(nested)}) + assert resolved["project"]["id"] == pid + # branch key is present (empty string when not a git repo). + assert "branch" in resolved + + +def test_update_and_archive(tmp_path): + pid = _call("projects.create", {"name": "Orig", "folders": [str(tmp_path)]})["project"]["id"] + + updated = _call("projects.update", {"id": pid, "name": "Renamed"}) + assert updated["project"]["name"] == "Renamed" + + payload = _call("projects.archive", {"id": pid}) + assert all(p["id"] != pid or p["archived"] for p in payload["projects"]) + + +def test_get_unknown_returns_error(): + resp = server._methods["projects.get"](1, {"id": "nope"}) + assert "error" in resp + + +def test_delete_removes_project(tmp_path): + pid = _call("projects.create", {"name": "Doomed", "folders": [str(tmp_path)]})["project"]["id"] + payload = _call("projects.delete", {"id": pid}) + + assert all(p["id"] != pid for p in payload["projects"]) + assert "projects.delete" in server._methods + + +def test_discover_repos_is_registered_long_handler(): + assert "projects.discover_repos" in server._methods + assert "projects.discover_repos" in server._LONG_HANDLERS + assert "projects.record_repos" in server._methods + assert "projects.record_repos" in server._LONG_HANDLERS + + +def test_record_repos_persists_and_shows_zero_session_repo(tmp_path): + repo = tmp_path / "fresh-repo" + repo.mkdir() + + # Repo-first: a scanned repo with no hermes sessions still surfaces. + _call("projects.record_repos", {"repos": [{"root": str(repo), "label": "fresh-repo"}]}) + + by_label = {r["label"]: r for r in _call("projects.discover_repos")["repos"]} + assert "fresh-repo" in by_label + assert by_label["fresh-repo"]["sessions"] == 0 + + +def test_discover_repos_from_full_history(tmp_path): + repo = tmp_path / "myrepo" + (repo / "src").mkdir(parents=True) + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + plain = tmp_path / "plain" + plain.mkdir() + + db = server._get_db() + db.create_session("s1", "cli", cwd=str(repo)) + db.create_session("s2", "cli", cwd=str(repo / "src")) + db.create_session("s3", "cli", cwd=str(plain)) # not a git repo → excluded + + repos = _call("projects.discover_repos")["repos"] + by_label = {r["label"]: r for r in repos} + + assert "myrepo" in by_label + assert by_label["myrepo"]["sessions"] == 2 # both repo cwds aggregate + assert "plain" not in by_label # non-git dir never promoted + + # The probe is persisted back onto the session rows (membership at the source). + assert os.path.realpath(db.get_session("s1")["git_repo_root"]) == os.path.realpath(str(repo)) diff --git a/tui_gateway/git_probe.py b/tui_gateway/git_probe.py new file mode 100644 index 00000000000..01b7998ad14 --- /dev/null +++ b/tui_gateway/git_probe.py @@ -0,0 +1,187 @@ +"""Git working-tree probing for the gateway: run git, resolve repo roots, fold +linked worktrees under their common root. + +Probing runs where the gateway runs, so it resolves repos for both local and +remote backends (unlike the desktop's electron probe, which only sees the local +fs). Resolved roots are cached with a thread-safe, single-flight cache: the +gateway's long handlers run on worker threads, so concurrent identical probes +(e.g. two overlapping project-tree builds) share one `git` invocation instead of +racing an unguarded dict. + +Positive results are cached for the process lifetime; negative results (a cwd +that isn't a git repo, or a deleted/nonexistent dir) are cached only for a short +TTL (`_NEG_TTL`). Caching negatives matters a lot for the desktop Projects tree: +``project_tree.build_tree`` resolves a cwd once *per session* (not per distinct +cwd), so a power user with hundreds of sessions in non-git/deleted dirs would +otherwise re-spawn ``git`` hundreds of times on *every* sidebar open — the cause +of the multi-second "Projects" load. The TTL keeps a not-yet-repo cwd +re-probable (we `git init` a new project's folder on its first worktree, and a +frozen "" would mislabel its main lane by the dir basename) — it just stops the +same "not a repo" answer from being re-derived dozens of times within one build +and across rapid re-opens. `invalidate()` drops everything after a known +mutation. +""" + +from __future__ import annotations + +import os +import subprocess +import threading +import time +from collections.abc import Iterable +from concurrent.futures import ThreadPoolExecutor + +_GIT_TIMEOUT = 1.5 +_WARM_WORKERS = 8 + +# How long a "not a git repo" answer stays cached before it's re-probed. Short +# enough that a freshly `git init`-ed / newly-created folder shows correctly +# within a few seconds; long enough to collapse the hundreds of redundant probes +# a single project-tree build (and rapid re-opens) would otherwise fire. +_NEG_TTL = 30.0 + + +def run_git(cwd: str, *args: str) -> str: + """``git -C `` → stripped stdout, or ``""`` on any failure.""" + if not cwd: + return "" + try: + result = subprocess.run( + ["git", "-C", cwd, *args], + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + check=False, + stdin=subprocess.DEVNULL, + ) + return result.stdout.strip() if result.returncode == 0 else "" + except Exception: + return "" + + +def branch(cwd: str) -> str: + return run_git(cwd, "branch", "--show-current") or run_git(cwd, "rev-parse", "--short", "HEAD") + + +class _RootCache: + """Thread-safe, single-flight cache of git-root probes. Positive results are + cached for the process lifetime; negative ("not a repo") results are cached + only for ``_NEG_TTL`` seconds so a not-yet-repo cwd stays re-probable. + Followers wait on the leader's probe instead of duplicating it.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._roots: dict[str, str] = {} + self._neg: dict[str, float] = {} # key -> monotonic expiry + self._inflight: dict[str, threading.Event] = {} + + def invalidate(self) -> None: + with self._lock: + self._roots.clear() + self._neg.clear() + self._inflight.clear() + + def resolve(self, key: str, probe) -> str: + while True: + with self._lock: + hit = self._roots.get(key) + if hit: + return hit + expiry = self._neg.get(key) + if expiry is not None: + if expiry > time.monotonic(): + # Recently probed as "not a repo" — trust it briefly + # instead of re-spawning git for the same dead/non-repo + # cwd on every session in the tree build. + return "" + # TTL elapsed: drop it and re-probe (it may be a repo now). + del self._neg[key] + gate = self._inflight.get(key) + if gate is None: + gate = threading.Event() + self._inflight[key] = gate + leader = True + else: + leader = False + + if not leader: + # Another thread is probing this key — wait, then re-read. + gate.wait(timeout=_GIT_TIMEOUT + 0.5) + continue + + value = "" + try: + value = probe() + finally: + with self._lock: + if value: + self._roots[key] = value + else: + self._neg[key] = time.monotonic() + _NEG_TTL + self._inflight.pop(key, None) + gate.set() + return value + + +_cache = _RootCache() + + +def invalidate() -> None: + """Drop cached roots after a known mutation (e.g. a worktree was added).""" + _cache.invalidate() + + +def repo_root(cwd: str) -> str: + """Top-level git repo root for ``cwd`` (``""`` when not a repo).""" + if not cwd: + return "" + return _cache.resolve(cwd, lambda: run_git(cwd, "rev-parse", "--show-toplevel")) + + +def common_repo_root(cwd: str) -> str: + """The MAIN (common) repo root for ``cwd``, folding linked worktrees. + + ``--show-toplevel`` returns a linked worktree's OWN root, so grouping by it + splits every worktree into a separate "repo". The common ``.git`` dir + (``--git-common-dir``) is shared by a repo and all its worktrees, so its + parent is the one true repo root; fall back to the toplevel root otherwise. + """ + if not cwd: + return "" + + def _probe() -> str: + gitdir = run_git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir") + if gitdir: + gitdir = os.path.realpath(gitdir) + if os.path.basename(gitdir) == ".git": + return os.path.dirname(gitdir) + return repo_root(cwd) + + return _cache.resolve(f"common:{cwd}", _probe) + + +def resolve(cwd: str) -> dict | None: + """Inject-able resolver for ``project_tree.build_tree``. + + Returns ``{"repo_root": , "worktree_root": }`` + or ``None`` when ``cwd`` is not in a git repo. ``build_tree`` treats + ``worktree_root == repo_root`` as the main checkout. + """ + worktree_root = repo_root(cwd) + if not worktree_root: + return None + return {"repo_root": common_repo_root(cwd) or worktree_root, "worktree_root": worktree_root} + + +def warm_roots(cwds: Iterable[str], max_workers: int = _WARM_WORKERS) -> None: + """Pre-resolve many cwds' roots in parallel (bounded) so a cold first paint + doesn't serialize one git subprocess per session cwd. Single-flight dedupes + overlap; results land in the shared cache for the sequential consumers.""" + pending = sorted({(cwd or "").strip() for cwd in cwds} - {""}) + if not pending: + return + if len(pending) == 1: + resolve(pending[0]) + return + with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as pool: + list(pool.map(resolve, pending)) diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py new file mode 100644 index 00000000000..ded460f2811 --- /dev/null +++ b/tui_gateway/project_tree.py @@ -0,0 +1,558 @@ +"""Authoritative project -> repo -> lane -> session tree builder. + +This is the single source of truth for how the desktop sidebar groups sessions +into projects, repos, and lanes. It is pure (all git resolution is injected via +``resolve``) so it can be unit-tested with fixtures and reused by the gateway's +``projects.tree`` / ``projects.project_sessions`` RPCs. + +It deliberately mirrors the desktop's former client-side grouping (the old +``workspace-groups.ts``) so the emitted ids and lane keys stay byte-compatible +with the renderer's persisted state (pins, manual ordering, dismissal), which +all key off these exact strings: + + - explicit project id .......... ``p_`` (from projects.db) + - auto/discovered project id ... the repo root path + - repo node id ................. the repo root path + - main branch lane id .......... ``::branch::`` (or ``::branch::``) + - kanban bucket lane id ........ ``::kanban`` + - linked worktree lane id ...... the worktree path + +The one correctness upgrade over the client version: linked worktrees are folded +under their MAIN repo via a git common-dir probe (injected as ``resolve``), +instead of being treated as separate repos (``git rev-parse --show-toplevel`` +returns the worktree's own root, which is why the client double-counted them). +""" + +from __future__ import annotations + +import re +from typing import Any, Callable, Optional + +# A cwd -> git identity resolver. Returns ``{"repo_root", "worktree_root"}`` where +# ``repo_root`` is the COMMON (main) repo root shared across worktrees and +# ``worktree_root`` is this cwd's own checkout root. Returns ``None`` when the +# cwd is not in a git repo (or cannot be probed, e.g. a remote backend). +Resolve = Callable[[str], Optional[dict]] + +# Only KANBAN-TASK worktrees (`/.worktrees/t_`, the `t_…` id kanban_db +# mints) collapse into one lane; user-named "New worktree" dirs under +# `.worktrees/` stay as their own lanes. +_KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$") +_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"} +DEFAULT_BRANCH_LABEL = "main" + + +def _branch_lane_id(repo_root: str, branch: str = "") -> str: + """The one definition of a main-checkout lane id (must match the desktop).""" + return f"{repo_root}::branch::{(branch or '').strip()}" + + +def _kanban_lane_id(repo_root: str) -> str: + return f"{repo_root}::kanban" + + +# --------------------------------------------------------------------------- +# Path helpers (match the TS segment logic so labels/ids line up) +# --------------------------------------------------------------------------- + + +def _segments(path: str) -> list[str]: + return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s] + + +def base_name(path: str) -> str: + segs = _segments(path) + return segs[-1] if segs else "" + + +def kanban_worktree_dir(path: str) -> Optional[str]: + """The ``/.worktrees`` dir for a ``.../.worktrees/`` path, else None.""" + m = _KANBAN_DIR_RE.match(path or "") + return m.group(1) if m else None + + +def _is_path_under(folder: str, target: str) -> bool: + """True when ``target`` equals ``folder`` or is nested under it (segment-wise).""" + f = _segments(folder) + t = _segments(target) + if not f or len(f) > len(t): + return False + return all(f[i] == t[i] for i in range(len(f))) + + +def _with_base_name(path: str, name: str) -> str: + stripped = re.sub(r"[/\\]+$", "", path) + return re.sub(r"[^/\\]+$", name, stripped) + + +# --------------------------------------------------------------------------- +# Lane placement +# --------------------------------------------------------------------------- + + +def _placement( + repo_root: str, + lane_key: str, + lane_label: str, + lane_path: str, + is_main: bool, + is_kanban: bool, +) -> dict: + return { + "repo_key": repo_root, + "repo_label": base_name(repo_root) or repo_root, + "repo_path": repo_root, + "lane_key": lane_key, + "lane_label": lane_label, + "lane_path": lane_path, + "is_main": is_main, + "is_kanban": is_kanban, + } + + +def _place_by_heuristic(path: str) -> Optional[dict]: + """Path-only fallback when there is no git probe and no persisted root.""" + base = base_name(path) + if not base: + return None + + kanban_dir = kanban_worktree_dir(path) + if kanban_dir: + repo_path = re.sub(r"[/\\]+$", "", _with_base_name(kanban_dir, "")) + return _placement(repo_path, _kanban_lane_id(repo_path), "kanban", kanban_dir, False, True) + + m = re.match(r"^(.+)-wt-(.+)$", base) + if m: + repo_path = _with_base_name(path, m.group(1)) + return _placement(repo_path, path, m.group(2), path, False, False) + + return _placement(path, path, base, path, True, False) + + +def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]: + info = resolve(cwd) if resolve else None + + if info and info.get("repo_root") and info.get("worktree_root"): + repo_root = info["repo_root"] + worktree_root = info["worktree_root"] + is_main = worktree_root == repo_root or bool(info.get("is_main")) + + if is_main: + # Unrecorded branch folds into the one trunk lane, so a repo never + # shows two "main" lanes (recorded "main" + the empty-branch bucket). + b = (branch or "").strip() or DEFAULT_BRANCH_LABEL + return _placement(repo_root, _branch_lane_id(repo_root, b), b, repo_root, True, False) + + kanban_dir = kanban_worktree_dir(worktree_root) + if kanban_dir: + return _placement(repo_root, _kanban_lane_id(repo_root), "kanban", kanban_dir, False, True) + + label = base_name(worktree_root) or worktree_root + return _placement(repo_root, worktree_root, label, worktree_root, False, False) + + # No live probe: trust the backend-persisted root (group by it, split main by + # the session's recorded branch). Kanban tasks still collapse by path shape. + if persisted_root: + kanban_dir = kanban_worktree_dir(cwd) + if kanban_dir: + return _placement(persisted_root, _kanban_lane_id(persisted_root), "kanban", kanban_dir, False, True) + b = (branch or "").strip() or DEFAULT_BRANCH_LABEL + return _placement(persisted_root, _branch_lane_id(persisted_root, b), b, persisted_root, True, False) + + return _place_by_heuristic(cwd) + + +def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str: + """The COMMON repo root a session belongs to (folds linked worktrees).""" + cwd = (session.get("cwd") or "").strip() + if cwd and resolve: + info = resolve(cwd) + if info and info.get("repo_root"): + return info["repo_root"] + return (session.get("git_repo_root") or "").strip() + + +# --------------------------------------------------------------------------- +# Ordering + label disambiguation (parity with the old client tree) +# --------------------------------------------------------------------------- + + +def _lane_sort_key(group: dict) -> tuple: + # Trunk pins to the top; the kanban aggregate sinks to the bottom; the rest + # (branches + linked worktrees) sort by most-recent activity, then label. + is_trunk = bool(group.get("isMain")) and group["label"].lower() in _TRUNK_BRANCHES + is_kanban = bool(group.get("isKanban")) + activity = max((_session_time(s) for s in group.get("sessions") or []), default=0.0) + return ( + 0 if is_trunk else 1, + 1 if is_kanban else 0, + -activity, + group["label"].lower(), + ) + + +def _sort_lanes(groups: list[dict]) -> list[dict]: + return sorted(groups, key=_lane_sort_key) + + +def _disambiguate_labels(items: list[dict]) -> None: + """Grow colliding basenames into path-prefixed labels (in place).""" + by_label: dict[str, list[dict]] = {} + for item in items: + by_label.setdefault(item["label"], []).append(item) + + for bucket in by_label.values(): + pathed = [g for g in bucket if g.get("path")] + if len(pathed) < 2: + continue + + parents = {id(g): _segments(g["path"])[:-1] for g in pathed} + max_depth = max(len(p) for p in parents.values()) + depth = 1 + while depth <= max_depth: + counts: dict[str, int] = {} + for g in pathed: + segs = parents[id(g)] + prefix = "/".join(segs[-depth:]) if depth else "" + base = base_name(g["path"]) or g["path"] + g["label"] = f"{prefix}/{base}" if prefix else base + counts[g["label"]] = counts.get(g["label"], 0) + 1 + if all(c == 1 for c in counts.values()): + break + depth += 1 + + +# --------------------------------------------------------------------------- +# Repo subtree assembly +# --------------------------------------------------------------------------- + + +def _session_time(session: dict) -> float: + return float(session.get("last_active") or session.get("started_at") or 0) + + +def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool) -> list[dict]: + """Build the ``repo -> lane -> sessions`` subtree for a set of sessions.""" + lanes: dict[str, dict] = {} # lane_key -> {group, repo_key, repo_label, repo_path} + + for session in sessions: + cwd = (session.get("cwd") or "").strip() + if not cwd: + continue + + placement = _place( + cwd, + (session.get("git_branch") or "").strip(), + resolve, + (session.get("git_repo_root") or "").strip(), + ) + if not placement: + continue + + entry = lanes.get(placement["lane_key"]) + if entry is None: + entry = { + "group": { + "id": placement["lane_key"], + "label": placement["lane_label"], + "path": placement["lane_path"], + "isMain": placement["is_main"], + "isKanban": placement["is_kanban"], + "sessions": [], + }, + "repo_key": placement["repo_key"], + "repo_label": placement["repo_label"], + "repo_path": placement["repo_path"], + } + lanes[placement["lane_key"]] = entry + entry["group"]["sessions"].append(session) + + repos: dict[str, dict] = {} + for entry in lanes.values(): + group = entry["group"] + group["sessions"].sort(key=_session_time, reverse=True) + count = len(group["sessions"]) + if not hydrate: + group["sessions"] = [] + + repo = repos.get(entry["repo_key"]) + if repo is None: + repo = { + "id": entry["repo_key"], + "label": entry["repo_label"], + "path": entry["repo_path"], + "groups": [], + "sessionCount": 0, + } + repos[entry["repo_key"]] = repo + repo["groups"].append(group) + repo["sessionCount"] += count + + repo_list = list(repos.values()) + for repo in repo_list: + repo["groups"] = _sort_lanes(repo["groups"]) + _disambiguate_labels(repo["groups"]) + _disambiguate_labels(repo_list) + return repo_list + + +def _seed_folder_repos( + repos: list[dict], folders: list[dict], resolve: Optional[Resolve] +) -> list[dict]: + """Ensure every declared project folder shows as a repo, even with 0 sessions. + + A brand-new project (or any project whose sessions haven't loaded yet) has an + empty session-derived ``repos`` list. That breaks two things on the desktop: + the entered-project view renders blank (it early-returns on no repos), and the + optimistic live-session overlay has no lane to drop a freshly-created session + into — so a new session in the project only appears after a full tree refresh. + Seeding each folder as an empty repo fixes both: the overlay matches a new + session's cwd under the folder root, and the drill-in renders a real (if + empty) project body. Folders already covered by a session-derived repo (same + git root) are left untouched. + """ + seen = {r["id"] for r in repos} | {r["path"] for r in repos if r.get("path")} + seeded = list(repos) + + for folder in folders or []: + raw = (folder.get("path") or "").strip() + if not raw: + continue + info = resolve(raw) if resolve else None + root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw) + if not root or root in seen: + continue + seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0}) + seen.add(root) + + if len(seeded) != len(repos): + _disambiguate_labels(seeded) + + return seeded + + +# --------------------------------------------------------------------------- +# Explicit-project ownership +# --------------------------------------------------------------------------- + + +class _FolderIndex: + """Maps a normalized folder path → (owning project, depth), so a session is + matched to its project by walking its cwd's ancestors (O(path depth) dict + lookups) instead of scanning every project × folder per session — the + difference between O(sessions × projects) and O(sessions) at power-user scale. + """ + + def __init__(self, projects: list[dict]) -> None: + self._by_path: dict[str, tuple[dict, int]] = {} + for project in projects: + for folder in project.get("folders") or []: + segs = _segments(folder.get("path") or "") + if not segs: + continue + key = "/".join(segs) + depth = len(segs) + # Deepest folder wins; ties keep the first project (scan order). + existing = self._by_path.get(key) + if existing is None or depth > existing[1]: + self._by_path[key] = (project, depth) + + def match(self, target: str) -> tuple[Optional[dict], int]: + """Owning project for ``target`` by longest ancestor folder, + its depth.""" + segs = _segments(target or "") + # Longest prefix first → deepest (most specific) folder wins. + for end in range(len(segs), 0, -1): + hit = self._by_path.get("/".join(segs[:end])) + if hit: + return hit + return None, -1 + + +def _project_for_path(index: _FolderIndex, target: str) -> Optional[dict]: + return index.match(target)[0] + + +def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]: + cwd = (session.get("cwd") or "").strip() + if not cwd: + return None + repo_root = _session_repo_root(session, resolve) + candidates = [cwd, repo_root] if repo_root and repo_root != cwd else [cwd] + + best: Optional[dict] = None + best_len = -1 + for target in candidates: + match, length = index.match(target) + if match and length > best_len: + best_len = length + best = match + return best + + +# --------------------------------------------------------------------------- +# Public builder +# --------------------------------------------------------------------------- + + +def _project_node( + *, + pid: str, + label: str, + path: Optional[str], + repos: list[dict], + session_count: int, + last_active: float, + preview_sessions: list[dict], + color: Any = None, + icon: Any = None, + is_auto: bool = False, +) -> dict: + return { + "id": pid, + "label": label, + "path": path, + "color": color, + "icon": icon, + "isAuto": is_auto, + "sessionCount": session_count, + "lastActive": last_active, + "repos": repos, + "previewSessions": preview_sessions, + } + + +def build_tree( + projects: list[dict], + sessions: list[dict], + discovered_repos: list[dict], + resolve: Optional[Resolve] = None, + *, + preview_limit: int = 3, + hydrate: bool = False, + is_junk_root: Optional[Callable[[str], bool]] = None, +) -> dict: + """Build the authoritative project tree. + + ``projects`` are ``projects_db.Project.to_dict()`` shapes (non-archived). + ``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``, + ``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``). + ``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``. + ``is_junk_root`` flags roots that must never become an AUTO project (the + bare home dir, the HERMES_HOME subtree) — their sessions fall through to the + flat Recents list. User-created projects are honored regardless. + + Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When + ``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but + every count is preserved and each project carries up to ``preview_limit`` + ``previewSessions``. When True (drill-in), lanes carry full session rows. + """ + active_projects = [p for p in projects if not p.get("archived")] + _junk = is_junk_root or (lambda _root: False) + folder_index = _FolderIndex(active_projects) + + by_project: dict[str, list[dict]] = {} + unowned: list[dict] = [] + for session in sessions: + owner = _project_for_session(session, folder_index, resolve) + if owner: + by_project.setdefault(owner["id"], []).append(session) + else: + unowned.append(session) + + scoped_ids: list[str] = [] + + def _previews(project_sessions: list[dict]) -> list[dict]: + if preview_limit <= 0: + return [] + ordered = sorted(project_sessions, key=_session_time, reverse=True) + return ordered[:preview_limit] + + def _last_active(project_sessions: list[dict]) -> float: + return max((_session_time(s) for s in project_sessions), default=0.0) + + result: list[dict] = [] + + # Tier 1: explicit, user-created projects (always shown, even with 0 sessions). + for project in active_projects: + psessions = by_project.get(project["id"], []) + scoped_ids.extend(s["id"] for s in psessions if s.get("id")) + repos = _seed_folder_repos( + _build_repos(psessions, resolve, hydrate), project.get("folders") or [], resolve + ) + result.append( + _project_node( + pid=project["id"], + label=project.get("name") or project["id"], + path=project.get("primary_path"), + color=project.get("color"), + icon=project.get("icon"), + repos=repos, + session_count=len(psessions), + last_active=_last_active(psessions), + preview_sessions=_previews(psessions), + ) + ) + + # Tier 2: auto projects from leftover sessions, one per common git repo root. + by_repo: dict[str, list[dict]] = {} + for session in unowned: + root = _session_repo_root(session, resolve) + if root: + by_repo.setdefault(root, []).append(session) + + seen: set[str] = set() + for repo_root, repo_sessions in by_repo.items(): + # The home dir / HERMES_HOME subtree is config + state, never a project; + # its sessions stay loose in Recents (not scoped to a phantom project). + if _junk(repo_root): + continue + repos = _build_repos(repo_sessions, resolve, hydrate) + repo_node = next((r for r in repos if r["id"] == repo_root or r["path"] == repo_root), None) + if repo_node is None: + continue + seen.add(repo_root) + scoped_ids.extend(s["id"] for s in repo_sessions if s.get("id")) + result.append( + _project_node( + pid=repo_root, + label=base_name(repo_root) or repo_root, + path=repo_root, + repos=repos, + session_count=repo_node["sessionCount"], + last_active=_last_active(repo_sessions), + preview_sessions=_previews(repo_sessions), + is_auto=True, + ) + ) + + # Tier 3: repos discovered from full history / disk scan with no loaded + # sessions, folded to their common root and not owned by an explicit project. + for repo in discovered_repos or []: + raw_root = (repo.get("root") or "").strip() + if not raw_root: + continue + info = resolve(raw_root) if resolve else None + root = (info or {}).get("repo_root") or raw_root + if root in seen or _junk(root) or _project_for_path(folder_index, root): + continue + seen.add(root) + label = repo.get("label") or base_name(root) or root + result.append( + _project_node( + pid=root, + label=label, + path=root, + repos=[{"id": root, "label": label, "path": root, "groups": [], "sessionCount": 0}], + session_count=int(repo.get("sessions") or 0), + last_active=float(repo.get("last_active") or 0), + preview_sessions=[], + is_auto=True, + ) + ) + + # Auto projects are labelled by repo basename, which can collide (two "app" + # repos in different parents). Grow path prefixes so each is distinct. + # Explicit projects keep their user-chosen names untouched. + _disambiguate_labels([p for p in result if p.get("isAuto")]) + + return {"projects": result, "scoped_session_ids": scoped_ids} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 24299a82ceb..159f23ddddc 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -25,6 +25,7 @@ from hermes_constants import ( ) from hermes_cli.env_loader import load_hermes_dotenv from utils import is_truthy_value +from tui_gateway import git_probe from tui_gateway.transport import ( StdioTransport, Transport, @@ -191,6 +192,11 @@ _LONG_HANDLERS = frozenset( "pet.select", "pet.thumb", "plugins.manage", + "projects.discover_repos", + "projects.record_repos", + "projects.for_cwd", + "projects.tree", + "projects.project_sessions", "session.branch", "session.compress", "session.resume", @@ -1363,31 +1369,14 @@ def _terminal_task_cwd(session: dict | None) -> str: return _session_cwd(session) -def _git_branch_for_cwd(cwd: str) -> str: - try: - result = subprocess.run( - ["git", "-C", cwd, "branch", "--show-current"], - capture_output=True, - text=True, - timeout=1.5, - check=False, - stdin=subprocess.DEVNULL, - ) - if result.returncode == 0: - branch = result.stdout.strip() - if branch: - return branch - head = subprocess.run( - ["git", "-C", cwd, "rev-parse", "--short", "HEAD"], - capture_output=True, - text=True, - timeout=1.5, - check=False, - stdin=subprocess.DEVNULL, - ) - return head.stdout.strip() if head.returncode == 0 else "" - except Exception: - return "" +# Git working-tree probing (run git, resolve roots, fold worktrees) lives in a +# focused, single-flight-cached module; these stay as the in-server names every +# call site already uses. +_git = git_probe.run_git +_git_branch_for_cwd = git_probe.branch +_git_repo_root_for_cwd = git_probe.repo_root +_git_common_repo_root_for_cwd = git_probe.common_repo_root +_resolve_cwd_git = git_probe.resolve def _session_cwd(session: dict | None) -> str: @@ -1396,6 +1385,75 @@ def _session_cwd(session: dict | None) -> str: return _completion_cwd() +def _heal_dead_cwd(cwd: str) -> str: + """Resolve a session cwd that points at a now-deleted directory. + + A session anchored to a linked worktree (``/.worktrees/``) keeps + that path after the worktree is removed (branch merged, `git worktree + remove`, etc). The literal dir is gone, so a probe of it returns nothing and + the composer shows no branch — while the sidebar still folds the path up to + the repo's main lane. Heal the mismatch: walk up to the first existing + ancestor, then resolve its common git root, so a dead-worktree cwd collapses + to the live repo root (and its real current branch). + + Only meaningful for local backends; a remote/SSH cwd may legitimately not + exist on the host, so callers must skip healing there. + """ + raw = (cwd or "").strip() + if not raw or os.path.isdir(raw): + return raw + + probe = raw + # Climb to the first ancestor that still exists on disk. + for _ in range(64): + parent = os.path.dirname(probe) + if not parent or parent == probe: + break + probe = parent + if os.path.isdir(probe): + break + + if not os.path.isdir(probe): + return raw + + try: + root = _git_common_repo_root_for_cwd(probe) or _git_repo_root_for_cwd(probe) + except Exception: + root = "" + + return root or probe + + +def _is_local_terminal_backend() -> bool: + backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower() + return not backend or backend == "local" + + +def _display_session_cwd(session: dict | None) -> str: + """Session cwd for display/probe surfaces, healed past deleted worktrees. + + Persists the healed value back to the session row (best-effort, local only) + so the next load is already coherent and the sidebar lane stops showing a + session pinned to a vanished path. + """ + cwd = _session_cwd(session) + if not _is_local_terminal_backend(): + return cwd + + healed = _heal_dead_cwd(cwd) + if healed and healed != cwd and session is not None: + session["cwd"] = healed + try: + with _session_db(session) as db: + if db is not None: + db.update_session_cwd(session.get("session_key", ""), healed) + except Exception: + logger.debug("failed to persist healed session cwd", exc_info=True) + _persist_session_git_meta(session, healed) + + return healed + + def _session_source(session: dict | None) -> str: if session: source = str(session.get("source") or "").strip() @@ -1500,12 +1558,19 @@ def _ensure_session_db_row(session: dict) -> None: model_config["reasoning_config"] = reasoning if tier := session.get("create_service_tier_override"): model_config["service_tier"] = tier + # Branch lineage: stamp the same ``_branched_from`` marker the TUI /branch + # uses so list_sessions_rich keeps the branch listed and the desktop sidebar + # can nest it under its parent. + parent_session_id = session.get("parent_session_id") or None + if parent_session_id: + model_config["_branched_from"] = parent_session_id try: db.create_session( key, source=_session_source(session), model=row_model, model_config=model_config or None, + parent_session_id=parent_session_id, cwd=_session_cwd(session) if session.get("explicit_cwd") else None, ) except Exception: @@ -1518,6 +1583,35 @@ def _ensure_session_db_row(session: dict) -> None: pass +def _persist_branch_seed(session: dict) -> None: + """First-turn persist of a branch's copied transcript. + + A branch is a draft until its first submit: the parent's messages live only + in ``session["history"]`` (they ride into the agent as ``conversation_history``, + which ``_flush_messages_to_session_db`` skips by identity). Without this the + branch row would resume missing its pre-branch context. Runs once; the row + + parent link are written by ``_ensure_session_db_row`` just before this. + """ + if not session.get("parent_session_id") or session.get("_branch_seed_persisted"): + return + key = session.get("session_key") + if not key: + return + with session["history_lock"]: + seed = [dict(msg) for msg in (session.get("history") or [])] + if not seed: + return + with _session_db(session) as db: + if db is None: + return + try: + for msg in seed: + db.append_message(session_id=key, role=msg.get("role", "user"), content=msg.get("content")) + session["_branch_seed_persisted"] = True + except Exception: + logger.debug("branch seed persist failed", exc_info=True) + + @contextlib.contextmanager def _session_db(session: dict): """Yield the SessionDB that owns this session's row (profile-aware). @@ -1546,6 +1640,41 @@ def _session_db(session: dict): db.close() +def _persist_session_git_meta(session: dict, cwd: str) -> None: + """Resolve + persist a session's git branch / repo root WITHOUT blocking. + + Branch and root come from ``git`` subprocess probes; running them inline on + the session-init / cwd-set path would stall startup whenever ``cwd`` is slow + or on an unreachable mount. Run them on a short-lived daemon thread instead + and persist via the same profile-aware db the caller writes ``cwd`` to. + + Best-effort: ``cwd`` itself is persisted synchronously by the caller, so a + probe failure just leaves these enrichment columns unset (the project tree + falls back to its live resolver / lazy backfill). Daemon, so a mid-flight + probe never delays gateway shutdown. + """ + session_key = session.get("session_key", "") + if not session_key or not cwd: + return + # Snapshot the routing fields now; the live session dict may be gone by the + # time the thread runs. `_session_db` reopens the profile-correct db inside. + db_session = {"session_key": session_key, "profile_home": session.get("profile_home")} + + def _run() -> None: + try: + branch = _git_branch_for_cwd(cwd) + root = _git_common_repo_root_for_cwd(cwd) + if not (branch or root): + return + with _session_db(db_session) as db: + if db is not None: + db.update_session_cwd(session_key, cwd, branch, root) + except Exception: + logger.debug("failed to persist session git metadata", exc_info=True) + + threading.Thread(target=_run, name="git-meta", daemon=True).start() + + def _set_session_cwd(session: dict, cwd: str) -> str: resolved = os.path.abspath(os.path.expanduser(str(cwd))) if not os.path.isdir(resolved): @@ -1561,6 +1690,8 @@ def _set_session_cwd(session: dict, cwd: str) -> str: db.update_session_cwd(session.get("session_key", ""), resolved) except Exception: logger.debug("failed to persist session cwd", exc_info=True) + # Branch/repo-root probes are git subprocesses — capture them off the hot path. + _persist_session_git_meta(session, resolved) try: from tools.terminal_tool import cleanup_vm @@ -2240,7 +2371,11 @@ def _load_enabled_toolsets() -> list[str] | None: selection = coding_selection(platform="tui") if selection is not None: - return selection + # Fold in `project` here too: this is a GUI-only resolver, and + # the focus-mode coding posture returns before the fallback path + # that normally adds it — without this the desktop loses the + # project tools exactly when sitting in a repo (see below). + return sorted({*selection, "project"}) except Exception: pass @@ -2346,12 +2481,18 @@ def _load_enabled_toolsets() -> list[str] | None: # list without baking in implicit MCP defaults. Using the wrong # variant at agent creation time makes MCP tools silently missing # from the TUI. See PR #3252 for the original design split. - enabled = sorted( - _get_platform_tools(cfg, "cli", include_default_mcp_servers=True) - ) + enabled = _get_platform_tools(cfg, "cli", include_default_mcp_servers=True) if fallback_notice is not None: print(fallback_notice, file=sys.stderr, flush=True) - return enabled or None + if not enabled: + return None + # The desktop Project tools are off _HERMES_CORE_TOOLS (every other + # platform would carry their schema for nothing), so the platform + # recovery above — which keys off hermes-cli's tool universe — can't + # surface them. This resolver runs ONLY in the desktop/TUI gateway, so + # folding in the `project` toolset here is the gate that exposes them on + # exactly the surface that can follow a project move. + return sorted(enabled | {"project"}) except Exception: if fallback_notice is not None: print( @@ -2914,7 +3055,7 @@ def _session_info(agent, session: dict | None = None) -> dict: if candidate.get("agent") is agent: session = candidate break - cwd = _session_cwd(session) + cwd = _display_session_cwd(session) session_key = str( (session or {}).get("session_key") or getattr(agent, "session_id", "") or "" ) @@ -3466,11 +3607,67 @@ def _agent_cbs(sid: str) -> dict: } +def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None: + """Intentional workspace move from the project_* tools: re-anchor the live + session's cwd to the chosen project's folder and push session.info so the + desktop follows (refresh tree + scope into the project). This is the ONLY + auto-cwd path — driven by an explicit tool call, never a terminal `cd`.""" + if not path: + return + + # The tool's task_id is the durable session_key, but _sessions is keyed by a + # short sid uuid (and the desktop routes events by that sid). Resolve it. + key = str(task_id or "") + sid = "" + session = None + with _sessions_lock: + if key in _sessions: + sid, session = key, _sessions[key] + else: + for cand_sid, cand in _sessions.items(): + if cand.get("session_key") == key or getattr(cand.get("agent"), "session_id", None) == key: + sid, session = cand_sid, cand + break + + if session is None: + return + + resolved = os.path.abspath(os.path.expanduser(str(path))) + if not os.path.isdir(resolved): + return + + session["cwd"] = resolved + session["explicit_cwd"] = True + _register_session_cwd(session) + + with _session_db(session) as db: + if db is not None: + try: + db.update_session_cwd(session.get("session_key", ""), resolved) + except Exception: + logger.debug("failed to persist project workspace cwd", exc_info=True) + + _persist_session_git_meta(session, resolved) + + try: + agent = session.get("agent") + info = ( + _session_info(agent, session) + if agent is not None + else {"cwd": resolved, "branch": _git_branch_for_cwd(resolved), "lazy": True} + ) + _emit("session.info", sid, info) + except Exception: + logger.debug("failed to emit session.info after project workspace move", exc_info=True) + + def _wire_callbacks(sid: str): from tools.terminal_tool import set_sudo_password_callback from tools.skills_tool import set_secret_capture_callback + from tools.project_tools import set_project_workspace_callback set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120)) + set_project_workspace_callback(_apply_project_workspace) def secret_cb(env_var, prompt, metadata=None): pl = {"prompt": prompt, "env_var": env_var} @@ -4119,7 +4316,10 @@ def _init_session( _sessions[sid]["cwd"] = row["cwd"] else: try: - db.update_session_cwd(key, _sessions[sid]["cwd"]) + _cwd = _sessions[sid]["cwd"] + db.update_session_cwd(key, _cwd) + # git branch/root probes run off the hot path (see _set_session_cwd). + _persist_session_git_meta(_sessions[sid], _cwd) except Exception: logger.debug("failed to persist resumed session cwd", exc_info=True) _register_session_cwd(_sessions[sid]) @@ -4592,6 +4792,10 @@ def _(rid, params: dict) -> dict: cols = int(params.get("cols", 80)) history = _coerce_seed_history(params.get("messages")) title = str(params.get("title") or "").strip() + # When set, this is a branch: the new chat copies an existing conversation's + # history and links back to it so list_sessions_rich keeps it visible and the + # sidebar can nest it under its parent. Mirrors the TUI /branch marker. + parent_session_id = str(params.get("parent_session_id") or "").strip() or None # Did the client pick a workspace, or are we falling back to the gateway's # launch directory? Only an explicit choice is persisted as the session's # workspace (see _ensure_session_db_row); otherwise it lands in "No @@ -4663,6 +4867,7 @@ def _(rid, params: dict) -> dict: "model_override": session_model_override, "create_reasoning_override": create_reasoning_override, "create_service_tier_override": create_service_tier_override, + "parent_session_id": parent_session_id, "pending_title": title or None, "profile_home": str(profile_home) if profile_home is not None else None, "running": False, @@ -4675,6 +4880,7 @@ def _(rid, params: dict) -> dict: "transport": current_transport() or _stdio_transport, } _register_session_cwd(_sessions[sid]) + # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop # launch (and every "New agent" / draft) opens a session here just to paint # the composer, so eagerly creating a row left an "Untitled" empty session @@ -7469,8 +7675,28 @@ def _(rid, params: dict) -> dict: session, err = _sess(params, rid) if err: return err - if hasattr(session["agent"], "interrupt"): + # Safety net: if the turn's run thread is already gone but `running` stayed + # stuck (a crash/desync that skipped the run loop's `finally`), force-clear it + # so the session can't be permanently bricked at 4009 "session busy" — every + # send/restore/resume would otherwise reject until a full backend restart. + # A genuinely live turn is left alone: its cooperative interrupt + `finally` + # release `running` the normal way; clearing it here would let a second turn + # race the first on the same session. + run_thread = session.get("_run_thread") + run_thread_alive = run_thread is not None and run_thread.is_alive() + should_interrupt = bool(session.get("running")) and run_thread_alive + if should_interrupt and hasattr(session["agent"], "interrupt"): session["agent"].interrupt() + if not run_thread_alive: + with session["history_lock"]: + if session.get("running"): + session["running"] = False + _clear_inflight_turn(session) + + # Stop = stop the TURN (cooperative interrupt above also kills the in-flight + # foreground subprocess). Background processes the agent started (dev servers, + # watchers) are intentionally left running — kill those individually with the + # "x" on the task row (process.kill). Don't reap them here. # Scope the pending-prompt release to THIS session. A global # _clear_pending() would collaterally cancel clarify/sudo/secret # prompts on unrelated sessions sharing the same tui_gateway @@ -7796,6 +8022,9 @@ def _(rid, params: dict) -> dict: # Persist the DB row lazily, now that the user has actually sent a message. _ensure_session_db_row(session) + # A branch becomes real here: copy its parent's transcript into the row so it + # resumes with full context (the agent won't persist the seed itself). + _persist_branch_seed(session) _start_agent_build(sid, session) def run_after_agent_ready() -> None: @@ -7816,7 +8045,11 @@ def _(rid, params: dict) -> dict: return _run_prompt_submit(rid, sid, session, text) - threading.Thread(target=run_after_agent_ready, daemon=True).start() + run_thread = threading.Thread(target=run_after_agent_ready, daemon=True) + # Keep a handle so session.interrupt can tell a live turn from a stuck + # `running` flag (a turn that died without clearing it) and recover the latter. + session["_run_thread"] = run_thread + run_thread.start() return _ok(rid, {"status": "streaming"}) @@ -8025,6 +8258,11 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: if not isinstance(session.get("inflight_turn"), dict): _start_inflight_turn(session, text) agent = session["agent"] + if hasattr(agent, "clear_interrupt"): + try: + agent.clear_interrupt() + except Exception: + pass _emit("message.start", sid) def run(): @@ -8329,12 +8567,19 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: try: from agent.title_generator import maybe_auto_title + _title_key = session.get("session_key") or sid maybe_auto_title( _get_db(), - session.get("session_key") or sid, + _title_key, text, raw, session.get("history", []), + # Push the generated title live so the sidebar renames + # without waiting for the next list refresh (the titler + # runs async, after this turn's refresh already fired). + title_callback=lambda t, _k=_title_key: _emit( + "session.title", sid, {"session_id": _k, "title": t} + ), ) except Exception: pass @@ -9803,6 +10048,438 @@ def _(rid, params: dict) -> dict: return _err(rid, 4002, f"unknown config key: {key}") +# --------------------------------------------------------------------------- +# Projects — first-class, per-profile, multi-folder workspaces +# --------------------------------------------------------------------------- + + +# JSON-RPC error codes for the projects surface. +_E_PROJECTS = 5061 # generic failure +_E_NO_PROJECT = 5062 # id resolved to nothing +_E_PROJECT_ARG = 5063 # invalid argument (e.g. bad name/slug) + + +class _NoProject(Exception): + """Raised inside a projects handler when ``params['id']`` resolves to None.""" + + +def _projects_payload(conn) -> dict: + from hermes_cli import projects_db as pdb + + return { + "projects": [p.to_dict() for p in pdb.list_projects(conn, include_archived=True)], + "active_id": pdb.get_active_id(conn), + } + + +def _projects_method(name: str): + """Register a projects RPC, injecting (pdb, conn) and unifying error mapping. + + Every project CRUD handler opened the per-profile DB, mapped a missing id to + 5062, bad args to 5063, and everything else to 5061. This collapses that + boilerplate so each handler is just its one meaningful operation. + """ + + def decorator(fn): + @method(name) + def handler(rid, params: dict) -> dict: + try: + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + return fn(rid, params, pdb, conn) + except _NoProject: + return _err(rid, _E_NO_PROJECT, "no such project") + except ValueError as e: + return _err(rid, _E_PROJECT_ARG, str(e)) + except Exception as e: + return _err(rid, _E_PROJECTS, str(e)) + + return handler + + return decorator + + +def _require_project(pdb, conn, params: dict): + """The project named by ``params['id']`` (or raise ``_NoProject``).""" + proj = pdb.get_project(conn, str(params.get("id") or "")) + if proj is None: + raise _NoProject + return proj + + +@_projects_method("projects.list") +def _(rid, params, pdb, conn) -> dict: + return _ok(rid, _projects_payload(conn)) + + +@_projects_method("projects.get") +def _(rid, params, pdb, conn) -> dict: + return _ok(rid, {"project": _require_project(pdb, conn, params).to_dict()}) + + +@_projects_method("projects.create") +def _(rid, params, pdb, conn) -> dict: + pid = pdb.create_project( + conn, + name=str(params.get("name") or ""), + slug=params.get("slug"), + folders=params.get("folders") or [], + primary_path=params.get("primary_path"), + description=params.get("description"), + icon=params.get("icon"), + color=params.get("color"), + board_slug=params.get("board_slug"), + ) + if params.get("use"): + pdb.set_active(conn, pid) + proj = pdb.get_project(conn, pid) + return _ok(rid, {"project": proj.to_dict() if proj else None}) + + +@_projects_method("projects.update") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.update_project( + conn, + proj.id, + name=params.get("name"), + description=params.get("description"), + icon=params.get("icon"), + color=params.get("color"), + board_slug=params.get("board_slug"), + ) + return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()}) + + +@_projects_method("projects.add_folder") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.add_folder( + conn, + proj.id, + str(params.get("path") or ""), + label=params.get("label"), + is_primary=bool(params.get("is_primary")), + ) + return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()}) + + +@_projects_method("projects.remove_folder") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.remove_folder(conn, proj.id, str(params.get("path") or "")) + return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()}) + + +@_projects_method("projects.set_primary") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.set_primary(conn, proj.id, str(params.get("path") or "")) + return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()}) + + +@_projects_method("projects.archive") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + (pdb.restore_project if params.get("restore") else pdb.archive_project)(conn, proj.id) + return _ok(rid, _projects_payload(conn)) + + +@_projects_method("projects.delete") +def _(rid, params, pdb, conn) -> dict: + proj = _require_project(pdb, conn, params) + pdb.delete_project(conn, proj.id) + return _ok(rid, _projects_payload(conn)) + + +@_projects_method("projects.set_active") +def _(rid, params, pdb, conn) -> dict: + pdb.set_active(conn, _require_project(pdb, conn, params).id if params.get("id") else None) + return _ok(rid, {"active_id": pdb.get_active_id(conn)}) + + +@_projects_method("projects.for_cwd") +def _(rid, params, pdb, conn) -> dict: + cwd = _completion_cwd({"cwd": str(params.get("cwd") or "").strip()} if params.get("cwd") else {}) + proj = pdb.project_for_path(conn, cwd) + return _ok(rid, {"project": proj.to_dict() if proj else None, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)}) + + +def _is_repo_junk(root: str) -> bool: + """A git root we never auto-surface as a project: the bare home dir or + anything under HERMES_HOME (~/.hermes by default) — config/sessions/skills, + not a workspace. User-created projects pointing there are still honored.""" + if not root: + return True + + from hermes_constants import get_hermes_home + + real = os.path.realpath(root) + home = os.path.realpath(os.path.expanduser("~")) + hermes_home = os.path.realpath(str(get_hermes_home())) + + return real == home or real == hermes_home or real.startswith(hermes_home + os.sep) + + +def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]: + """Merge filesystem-scanned repos (cached) with session-derived repo roots. + + Repo-first: the disk scan (persisted by `projects.record_repos`) surfaces + repos even with zero hermes sessions. Session-derived roots cover repos + outside the scan roots. Both are junk-filtered (hermes home subtree + bare + home) and carry their session totals for the overview. + + ``conn`` reuses an already-open projects.db connection (the tree path holds + one); ``backfill`` persists resolved roots back onto session rows — kept off + the per-turn tree path (grouping uses the live git resolver regardless) and + done only on the explicit discover/record refresh. + """ + _is_junk = _is_repo_junk + repos: dict[str, dict] = {} + + def _agg(root: str) -> dict: + return repos.setdefault(root, {"root": root, "label": "", "sessions": 0, "last_active": 0.0}) + + # Session-derived roots (common repo root, folding worktrees; cached) + + # backfill the column so persisted git_repo_root matches the tree grouping. + cwd_rows = list(db.distinct_session_cwds()) + # Warm the per-cwd git probes in parallel so a cold first paint doesn't + # serialize one subprocess per distinct cwd before this loop reads the cache. + git_probe.warm_roots(str(r.get("cwd") or "") for r in cwd_rows) + cwd_to_root: dict[str, str] = {} + for row in cwd_rows: + cwd = str(row.get("cwd") or "") + root = _git_common_repo_root_for_cwd(cwd) + if not root: + continue + cwd_to_root[cwd] = root + if _is_junk(root): + continue + agg = _agg(root) + agg["sessions"] += int(row.get("sessions") or 0) + agg["last_active"] = max(agg["last_active"], float(row.get("last_active") or 0)) + + if backfill: + try: + db.backfill_repo_roots(cwd_to_root) + except Exception: + logger.debug("failed to backfill repo roots", exc_info=True) + + # Filesystem-scanned roots from the cache (may have zero sessions). Reuse the + # caller's projects.db connection when given, else open a short-lived one. + try: + from hermes_cli import projects_db as pdb + + def _read(c) -> None: + for entry in pdb.list_discovered_repos(c): + root = str(entry.get("root") or "") + if not root or _is_junk(root): + continue + agg = _agg(root) + if entry.get("label"): + agg["label"] = entry["label"] + agg["last_active"] = max(agg["last_active"], float(entry.get("last_seen") or 0)) + + if conn is not None: + _read(conn) + else: + with pdb.connect_closing() as own: + _read(own) + except Exception: + logger.debug("failed to read discovered repo cache", exc_info=True) + + out = sorted(repos.values(), key=lambda r: r["last_active"], reverse=True) + for r in out: + r["label"] = r["label"] or os.path.basename(r["root"].rstrip("/\\")) or r["root"] + return out + + +@method("projects.discover_repos") +def _(rid, params: dict) -> dict: + """Repos for the desktop overview: scanned-from-disk (cached) ∪ session-derived.""" + try: + db = _get_db() + if db is None: + return _ok(rid, {"repos": []}) + return _ok(rid, {"repos": _discover_repos_payload(db)}) + except Exception as e: + return _err(rid, 5061, str(e)) + + +@method("projects.record_repos") +def _(rid, params: dict) -> dict: + """Persist git repo roots found by the client's filesystem scan, then return + the merged repo list. The native crawl runs on the desktop (local fs); this + caches the result so later reads are instant instead of re-walking disk.""" + try: + from hermes_cli import projects_db as pdb + + pairs: list[tuple[str, str | None]] = [] + for item in params.get("repos") or []: + if isinstance(item, str): + pairs.append((item, None)) + elif isinstance(item, dict) and item.get("root"): + pairs.append((str(item["root"]), item.get("label"))) + + with pdb.connect_closing() as conn: + pdb.record_discovered_repos(conn, pairs, replace=True) + + db = _get_db() + return _ok(rid, {"repos": _discover_repos_payload(db) if db is not None else []}) + except Exception as e: + return _err(rid, 5061, str(e)) + + +# Sources excluded from the project tree: cron runs and tool/subagent children +# are not user conversations. Subagent/compression children are already dropped +# by list_sessions_rich(include_children=False); cron has its own section. +_PROJECT_TREE_EXCLUDED_SOURCES = ["cron"] + + +def _project_tree_row(r: dict) -> dict: + """Project a SessionDB row to the minimal shape the sidebar renders. + + Keeps the fields the grouping needs (cwd / git_branch / git_repo_root) plus + everything ``SidebarSessionRow`` reads, and drops the heavy columns + (system_prompt, model_config, ...) so the tree payload stays lean. + """ + return { + "id": r.get("id"), + "_lineage_root_id": r.get("_lineage_root_id"), + # The sidebar nests branch/fork sessions under their parent + # (flattenSessionsWithBranches keys on this); without it, lane rows can't + # draw the └─ connector the flat Recents list shows. + "parent_session_id": r.get("parent_session_id"), + "title": r.get("title"), + "preview": r.get("preview"), + "started_at": r.get("started_at") or 0, + "ended_at": r.get("ended_at"), + "last_active": r.get("last_active") or r.get("started_at") or 0, + "source": r.get("source"), + "archived": bool(r.get("archived")), + "message_count": r.get("message_count") or 0, + "tool_call_count": r.get("tool_call_count") or 0, + "input_tokens": r.get("input_tokens") or 0, + "output_tokens": r.get("output_tokens") or 0, + "model": r.get("model"), + "is_active": False, + "cwd": r.get("cwd"), + "git_branch": r.get("git_branch"), + "git_repo_root": r.get("git_repo_root"), + } + + +def _project_tree_inputs( + db, session_limit: int, *, include_discovered: bool +) -> tuple[list[dict], list[dict], list[dict], str | None]: + """Gather (sessions, projects, discovered_repos, active_id) for build_tree. + + ``include_discovered`` is the zero-session-repo overview tier; the entered + view (drill-in) skips it entirely — it only needs the project it's showing, + which already has sessions — avoiding the distinct-cwd scan + git probes on + that per-turn path. One projects.db connection serves both reads. + """ + rows = db.list_sessions_rich( + limit=session_limit, + offset=0, + order_by_last_active=True, + min_message_count=1, + include_children=False, + exclude_sources=_PROJECT_TREE_EXCLUDED_SOURCES, + include_archived=False, + ) + sessions = [_project_tree_row(r) for r in rows] + # Parallel-warm the git cache so build_tree's resolver reads it instead of + # cold-probing each cwd in sequence (matters on the drill-in path, which + # skips the discovery warm-up below). + git_probe.warm_roots(s["cwd"] for s in sessions if s.get("cwd")) + + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + projects = [p.to_dict() for p in pdb.list_projects(conn)] + active_id = pdb.get_active_id(conn) + # backfill stays off the hot tree path — grouping uses the live resolver. + discovered = _discover_repos_payload(db, conn=conn, backfill=False) if include_discovered else [] + + return sessions, projects, discovered, active_id + + +def _build_project_tree( + db, *, preview_limit: int, hydrate: bool, session_limit: int, include_discovered: bool +) -> tuple[dict, str | None]: + """Gather inputs and run the one authoritative builder. Returns (tree, active_id).""" + from tui_gateway import project_tree + + sessions, projects, discovered, active_id = _project_tree_inputs( + db, session_limit, include_discovered=include_discovered + ) + tree = project_tree.build_tree( + projects, + sessions, + discovered, + _resolve_cwd_git, + preview_limit=preview_limit, + hydrate=hydrate, + is_junk_root=_is_repo_junk, + ) + return tree, active_id + + +@method("projects.tree") +def _(rid, params: dict) -> dict: + """Authoritative project overview: project -> repo -> lane structure with + counts + a few preview sessions per project, plus the flat set of session + ids claimed by any project (so the desktop excludes them from flat Recents). + Lanes carry no session rows here; drill-in uses ``projects.project_sessions``. + """ + try: + db = _get_db() + if db is None: + return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []}) + + tree, active_id = _build_project_tree( + db, + preview_limit=int(params.get("preview_limit") or 3), + hydrate=False, + session_limit=int(params.get("session_limit") or 2000), + include_discovered=True, + ) + return _ok( + rid, + {"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]}, + ) + except Exception as e: + return _err(rid, 5061, str(e)) + + +@method("projects.project_sessions") +def _(rid, params: dict) -> dict: + """Fully hydrated lanes (repo -> lane -> session rows) for one project, + built from the same authoritative grouping as ``projects.tree`` so ids and + membership match exactly. Used when the user enters a project.""" + try: + project_id = str(params.get("project_id") or "") + if not project_id: + return _err(rid, 5063, "project_id required") + + db = _get_db() + if db is None: + return _ok(rid, {"project": None}) + + # Drill-in only needs the entered project (which has sessions), so skip + # the zero-session discovery tier entirely. + tree, _active = _build_project_tree( + db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000), + include_discovered=False, + ) + proj = next((p for p in tree["projects"] if p["id"] == project_id), None) + return _ok(rid, {"project": proj}) + except Exception as e: + return _err(rid, 5061, str(e)) + + @method("config.get") def _(rid, params: dict) -> dict: key = params.get("key", "")