Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
"""Authoritative project -> repo -> lane -> session tree builder.
|
||||
|
||||
This is the single source of truth for how the desktop sidebar groups sessions
|
||||
into projects, repos, and lanes. It is pure (all git resolution is injected via
|
||||
``resolve``) so it can be unit-tested with fixtures and reused by the gateway's
|
||||
``projects.tree`` / ``projects.project_sessions`` RPCs.
|
||||
|
||||
It deliberately mirrors the desktop's former client-side grouping (the old
|
||||
``workspace-groups.ts``) so the emitted ids and lane keys stay byte-compatible
|
||||
with the renderer's persisted state (pins, manual ordering, dismissal), which
|
||||
all key off these exact strings:
|
||||
|
||||
- explicit project id .......... ``p_<hex>`` (from projects.db)
|
||||
- auto/discovered project id ... the repo root path
|
||||
- home (no-project) bucket ..... ``__no_project__``
|
||||
- repo node id ................. the repo root path
|
||||
- main branch lane id .......... ``<repoRoot>::branch::<branch>`` (or ``::branch::``)
|
||||
- kanban bucket lane id ........ ``<repoRoot>::kanban``
|
||||
- linked worktree lane id ...... the worktree path
|
||||
|
||||
The one correctness upgrade over the client version: linked worktrees are folded
|
||||
under their MAIN repo via a git common-dir probe (injected as ``resolve``),
|
||||
instead of being treated as separate repos (``git rev-parse --show-toplevel``
|
||||
returns the worktree's own root, which is why the client double-counted them).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# A cwd -> git identity resolver. Returns ``{"repo_root", "worktree_root"}`` where
|
||||
# ``repo_root`` is the COMMON (main) repo root shared across worktrees and
|
||||
# ``worktree_root`` is this cwd's own checkout root. Returns ``None`` when the
|
||||
# cwd is not in a git repo (or cannot be probed, e.g. a remote backend).
|
||||
Resolve = Callable[[str], Optional[dict]]
|
||||
|
||||
# A "does this directory still exist?" predicate, injected for the same reason
|
||||
# ``Resolve`` is: the builder stays pure and unit-testable. Defaults to assuming
|
||||
# everything exists, which preserves the previous behavior for callers that
|
||||
# can't stat (remote backends), where guessing "gone" would wrongly hide a
|
||||
# project that lives on the other host.
|
||||
Exists = Callable[[str], bool]
|
||||
|
||||
# Only KANBAN-TASK worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db
|
||||
# mints) collapse into one lane; user-named "New worktree" dirs under
|
||||
# `.worktrees/` stay as their own lanes.
|
||||
_KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
|
||||
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
|
||||
DEFAULT_BRANCH_LABEL = "main"
|
||||
|
||||
# The synthetic bucket holding every session no project claimed — a chat with no
|
||||
# cwd at all, or one whose folder can't be promoted (the bare home dir, HERMES
|
||||
# state, a workspace that has since been deleted). Without it those sessions are
|
||||
# invisible in the grouped view. The desktop labels it "Home"; the id/flag stay
|
||||
# named for what the bucket MEANS, since that's what membership keys off.
|
||||
NO_PROJECT_ID = "__no_project__"
|
||||
NO_PROJECT_LABEL = "Home"
|
||||
|
||||
# How many sibling candidates to try when recovering a deleted worktree's parent
|
||||
# repo (see ``_probe_sibling_worktree``). Each miss costs a git probe, so keep it
|
||||
# tight — real suffixes are one or two segments.
|
||||
_MAX_SIBLING_PROBES = 4
|
||||
|
||||
|
||||
def stamp_profile(projects: list[dict], profile: str) -> None:
|
||||
"""Make every session row self-describing for cross-profile routing.
|
||||
|
||||
A scoped project tree is built from one profile's state.db, so the request
|
||||
scope is authoritative even for legacy rows whose ``profile_name`` is NULL.
|
||||
"""
|
||||
for project in projects:
|
||||
for session in project.get("previewSessions") or []:
|
||||
session["profile"] = profile
|
||||
for repo in project.get("repos") or []:
|
||||
for group in repo.get("groups") or []:
|
||||
for session in group.get("sessions") or []:
|
||||
session["profile"] = profile
|
||||
|
||||
|
||||
def _branch_lane_id(repo_root: str, branch: str = "") -> str:
|
||||
"""The one definition of a main-checkout lane id (must match the desktop)."""
|
||||
return f"{repo_root}::branch::{(branch or '').strip()}"
|
||||
|
||||
|
||||
def _kanban_lane_id(repo_root: str) -> str:
|
||||
return f"{repo_root}::kanban"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers (match the TS segment logic so labels/ids line up)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _segments(path: str) -> list[str]:
|
||||
return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s]
|
||||
|
||||
|
||||
def _is_windows_path(path: str) -> bool:
|
||||
value = (path or "").strip()
|
||||
# Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path
|
||||
# — the root-relative `\wsl.localhost\…` / `\Users\…` spellings included. A
|
||||
# single leading `/` stays POSIX (case-sensitive).
|
||||
return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//"))
|
||||
|
||||
|
||||
def _comparison_segments(path: str) -> list[str]:
|
||||
"""Path segments suitable for identity comparisons on any host.
|
||||
|
||||
Windows paths remain case-insensitive even when tests or remote backends run
|
||||
on POSIX. Display paths and emitted IDs keep their original spelling.
|
||||
"""
|
||||
segs = _segments(path)
|
||||
return [segment.casefold() for segment in segs] if _is_windows_path(path) else segs
|
||||
|
||||
|
||||
def _path_key(path: str) -> str:
|
||||
"""Canonical comparison key (separator/trailing-slash agnostic)."""
|
||||
return "/".join(_comparison_segments(path))
|
||||
|
||||
|
||||
def _lane_key(path_or_lane: str) -> str:
|
||||
"""Canonicalize only the path portion of a lane id.
|
||||
|
||||
Branch labels remain byte-preserved; repo/worktree paths follow platform path
|
||||
identity so equivalent Windows spellings do not create duplicate lanes.
|
||||
"""
|
||||
for marker in ("::branch::", "::kanban"):
|
||||
if marker in path_or_lane:
|
||||
root, suffix = path_or_lane.split(marker, 1)
|
||||
return f"{_path_key(root)}{marker}{suffix}"
|
||||
return _path_key(path_or_lane)
|
||||
|
||||
|
||||
def base_name(path: str) -> str:
|
||||
segs = _segments(path)
|
||||
return segs[-1] if segs else ""
|
||||
|
||||
|
||||
def kanban_worktree_dir(path: str) -> Optional[str]:
|
||||
"""The ``<repo>/.worktrees`` dir for a ``.../.worktrees/<task>`` path, else None."""
|
||||
m = _KANBAN_DIR_RE.match(path or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _is_path_under(folder: str, target: str) -> bool:
|
||||
"""True when ``target`` equals ``folder`` or is nested under it (segment-wise)."""
|
||||
f = _comparison_segments(folder)
|
||||
t = _comparison_segments(target)
|
||||
if not f or len(f) > len(t):
|
||||
return False
|
||||
return all(f[i] == t[i] for i in range(len(f)))
|
||||
|
||||
|
||||
def _with_base_name(path: str, name: str) -> str:
|
||||
stripped = re.sub(r"[/\\]+$", "", path)
|
||||
return re.sub(r"[^/\\]+$", name, stripped)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lane placement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _placement(
|
||||
repo_root: str,
|
||||
lane_key: str,
|
||||
lane_label: str,
|
||||
lane_path: str,
|
||||
is_main: bool,
|
||||
is_kanban: bool,
|
||||
) -> dict:
|
||||
return {
|
||||
"repo_key": repo_root,
|
||||
"repo_label": base_name(repo_root) or repo_root,
|
||||
"repo_path": repo_root,
|
||||
"lane_key": lane_key,
|
||||
"lane_label": lane_label,
|
||||
"lane_path": lane_path,
|
||||
"is_main": is_main,
|
||||
"is_kanban": is_kanban,
|
||||
}
|
||||
|
||||
|
||||
def _parent_dir(path: str) -> str:
|
||||
"""The containing directory of ``path`` (``""`` once the root is passed)."""
|
||||
stripped = re.sub(r"[/\\]+$", "", path or "")
|
||||
return re.sub(r"[/\\]+$", "", re.sub(r"[^/\\]+$", "", stripped))
|
||||
|
||||
|
||||
def _probe_sibling_worktree(cwd: str, resolve: Resolve) -> str:
|
||||
"""The parent repo root of a deleted ``<repo>-<suffix>`` worktree, else ``""``.
|
||||
|
||||
A deleted worktree dir can't be probed, so walk back up its name — trimming
|
||||
one ``-<segment>`` at a time — and return the first sibling that resolves.
|
||||
|
||||
The session's cwd is frequently a SUBDIR of the deleted worktree (an agent
|
||||
that ``cd``-ed into ``<repo>-<suffix>/apps/desktop``), whose basename shares
|
||||
nothing with the repo. So the trim is applied to each ANCESTOR, deepest
|
||||
first, not just to the leaf — otherwise the probe silently no-ops and the
|
||||
dead path gets minted as its own top-level project. Probes are bounded in
|
||||
total (each costs a git invocation) and served from the shared probe cache.
|
||||
"""
|
||||
probes = 0
|
||||
path = re.sub(r"[/\\]+$", "", cwd or "")
|
||||
|
||||
while path and probes < _MAX_SIBLING_PROBES:
|
||||
parts = base_name(path).split("-")
|
||||
|
||||
for i in range(len(parts) - 1, 0, -1):
|
||||
if probes >= _MAX_SIBLING_PROBES:
|
||||
break
|
||||
probes += 1
|
||||
info = resolve(_with_base_name(path, "-".join(parts[:i])))
|
||||
if info and info.get("repo_root"):
|
||||
return (info["repo_root"] or "").strip()
|
||||
|
||||
path = _parent_dir(path)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _place_by_heuristic(path: str) -> Optional[dict]:
|
||||
"""Path-only fallback when there is no git probe and no persisted root."""
|
||||
base = base_name(path)
|
||||
if not base:
|
||||
return None
|
||||
|
||||
kanban_dir = kanban_worktree_dir(path)
|
||||
if kanban_dir:
|
||||
repo_path = re.sub(r"[/\\]+$", "", _with_base_name(kanban_dir, ""))
|
||||
return _placement(repo_path, _kanban_lane_id(repo_path), "kanban", kanban_dir, False, True)
|
||||
|
||||
m = re.match(r"^(.+)-wt-(.+)$", base)
|
||||
if m:
|
||||
repo_path = _with_base_name(path, m.group(1))
|
||||
return _placement(repo_path, path, m.group(2), path, False, False)
|
||||
|
||||
return _placement(path, _branch_lane_id(path, DEFAULT_BRANCH_LABEL), base, path, True, False)
|
||||
|
||||
|
||||
def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]:
|
||||
info = resolve(cwd) if resolve else None
|
||||
|
||||
if info and info.get("repo_root") and info.get("worktree_root"):
|
||||
repo_root = info["repo_root"]
|
||||
worktree_root = info["worktree_root"]
|
||||
is_main = _path_key(worktree_root) == _path_key(repo_root) or bool(info.get("is_main"))
|
||||
|
||||
if is_main:
|
||||
# Unrecorded branch folds into the one trunk lane, so a repo never
|
||||
# shows two "main" lanes (recorded "main" + the empty-branch bucket).
|
||||
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
|
||||
return _placement(repo_root, _branch_lane_id(repo_root, b), b, repo_root, True, False)
|
||||
|
||||
kanban_dir = kanban_worktree_dir(worktree_root)
|
||||
if kanban_dir:
|
||||
return _placement(repo_root, _kanban_lane_id(repo_root), "kanban", kanban_dir, False, True)
|
||||
|
||||
label = base_name(worktree_root) or worktree_root
|
||||
return _placement(repo_root, worktree_root, label, worktree_root, False, False)
|
||||
|
||||
# No live probe: trust the backend-persisted root (group by it, split main by
|
||||
# the session's recorded branch). Kanban tasks still collapse by path shape.
|
||||
if persisted_root:
|
||||
kanban_dir = kanban_worktree_dir(cwd)
|
||||
if kanban_dir:
|
||||
return _placement(persisted_root, _kanban_lane_id(persisted_root), "kanban", kanban_dir, False, True)
|
||||
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
|
||||
return _placement(persisted_root, _branch_lane_id(persisted_root, b), b, persisted_root, True, False)
|
||||
|
||||
# Unresolvable cwd: a deleted ``<repo>-<suffix>`` worktree still belongs to
|
||||
# its parent. It has no checkout to return to, so absorb it into the trunk
|
||||
# lane rather than stranding a dead-path lane in the project forever.
|
||||
sibling_root = _probe_sibling_worktree(cwd, resolve) if resolve else ""
|
||||
if sibling_root:
|
||||
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
|
||||
return _placement(sibling_root, _branch_lane_id(sibling_root, b), b, sibling_root, True, False)
|
||||
|
||||
return _place_by_heuristic(cwd)
|
||||
|
||||
|
||||
def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str:
|
||||
"""The COMMON repo root a session belongs to (folds linked worktrees)."""
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if cwd and resolve:
|
||||
info = resolve(cwd)
|
||||
if info and info.get("repo_root"):
|
||||
return info["repo_root"]
|
||||
return (session.get("git_repo_root") or "").strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ordering + label disambiguation (parity with the old client tree)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _lane_sort_key(group: dict) -> tuple:
|
||||
# Trunk pins to the top; the kanban aggregate sinks to the bottom; the rest
|
||||
# (branches + linked worktrees) sort by most-recent activity, then label.
|
||||
is_trunk = bool(group.get("isMain")) and group["label"].lower() in _TRUNK_BRANCHES
|
||||
is_kanban = bool(group.get("isKanban"))
|
||||
activity = max((_session_time(s) for s in group.get("sessions") or []), default=0.0)
|
||||
return (
|
||||
0 if is_trunk else 1,
|
||||
1 if is_kanban else 0,
|
||||
-activity,
|
||||
group["label"].lower(),
|
||||
)
|
||||
|
||||
|
||||
def _sort_lanes(groups: list[dict]) -> list[dict]:
|
||||
return sorted(groups, key=_lane_sort_key)
|
||||
|
||||
|
||||
def _disambiguate_labels(items: list[dict]) -> None:
|
||||
"""Grow colliding basenames into path-prefixed labels (in place)."""
|
||||
by_label: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
by_label.setdefault(item["label"], []).append(item)
|
||||
|
||||
for bucket in by_label.values():
|
||||
pathed = [g for g in bucket if g.get("path")]
|
||||
if len(pathed) < 2:
|
||||
continue
|
||||
|
||||
parents = {id(g): _segments(g["path"])[:-1] for g in pathed}
|
||||
max_depth = max(len(p) for p in parents.values())
|
||||
depth = 1
|
||||
while depth <= max_depth:
|
||||
counts: dict[str, int] = {}
|
||||
for g in pathed:
|
||||
segs = parents[id(g)]
|
||||
prefix = "/".join(segs[-depth:]) if depth else ""
|
||||
base = base_name(g["path"]) or g["path"]
|
||||
g["label"] = f"{prefix}/{base}" if prefix else base
|
||||
counts[g["label"]] = counts.get(g["label"], 0) + 1
|
||||
if all(c == 1 for c in counts.values()):
|
||||
break
|
||||
depth += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repo subtree assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _session_time(session: dict) -> float:
|
||||
return float(session.get("last_active") or session.get("started_at") or 0)
|
||||
|
||||
|
||||
def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool) -> list[dict]:
|
||||
"""Build the ``repo -> lane -> sessions`` subtree for a set of sessions."""
|
||||
lanes: dict[str, dict] = {} # lane_key -> {group, repo_key, repo_label, repo_path}
|
||||
|
||||
for session in sessions:
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd:
|
||||
continue
|
||||
|
||||
placement = _place(
|
||||
cwd,
|
||||
(session.get("git_branch") or "").strip(),
|
||||
resolve,
|
||||
(session.get("git_repo_root") or "").strip(),
|
||||
)
|
||||
if not placement:
|
||||
continue
|
||||
|
||||
lane_identity = _lane_key(placement["lane_key"])
|
||||
entry = lanes.get(lane_identity)
|
||||
if entry is None:
|
||||
entry = {
|
||||
"group": {
|
||||
"id": placement["lane_key"],
|
||||
"label": placement["lane_label"],
|
||||
"path": placement["lane_path"],
|
||||
"isMain": placement["is_main"],
|
||||
"isKanban": placement["is_kanban"],
|
||||
"sessions": [],
|
||||
},
|
||||
"repo_key": placement["repo_key"],
|
||||
"repo_label": placement["repo_label"],
|
||||
"repo_path": placement["repo_path"],
|
||||
}
|
||||
lanes[lane_identity] = entry
|
||||
entry["group"]["sessions"].append(session)
|
||||
|
||||
repos: dict[str, dict] = {}
|
||||
for entry in lanes.values():
|
||||
group = entry["group"]
|
||||
group["sessions"].sort(key=_session_time, reverse=True)
|
||||
count = len(group["sessions"])
|
||||
|
||||
repo_identity = _path_key(entry["repo_key"])
|
||||
repo = repos.get(repo_identity)
|
||||
if repo is None:
|
||||
repo = {
|
||||
"id": entry["repo_key"],
|
||||
"label": entry["repo_label"],
|
||||
"path": entry["repo_path"],
|
||||
"groups": [],
|
||||
"sessionCount": 0,
|
||||
}
|
||||
repos[repo_identity] = repo
|
||||
repo["groups"].append(group)
|
||||
repo["sessionCount"] += count
|
||||
|
||||
repo_list = list(repos.values())
|
||||
for repo in repo_list:
|
||||
repo["groups"] = _sort_lanes(repo["groups"])
|
||||
_disambiguate_labels(repo["groups"])
|
||||
# Drop per-lane session rows only AFTER sorting: _lane_sort_key ranks
|
||||
# non-trunk lanes by most-recent activity, which it derives from the
|
||||
# session rows. Clearing them earlier makes every lane look inactive on
|
||||
# the overview (hydrate=False) path and collapses the sort to
|
||||
# alphabetical. Counts were already captured above, so the payload stays
|
||||
# slim without losing the recency order.
|
||||
if not hydrate:
|
||||
for group in repo["groups"]:
|
||||
group["sessions"] = []
|
||||
_disambiguate_labels(repo_list)
|
||||
return repo_list
|
||||
|
||||
|
||||
def _seed_folder_repos(
|
||||
repos: list[dict], folders: list[dict], resolve: Optional[Resolve]
|
||||
) -> list[dict]:
|
||||
"""Ensure every declared project folder shows as a repo, even with 0 sessions.
|
||||
|
||||
A brand-new project (or any project whose sessions haven't loaded yet) has an
|
||||
empty session-derived ``repos`` list. That breaks two things on the desktop:
|
||||
the entered-project view renders blank (it early-returns on no repos), and the
|
||||
optimistic live-session overlay has no lane to drop a freshly-created session
|
||||
into — so a new session in the project only appears after a full tree refresh.
|
||||
Seeding each folder as an empty repo fixes both: the overlay matches a new
|
||||
session's cwd under the folder root, and the drill-in renders a real (if
|
||||
empty) project body. Folders already covered by a session-derived repo (same
|
||||
git root) are left untouched.
|
||||
"""
|
||||
seen = {
|
||||
_path_key(value)
|
||||
for repo in repos
|
||||
for value in (repo.get("id"), repo.get("path"))
|
||||
if value
|
||||
}
|
||||
seeded = list(repos)
|
||||
|
||||
for folder in folders or []:
|
||||
raw = (folder.get("path") or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
info = resolve(raw) if resolve else None
|
||||
root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw)
|
||||
root_key = _path_key(root)
|
||||
if not root_key or root_key in seen:
|
||||
continue
|
||||
seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0})
|
||||
seen.add(root_key)
|
||||
|
||||
if len(seeded) != len(repos):
|
||||
_disambiguate_labels(seeded)
|
||||
|
||||
return seeded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Explicit-project ownership
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FolderIndex:
|
||||
"""Maps a normalized folder path → (owning project, depth), so a session is
|
||||
matched to its project by walking its cwd's ancestors (O(path depth) dict
|
||||
lookups) instead of scanning every project × folder per session — the
|
||||
difference between O(sessions × projects) and O(sessions) at power-user scale.
|
||||
"""
|
||||
|
||||
def __init__(self, projects: list[dict]) -> None:
|
||||
self._by_path: dict[str, tuple[dict, int]] = {}
|
||||
for project in projects:
|
||||
for folder in project.get("folders") or []:
|
||||
segs = _comparison_segments(folder.get("path") or "")
|
||||
if not segs:
|
||||
continue
|
||||
key = "/".join(segs)
|
||||
depth = len(segs)
|
||||
# Deepest folder wins; ties keep the first project (scan order).
|
||||
existing = self._by_path.get(key)
|
||||
if existing is None or depth > existing[1]:
|
||||
self._by_path[key] = (project, depth)
|
||||
|
||||
def match(self, target: str) -> tuple[Optional[dict], int]:
|
||||
"""Owning project for ``target`` by longest ancestor folder, + its depth."""
|
||||
segs = _comparison_segments(target or "")
|
||||
# Longest prefix first → deepest (most specific) folder wins.
|
||||
for end in range(len(segs), 0, -1):
|
||||
hit = self._by_path.get("/".join(segs[:end]))
|
||||
if hit:
|
||||
return hit
|
||||
return None, -1
|
||||
|
||||
|
||||
def _project_for_path(index: _FolderIndex, target: str) -> Optional[dict]:
|
||||
return index.match(target)[0]
|
||||
|
||||
|
||||
def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd:
|
||||
return None
|
||||
repo_root = _session_repo_root(session, resolve)
|
||||
candidates = [cwd, repo_root] if repo_root and repo_root != cwd else [cwd]
|
||||
|
||||
best: Optional[dict] = None
|
||||
best_len = -1
|
||||
for target in candidates:
|
||||
match, length = index.match(target)
|
||||
if match and length > best_len:
|
||||
best_len = length
|
||||
best = match
|
||||
return best
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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,
|
||||
label: str,
|
||||
path: Optional[str],
|
||||
repos: list[dict],
|
||||
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,
|
||||
is_no_project: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": pid,
|
||||
"label": label,
|
||||
"path": path,
|
||||
"color": color,
|
||||
"icon": icon,
|
||||
"isAuto": is_auto,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
def build_tree(
|
||||
projects: list[dict],
|
||||
sessions: list[dict],
|
||||
discovered_repos: list[dict],
|
||||
resolve: Optional[Resolve] = None,
|
||||
*,
|
||||
preview_limit: int = 3,
|
||||
hydrate: bool = False,
|
||||
is_junk_root: Optional[Callable[[str], bool]] = None,
|
||||
is_junk_cwd: Optional[Callable[[str], bool]] = None,
|
||||
exists: Optional[Exists] = None,
|
||||
) -> dict:
|
||||
"""Build the authoritative project tree.
|
||||
|
||||
``projects`` are ``projects_db.Project.to_dict()`` shapes (non-archived).
|
||||
``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``,
|
||||
``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``).
|
||||
``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``.
|
||||
``is_junk_root`` flags git roots that must never become an AUTO project (the
|
||||
bare home dir, the HERMES_HOME subtree). ``is_junk_cwd`` is the narrower
|
||||
policy for non-git session folders: selected descendants may be intentional
|
||||
workspaces even when their parent tree contains Hermes state. User-created
|
||||
projects are honored regardless. ``exists`` reports whether a directory is
|
||||
still on disk, so a session whose workspace was DELETED (a removed worktree,
|
||||
a scratch dir under /tmp) doesn't get promoted to a phantom AUTO project;
|
||||
omit it (remote backends) to keep every candidate.
|
||||
|
||||
Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When
|
||||
``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but
|
||||
every count is preserved and each project carries up to ``preview_limit``
|
||||
``previewSessions``. When True (drill-in), lanes carry full session rows.
|
||||
"""
|
||||
active_projects = [p for p in projects if not p.get("archived")]
|
||||
_junk = is_junk_root or (lambda _root: False)
|
||||
_junk_cwd = is_junk_cwd or (lambda _cwd: False)
|
||||
_exists = exists or (lambda _path: True)
|
||||
folder_index = _FolderIndex(active_projects)
|
||||
|
||||
by_project: dict[str, list[dict]] = {}
|
||||
unowned: list[dict] = []
|
||||
for session in sessions:
|
||||
owner = _project_for_session(session, folder_index, resolve)
|
||||
if owner:
|
||||
by_project.setdefault(owner["id"], []).append(session)
|
||||
else:
|
||||
unowned.append(session)
|
||||
|
||||
scoped_ids: list[str] = []
|
||||
|
||||
def _previews(project_sessions: list[dict]) -> list[dict]:
|
||||
if preview_limit <= 0:
|
||||
return []
|
||||
ordered = sorted(project_sessions, key=_session_time, reverse=True)
|
||||
return ordered[:preview_limit]
|
||||
|
||||
def _last_active(project_sessions: list[dict]) -> float:
|
||||
return max((_session_time(s) for s in project_sessions), default=0.0)
|
||||
|
||||
result: list[dict] = []
|
||||
|
||||
# Tier 1: explicit, user-created projects (always shown, even with 0 sessions).
|
||||
for project in active_projects:
|
||||
psessions = by_project.get(project["id"], [])
|
||||
scoped_ids.extend(s["id"] for s in psessions if s.get("id"))
|
||||
repos = _seed_folder_repos(
|
||||
_build_repos(psessions, resolve, hydrate), project.get("folders") or [], resolve
|
||||
)
|
||||
result.append(
|
||||
_project_node(
|
||||
pid=project["id"],
|
||||
label=project.get("name") or project["id"],
|
||||
path=project.get("primary_path"),
|
||||
color=project.get("color"),
|
||||
icon=project.get("icon"),
|
||||
repos=repos,
|
||||
session_count=len(psessions),
|
||||
last_active=_last_active(psessions),
|
||||
preview_sessions=_previews(psessions),
|
||||
sessions=psessions,
|
||||
)
|
||||
)
|
||||
|
||||
# Tier 2: auto projects from leftover sessions. Prefer the common git repo
|
||||
# root, then fall back to the session cwd for historical/non-git workspaces.
|
||||
# The pre-Projects desktop grouped every non-empty cwd; keeping that fallback
|
||||
# prevents upgrades from flattening those sessions into Recents.
|
||||
by_auto_root: dict[str, dict] = {}
|
||||
# Every session no tier could place. These are the Home bucket's rows.
|
||||
homeless: list[dict] = []
|
||||
|
||||
def _add_auto(root: str, session: dict) -> None:
|
||||
key = _path_key(root)
|
||||
if not key:
|
||||
homeless.append(session)
|
||||
return
|
||||
bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []})
|
||||
bucket["sessions"].append(session)
|
||||
|
||||
for session in unowned:
|
||||
root = _session_repo_root(session, resolve)
|
||||
if root:
|
||||
# A real git root uses the stricter repo policy. Do not reinterpret a
|
||||
# filtered internal repo as a cwd-only project. A root that no longer
|
||||
# exists is a stale persisted value (the repo was deleted after the
|
||||
# session ran) and must not resurrect as a project.
|
||||
if not _junk(root) and _exists(root):
|
||||
_add_auto(root, session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
continue
|
||||
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd or _junk_cwd(cwd):
|
||||
homeless.append(session)
|
||||
continue
|
||||
placement = _place(
|
||||
cwd,
|
||||
(session.get("git_branch") or "").strip(),
|
||||
resolve,
|
||||
(session.get("git_repo_root") or "").strip(),
|
||||
)
|
||||
# A placement that only echoes back the unresolvable cwd is the
|
||||
# path-only heuristic guessing — it never found a repo. When that dir is
|
||||
# also gone from disk (a deleted worktree whose name shares no prefix
|
||||
# with its parent, a removed /tmp scratch dir), promoting it mints a
|
||||
# phantom project that can never be opened and can only be dismissed by
|
||||
# hand. The session goes to Home instead.
|
||||
if placement and _exists(placement["repo_key"]):
|
||||
_add_auto(placement["repo_key"], session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
|
||||
seen: set[str] = set()
|
||||
for bucket in by_auto_root.values():
|
||||
auto_root = bucket["root"]
|
||||
auto_sessions = bucket["sessions"]
|
||||
auto_key = _path_key(auto_root)
|
||||
repos = _build_repos(auto_sessions, resolve, hydrate)
|
||||
repo_node = next(
|
||||
(
|
||||
repo
|
||||
for repo in repos
|
||||
if _path_key(repo.get("id") or repo.get("path") or "") == auto_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if repo_node is None:
|
||||
homeless.extend(auto_sessions)
|
||||
continue
|
||||
seen.add(auto_key)
|
||||
scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id"))
|
||||
result.append(
|
||||
_project_node(
|
||||
pid=auto_root,
|
||||
label=base_name(auto_root) or auto_root,
|
||||
path=auto_root,
|
||||
repos=repos,
|
||||
session_count=repo_node["sessionCount"],
|
||||
last_active=_last_active(auto_sessions),
|
||||
preview_sessions=_previews(auto_sessions),
|
||||
sessions=auto_sessions,
|
||||
is_auto=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Tier 3: repos discovered from full history / disk scan with no loaded
|
||||
# sessions, folded to their common root and not owned by an explicit project.
|
||||
for repo in discovered_repos or []:
|
||||
raw_root = (repo.get("root") or "").strip()
|
||||
if not raw_root:
|
||||
continue
|
||||
info = resolve(raw_root) if resolve else None
|
||||
root = (info or {}).get("repo_root") or raw_root
|
||||
root_key = _path_key(root)
|
||||
if root_key in seen or _junk(root) or _project_for_path(folder_index, root):
|
||||
continue
|
||||
seen.add(root_key)
|
||||
label = repo.get("label") or base_name(root) or root
|
||||
result.append(
|
||||
_project_node(
|
||||
pid=root,
|
||||
label=label,
|
||||
path=root,
|
||||
repos=[{"id": root, "label": label, "path": root, "groups": [], "sessionCount": 0}],
|
||||
session_count=int(repo.get("sessions") or 0),
|
||||
last_active=float(repo.get("last_active") or 0),
|
||||
preview_sessions=[],
|
||||
is_auto=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Auto projects are labelled by repo basename, which can collide (two "app"
|
||||
# repos in different parents). Grow path prefixes so each is distinct.
|
||||
# Explicit projects keep their user-chosen names untouched.
|
||||
_disambiguate_labels([p for p in result if p.get("isAuto")])
|
||||
|
||||
# Tier 0: everything the tiers above could not place, so the grouped view
|
||||
# loses no session. It has no folder, hence no repo/lane structure — the one
|
||||
# synthetic lane exists purely to carry the rows in the tree's shape. Leads
|
||||
# the list; omitted entirely when empty, so a project-less install is blank.
|
||||
if homeless:
|
||||
homeless.sort(key=_session_time, reverse=True)
|
||||
scoped_ids.extend(s["id"] for s in homeless if s.get("id"))
|
||||
lane = {
|
||||
"id": NO_PROJECT_ID,
|
||||
"label": NO_PROJECT_LABEL,
|
||||
"path": None,
|
||||
"isMain": False,
|
||||
"isKanban": False,
|
||||
"sessions": homeless if hydrate else [],
|
||||
}
|
||||
result.insert(
|
||||
0,
|
||||
_project_node(
|
||||
pid=NO_PROJECT_ID,
|
||||
label=NO_PROJECT_LABEL,
|
||||
path=None,
|
||||
repos=[
|
||||
{
|
||||
"id": NO_PROJECT_ID,
|
||||
"label": NO_PROJECT_LABEL,
|
||||
"path": None,
|
||||
"groups": [lane],
|
||||
"sessionCount": len(homeless),
|
||||
}
|
||||
],
|
||||
session_count=len(homeless),
|
||||
last_active=_last_active(homeless),
|
||||
preview_sessions=_previews(homeless),
|
||||
sessions=homeless,
|
||||
is_no_project=True,
|
||||
),
|
||||
)
|
||||
|
||||
return {"projects": result, "scoped_session_ids": scoped_ids}
|
||||
Reference in New Issue
Block a user