refactor(paths): unify global data directory to ~/.evoscientist and u… (#164)
* refactor(paths): unify global data directory to ~/.evoscientist and update related paths * refactor(paths): update legacy session migration to respect XDG_CONFIG_HOME * refactor(tests): clear XDG_CONFIG_HOME in legacy session migration tests for deterministic behavior
This commit is contained in:
@@ -291,7 +291,7 @@ class MergedSkillsBackend(BackendProtocol):
|
||||
|
||||
Priority (high → low):
|
||||
1. primary — workspace/skills/ (project-local, writable)
|
||||
2. global — ~/.config/evoscientist/skills/ (user global, read-only)
|
||||
2. global — ~/.evoscientist/skills/ (user global, read-only)
|
||||
3. secondary — EvoScientist/skills/ (built-in, PyPI, read-only)
|
||||
|
||||
Higher-priority skills override lower-priority skills with the same name.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""History-based auto-suggest for Textual TUI Input widget.
|
||||
|
||||
Reads prompt_toolkit FileHistory format so Rich CLI and TUI share the same
|
||||
history file at ~/.config/evoscientist/history.
|
||||
history file at ~/.evoscientist/history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -296,11 +296,8 @@ def cmd_interactive(
|
||||
|
||||
memory_dir = str(paths.MEMORIES_DIR)
|
||||
|
||||
from ..config.settings import get_config_dir
|
||||
|
||||
config_dir = get_config_dir()
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
history_file = str(config_dir / "history")
|
||||
paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
history_file = str(paths.DATA_DIR / "history")
|
||||
|
||||
# Key bindings: Enter submits, Alt+Enter (Option+Enter) inserts newline
|
||||
_kb = KeyBindings()
|
||||
|
||||
@@ -58,7 +58,7 @@ def _cmd_list_skills() -> None:
|
||||
def _cmd_install_skill(args: str) -> None:
|
||||
"""Install a skill from local path or GitHub URL.
|
||||
|
||||
By default, installs to the global skills directory (~/.config/evoscientist/skills/).
|
||||
By default, installs to the global skills directory (~/.evoscientist/skills/).
|
||||
Append --local to install to the current workspace instead.
|
||||
|
||||
Usage: /install-skill <path-or-url> [--local]
|
||||
|
||||
@@ -22,7 +22,7 @@ import EvoScientist.cli.channel as _ch_mod
|
||||
|
||||
from ..commands import CommandContext
|
||||
from ..commands import manager as cmd_manager
|
||||
from ..config.settings import get_config_dir
|
||||
from ..paths import DATA_DIR
|
||||
from ..sessions import (
|
||||
find_similar_threads,
|
||||
generate_thread_id,
|
||||
@@ -353,7 +353,7 @@ def run_textual_interactive(
|
||||
self._picker_future: asyncio.Future | None = None
|
||||
self._browser_future: asyncio.Future | None = None
|
||||
self._mcp_browser_future: asyncio.Future | None = None
|
||||
self._history_suggester = HistorySuggester(get_config_dir() / "history")
|
||||
self._history_suggester = HistorySuggester(DATA_DIR / "history")
|
||||
self._history_index: int = -1 # -1 = not browsing history
|
||||
self._history_saved_input: str = "" # saved current input before browsing
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
+100
-10
@@ -2,10 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _expand(path: str) -> Path:
|
||||
return Path(path).expanduser()
|
||||
@@ -26,22 +30,32 @@ USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "ski
|
||||
MEDIA_DIR = _env_path("EVOSCIENTIST_MEDIA_DIR") or (WORKSPACE_ROOT / "media")
|
||||
|
||||
|
||||
def _global_data_dir() -> Path:
|
||||
"""Global application data directory (~/.evoscientist/ by default).
|
||||
|
||||
This is the base for sessions.db, skills/, memories/, history — things
|
||||
that are NOT configuration but application state. Config files (config.yaml,
|
||||
mcp.yaml) continue to live in XDG_CONFIG_HOME.
|
||||
"""
|
||||
return Path.home() / ".evoscientist"
|
||||
|
||||
|
||||
# Global data dir: ~/.evoscientist/ by default, overridable via env var.
|
||||
DATA_DIR: Path = _env_path("EVOSCIENTIST_DATA_DIR") or _global_data_dir()
|
||||
|
||||
|
||||
def _global_skills_dir() -> Path:
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else Path.home() / ".config"
|
||||
return base / "evoscientist" / "skills"
|
||||
return DATA_DIR / "skills"
|
||||
|
||||
|
||||
def _global_memories_dir() -> Path:
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else Path.home() / ".config"
|
||||
return base / "evoscientist" / "memories"
|
||||
return DATA_DIR / "memories"
|
||||
|
||||
|
||||
# Global skills: shared across all workspaces (~/.config/evoscientist/skills/)
|
||||
# Global skills: shared across all workspaces (~/.evoscientist/skills/)
|
||||
GLOBAL_SKILLS_DIR: Path = _global_skills_dir()
|
||||
|
||||
# Global memories: shared across all workspaces (~/.config/evoscientist/memories/)
|
||||
# Global memories: shared across all workspaces (~/.evoscientist/memories/)
|
||||
GLOBAL_MEMORIES_DIR: Path = _global_memories_dir()
|
||||
|
||||
# Memories dir: global by default, overridable via env var.
|
||||
@@ -54,6 +68,80 @@ MEMORIES_DIR: Path = (
|
||||
MEMORY_DIR = MEMORIES_DIR # backward compat alias
|
||||
|
||||
|
||||
# DEPRECATED(0.1.0): remove this migration helper and its call site below.
|
||||
def migrate_legacy_sessions_db() -> None:
|
||||
"""One-time migration: copy sessions.db (and its WAL/SHM siblings) from
|
||||
~/.config/evoscientist/ to ~/.evoscientist/.
|
||||
|
||||
Scope is intentionally narrow — only the SQLite trio, because users can't
|
||||
easily move those by hand. User-facing files (skills/, memories/, history)
|
||||
are migrated via an agent prompt documented in the release notes.
|
||||
|
||||
Idempotent via ``.migrated`` marker file. The marker is not written when
|
||||
a copy fails, so transient I/O errors don't permanently block retry.
|
||||
"""
|
||||
marker = DATA_DIR / ".migrated"
|
||||
if marker.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
logger.debug("Could not create %s; skipping legacy migration.", DATA_DIR)
|
||||
return
|
||||
|
||||
# Resolve legacy source via XDG_CONFIG_HOME (matches config.settings.get_config_dir).
|
||||
# Inlined here to avoid importing config.settings at paths load time.
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
legacy = (
|
||||
(Path(xdg) / "evoscientist")
|
||||
if xdg
|
||||
else (Path.home() / ".config" / "evoscientist")
|
||||
)
|
||||
if not legacy.exists():
|
||||
marker.touch()
|
||||
return
|
||||
|
||||
migrated: list[str] = []
|
||||
failed: list[str] = []
|
||||
for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"):
|
||||
src = legacy / name
|
||||
dst = DATA_DIR / name
|
||||
if not src.exists():
|
||||
continue
|
||||
if dst.exists():
|
||||
continue # already migrated
|
||||
try:
|
||||
shutil.copy2(src, dst)
|
||||
migrated.append(name)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to migrate %s: %s", src, e)
|
||||
failed.append(name)
|
||||
|
||||
if migrated:
|
||||
logger.info(
|
||||
"Migrated legacy session DB from %s to %s: %s. "
|
||||
"Legacy files are kept as backup; this auto-migration will be "
|
||||
"removed in EvoScientist 0.1.0.",
|
||||
legacy,
|
||||
DATA_DIR,
|
||||
", ".join(migrated),
|
||||
)
|
||||
|
||||
# Only write the marker when there were no failures — preserves retry
|
||||
# on transient I/O errors.
|
||||
if not failed:
|
||||
marker.touch()
|
||||
|
||||
|
||||
# DEPRECATED(0.1.0): remove this call together with migrate_legacy_sessions_db().
|
||||
try:
|
||||
migrate_legacy_sessions_db()
|
||||
except Exception:
|
||||
# Never block startup on migration failures
|
||||
logger.exception("Legacy session DB migration failed; continuing without it.")
|
||||
|
||||
|
||||
def set_workspace_root(path: str | Path) -> None:
|
||||
"""Update workspace root and re-derive dependent directories.
|
||||
|
||||
@@ -90,12 +178,14 @@ def set_workspace_root(path: str | Path) -> None:
|
||||
def ensure_dirs() -> None:
|
||||
"""Create runtime subdirectories if they do not exist.
|
||||
|
||||
Only memories is created eagerly — skills directories are created on demand
|
||||
by install_skill() when the user first installs a skill.
|
||||
Creates DATA_DIR (and MEMORIES_DIR as its subdir). Skills directories
|
||||
are created on demand by ``install_skill()`` when the user first
|
||||
installs a skill.
|
||||
|
||||
Does NOT create the workspace root itself — it should already exist
|
||||
(either the user's cwd or a directory they specified).
|
||||
"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
MEMORIES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
|
||||
@@ -64,13 +64,13 @@ def _to_short_path(path: str) -> str:
|
||||
def get_db_path() -> Path:
|
||||
"""Return the sessions database path, creating parents.
|
||||
|
||||
Reuses ``get_config_dir()`` for XDG_CONFIG_HOME support, then applies
|
||||
Uses ``paths.DATA_DIR`` (~/.evoscientist/ by default), then applies
|
||||
a best-effort Windows 8.3 short-path conversion on the *directory*
|
||||
(which exists after ``mkdir``) so sqlite3 can handle non-ASCII paths.
|
||||
"""
|
||||
from .config.settings import get_config_dir
|
||||
from .paths import DATA_DIR
|
||||
|
||||
db_dir = get_config_dir()
|
||||
db_dir = DATA_DIR
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
return Path(_to_short_path(str(db_dir))) / "sessions.db"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Skill installation and management for EvoScientist.
|
||||
|
||||
This module provides functions for installing, listing, and uninstalling user skills.
|
||||
Skills are installed to GLOBAL_SKILLS_DIR by default (~/.config/evoscientist/skills/).
|
||||
Skills are installed to GLOBAL_SKILLS_DIR by default (~/.evoscientist/skills/).
|
||||
Pass global_install=False to install to USER_SKILLS_DIR (<workspace>/skills/) instead.
|
||||
|
||||
Supported installation sources:
|
||||
@@ -278,7 +278,7 @@ def install_skill(
|
||||
source: Local directory path or GitHub URL/shorthand.
|
||||
dest_dir: Explicit destination directory (overrides global_install).
|
||||
global_install: If True (default), install to GLOBAL_SKILLS_DIR
|
||||
(~/.config/evoscientist/skills/). If False, install to the
|
||||
(~/.evoscientist/skills/). If False, install to the
|
||||
workspace-local USER_SKILLS_DIR.
|
||||
|
||||
Returns:
|
||||
@@ -501,7 +501,7 @@ def list_skills(include_system: bool = False) -> list[SkillInfo]:
|
||||
# Tier 1: workspace-local skills (always highest priority, no dedup needed)
|
||||
_add_tier(Path(paths.USER_SKILLS_DIR), source="workspace", check_seen=False)
|
||||
|
||||
# Tier 2: global skills (~/.config/evoscientist/skills/)
|
||||
# Tier 2: global skills (~/.evoscientist/skills/)
|
||||
_add_tier(Path(paths.GLOBAL_SKILLS_DIR), source="global")
|
||||
|
||||
# Tier 3: built-in skills (optional)
|
||||
|
||||
@@ -10,12 +10,13 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .config.settings import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PYPI_URL = "https://pypi.org/pypi/EvoScientist/json"
|
||||
CACHE_DIR = Path("~/.config/evoscientist").expanduser()
|
||||
CACHE_DIR = get_config_dir()
|
||||
CACHE_FILE = CACHE_DIR / "latest_version.json"
|
||||
CACHE_TTL = 86_400 # 24 hours
|
||||
|
||||
|
||||
@@ -14,16 +14,22 @@ def _restore_paths():
|
||||
orig = {
|
||||
"WORKSPACE_ROOT": paths.WORKSPACE_ROOT,
|
||||
"RUNS_DIR": paths.RUNS_DIR,
|
||||
"DATA_DIR": paths.DATA_DIR,
|
||||
"MEMORIES_DIR": paths.MEMORIES_DIR,
|
||||
"MEMORY_DIR": paths.MEMORY_DIR,
|
||||
"GLOBAL_SKILLS_DIR": paths.GLOBAL_SKILLS_DIR,
|
||||
"GLOBAL_MEMORIES_DIR": paths.GLOBAL_MEMORIES_DIR,
|
||||
"USER_SKILLS_DIR": paths.USER_SKILLS_DIR,
|
||||
"_active_workspace": paths._active_workspace,
|
||||
}
|
||||
yield
|
||||
paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"]
|
||||
paths.RUNS_DIR = orig["RUNS_DIR"]
|
||||
paths.DATA_DIR = orig["DATA_DIR"]
|
||||
paths.MEMORIES_DIR = orig["MEMORIES_DIR"]
|
||||
paths.MEMORY_DIR = orig["MEMORY_DIR"]
|
||||
paths.GLOBAL_SKILLS_DIR = orig["GLOBAL_SKILLS_DIR"]
|
||||
paths.GLOBAL_MEMORIES_DIR = orig["GLOBAL_MEMORIES_DIR"]
|
||||
paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"]
|
||||
paths._active_workspace = orig["_active_workspace"]
|
||||
|
||||
@@ -120,3 +126,125 @@ class TestEnsureDirsUsesUpdatedPaths:
|
||||
assert not (
|
||||
new_root / "skills"
|
||||
).exists() # skills created on demand by install_skill()
|
||||
|
||||
|
||||
class TestDataDir:
|
||||
"""Tests for DATA_DIR and global data-dir helpers."""
|
||||
|
||||
def test_global_skills_dir_under_data_dir(self):
|
||||
"""GLOBAL_SKILLS_DIR must live under DATA_DIR."""
|
||||
assert paths.GLOBAL_SKILLS_DIR == paths.DATA_DIR / "skills"
|
||||
|
||||
def test_global_memories_dir_under_data_dir(self):
|
||||
"""GLOBAL_MEMORIES_DIR must live under DATA_DIR."""
|
||||
assert paths.GLOBAL_MEMORIES_DIR == paths.DATA_DIR / "memories"
|
||||
|
||||
|
||||
class TestLegacySessionsDbMigration:
|
||||
"""Tests for migrate_legacy_sessions_db() — transitional helper.
|
||||
|
||||
Tests redirect ``DATA_DIR`` and ``Path.home()`` so the real user home
|
||||
is never touched.
|
||||
"""
|
||||
|
||||
def _setup(self, tmp_path, monkeypatch):
|
||||
"""Redirect data dir to tmp_path/new_data and Path.home() to
|
||||
tmp_path/fake_home so legacy resolves to tmp_path/fake_home/.config/evoscientist.
|
||||
|
||||
Clears XDG_CONFIG_HOME so the legacy resolver deterministically uses
|
||||
the Path.home() fallback. Tests that want the XDG branch set the
|
||||
env var explicitly.
|
||||
"""
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
data_dir = tmp_path / "new_data"
|
||||
fake_home = tmp_path / "fake_home"
|
||||
fake_home.mkdir()
|
||||
legacy_dir = fake_home / ".config" / "evoscientist"
|
||||
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
|
||||
monkeypatch.setattr(paths.Path, "home", classmethod(lambda cls: fake_home))
|
||||
return data_dir, legacy_dir
|
||||
|
||||
def test_copies_sqlite_trio_when_legacy_exists(self, tmp_path, monkeypatch):
|
||||
"""All three SQLite files should be copied to the new location."""
|
||||
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
|
||||
legacy_dir.mkdir(parents=True)
|
||||
for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"):
|
||||
(legacy_dir / name).write_bytes(b"stub-" + name.encode())
|
||||
|
||||
paths.migrate_legacy_sessions_db()
|
||||
|
||||
for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"):
|
||||
assert (data_dir / name).read_bytes() == b"stub-" + name.encode()
|
||||
# Legacy files must remain (copy, not move)
|
||||
assert (legacy_dir / name).exists()
|
||||
assert (data_dir / ".migrated").exists()
|
||||
|
||||
def test_idempotent_via_marker(self, tmp_path, monkeypatch):
|
||||
"""Once .migrated exists, migration should be a no-op."""
|
||||
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
|
||||
data_dir.mkdir()
|
||||
(data_dir / ".migrated").touch()
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "sessions.db").write_bytes(b"should-not-copy")
|
||||
|
||||
paths.migrate_legacy_sessions_db()
|
||||
|
||||
assert not (data_dir / "sessions.db").exists()
|
||||
|
||||
def test_no_legacy_just_creates_marker(self, tmp_path, monkeypatch):
|
||||
"""When legacy dir doesn't exist, only the marker is created."""
|
||||
data_dir, _ = self._setup(tmp_path, monkeypatch)
|
||||
|
||||
paths.migrate_legacy_sessions_db()
|
||||
|
||||
assert data_dir.is_dir()
|
||||
assert (data_dir / ".migrated").exists()
|
||||
assert not (data_dir / "sessions.db").exists()
|
||||
|
||||
def test_does_not_overwrite_existing_files(self, tmp_path, monkeypatch):
|
||||
"""If new location already has a file, don't overwrite it."""
|
||||
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
|
||||
data_dir.mkdir()
|
||||
(data_dir / "sessions.db").write_bytes(b"new-content-keep")
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "sessions.db").write_bytes(b"legacy-content")
|
||||
|
||||
paths.migrate_legacy_sessions_db()
|
||||
|
||||
assert (data_dir / "sessions.db").read_bytes() == b"new-content-keep"
|
||||
|
||||
def test_respects_xdg_config_home(self, tmp_path, monkeypatch):
|
||||
"""Legacy source must honor XDG_CONFIG_HOME so users who customize it
|
||||
don't silently get skipped by the migration."""
|
||||
data_dir = tmp_path / "new_data"
|
||||
xdg = tmp_path / "xdg"
|
||||
legacy_dir = xdg / "evoscientist"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "sessions.db").write_bytes(b"xdg-db")
|
||||
|
||||
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
|
||||
|
||||
paths.migrate_legacy_sessions_db()
|
||||
|
||||
assert (data_dir / "sessions.db").read_bytes() == b"xdg-db"
|
||||
|
||||
def test_marker_not_written_on_partial_failure(self, tmp_path, monkeypatch):
|
||||
"""If any copy fails, the .migrated marker must not be written."""
|
||||
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
|
||||
legacy_dir.mkdir(parents=True)
|
||||
for name in ("sessions.db", "sessions.db-wal"):
|
||||
(legacy_dir / name).write_bytes(b"ok")
|
||||
|
||||
real_copy2 = paths.shutil.copy2
|
||||
|
||||
def flaky_copy2(src, dst, *args, **kwargs):
|
||||
if str(src).endswith("sessions.db-wal"):
|
||||
raise OSError("simulated I/O failure")
|
||||
return real_copy2(src, dst, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(paths.shutil, "copy2", side_effect=flaky_copy2):
|
||||
paths.migrate_legacy_sessions_db()
|
||||
|
||||
assert (data_dir / "sessions.db").exists() # main db copied
|
||||
assert not (data_dir / ".migrated").exists() # retry allowed
|
||||
|
||||
@@ -41,11 +41,10 @@ class TestGenerateThreadId(unittest.TestCase):
|
||||
|
||||
|
||||
class TestGetDbPath(unittest.TestCase):
|
||||
def test_uses_config_dir(self):
|
||||
def test_uses_data_dir(self):
|
||||
path = get_db_path()
|
||||
assert str(path).endswith("sessions.db")
|
||||
assert ".config" in str(path)
|
||||
assert "evoscientist" in str(path)
|
||||
assert ".evoscientist" in str(path)
|
||||
|
||||
|
||||
class TestFormatRelativeTime(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user