5b68d2271b
`projects.tree` answers for the backend's own profile, so the grouped sidebar had nothing to draw once the user asked to see every profile. Run the same authoritative builder once per profile against that profile's state.db and merge the results by folder, so one checkout is one group no matter how many profiles work in it, and the owning profile rides on each session row where the badge and filter can read it. Group totals are summed in SQL rather than over the loaded page — a number that shrank as you scrolled would be worse than no number. Scope the batched sidebar slices while we're here: cron and messaging came back cross-profile unconditionally, which is why a concrete profile showed another profile's Telegram threads and cronjobs. Closes #65710 Closes #42651 Closes #70629
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""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]
|