mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(gateway): build authoritative project tree
This commit is contained in:
parent
e7811345c1
commit
4e023f5bc9
7 changed files with 2073 additions and 46 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
352
tests/tui_gateway/test_project_tree.py
Normal file
352
tests/tui_gateway/test_project_tree.py
Normal file
|
|
@ -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 <repo>/.worktrees/<slug> (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"]
|
||||
237
tests/tui_gateway/test_projects_rpc.py
Normal file
237
tests/tui_gateway/test_projects_rpc.py
Normal file
|
|
@ -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))
|
||||
187
tui_gateway/git_probe.py
Normal file
187
tui_gateway/git_probe.py
Normal file
|
|
@ -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 <cwd> <args>`` → 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": <common root>, "worktree_root": <this checkout>}``
|
||||
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))
|
||||
558
tui_gateway/project_tree.py
Normal file
558
tui_gateway/project_tree.py
Normal file
|
|
@ -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_<hex>`` (from projects.db)
|
||||
- auto/discovered project id ... the repo root path
|
||||
- repo node id ................. the repo root path
|
||||
- main branch lane id .......... ``<repoRoot>::branch::<branch>`` (or ``::branch::``)
|
||||
- kanban bucket lane id ........ ``<repoRoot>::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 (`<repo>/.worktrees/t_<hex>`, 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 ``<repo>/.worktrees`` dir for a ``.../.worktrees/<task>`` 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}
|
||||
|
|
@ -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 (``<repo>/.worktrees/<name>``) 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", "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue