4fc74e7da7
Docker / build (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
281 lines
8.9 KiB
Python
281 lines
8.9 KiB
Python
"""Path resolution utilities for EvoScientist runtime directories."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from collections.abc import Iterator
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _expand(path: str) -> Path:
|
|
return Path(path).expanduser()
|
|
|
|
|
|
def _env_path(key: str) -> Path | None:
|
|
value = os.getenv(key)
|
|
if not value:
|
|
return None
|
|
return _expand(value)
|
|
|
|
|
|
# Workspace root: current working directory by default (user's project dir)
|
|
WORKSPACE_ROOT = _env_path("EVOSCIENTIST_WORKSPACE_DIR") or Path.cwd()
|
|
|
|
RUNS_DIR = _env_path("EVOSCIENTIST_RUNS_DIR") or (WORKSPACE_ROOT / "runs")
|
|
USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "skills")
|
|
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:
|
|
return DATA_DIR / "skills"
|
|
|
|
|
|
def _global_memories_dir() -> Path:
|
|
return DATA_DIR / "memories"
|
|
|
|
|
|
# Global skills: shared across all workspaces (~/.evoscientist/skills/)
|
|
GLOBAL_SKILLS_DIR: Path = _global_skills_dir()
|
|
|
|
# Global memories: shared across all workspaces (~/.evoscientist/memories/)
|
|
GLOBAL_MEMORIES_DIR: Path = _global_memories_dir()
|
|
|
|
# Memories dir: global by default, overridable via env var.
|
|
# Supports both new (EVOSCIENTIST_MEMORIES_DIR) and old (EVOSCIENTIST_MEMORY_DIR) env vars.
|
|
MEMORIES_DIR: Path = (
|
|
_env_path("EVOSCIENTIST_MEMORIES_DIR")
|
|
or _env_path("EVOSCIENTIST_MEMORY_DIR")
|
|
or GLOBAL_MEMORIES_DIR
|
|
)
|
|
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.
|
|
|
|
Directories with an explicit environment-variable override keep their
|
|
env-var value; all others are re-derived from the new root.
|
|
Also resets ``_active_workspace`` to the new root as a safe default.
|
|
|
|
Note: MEMORIES_DIR is global (not workspace-scoped) but env var overrides
|
|
are re-evaluated here to support late-set environment variables.
|
|
"""
|
|
global \
|
|
WORKSPACE_ROOT, \
|
|
RUNS_DIR, \
|
|
MEMORIES_DIR, \
|
|
MEMORY_DIR, \
|
|
USER_SKILLS_DIR, \
|
|
MEDIA_DIR, \
|
|
_active_workspace
|
|
WORKSPACE_ROOT = Path(path).resolve()
|
|
_active_workspace = WORKSPACE_ROOT
|
|
RUNS_DIR = _env_path("EVOSCIENTIST_RUNS_DIR") or (WORKSPACE_ROOT / "runs")
|
|
MEMORIES_DIR = (
|
|
_env_path("EVOSCIENTIST_MEMORIES_DIR")
|
|
or _env_path("EVOSCIENTIST_MEMORY_DIR")
|
|
or GLOBAL_MEMORIES_DIR
|
|
)
|
|
MEMORY_DIR = MEMORIES_DIR
|
|
USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (
|
|
WORKSPACE_ROOT / "skills"
|
|
)
|
|
MEDIA_DIR = _env_path("EVOSCIENTIST_MEDIA_DIR") or (WORKSPACE_ROOT / "media")
|
|
|
|
|
|
def ensure_dirs() -> None:
|
|
"""Create runtime subdirectories if they do not exist.
|
|
|
|
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)
|
|
|
|
|
|
def default_workspace_dir() -> Path:
|
|
"""Default workspace for non-CLI usage."""
|
|
return WORKSPACE_ROOT
|
|
|
|
|
|
def new_run_dir(session_id: str | None = None) -> Path:
|
|
"""Create a new run directory name under RUNS_DIR (path only)."""
|
|
if session_id is None:
|
|
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
return RUNS_DIR / session_id
|
|
|
|
|
|
# Active workspace (may differ from WORKSPACE_ROOT in per-session modes)
|
|
_active_workspace: Path = WORKSPACE_ROOT
|
|
|
|
|
|
def set_active_workspace(path: str | Path) -> None:
|
|
"""Update the active workspace root (called on agent creation)."""
|
|
global _active_workspace
|
|
_active_workspace = Path(path).resolve()
|
|
|
|
|
|
def resolve_virtual_path(virtual_path: str) -> Path:
|
|
"""Resolve a virtual workspace path (e.g. /image.png) to a real filesystem path."""
|
|
vpath = virtual_path if virtual_path.startswith("/") else "/" + virtual_path
|
|
return (_active_workspace / vpath.lstrip("/")).resolve()
|
|
|
|
|
|
def evoscientist_root() -> Path:
|
|
"""Return the application root used by Gateway-managed runtime data."""
|
|
env_root = os.environ.get("EVOSCIENTIST_HOME")
|
|
if env_root:
|
|
return Path(env_root).expanduser().resolve()
|
|
return DATA_DIR.expanduser().resolve()
|
|
|
|
|
|
_EVOSCIENTIST_DATA_ROOT: Path | None = None
|
|
|
|
|
|
def _data_root() -> Path:
|
|
"""Return the root directory for isolated Web user workspaces."""
|
|
global _EVOSCIENTIST_DATA_ROOT
|
|
if _EVOSCIENTIST_DATA_ROOT is not None:
|
|
return _EVOSCIENTIST_DATA_ROOT
|
|
|
|
env_root = os.environ.get("EVOSCIENTIST_DATA_ROOT")
|
|
if env_root:
|
|
root = Path(env_root).expanduser().resolve()
|
|
else:
|
|
root = evoscientist_root() / "data"
|
|
_EVOSCIENTIST_DATA_ROOT = root
|
|
return root
|
|
|
|
|
|
def user_data_dir(user_id: str) -> Path:
|
|
"""Return and create the isolated data directory for a Web user."""
|
|
path = _data_root() / user_id
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def iter_user_data_dirs() -> Iterator[Path]:
|
|
"""Yield existing Web user directories without creating the data root."""
|
|
root = _data_root()
|
|
if not root.exists():
|
|
return
|
|
for path in root.iterdir():
|
|
if path.is_dir():
|
|
yield path
|
|
|
|
|
|
def thread_data_dir(user_id: str, thread_id: str) -> Path:
|
|
"""Return and create a user's isolated thread workspace."""
|
|
path = user_data_dir(user_id) / thread_id
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def global_data_dir(user_id: str) -> Path:
|
|
"""Return and create a user's directory shared across all threads."""
|
|
path = user_data_dir(user_id) / "__global__"
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def uploads_dir() -> Path:
|
|
"""Return the Gateway upload staging directory."""
|
|
return evoscientist_root() / "uploads"
|