HERMES-GIELIE/api/workspace.py

289 lines
11 KiB
Python
Raw Normal View History

"""
2026-03-31 07:02:47 +00:00
Hermes Web UI -- Workspace and file system helpers.
Workspace lists and last-used workspace are stored per-profile so each
profile has its own workspace configuration. State files live at
``{profile_home}/webui_state/workspaces.json`` and
``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
paths are used as fallback when no profile module is available.
"""
import json
import os
import subprocess
from pathlib import Path
from api.config import (
WORKSPACES_FILE as _GLOBAL_WS_FILE,
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
)
# ── Profile-aware path resolution ───────────────────────────────────────────
def _profile_state_dir() -> Path:
"""Return the webui_state directory for the active profile.
For the default profile, returns the global STATE_DIR (respects
HERMES_WEBUI_STATE_DIR env var for test isolation).
For named profiles, returns {profile_home}/webui_state/.
"""
try:
from api.profiles import get_active_profile_name, get_active_hermes_home
name = get_active_profile_name()
if name and name != 'default':
d = get_active_hermes_home() / 'webui_state'
d.mkdir(parents=True, exist_ok=True)
return d
except ImportError:
pass
return _GLOBAL_WS_FILE.parent
def _workspaces_file() -> Path:
"""Return the workspaces.json path for the active profile."""
return _profile_state_dir() / 'workspaces.json'
def _last_workspace_file() -> Path:
"""Return the last_workspace.txt path for the active profile."""
return _profile_state_dir() / 'last_workspace.txt'
def _profile_default_workspace() -> str:
"""Read the profile's default workspace from its config.yaml.
Checks keys in priority order:
1. 'workspace' — explicit webui workspace key
2. 'default_workspace' — alternate explicit key
3. 'terminal.cwd' — hermes-agent terminal working dir (most common)
Falls back to the boot-time DEFAULT_WORKSPACE constant.
"""
try:
from api.config import get_config
cfg = get_config()
# Explicit webui workspace keys first
for key in ('workspace', 'default_workspace'):
ws = cfg.get(key)
if ws:
p = Path(str(ws)).expanduser().resolve()
if p.is_dir():
return str(p)
# Fall through to terminal.cwd — the agent's configured working directory
terminal_cfg = cfg.get('terminal', {})
if isinstance(terminal_cfg, dict):
cwd = terminal_cfg.get('cwd', '')
if cwd and str(cwd) not in ('.', ''):
p = Path(str(cwd)).expanduser().resolve()
if p.is_dir():
return str(p)
except (ImportError, Exception):
pass
return str(_BOOT_DEFAULT_WORKSPACE)
# ── Public API ──────────────────────────────────────────────────────────────
fix: workspace isolation, session filtering, and clean migration path Three interrelated fixes: 1. api/workspace.py — clean workspace isolation with auto-migration _clean_workspace_list(): sanitizes any workspace list by: - Removing test artifacts (webui-mvp-test, test-workspace paths) - Removing paths that no longer exist on disk - Removing cross-profile leaks (paths under ~/.hermes/profiles/*) - Renaming 'default' workspace label to 'Home' (avoids confusion with the 'default' profile name) _migrate_global_workspaces(): one-time migration for upgrading users. Reads the legacy global workspaces.json, runs _clean_workspace_list, rewrites it cleaned. This runs automatically on first load after upgrade for the default profile only. load_workspaces(): now cleans every read and persists cleaned version if anything changed. Named profiles always start fresh (no global leak). Empty results fall back to 'Home' entry pointing at profile's workspace. Default label for auto-generated single-entry lists is 'Home', not 'default'. 2. api/models.py — legacy session profile backfill (already committed, this commit adds the sessions.js filter tightening counterpart) 3. static/sessions.js — strict profile filter Removed the '!s.profile' escape hatch from the profile filter. Server now backfills profile='default' on legacy sessions, so every session has an explicit tag. Filter is now exact: s.profile === S.activeProfile Named profiles see zero legacy clutter. Default profile sees its own sessions. 'All profiles' toggle still shows everything. Migration story for users pulling this update: - Existing sessions (profile=null) -> attributed to 'default' at read time - Global workspaces.json -> cleaned of test artifacts and cross-profile paths on first server start after upgrade - Named profile workspace files -> cleaned on first read, persisted clean - No manual intervention needed Tests: 426 passed, 0 failed.
2026-04-03 20:01:12 +00:00
def _clean_workspace_list(workspaces: list) -> list:
"""Sanitize a workspace list:
- Remove entries whose paths no longer exist on disk.
- Remove entries that look like test artifacts (webui-mvp-test, test-workspace).
- Remove entries whose paths live inside another profile's directory
(e.g. ~/.hermes/profiles/X/... should not appear on a different profile).
- Rename any entry whose name is literally 'default' to 'Home' (avoids
confusion with the 'default' profile name).
Returns the cleaned list (may be empty).
"""
hermes_profiles = (Path.home() / '.hermes' / 'profiles').resolve()
result = []
for w in workspaces:
path = w.get('path', '')
name = w.get('name', '')
p = Path(path).resolve() if path else Path('/')
# Skip test artifacts
if 'test-workspace' in path or 'webui-mvp-test' in path:
continue
# Skip paths that no longer exist
if not p.is_dir():
continue
# Skip paths inside a named profile's directory (cross-profile leak)
try:
p.relative_to(hermes_profiles)
continue # it IS under profiles/ — remove it
except ValueError:
pass
# Rename confusing 'default' label to 'Home'
if name.lower() == 'default':
name = 'Home'
result.append({'path': str(p), 'name': name})
return result
def _migrate_global_workspaces() -> list:
"""Read the legacy global workspaces.json, clean it, and return the result.
This is the migration path for users upgrading from a pre-profile version:
their global file may contain cross-profile entries, test artifacts, and
stale paths accumulated over time. We clean it in-place and rewrite it.
"""
if not _GLOBAL_WS_FILE.exists():
return []
try:
raw = json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
cleaned = _clean_workspace_list(raw)
if len(cleaned) != len(raw):
# Rewrite the cleaned version so future reads are already clean
_GLOBAL_WS_FILE.write_text(
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
)
return cleaned
except Exception:
return []
def load_workspaces() -> list:
ws_file = _workspaces_file()
if ws_file.exists():
try:
fix: workspace isolation, session filtering, and clean migration path Three interrelated fixes: 1. api/workspace.py — clean workspace isolation with auto-migration _clean_workspace_list(): sanitizes any workspace list by: - Removing test artifacts (webui-mvp-test, test-workspace paths) - Removing paths that no longer exist on disk - Removing cross-profile leaks (paths under ~/.hermes/profiles/*) - Renaming 'default' workspace label to 'Home' (avoids confusion with the 'default' profile name) _migrate_global_workspaces(): one-time migration for upgrading users. Reads the legacy global workspaces.json, runs _clean_workspace_list, rewrites it cleaned. This runs automatically on first load after upgrade for the default profile only. load_workspaces(): now cleans every read and persists cleaned version if anything changed. Named profiles always start fresh (no global leak). Empty results fall back to 'Home' entry pointing at profile's workspace. Default label for auto-generated single-entry lists is 'Home', not 'default'. 2. api/models.py — legacy session profile backfill (already committed, this commit adds the sessions.js filter tightening counterpart) 3. static/sessions.js — strict profile filter Removed the '!s.profile' escape hatch from the profile filter. Server now backfills profile='default' on legacy sessions, so every session has an explicit tag. Filter is now exact: s.profile === S.activeProfile Named profiles see zero legacy clutter. Default profile sees its own sessions. 'All profiles' toggle still shows everything. Migration story for users pulling this update: - Existing sessions (profile=null) -> attributed to 'default' at read time - Global workspaces.json -> cleaned of test artifacts and cross-profile paths on first server start after upgrade - Named profile workspace files -> cleaned on first read, persisted clean - No manual intervention needed Tests: 426 passed, 0 failed.
2026-04-03 20:01:12 +00:00
raw = json.loads(ws_file.read_text(encoding='utf-8'))
cleaned = _clean_workspace_list(raw)
if len(cleaned) != len(raw):
# Persist the cleaned version so stale entries don't keep reappearing
try:
ws_file.write_text(
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
)
except Exception:
pass
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
except Exception:
pass
fix: workspace isolation, session filtering, and clean migration path Three interrelated fixes: 1. api/workspace.py — clean workspace isolation with auto-migration _clean_workspace_list(): sanitizes any workspace list by: - Removing test artifacts (webui-mvp-test, test-workspace paths) - Removing paths that no longer exist on disk - Removing cross-profile leaks (paths under ~/.hermes/profiles/*) - Renaming 'default' workspace label to 'Home' (avoids confusion with the 'default' profile name) _migrate_global_workspaces(): one-time migration for upgrading users. Reads the legacy global workspaces.json, runs _clean_workspace_list, rewrites it cleaned. This runs automatically on first load after upgrade for the default profile only. load_workspaces(): now cleans every read and persists cleaned version if anything changed. Named profiles always start fresh (no global leak). Empty results fall back to 'Home' entry pointing at profile's workspace. Default label for auto-generated single-entry lists is 'Home', not 'default'. 2. api/models.py — legacy session profile backfill (already committed, this commit adds the sessions.js filter tightening counterpart) 3. static/sessions.js — strict profile filter Removed the '!s.profile' escape hatch from the profile filter. Server now backfills profile='default' on legacy sessions, so every session has an explicit tag. Filter is now exact: s.profile === S.activeProfile Named profiles see zero legacy clutter. Default profile sees its own sessions. 'All profiles' toggle still shows everything. Migration story for users pulling this update: - Existing sessions (profile=null) -> attributed to 'default' at read time - Global workspaces.json -> cleaned of test artifacts and cross-profile paths on first server start after upgrade - Named profile workspace files -> cleaned on first read, persisted clean - No manual intervention needed Tests: 426 passed, 0 failed.
2026-04-03 20:01:12 +00:00
# No profile-local file yet.
# For the DEFAULT profile: migrate from the legacy global file (one-time cleanup).
# For NAMED profiles: always start clean with just their own workspace.
fix(review): 5 issues found in agent review of PR #43 BUG-1 (critical): api/profiles.py _DEFAULT_HERMES_HOME used Path.home()/.hermes hardcoded, ignoring the HERMES_HOME env var. conftest.py sets HERMES_HOME to a test-isolated state dir -- but profiles.py bypassed it and read/wrote real ~/.hermes during every test run (active_profile file, .env loading). Fixed by reading os.getenv('HERMES_HOME', ...) at module load time. BUG-7 (medium): api/workspace.py load_workspaces() fell back to the global workspaces.json for ALL profiles when their profile-local file didn't exist yet. New named profiles silently inherited the default profile's workspace list instead of starting clean. Fixed: the global file fallback now only applies to the default profile (migration path); named profiles start with a fresh default entry. BUG-4 (high): test_sessions_list_includes_profile had a vacuous 'if matching:' guard -- if the session wasn't found the assert was silently skipped and the test passed. Fixed with hard assert. Also changed to use /api/session?session_id= directly instead of scanning /api/sessions (which filters out empty Untitled sessions with 0 messages, causing the test to always see an empty match list). BUG-5 / test ordering regression: test_profile_switch_returns_default_model_and_workspace failed with 409 because test_chat_stream_opens_successfully (runs earlier in the suite) starts a real LLM stream that stays alive in STREAMS. Added a wait loop (up to 30s) polling /health active_streams before attempting the profile switch. BUG-8 (low): Removed dead import _profile_default_workspace in switch_profile() -- was imported but never used (get_last_workspace() already delegates to it). Also: test_profile_active_endpoint hardcoded assert data['name'] == 'default' which fails if a prior run left a non-default active_profile on disk. Changed to assert name is a non-empty string (the endpoint contract), not a specific value. Tests: 423 passed, 0 failed.
2026-04-03 19:03:16 +00:00
try:
from api.profiles import get_active_profile_name
is_default = get_active_profile_name() in ('default', None)
except ImportError:
is_default = True
fix: workspace isolation, session filtering, and clean migration path Three interrelated fixes: 1. api/workspace.py — clean workspace isolation with auto-migration _clean_workspace_list(): sanitizes any workspace list by: - Removing test artifacts (webui-mvp-test, test-workspace paths) - Removing paths that no longer exist on disk - Removing cross-profile leaks (paths under ~/.hermes/profiles/*) - Renaming 'default' workspace label to 'Home' (avoids confusion with the 'default' profile name) _migrate_global_workspaces(): one-time migration for upgrading users. Reads the legacy global workspaces.json, runs _clean_workspace_list, rewrites it cleaned. This runs automatically on first load after upgrade for the default profile only. load_workspaces(): now cleans every read and persists cleaned version if anything changed. Named profiles always start fresh (no global leak). Empty results fall back to 'Home' entry pointing at profile's workspace. Default label for auto-generated single-entry lists is 'Home', not 'default'. 2. api/models.py — legacy session profile backfill (already committed, this commit adds the sessions.js filter tightening counterpart) 3. static/sessions.js — strict profile filter Removed the '!s.profile' escape hatch from the profile filter. Server now backfills profile='default' on legacy sessions, so every session has an explicit tag. Filter is now exact: s.profile === S.activeProfile Named profiles see zero legacy clutter. Default profile sees its own sessions. 'All profiles' toggle still shows everything. Migration story for users pulling this update: - Existing sessions (profile=null) -> attributed to 'default' at read time - Global workspaces.json -> cleaned of test artifacts and cross-profile paths on first server start after upgrade - Named profile workspace files -> cleaned on first read, persisted clean - No manual intervention needed Tests: 426 passed, 0 failed.
2026-04-03 20:01:12 +00:00
if is_default:
migrated = _migrate_global_workspaces()
if migrated:
return migrated
# Fresh start: single entry from the profile's configured workspace, labeled "Home"
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
def save_workspaces(workspaces: list):
ws_file = _workspaces_file()
ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
def get_last_workspace() -> str:
lw_file = _last_workspace_file()
if lw_file.exists():
try:
p = lw_file.read_text(encoding='utf-8').strip()
if p and Path(p).is_dir():
return p
except Exception:
pass
# Fallback: try global file
if _GLOBAL_LW_FILE.exists():
try:
p = _GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()
if p and Path(p).is_dir():
return p
except Exception:
pass
return _profile_default_workspace()
def set_last_workspace(path: str):
try:
lw_file = _last_workspace_file()
lw_file.parent.mkdir(parents=True, exist_ok=True)
lw_file.write_text(str(path), encoding='utf-8')
except Exception:
pass
def safe_resolve_ws(root: Path, requested: str) -> Path:
"""Resolve a relative path inside a workspace root, raising ValueError on traversal."""
resolved = (root / requested).resolve()
resolved.relative_to(root.resolve())
return resolved
def list_dir(workspace: Path, rel='.'):
target = safe_resolve_ws(workspace, rel)
if not target.is_dir():
raise FileNotFoundError(f"Not a directory: {rel}")
entries = []
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
entries.append({
'name': item.name,
'path': str(item.relative_to(workspace)),
'type': 'dir' if item.is_dir() else 'file',
'size': item.stat().st_size if item.is_file() else None,
})
if len(entries) >= 200:
break
return entries
def read_file_content(workspace: Path, rel: str):
target = safe_resolve_ws(workspace, rel)
if not target.is_file():
raise FileNotFoundError(f"Not a file: {rel}")
size = target.stat().st_size
if size > MAX_FILE_BYTES:
raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})")
content = target.read_text(encoding='utf-8', errors='replace')
return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1}
# ── Git detection ──────────────────────────────────────────────────────────
def _run_git(args, cwd, timeout=3):
"""Run a git command and return stdout, or None on failure."""
try:
r = subprocess.run(
['git'] + args, cwd=str(cwd), capture_output=True,
text=True, timeout=timeout,
)
return r.stdout.strip() if r.returncode == 0 else None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
def git_info_for_workspace(workspace: Path) -> dict:
"""Return git info for a workspace directory, or None if not a git repo."""
if not (workspace / '.git').exists():
return None
branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], workspace)
if branch is None:
return None
# Status counts
status_out = _run_git(['status', '--porcelain'], workspace) or ''
lines = [l for l in status_out.splitlines() if l]
# git status --porcelain: XY format where X=index, Y=worktree
modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR'))
untracked = sum(1 for l in lines if l.startswith('??'))
dirty = len(lines)
# Ahead/behind
ahead = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace)
behind = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace)
return {
'branch': branch,
'dirty': dirty,
'modified': modified,
'untracked': untracked,
'ahead': int(ahead) if ahead and ahead.isdigit() else 0,
'behind': int(behind) if behind and behind.isdigit() else 0,
'is_git': True,
}