128 lines
5.8 KiB
Python
128 lines
5.8 KiB
Python
"""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"}} |