feat(sessions): record git workspace metadata
This commit is contained in:
parent
c4ba4770eb
commit
4cdd1a3230
4 changed files with 218 additions and 5 deletions
|
|
@ -99,6 +99,9 @@ export type {
|
|||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
ProjectFolder,
|
||||
ProjectInfo,
|
||||
ProjectsPayload,
|
||||
RpcEvent,
|
||||
SessionCreateResponse,
|
||||
SessionInfo,
|
||||
|
|
@ -150,7 +153,9 @@ export async function listSessions(
|
|||
order: 'created' | 'recent' = 'recent'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`,
|
||||
path:
|
||||
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -323,6 +323,16 @@ export interface SessionCreateResponse {
|
|||
export interface SessionInfo {
|
||||
archived?: boolean
|
||||
cwd?: null | string
|
||||
/** Git branch checked out in {@link cwd} when the session started/resumed.
|
||||
* The sidebar groups main-checkout sessions by this so feature-branch work
|
||||
* doesn't collapse under a single directory-named "main" row. Null for
|
||||
* non-git workspaces and sessions created before branch capture landed. */
|
||||
git_branch?: null | string
|
||||
/** Git repo root that owns {@link cwd} — the authoritative project key,
|
||||
* resolved server-side at cwd-set (and backfilled for history). The sidebar
|
||||
* groups by this instead of probing git in the GUI. Null for non-git
|
||||
* workspaces and not-yet-backfilled rows. */
|
||||
git_repo_root?: null | string
|
||||
ended_at: null | number
|
||||
id: string
|
||||
/** Original root id of a compression chain, when this entry is a projected
|
||||
|
|
@ -335,6 +345,8 @@ export interface SessionInfo {
|
|||
message_count: number
|
||||
model: null | string
|
||||
output_tokens: number
|
||||
/** Parent conversation when this row is a /branch fork. */
|
||||
parent_session_id?: null | string
|
||||
preview: null | string
|
||||
source: null | string
|
||||
started_at: number
|
||||
|
|
@ -533,6 +545,35 @@ export interface ProfileSetupCommand {
|
|||
command: string
|
||||
}
|
||||
|
||||
// ── Projects ───────────────────────────────────────────────────────────────
|
||||
// A first-class, per-profile, human-named workspace spanning one or more
|
||||
// folders. Mirrors hermes_cli/projects_db.Project.to_dict().
|
||||
export interface ProjectFolder {
|
||||
path: string
|
||||
label: null | string
|
||||
is_primary: boolean
|
||||
added_at: number
|
||||
}
|
||||
|
||||
export interface ProjectInfo {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: null | string
|
||||
icon: null | string
|
||||
color: null | string
|
||||
board_slug: null | string
|
||||
primary_path: null | string
|
||||
archived: boolean
|
||||
created_at: number
|
||||
folders: ProjectFolder[]
|
||||
}
|
||||
|
||||
export interface ProjectsPayload {
|
||||
projects: ProjectInfo[]
|
||||
active_id: null | string
|
||||
}
|
||||
|
||||
export interface ProfileSoul {
|
||||
content: string
|
||||
exists: boolean
|
||||
|
|
|
|||
100
hermes_state.py
100
hermes_state.py
|
|
@ -33,6 +33,11 @@ def _delegate_from_json(col: str = "model_config") -> str:
|
|||
return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')"
|
||||
|
||||
|
||||
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
|
||||
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
|
||||
return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"]
|
||||
|
||||
|
||||
# A child session counts as a /branch (kept visible, never cascade-deleted) if
|
||||
# it carries the stable marker OR the legacy end_reason heuristic holds.
|
||||
_BRANCH_CHILD_SQL = (
|
||||
|
|
@ -539,6 +544,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
cwd TEXT,
|
||||
git_branch TEXT,
|
||||
git_repo_root TEXT,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
|
|
@ -1407,13 +1414,62 @@ class SessionDB:
|
|||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def update_session_cwd(self, session_id: str, cwd: str) -> None:
|
||||
"""Persist the session working directory when a frontend knows it."""
|
||||
def update_session_cwd(
|
||||
self, session_id: str, cwd: str, git_branch: str = None, git_repo_root: str = None
|
||||
) -> None:
|
||||
"""Persist the session working directory when a frontend knows it.
|
||||
|
||||
``git_branch`` records the git branch checked out in ``cwd`` at the time
|
||||
the session started/resumed. The sidebar groups main-checkout sessions
|
||||
by this so feature-branch work doesn't pile under a single "main" row
|
||||
(the main checkout's *current* branch is transient and would
|
||||
misattribute past sessions).
|
||||
|
||||
``git_repo_root`` records the git repo this cwd belongs to — the
|
||||
authoritative project key. Resolving it here, at the lowest level, means
|
||||
every surface reads the same membership instead of re-probing git in the
|
||||
GUI over a partial page. Each field is only written when non-empty so a
|
||||
probe failure never clobbers a previously-captured value.
|
||||
"""
|
||||
if not session_id or not cwd:
|
||||
return
|
||||
|
||||
branch = (git_branch or "").strip()
|
||||
repo_root = (git_repo_root or "").strip()
|
||||
|
||||
sets = ["cwd = ?"]
|
||||
params: List[Any] = [cwd]
|
||||
if branch:
|
||||
sets.append("git_branch = ?")
|
||||
params.append(branch)
|
||||
if repo_root:
|
||||
sets.append("git_repo_root = ?")
|
||||
params.append(repo_root)
|
||||
params.append(session_id)
|
||||
|
||||
def _do(conn):
|
||||
conn.execute("UPDATE sessions SET cwd = ? WHERE id = ?", (cwd, session_id))
|
||||
conn.execute(f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", params)
|
||||
|
||||
self._execute_write(_do)
|
||||
|
||||
def backfill_repo_roots(self, cwd_to_root: Dict[str, str]) -> None:
|
||||
"""Persist resolved git repo roots for cwds that don't have one yet.
|
||||
|
||||
Backfills history so projects light up for sessions created before the
|
||||
column existed, without clobbering an already-recorded root. Only
|
||||
non-empty roots are written (a non-git cwd stays NULL).
|
||||
"""
|
||||
pairs = [(root, cwd) for cwd, root in cwd_to_root.items() if root and cwd]
|
||||
if not pairs:
|
||||
return
|
||||
|
||||
def _do(conn):
|
||||
for root, cwd in pairs:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET git_repo_root = ? "
|
||||
"WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''",
|
||||
(root, cwd),
|
||||
)
|
||||
|
||||
self._execute_write(_do)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -2102,10 +2158,37 @@ class SessionDB:
|
|||
current = row["id"]
|
||||
return current
|
||||
|
||||
def distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Distinct non-empty session cwds with usage stats, for repo discovery.
|
||||
|
||||
Aggregates across ALL session history (not a single page), so the desktop
|
||||
can surface every git repo the user has worked in — not just the repos
|
||||
that happen to be in the currently-loaded recents. Children/branches
|
||||
count: a worktree session is still a real workspace signal.
|
||||
"""
|
||||
where = "cwd IS NOT NULL AND TRIM(cwd) != ''"
|
||||
if not include_archived:
|
||||
where += " AND archived = 0"
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT cwd AS cwd, COUNT(*) AS sessions, "
|
||||
"MAX(COALESCE(ended_at, started_at, 0)) AS last_active "
|
||||
f"FROM sessions WHERE {where} GROUP BY cwd"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"cwd": r["cwd"],
|
||||
"sessions": int(r["sessions"] or 0),
|
||||
"last_active": float(r["last_active"] or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def list_sessions_rich(
|
||||
self,
|
||||
source: str = None,
|
||||
exclude_sources: List[str] = None,
|
||||
cwd_prefix: str = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_children: bool = False,
|
||||
|
|
@ -2171,6 +2254,10 @@ class SessionDB:
|
|||
placeholders = ",".join("?" for _ in exclude_sources)
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(exclude_sources)
|
||||
if cwd_prefix:
|
||||
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
|
||||
where_clauses.append(clause)
|
||||
params.extend(clause_params)
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("s.message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
|
|
@ -2330,7 +2417,7 @@ class SessionDB:
|
|||
for key in (
|
||||
"id", "ended_at", "end_reason", "message_count",
|
||||
"tool_call_count", "title", "last_active", "preview",
|
||||
"model", "system_prompt", "cwd",
|
||||
"model", "system_prompt", "cwd", "git_branch", "git_repo_root",
|
||||
):
|
||||
if key in tip_row:
|
||||
merged[key] = tip_row[key]
|
||||
|
|
@ -3874,6 +3961,7 @@ class SessionDB:
|
|||
def session_count(
|
||||
self,
|
||||
source: str = None,
|
||||
cwd_prefix: str = None,
|
||||
min_message_count: int = 0,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
|
|
@ -3910,6 +3998,10 @@ class SessionDB:
|
|||
placeholders = ",".join("?" for _ in exclude_sources)
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(exclude_sources)
|
||||
if cwd_prefix:
|
||||
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
|
||||
where_clauses.append(clause)
|
||||
params.extend(clause_params)
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("s.message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,66 @@ class TestSessionLifecycle:
|
|||
def test_get_nonexistent_session(self, db):
|
||||
assert db.get_session("nonexistent") is None
|
||||
|
||||
def test_update_session_cwd_persists_git_branch(self, db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.update_session_cwd("s1", "/work/repo", git_branch="pets-feature")
|
||||
|
||||
session = db.get_session("s1")
|
||||
assert session["cwd"] == "/work/repo"
|
||||
assert session["git_branch"] == "pets-feature"
|
||||
|
||||
def test_update_session_cwd_empty_branch_does_not_clobber(self, db):
|
||||
"""A failed branch probe (empty string) must not wipe a branch we
|
||||
already captured — only the cwd updates."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.update_session_cwd("s1", "/work/repo", git_branch="main")
|
||||
db.update_session_cwd("s1", "/work/repo", git_branch="")
|
||||
|
||||
session = db.get_session("s1")
|
||||
assert session["git_branch"] == "main"
|
||||
|
||||
def test_update_session_cwd_without_branch_arg(self, db):
|
||||
"""Back-compat: callers that pass only (id, cwd) still work."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.update_session_cwd("s1", "/work/repo")
|
||||
|
||||
session = db.get_session("s1")
|
||||
assert session["cwd"] == "/work/repo"
|
||||
assert session["git_branch"] is None
|
||||
|
||||
def test_update_session_cwd_persists_git_repo_root(self, db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.update_session_cwd("s1", "/work/repo/src", git_repo_root="/work/repo")
|
||||
|
||||
assert db.get_session("s1")["git_repo_root"] == "/work/repo"
|
||||
|
||||
def test_update_session_cwd_empty_repo_root_does_not_clobber(self, db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.update_session_cwd("s1", "/work/repo", git_repo_root="/work/repo")
|
||||
db.update_session_cwd("s1", "/work/repo", git_repo_root="")
|
||||
|
||||
assert db.get_session("s1")["git_repo_root"] == "/work/repo"
|
||||
|
||||
def test_distinct_session_cwds_aggregates_history(self, db):
|
||||
db.create_session("s1", "cli", cwd="/repo")
|
||||
db.create_session("s2", "cli", cwd="/repo")
|
||||
db.create_session("s3", "cli", cwd="/other")
|
||||
db.create_session("s4", "cli") # no cwd — excluded
|
||||
|
||||
rows = {r["cwd"]: r["sessions"] for r in db.distinct_session_cwds()}
|
||||
assert rows == {"/repo": 2, "/other": 1}
|
||||
|
||||
def test_backfill_repo_roots_fills_only_empty(self, db):
|
||||
db.create_session("s1", "cli", cwd="/repo/a")
|
||||
db.create_session("s2", "cli", cwd="/repo/b")
|
||||
db.update_session_cwd("s2", "/repo/b", git_repo_root="/already")
|
||||
|
||||
db.backfill_repo_roots({"/repo/a": "/repo", "/repo/b": "/repo"})
|
||||
|
||||
assert db.get_session("s1")["git_repo_root"] == "/repo"
|
||||
# Pre-existing root is preserved, not clobbered.
|
||||
assert db.get_session("s2")["git_repo_root"] == "/already"
|
||||
|
||||
def test_end_session(self, db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.end_session("s1", end_reason="user_exit")
|
||||
|
|
@ -1526,6 +1586,13 @@ class TestCounts:
|
|||
assert db.session_count(source="cli") == 2
|
||||
assert db.session_count(source="telegram") == 1
|
||||
|
||||
def test_session_count_by_cwd_prefix(self, db):
|
||||
db.create_session("s1", "cli", cwd="/repo")
|
||||
db.create_session("s2", "cli", cwd="/repo-wt-feature")
|
||||
db.create_session("s3", "cli", cwd="/repo/subdir")
|
||||
|
||||
assert db.session_count(cwd_prefix="/repo") == 2
|
||||
|
||||
def test_message_count_total(self, db):
|
||||
assert db.message_count() == 0
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
|
|
@ -3057,6 +3124,14 @@ class TestListSessionsRich:
|
|||
assert len(sessions) == 1
|
||||
assert sessions[0]["id"] == "s1"
|
||||
|
||||
def test_rich_list_cwd_prefix_filter(self, db):
|
||||
db.create_session("s1", "cli", cwd="/repo")
|
||||
db.create_session("s2", "cli", cwd="/repo/subdir")
|
||||
db.create_session("s3", "cli", cwd="/repo-wt-feature")
|
||||
|
||||
sessions = db.list_sessions_rich(cwd_prefix="/repo")
|
||||
assert [session["id"] for session in sessions] == ["s2", "s1"]
|
||||
|
||||
def test_preview_newlines_collapsed(self, db):
|
||||
db.create_session("s1", "cli")
|
||||
db.append_message("s1", "user", "Line one\nLine two\nLine three")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue