diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index 31197c9720..145806c6eb 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -241,13 +241,18 @@ def get_profiles_sessions_sidebar( """Batched sidebar session slices — one profile-DB open per refresh. The desktop sidebar needs three source-scoped windows per refresh: recents - (local chats, scoped to the active profile), cron sessions (all profiles), - and messaging-platform sessions (all profiles). Served as three separate - ``/api/profiles/sessions`` calls they reopened every profile's ``state.db`` - three times and re-counted each refresh. This opens each DB once and runs - the three filtered queries together, returning the three windows in one - payload. Read-only and process-light, same row projection and 300s active - heuristic as ``/api/profiles/sessions``. + (local chats), cron sessions, and messaging-platform sessions. Served as + three separate ``/api/profiles/sessions`` calls they reopened every + profile's ``state.db`` three times and re-counted each refresh. This opens + each DB once and runs the three filtered queries together, returning the + three windows in one payload. Read-only and process-light, same row + projection and 300s active heuristic as ``/api/profiles/sessions``. + + ``recents_profile`` scopes the whole payload, not just recents. Cron and + messaging used to come back cross-profile unconditionally, which is what + made a concrete profile show another profile's Telegram threads and + cronjobs (#65710, #42651, #70629) — the sidebar has one scope, so every + slice answers to it, and ``all`` is how the caller asks for everything. The caller passes the source taxonomy (``recents_exclude`` / ``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays @@ -257,8 +262,6 @@ def get_profiles_sessions_sidebar( """ from hermes_cli import profiles as profiles_mod - # cron + messaging are cross-profile; recents is scoped to recents_profile. - # Scan every profile once regardless (each DB opened a single time). try: infos = profiles_mod.list_profiles() targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos] @@ -280,6 +283,7 @@ def get_profiles_sessions_sidebar( cron_rows: List[Dict[str, Any]] = [] messaging_rows: List[Dict[str, Any]] = [] recents_truncated: Dict[str, bool] = {} + profile_totals: Dict[str, Dict[str, float]] = {} errors: List[Dict[str, str]] = [] now = time.time() @@ -314,6 +318,8 @@ def get_profiles_sessions_sidebar( ) for name, home in targets: + if recents_scope != "all" and name != recents_scope: + continue db_path = Path(home) / "state.db" if not db_path.exists(): continue @@ -327,16 +333,19 @@ def get_profiles_sessions_sidebar( errors.append({"profile": name, "error": str(exc)}) continue try: - if recents_scope == "all" or name == recents_scope: - profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap) - # A full window means more rows remain on disk. That is all the - # sidebar's "load more" needs, and unlike an exact COUNT(*) per - # profile per refresh it costs nothing beyond the rows already - # read. Discount pinned back-fills — they arrive past the LIMIT - # and would otherwise fake a full page on a short list. - unpinned_count = sum(1 for s in profile_rows if not s.get("pinned")) - recents_truncated[name] = unpinned_count >= recents_cap - recents_rows.extend(_tag(profile_rows, name)) + profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap) + # A full window means more rows remain on disk. That is all the + # sidebar's "load more" needs, and unlike an exact COUNT(*) per + # profile per refresh it costs nothing beyond the rows already + # read. Discount pinned back-fills — they arrive past the LIMIT + # and would otherwise fake a full page on a short list. + unpinned_count = sum(1 for s in profile_rows if not s.get("pinned")) + recents_truncated[name] = unpinned_count >= recents_cap + recents_rows.extend(_tag(profile_rows, name)) + # Aggregated in SQL rather than over the window above: the window is + # a page, and a total that shrank when you scrolled would be worse + # than no total at all. + profile_totals[name] = db.usage_totals() cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name)) messaging_rows.extend( _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) @@ -363,6 +372,7 @@ def get_profiles_sessions_sidebar( "recents": { "sessions": _window(recents_rows, recents_cap), "profiles_truncated": recents_truncated, + "profiles_usage": profile_totals, }, "cron": {"sessions": _window(cron_rows, cron_cap)}, "messaging": { @@ -373,6 +383,156 @@ def get_profiles_sessions_sidebar( } +def _merge_by_id(into: Dict[str, Dict[str, Any]], entries: List[Dict[str, Any]], child_key: str) -> None: + """Fold ``entries`` into ``into`` by id, recursing through one child list. + + Repos merge their lanes, lanes merge their sessions. Counts add up and the + newest activity wins; everything else is first-writer, since the entries + describe the same path either way. + """ + for entry in entries: + existing = into.get(entry["id"]) + if existing is None: + into[entry["id"]] = entry + continue + if child_key == "sessions": + existing["sessions"].extend(entry.get("sessions") or []) + else: + children: Dict[str, Dict[str, Any]] = {c["id"]: c for c in existing.get(child_key) or []} + _merge_by_id(children, entry.get(child_key) or [], "sessions") + existing[child_key] = list(children.values()) + if "sessionCount" in existing: + existing["sessionCount"] = (existing.get("sessionCount") or 0) + (entry.get("sessionCount") or 0) + + +def _merge_profile_tree( + merged: Dict[str, Dict[str, Any]], + projects: List[Dict[str, Any]], + profile: str, + preview_limit: int, +) -> None: + """Fold one profile's projects into the shared tree, keyed by folder. + + The same checkout in two profiles is one group, as is ``__no_project__``, + which every profile has and which would otherwise put a "Home" on screen per + profile. Keying on the path rather than the id also folds a profile's + declared project (``p_``) together with the auto entry another profile + grows for the same folder. Sessions carry the owning profile instead, which + is what the row badge and the profile filter read; a group header never + claims a single owner. + """ + for project in projects: + for lane in (repo for r in project.get("repos") or [] for repo in r.get("groups") or []): + for session in lane.get("sessions") or []: + session["profile"] = profile + session["is_default_profile"] = profile == "default" + for session in project.get("previewSessions") or []: + session["profile"] = profile + session["is_default_profile"] = profile == "default" + + key = project.get("path") or project["id"] + existing = merged.get(key) + if existing is None: + merged[key] = project + continue + + # A declared project carries the label, color and icon the user chose, + # so it wins the identity when it meets another profile's auto entry. + if existing.get("isAuto") and not project.get("isAuto"): + existing, project = project, existing + merged[key] = existing + + repos: Dict[str, Dict[str, Any]] = {r["id"]: r for r in existing.get("repos") or []} + _merge_by_id(repos, project.get("repos") or [], "groups") + existing["repos"] = list(repos.values()) + existing["sessionCount"] = (existing.get("sessionCount") or 0) + (project.get("sessionCount") or 0) + existing["totalTokens"] = (existing.get("totalTokens") or 0) + (project.get("totalTokens") or 0) + existing["totalCostUsd"] = (existing.get("totalCostUsd") or 0) + (project.get("totalCostUsd") or 0) + existing["lastActive"] = max(existing.get("lastActive") or 0, project.get("lastActive") or 0) + previews = (existing.get("previewSessions") or []) + (project.get("previewSessions") or []) + previews.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True) + existing["previewSessions"] = previews[:preview_limit] + + +@sessions_router.get("/api/profiles/projects/tree") +def get_profiles_projects_tree(preview_limit: int = 3, session_limit: int = 2000): + """Project tree for every profile at once, for the all-profiles sidebar. + + ``projects.tree`` over JSON-RPC answers for the backend's own profile, so + the grouped sidebar had nothing to draw once the user asked for all of + them. This runs the same authoritative builder once per profile against + that profile's ``state.db``, scoping the rest of its inputs — projects.db, + the repo-scan policy, the HERMES_HOME junk filters — through the + context-local home override the profile-scoped writers already use. + + Projects merge by id across profiles, so a group stands for a checkout + rather than a checkout-and-owner, and the profile shows up per row where + the filter can act on it. + + Discovery is off. A repo with zero sessions is the same repo in every + profile, so folding the disk scan in would multiply empty lanes by the + profile count — and it is the one part of the builder that writes + (policy reconciliation), which this read-only fan-out should not do to a + profile the user is not driving. + """ + from hermes_cli import profiles as profiles_mod + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from tui_gateway import server as gateway_server + + try: + targets: List[Tuple[str, Path]] = [ + (info.name, info.path) for info in profiles_mod.list_profiles() + ] + except Exception: + _log.exception("GET /api/profiles/projects/tree: list_profiles failed") + targets = [] + if not targets: + targets.append(("default", profiles_mod.get_profile_dir("default"))) + + merged: Dict[str, Dict[str, Any]] = {} + scoped_session_ids: List[str] = [] + errors: List[Dict[str, str]] = [] + + for name, home in targets: + db_path = Path(home) / "state.db" + if not db_path.exists(): + continue + try: + db = _open_session_db_at_path(db_path, read_only=True) + except Exception as exc: + _warn_profile_read_error(name, exc) + errors.append({"profile": name, "error": str(exc)}) + continue + + token = set_hermes_home_override(str(home)) + try: + tree, _active_id = gateway_server._build_project_tree( + db, + preview_limit=preview_limit, + hydrate=False, + session_limit=session_limit, + include_discovered=False, + ) + _merge_profile_tree(merged, tree["projects"], name, preview_limit) + scoped_session_ids.extend(tree["scoped_session_ids"]) + except Exception as exc: + _warn_profile_read_error(name, exc) + errors.append({"profile": name, "error": str(exc)}) + finally: + reset_hermes_home_override(token) + db.close() + + projects = sorted(merged.values(), key=lambda p: p.get("lastActive") or 0, reverse=True) + return { + "projects": projects, + # Ownership is per profile, so no single project is "the active one" + # here; the desktop only reads active_id to bias its overview sort. + "active_id": None, + "scoped_session_ids": scoped_session_ids, + "errors": errors, + } + + # `gh pr create` prints the PR url and nothing else, so a tool result whose # whole output IS a PR url means this session opened that PR. Anything looser — # a url inside prose, a `gh pr view` payload, an issue link — is a session diff --git a/hermes_state.py b/hermes_state.py index 27aaee7a4b..18c8e1aec7 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -6886,6 +6886,35 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) ) _session_compact_cols_sql: Optional[str] = None + def usage_totals(self, *, min_message_count: int = 1, include_archived: bool = False) -> Dict[str, float]: + """Tokens and spend across this store, as one aggregate. + + The sidebar shows a profile's totals beside a page of its sessions, so + summing the rows it happens to have loaded would report a fraction of + the truth and shrink as paging changed. SQLite adds the columns up over + every row instead, at the cost of one scan. + + Spend is the billed figure when the provider returned one and the + estimate otherwise — the same precedence a single row renders. + """ + where = ["parent_session_id IS NULL", "message_count >= ?"] + params: List[Any] = [min_message_count] + if not include_archived: + where.append("COALESCE(archived, 0) = 0") + + with self._read_ctx() as conn: + row = conn.execute( + f""" + SELECT COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0), + COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) + FROM sessions + WHERE {' AND '.join(where)} + """, + params, + ).fetchone() + + return {"tokens": int(row[0] or 0), "cost_usd": float(row[1] or 0.0)} + def list_sessions_rich( self, source: str = None, diff --git a/tests/hermes_cli/test_profiles_sidebar_scope.py b/tests/hermes_cli/test_profiles_sidebar_scope.py new file mode 100644 index 0000000000..75309fbd00 --- /dev/null +++ b/tests/hermes_cli/test_profiles_sidebar_scope.py @@ -0,0 +1,248 @@ +"""The sidebar's profile scope, across both endpoints that serve it. + +Two behaviors that only show up with more than one profile on disk: + +* ``/api/profiles/sessions/sidebar`` must answer to one scope for all three of + its slices. Cron and messaging ignoring it is what made a concrete profile + show another profile's Telegram threads and cronjobs (#65710, #42651, + #70629). +* ``/api/profiles/projects/tree`` must build each profile's tree from that + profile's own state.db AND its own projects.db, and hand back ids that can + coexist in one list. +""" + +import pytest + + +@pytest.fixture +def profiles_on_disk(tmp_path, monkeypatch, _isolate_hermes_home): + """An isolated default home plus one named profile, each with a state.db.""" + from hermes_cli import profiles + from hermes_constants import get_hermes_home + + default_home = get_hermes_home() + profiles_root = default_home / "profiles" + worker_home = profiles_root / "worker" + + for home in (default_home, worker_home): + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("{}\n", encoding="utf-8") + + monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) + monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root) + + return {"default": default_home, "worker": worker_home} + + +@pytest.fixture +def client(monkeypatch, profiles_on_disk): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app + from hermes_constants import get_hermes_home + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") + c = TestClient(app) + c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + return c + + +def _seed_session(home, session_id, *, source, cwd=None, tokens=None, cost=None): + """One session with a message, so it clears the sidebar's min_messages=1. + + ``cwd`` is what attaches it to a project — without one it lands in Home. + ``tokens`` is an (input, output) pair; both it and ``cost`` are written + straight to the row, the shape a finished turn leaves behind. + """ + import sqlite3 + + from hermes_state import SessionDB + + db = SessionDB(db_path=home / "state.db") + try: + db.create_session(session_id, source=source, cwd=str(cwd) if cwd else None) + db.append_message(session_id=session_id, role="user", content="hi") + finally: + db.close() + + if tokens is None and cost is None: + return + + conn = sqlite3.connect(home / "state.db") + try: + conn.execute( + "UPDATE sessions SET input_tokens = ?, output_tokens = ?, estimated_cost_usd = ? WHERE id = ?", + (*(tokens or (0, 0)), cost or 0.0, session_id), + ) + conn.commit() + finally: + conn.close() + + +def _seed_project(home, name, folder): + from hermes_cli import projects_db + + with projects_db.connect_closing(db_path=home / "projects.db") as conn: + return projects_db.create_project(conn, name=name, folders=[str(folder)]) + + +def _slice_ids(payload, slice_name): + return {row["id"] for row in payload[slice_name]["sessions"]} + + +class TestSidebarScope: + + def test_concrete_profile_sees_only_its_own_slices(self, client, profiles_on_disk): + _seed_session(profiles_on_disk["default"], "default-chat", source="cli") + _seed_session(profiles_on_disk["default"], "default-cron", source="cron") + _seed_session(profiles_on_disk["default"], "default-telegram", source="telegram") + _seed_session(profiles_on_disk["worker"], "worker-chat", source="cli") + _seed_session(profiles_on_disk["worker"], "worker-cron", source="cron") + _seed_session(profiles_on_disk["worker"], "worker-telegram", source="telegram") + + payload = client.get( + "/api/profiles/sessions/sidebar", + params={"recents_profile": "worker", "recents_exclude": "cron,telegram", "messaging_exclude": "cli,cron"}, + ).json() + + assert payload["errors"] == [] + assert _slice_ids(payload, "recents") == {"worker-chat"} + # The bug: these two used to come back with the default profile's rows + # folded in, whatever scope the sidebar asked for. + assert _slice_ids(payload, "cron") == {"worker-cron"} + assert _slice_ids(payload, "messaging") == {"worker-telegram"} + + def test_all_scope_still_spans_every_profile(self, client, profiles_on_disk): + _seed_session(profiles_on_disk["default"], "default-telegram", source="telegram") + _seed_session(profiles_on_disk["worker"], "worker-telegram", source="telegram") + + payload = client.get( + "/api/profiles/sessions/sidebar", + params={"recents_profile": "all", "messaging_exclude": "cli,cron"}, + ).json() + + assert _slice_ids(payload, "messaging") == {"default-telegram", "worker-telegram"} + assert {row["profile"] for row in payload["messaging"]["sessions"]} == {"default", "worker"} + + +class TestCrossProfileProjectTree: + + def test_one_folder_worked_in_by_two_profiles_is_one_project(self, client, profiles_on_disk, tmp_path): + # A folder is a folder no matter who opened it. Two profiles working the + # same checkout is the normal case (that's the point of profiles), so it + # heads ONE group carrying both their sessions — not one group each. + shared = tmp_path / "repos" / "shared" + shared.mkdir(parents=True) + + for name, home in profiles_on_disk.items(): + _seed_session(home, f"{name}-chat", source="cli", cwd=shared) + _seed_project(home, "Shared", shared) + + payload = client.get("/api/profiles/projects/tree").json() + + assert payload["errors"] == [] + + declared = [project for project in payload["projects"] if not project["isNoProject"]] + assert [project["path"] for project in declared] == [str(shared)] + assert declared[0]["sessionCount"] == 2 + + def test_group_totals_add_up_the_sessions_the_group_counts(self, client, profiles_on_disk, tmp_path): + # A header total is only meaningful if it covers exactly what the header + # says it counts — a project's totals span every profile working it, the + # same set `sessionCount` reports. + shared = tmp_path / "repos" / "shared" + shared.mkdir(parents=True) + + for name, home in profiles_on_disk.items(): + _seed_session(home, f"{name}-chat", source="cli", cwd=shared, tokens=(100, 20), cost=0.25) + _seed_project(home, "Shared", shared) + + payload = client.get("/api/profiles/projects/tree").json() + project = next(p for p in payload["projects"] if not p["isNoProject"]) + + assert project["sessionCount"] == 2 + assert project["totalTokens"] == 240 + assert project["totalCostUsd"] == pytest.approx(0.5) + + def test_profile_usage_covers_sessions_past_the_window(self, client, profiles_on_disk): + # The whole point of aggregating in SQL: the total must not be a sum of + # whichever page the sidebar happens to have asked for. + for index in range(3): + _seed_session( + profiles_on_disk["worker"], f"worker-{index}", source="cli", tokens=(10, 5), cost=1.5 + ) + + payload = client.get( + "/api/profiles/sessions/sidebar", params={"recents_profile": "all", "recents_limit": 1} + ).json() + + assert payload["recents"]["profiles_usage"]["worker"] == { + "cost_usd": pytest.approx(4.5), + "tokens": 45, + } + + def test_home_is_one_bucket_across_profiles(self, client, profiles_on_disk): + # Every profile builds its own unowned-sessions bucket. Merging by id is + # what keeps the sidebar from stacking N identical "Home" rows. + for name, home in profiles_on_disk.items(): + _seed_session(home, f"{name}-chat", source="cli") + + payload = client.get("/api/profiles/projects/tree").json() + + homes = [project for project in payload["projects"] if project["isNoProject"]] + assert len(homes) == 1 + assert homes[0]["sessionCount"] == 2 + + def test_each_profile_contributes_its_own_projects_db(self, client, profiles_on_disk, tmp_path): + """Proves the per-profile scoping, not just that two trees got merged. + + The builder reads projects.db, the repo-scan policy and the junk + filters through ``get_hermes_home()``. If the fan-out failed to rebind + it per profile, every tree would come back describing whichever home + the process happens to be running as. + """ + for name in profiles_on_disk: + (tmp_path / "repos" / f"only-{name}").mkdir(parents=True) + _seed_session(profiles_on_disk[name], f"{name}-chat", source="cli") + _seed_project(profiles_on_disk[name], f"Only {name}", tmp_path / "repos" / f"only-{name}") + + payload = client.get("/api/profiles/projects/tree").json() + + labels = {project["label"] for project in payload["projects"] if not project["isNoProject"]} + + assert labels == {"Only default", "Only worker"} + + def test_a_profile_that_cannot_be_read_does_not_sink_the_rest( + self, client, profiles_on_disk, tmp_path, monkeypatch + ): + (tmp_path / "repos" / "healthy").mkdir(parents=True) + # A state.db has to exist for a profile to be visited at all. + for name, home in profiles_on_disk.items(): + _seed_session(home, f"{name}-chat", source="cli") + _seed_project(profiles_on_disk["default"], "Healthy", tmp_path / "repos" / "healthy") + + from tui_gateway import server as gateway_server + + real_build = gateway_server._build_project_tree + + def explode_for_worker(db, **kwargs): + from hermes_constants import get_hermes_home + + if get_hermes_home().name == "worker": + raise RuntimeError("worker store is unreadable") + + return real_build(db, **kwargs) + + monkeypatch.setattr(gateway_server, "_build_project_tree", explode_for_worker) + + payload = client.get("/api/profiles/projects/tree").json() + + assert [error["profile"] for error in payload["errors"]] == ["worker"] + # The healthy profile's tree still lands; only the broken one drops out. + assert "Healthy" in [project["label"] for project in payload["projects"]] + assert [project["sessionCount"] for project in payload["projects"] if project["isNoProject"]] == [1] diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index 2991124a63..cd5c966a55 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -505,6 +505,15 @@ def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[R # --------------------------------------------------------------------------- +def _session_cost(session: dict) -> float: + """A session's spend, billed if the provider reported it, else estimated.""" + for key in ("actual_cost_usd", "estimated_cost_usd"): + value = session.get(key) + if value: + return float(value) + return 0.0 + + def _project_node( *, pid: str, @@ -514,6 +523,7 @@ def _project_node( session_count: int, last_active: float, preview_sessions: list[dict], + sessions: Optional[list[dict]] = None, color: Any = None, icon: Any = None, is_auto: bool = False, @@ -529,6 +539,11 @@ def _project_node( "isNoProject": is_no_project, "sessionCount": session_count, "lastActive": last_active, + # Totals over the same sessions `sessionCount` counts, so a project's + # header can add up what its rows show. The window the caller loaded is + # the whole truth either way — count and totals can't disagree. + "totalTokens": sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions or []), + "totalCostUsd": sum(_session_cost(s) for s in sessions or []), "repos": repos, "previewSessions": preview_sessions, } @@ -612,6 +627,7 @@ def build_tree( session_count=len(psessions), last_active=_last_active(psessions), preview_sessions=_previews(psessions), + sessions=psessions, ) ) @@ -693,6 +709,7 @@ def build_tree( session_count=repo_node["sessionCount"], last_active=_last_active(auto_sessions), preview_sessions=_previews(auto_sessions), + sessions=auto_sessions, is_auto=True, ) ) @@ -761,6 +778,7 @@ def build_tree( session_count=len(homeless), last_active=_last_active(homeless), preview_sessions=_previews(homeless), + sessions=homeless, is_no_project=True, ), ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 874e8d3188..48585a715c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -11928,6 +11928,11 @@ def _project_tree_row(r: dict) -> dict: "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, + # Cost is one of the fields SidebarSessionRow renders, so a lane row has + # to carry it too — without it, switching Show → cost on filled every + # figure in Recents and left the same sessions blank under a project. + "actual_cost_usd": r.get("actual_cost_usd"), + "estimated_cost_usd": r.get("estimated_cost_usd"), "model": r.get("model"), "is_active": False, "cwd": r.get("cwd"),