"""Independent, metadata-only project scope for history retrieval. The caller supplies the current profile's SessionDB and an exact session id. Only ``status == 'ready'`` authorizes ``allowed_session_ids``. Re-resolve immediately before returning history and compare ``revision``; this is an optimistic revision, not a transaction spanning projects.db, state.db, and the filesystem. No active-project pointer, process cwd, transcript, or plugin data is consulted. """ from __future__ import annotations import hashlib import json import os import sqlite3 from contextlib import closing from functools import lru_cache from hermes_cli import projects_db from hermes_constants import get_hermes_home from tui_gateway import git_probe from tui_gateway.project_tree import SessionProjectOwnership, base_name _EXCLUDED_SOURCES = frozenset({"kanban", "subagent", "tool", "cron"}) def _projects() -> list[dict]: path = projects_db.projects_db_path() if not path.exists(): return [] with closing(sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True)) as conn: conn.row_factory = sqlite3.Row conn.execute("BEGIN") return [p.to_dict() for p in projects_db.list_projects(conn, include_archived=True)] def _ownership(projects: list[dict]) -> SessionProjectOwnership: # Match Desktop's host policy without importing the RPC/server binding layer. home = os.path.realpath(os.path.expanduser("~")) broad = {os.path.normcase(os.path.realpath(p)) for p in (os.sep, home, os.path.dirname(home), "/home", "/Users") if p} hermes_home = os.path.normcase(os.path.realpath(str(get_hermes_home()))) def junk_cwd(path): real = os.path.normcase(os.path.realpath(path)) return not path or real in broad or real == hermes_home def junk_root(path): real = os.path.normcase(os.path.realpath(path)) return junk_cwd(path) or real.startswith(hermes_home + os.sep) return SessionProjectOwnership( projects, lru_cache(maxsize=None)(git_probe.resolve), is_junk_root=lru_cache(maxsize=None)(junk_root), is_junk_cwd=lru_cache(maxsize=None)(junk_cwd), exists=lru_cache(maxsize=None)(os.path.isdir)) def resolve_project_scope(db, current_session_id: str) -> dict: """Resolve one session and enumerate every same-project session metadata row. Returns JSON-safe ``status``, ``project_key``, ``label``, ``revision``, ``allowed_session_ids`` and ``coverage``. Only ``ready`` authorizes IDs; ``scope_unresolved`` and ``scope_ambiguous`` deny. Database/probe errors propagate: callers must fail closed, never fall back to unscoped search. Hidden/background rows are excluded; ordinary archived rows are included. User IDs match exactly; absent/empty principals match only other absent/empty principals in this local profile DB, not identified users. """ projects = _projects() ownership = _ownership(projects) digest = hashlib.sha256() def stamp(value): digest.update(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")) digest.update(b"\n") stamp({"version": 1, "profile_home": str(get_hermes_home().resolve()), "current_session_id": current_session_id}) for project in sorted(projects, key=lambda p: p["id"]): project["folders"] = sorted(project["folders"], key=lambda f: f["path"]) stamp(project) buckets: dict[tuple, list[str]] = {} current = {"status": "session_not_found", "project_key": None, "project_kind": None} current_principal = None current_eligible = False scanned = 0 with db._read_ctx() as conn: cursor = conn.execute( "SELECT id, cwd, git_repo_root, git_branch, parent_session_id, profile_name, " "source, user_id, archived, hidden FROM sessions ORDER BY id") try: for raw in cursor: row = dict(raw) scanned += 1 assigned = ownership.classify(row) stamp([row, assigned]) principal = row["user_id"] or None eligible = not row["hidden"] and row["source"] not in _EXCLUDED_SOURCES if row["id"] == current_session_id: current = assigned current_principal = principal current_eligible = eligible if assigned["status"] == "ok" and eligible: key = (ownership.identity(assigned), principal) buckets.setdefault(key, []).append(row["id"]) finally: cursor.close() status = {"ok": "ready", "ambiguous": "scope_ambiguous"}.get( current["status"], "scope_unresolved") if not current_eligible: status = "scope_unresolved" allowed = buckets.get((ownership.identity(current), current_principal), []) if status == "ready" else [] project_key = current["project_key"] if status == "ready" else None label = None if project_key: label = next((p["name"] for p in projects if p["id"] == project_key), None) label = label or base_name(project_key) or project_key return { "status": status, "project_key": project_key, "label": label, "revision": digest.hexdigest(), "allowed_session_ids": allowed, "coverage": {"metadata_complete": True, "scanned_sessions": scanned, "allowed_sessions": len(allowed), "archived_included": True, "excluded_sources": sorted(_EXCLUDED_SOURCES), "hidden_excluded": True, "principal_policy": "same_user_id" if current_principal else "unknown_local_only", "project_kind": current["project_kind"], "resolution": current["status"], "revision_policy": "optimistic_metadata_and_cached_git"}}