diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 32a8fc67a50..b2a552980a6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2535,6 +2535,7 @@ async def get_sessions( order: str = "created", source: str = None, exclude_sources: str = None, + profile: Optional[str] = None, ): """List sessions. @@ -2558,9 +2559,11 @@ async def get_sessions( status_code=400, detail="order must be one of: created, recent", ) + profile_name: Optional[str] = None + if profile: + profile_name, _ = _cron_profile_home(profile) try: - from hermes_state import SessionDB - db = SessionDB() + db = _open_session_db_for_profile(profile) try: min_message_count = max(0, min_messages) archived_only = archived == "only" @@ -2594,11 +2597,16 @@ async def get_sessions( s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) + if profile_name: + s["profile"] = profile_name + s["is_default_profile"] = profile_name == "default" # SQLite stores the flag as 0/1; expose a real JSON boolean. s["archived"] = bool(s.get("archived")) return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} finally: db.close() + except HTTPException: + raise except Exception: _log.exception("GET /api/sessions failed") raise HTTPException(status_code=500, detail="Internal server error") @@ -2724,7 +2732,7 @@ async def get_profiles_sessions( @app.get("/api/sessions/search") -async def search_sessions(q: str = "", limit: int = 20): +async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None): """Search sessions by ID plus full-text message content using FTS5. Direct session-id matches are surfaced first, then FTS message-content @@ -2738,8 +2746,7 @@ async def search_sessions(q: str = "", limit: int = 20): if not q or not q.strip(): return {"results": []} try: - from hermes_state import SessionDB - db = SessionDB() + db = _open_session_db_for_profile(profile) try: safe_limit = max(1, min(int(limit or 20), 100)) @@ -2881,6 +2888,8 @@ async def search_sessions(q: str = "", limit: int = 20): return {"results": list(seen.values())} finally: db.close() + except HTTPException: + raise except Exception: _log.exception("GET /api/sessions/search failed") raise HTTPException(status_code=500, detail="Search failed") @@ -6290,6 +6299,7 @@ def _session_latest_descendant(session_id: str): # reorder this block, move every route in it together. class BulkDeleteSessions(BaseModel): ids: List[str] + profile: Optional[str] = None @app.post("/api/sessions/bulk-delete") @@ -6334,8 +6344,7 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): status_code=400, detail="ids must contain at most 500 entries", ) - from hermes_state import SessionDB - db = SessionDB() + db = _open_session_db_for_profile(body.profile) try: deleted = db.delete_sessions(body.ids) return {"ok": True, "deleted": deleted} @@ -6344,15 +6353,14 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): @app.get("/api/sessions/empty/count") -async def count_empty_sessions_endpoint(): +async def count_empty_sessions_endpoint(profile: Optional[str] = None): """Return the number of empty, ended, non-archived sessions. Drives the dashboard's "Delete empty (N)" button — when N is 0 the UI hides the affordance so users aren't presented with a button that does nothing. Cheap, single-COUNT query. """ - from hermes_state import SessionDB - db = SessionDB() + db = _open_session_db_for_profile(profile) try: return {"count": db.count_empty_sessions()} finally: @@ -6360,7 +6368,7 @@ async def count_empty_sessions_endpoint(): @app.delete("/api/sessions/empty") -async def delete_empty_sessions_endpoint(): +async def delete_empty_sessions_endpoint(profile: Optional[str] = None): """Delete every empty (``message_count == 0``), ended, non-archived session in a single transaction. @@ -6379,8 +6387,7 @@ async def delete_empty_sessions_endpoint(): prune-on-startup pass. Matching that pre-existing trade-off keeps the two delete endpoints' DB-vs-disk behaviour consistent. """ - from hermes_state import SessionDB - db = SessionDB() + db = _open_session_db_for_profile(profile) try: deleted = db.delete_empty_sessions() return {"ok": True, "deleted": deleted} @@ -6389,15 +6396,13 @@ async def delete_empty_sessions_endpoint(): @app.get("/api/sessions/stats") -async def get_session_stats(): +async def get_session_stats(profile: Optional[str] = None): """Session-store statistics for the Sessions page (mirrors `hermes sessions stats`). Registered before ``/api/sessions/{session_id}`` so the literal ``stats`` path isn't captured as a session id by the parameterized route. """ - from hermes_state import SessionDB - - db = SessionDB() + db = _open_session_db_for_profile(profile) try: total = db.session_count(include_archived=True) active_store = db.session_count(include_archived=False) @@ -6535,11 +6540,9 @@ async def rename_session_endpoint(session_id: str, body: SessionRename): @app.get("/api/sessions/{session_id}/export") -async def export_session_endpoint(session_id: str): +async def export_session_endpoint(session_id: str, profile: Optional[str] = None): """Export a single session (metadata + messages) as JSON.""" - from hermes_state import SessionDB - - db = SessionDB() + db = _open_session_db_for_profile(profile) try: sid = db.resolve_session_id(session_id) if not sid: @@ -6555,6 +6558,7 @@ async def export_session_endpoint(session_id: str): class SessionPrune(BaseModel): older_than_days: int = 90 source: Optional[str] = None + profile: Optional[str] = None @app.post("/api/sessions/prune") @@ -6562,11 +6566,10 @@ async def prune_sessions_endpoint(body: SessionPrune): """Delete ended sessions older than N days (mirrors `hermes sessions prune`).""" if body.older_than_days < 1: raise HTTPException(status_code=400, detail="older_than_days must be >= 1") - from hermes_state import SessionDB - - db = SessionDB() + profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home() + db = _open_session_db_for_profile(body.profile) try: - sessions_dir = get_hermes_home() / "sessions" + sessions_dir = profile_home / "sessions" removed = db.prune_sessions( older_than_days=body.older_than_days, source=(body.source or None), @@ -9612,11 +9615,10 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None @app.get("/api/analytics/usage") -async def get_usage_analytics(days: int = 30): - from hermes_state import SessionDB +async def get_usage_analytics(days: int = 30, profile: Optional[str] = None): from agent.insights import InsightsEngine - db = SessionDB() + db = _open_session_db_for_profile(profile) try: cutoff = time.time() - (days * 86400) cur = db._conn.execute(""" @@ -9681,15 +9683,13 @@ async def get_usage_analytics(days: int = 30): @app.get("/api/analytics/models") -async def get_models_analytics(days: int = 30): +async def get_models_analytics(days: int = 30, profile: Optional[str] = None): """Rich per-model analytics for the Models dashboard page. Returns token/cost/session breakdown per model plus capability metadata from models.dev (context window, vision, tools, reasoning, etc.). """ - from hermes_state import SessionDB - - db = SessionDB() + db = _open_session_db_for_profile(profile) try: cutoff = time.time() - (days * 86400) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 4782176caf4..c046a8f2ec7 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -520,6 +520,87 @@ class TestWebServerEndpoints: resp = self.client.get("/api/profiles/sessions?archived=bogus") assert resp.status_code == 400 + def test_sessions_endpoint_reads_requested_profile(self): + """The machine dashboard's global profile switcher must retarget + the Sessions page, not just config/skills/model pages.""" + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + worker_home = profiles_mod.get_profile_dir("worker") + worker_home.mkdir(parents=True) + + default_db = SessionDB() + try: + default_db.create_session(session_id="default-only", source="cli") + default_db.append_message("default-only", role="user", content="default") + finally: + default_db.close() + + worker_db = SessionDB(db_path=worker_home / "state.db") + try: + worker_db.create_session(session_id="worker-only", source="cli") + worker_db.append_message("worker-only", role="user", content="worker") + finally: + worker_db.close() + + resp = self.client.get("/api/sessions?profile=worker&limit=20&min_messages=0") + assert resp.status_code == 200 + data = resp.json() + ids = {s["id"] for s in data["sessions"]} + assert "worker-only" in ids + assert "default-only" not in ids + row = next(s for s in data["sessions"] if s["id"] == "worker-only") + assert row["profile"] == "worker" + assert row["is_default_profile"] is False + + stats = self.client.get("/api/sessions/stats?profile=worker").json() + assert stats["total"] == 1 + assert stats["messages"] == 1 + + messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json() + assert [m["content"] for m in messages["messages"]] == ["worker"] + + def test_analytics_endpoints_read_requested_profile(self): + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + worker_home = profiles_mod.get_profile_dir("worker") + worker_home.mkdir(parents=True) + + default_db = SessionDB() + try: + default_db.create_session(session_id="default-usage", source="cli", model="default/model") + default_db.update_token_counts("default-usage", input_tokens=10, output_tokens=5) + finally: + default_db.close() + + worker_db = SessionDB(db_path=worker_home / "state.db") + try: + worker_db.create_session(session_id="worker-usage", source="cli", model="worker/model") + worker_db.update_token_counts( + "worker-usage", + input_tokens=123, + output_tokens=45, + billing_provider="worker-provider", + ) + finally: + worker_db.close() + + usage = self.client.get("/api/analytics/usage?days=7&profile=worker").json() + assert usage["totals"]["total_sessions"] == 1 + assert usage["totals"]["total_input"] == 123 + assert [m["model"] for m in usage["by_model"]] == ["worker/model"] + + models = self.client.get("/api/analytics/models?days=7&profile=worker").json() + assert models["totals"]["distinct_models"] == 1 + assert models["totals"]["total_input"] == 123 + assert models["models"][0]["model"] == "worker/model" + assert models["models"][0]["provider"] == "worker-provider" + + default_usage = self.client.get("/api/analytics/usage?days=7").json() + assert default_usage["totals"]["total_input"] == 10 + assert default_usage["totals"]["total_output"] == 5 + def test_get_sessions_rejects_unknown_archived_value(self): resp = self.client.get("/api/sessions?archived=bogus") assert resp.status_code == 400 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6af6e8a6cc6..b4390b80729 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -59,11 +59,12 @@ export function getManagementProfile(): string { } // Endpoint families that honor ?profile= on the backend (web_server.py -// _profile_scope). Anything else — sessions, analytics, ops, pairing, -// telegram onboarding, cron (which has its own per-job profile params), -// profiles themselves — is machine-global or self-scoped and must NOT be -// rewritten. +// _profile_scope or explicit per-profile DB opens). Anything else — ops, +// pairing, telegram onboarding, cron (which has its own per-job profile +// params), profiles themselves — is machine-global or self-scoped and must +// NOT be rewritten. const PROFILE_SCOPED_PREFIXES = [ + "/api/analytics", "/api/skills", "/api/tools/toolsets", "/api/config", @@ -302,6 +303,11 @@ function profileQuery(profile?: string): string { return profile ? `?profile=${encodeURIComponent(profile)}` : ""; } +function appendProfileParam(url: string, profile?: string): string { + if (!profile || url.includes("profile=")) return url; + return `${url}${url.includes("?") ? "&" : "?"}profile=${encodeURIComponent(profile)}`; +} + export const api = { getStatus: () => fetchJSON("/api/status"), /** @@ -336,47 +342,64 @@ export const api = { window.location.assign("/login"); return r; }), - getSessions: (limit = 20, offset = 0) => - fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), - getSessionMessages: (id: string) => - fetchJSON(`/api/sessions/${encodeURIComponent(id)}/messages`), + getSessions: (limit = 20, offset = 0, profile = getManagementProfile()) => + fetchJSON( + appendProfileParam(`/api/sessions?limit=${limit}&offset=${offset}`, profile), + ), + getSessionMessages: (id: string, profile = getManagementProfile()) => + fetchJSON( + appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/messages`, profile), + ), getSessionLatestDescendant: (id: string) => fetchJSON( `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, ), - deleteSession: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, { - method: "DELETE", - }), - getEmptySessionsCount: () => - fetchJSON<{ count: number }>("/api/sessions/empty/count"), - deleteEmptySessions: () => - fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", { - method: "DELETE", - }), - bulkDeleteSessions: (ids: string[]) => + deleteSession: (id: string, profile = getManagementProfile()) => + fetchJSON<{ ok: boolean }>( + appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile), + { + method: "DELETE", + }, + ), + getEmptySessionsCount: (profile = getManagementProfile()) => + fetchJSON<{ count: number }>( + appendProfileParam("/api/sessions/empty/count", profile), + ), + deleteEmptySessions: (profile = getManagementProfile()) => + fetchJSON<{ ok: boolean; deleted: number }>( + appendProfileParam("/api/sessions/empty", profile), + { + method: "DELETE", + }, + ), + bulkDeleteSessions: (ids: string[], profile = getManagementProfile()) => fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ids }), + body: JSON.stringify({ ids, profile: profile || undefined }), }), - renameSession: (id: string, title: string) => + renameSession: (id: string, title: string, profile = getManagementProfile()) => fetchJSON<{ ok: boolean; title: string }>( `/api/sessions/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title }), + body: JSON.stringify({ title, profile: profile || undefined }), }, ), - getSessionStats: () => fetchJSON("/api/sessions/stats"), - exportSessionUrl: (id: string) => - `/api/sessions/${encodeURIComponent(id)}/export`, - pruneSessions: (older_than_days: number, source?: string) => + getSessionStats: (profile = getManagementProfile()) => + fetchJSON(appendProfileParam("/api/sessions/stats", profile)), + exportSessionUrl: (id: string, profile = getManagementProfile()) => + appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile), + pruneSessions: ( + older_than_days: number, + source?: string, + profile = getManagementProfile(), + ) => fetchJSON<{ ok: boolean; removed: number }>("/api/sessions/prune", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ older_than_days, source }), + body: JSON.stringify({ older_than_days, source, profile: profile || undefined }), }), listFiles: (path?: string) => { const query = path ? `?path=${encodeURIComponent(path)}` : ""; @@ -412,10 +435,14 @@ export const api = { if (params.component && params.component !== "all") qs.set("component", params.component); return fetchJSON(`/api/logs?${qs.toString()}`); }, - getAnalytics: (days: number) => - fetchJSON(`/api/analytics/usage?days=${days}`), - getModelsAnalytics: (days: number) => - fetchJSON(`/api/analytics/models?days=${days}`), + getAnalytics: (days: number, profile = getManagementProfile()) => + fetchJSON( + appendProfileParam(`/api/analytics/usage?days=${days}`, profile), + ), + getModelsAnalytics: (days: number, profile = getManagementProfile()) => + fetchJSON( + appendProfileParam(`/api/analytics/models?days=${days}`, profile), + ), getConfig: () => fetchJSON>("/api/config"), getDefaults: () => fetchJSON>("/api/config/defaults"), getSchema: () => fetchJSON<{ fields: Record; category_order: string[] }>("/api/config/schema"), @@ -680,8 +707,10 @@ export const api = { ), // Session search (FTS5) - searchSessions: (q: string) => - fetchJSON(`/api/sessions/search?q=${encodeURIComponent(q)}`), + searchSessions: (q: string, profile = getManagementProfile()) => + fetchJSON( + appendProfileParam(`/api/sessions/search?q=${encodeURIComponent(q)}`, profile), + ), // OAuth provider management getOAuthProviders: () =>