fix: write runtime data under OPENSPACE_HOME, not site-packages

After a normal pip install, PROJECT_ROOT resolves to site-packages, so
SkillStore, caches, telemetry, and MCP logs could not create writable
state. Route mutable paths through get_data_home() and sync __version__.
This commit is contained in:
Ayush7614 2026-07-29 02:49:24 +05:30
parent 618c8461da
commit 5140ea47a3
13 changed files with 246 additions and 35 deletions

3
.gitignore vendored
View file

@ -62,6 +62,9 @@ tests/skill_engine/evolution/*
!tests/cloud/
tests/cloud/*
!tests/cloud/test_upload_trust.py
!tests/config/
tests/config/*
!tests/config/test_data_home.py
scripts/
# Local agent/project memory

View file

@ -7,7 +7,7 @@ if _TYPE_CHECKING:
from openspace.llm import LLMClient as LLMClient
from openspace.recording import RecordingManager as RecordingManager
__version__ = "0.1.0"
__version__ = "2.0.0"
__all__ = [
# Version

View file

@ -15,7 +15,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Generator, Iterable, Mapping
from openspace.config.constants import PROJECT_ROOT
from openspace.config.constants import get_default_db_path
SKILL_ID_FILENAME = ".skill_id"
CLOUD_SKILL_SIDECAR_FILENAME = ".cloud_skill.json"
@ -179,9 +179,7 @@ class CloudLocalMappingStore:
def __init__(self, db_path: str | Path | None = None) -> None:
if db_path is None:
db_dir = PROJECT_ROOT / ".openspace"
db_dir.mkdir(parents=True, exist_ok=True)
db_path = db_dir / "openspace.db"
db_path = get_default_db_path(create=True)
self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._mu = threading.Lock()

View file

@ -18,7 +18,7 @@ from openspace.cloud.telemetry_payloads import (
build_usage_report_payload,
short_cloud_request_id,
)
from openspace.config.constants import PROJECT_ROOT
from openspace.config.constants import PROJECT_ROOT, get_data_home
class CloudTaskTraceReporter:
@ -37,7 +37,7 @@ class CloudTaskTraceReporter:
self._mapping_store = mapping_store
self._outbox = outbox
self._artifact_dir = Path(
artifact_dir or PROJECT_ROOT / ".openspace" / "cloud-task-traces"
artifact_dir or get_data_home(create=True) / "cloud-task-traces"
)
self._workspace_root = Path(workspace_root).resolve() if workspace_root else PROJECT_ROOT

View file

@ -12,7 +12,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Generator, Iterable
from openspace.config.constants import PROJECT_ROOT
from openspace.config.constants import get_default_db_path
from openspace.cloud.redaction import redact_telemetry_payload
_DDL = """
@ -52,9 +52,7 @@ class CloudTelemetryOutbox:
def __init__(self, db_path: str | Path | None = None) -> None:
if db_path is None:
db_dir = PROJECT_ROOT / ".openspace"
db_dir.mkdir(parents=True, exist_ok=True)
db_path = db_dir / "openspace.db"
db_path = get_default_db_path(create=True)
self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._mu = threading.Lock()

View file

@ -1,3 +1,14 @@
"""Shared path and config-name constants for OpenSpace.
``PROJECT_ROOT`` is the directory that *contains* the ``openspace`` package.
In a source checkout that is the repository root; after a normal ``pip install``
it resolves to ``site-packages``. Mutable runtime state must never be written
there use :func:`get_data_home` / :func:`get_default_db_path` instead.
"""
from __future__ import annotations
import os
from pathlib import Path
CONFIG_GROUNDING = "config_grounding.json"
@ -8,8 +19,79 @@ CONFIG_AGENTS = "config_agents.json"
LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# Project root directory (OpenSpace/)
PROJECT_ROOT = Path(__file__).parent.parent.parent
# Directory that contains the ``openspace`` package (repo root or site-packages).
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# Package directory itself (``.../openspace``).
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
_DATA_HOME_ENV = "OPENSPACE_HOME"
_DATA_DIR_ENV = "OPENSPACE_DATA_DIR"
def is_source_checkout(root: Path | None = None) -> bool:
"""Return True when *root* looks like a git/source checkout of OpenSpace.
Editable installs keep writing under the repo ``.openspace/`` directory.
Wheel/site-package installs do not those must use a user data home.
"""
candidate = Path(root) if root is not None else PROJECT_ROOT
return (candidate / "pyproject.toml").is_file() and (
candidate / "openspace" / "__init__.py"
).is_file()
def get_data_home(*, create: bool = False) -> Path:
"""Resolve the writable OpenSpace data directory.
Resolution order:
1. ``OPENSPACE_HOME`` explicit data home override
2. ``OPENSPACE_DATA_DIR`` alias for the same override
3. ``<repo>/.openspace`` when running from a source/editable checkout
4. ``~/.openspace`` for installed (site-packages) environments
The returned path is the data home itself (already named ``.openspace`` or
an explicit override). Callers should put databases/caches directly under it
(for example ``get_data_home() / "openspace.db"``).
"""
for env_name in (_DATA_HOME_ENV, _DATA_DIR_ENV):
raw = os.environ.get(env_name, "").strip()
if raw:
path = Path(raw).expanduser().resolve()
if create:
path.mkdir(parents=True, exist_ok=True)
return path
if is_source_checkout():
path = (PROJECT_ROOT / ".openspace").resolve()
else:
path = (Path.home() / ".openspace").resolve()
if create:
path.mkdir(parents=True, exist_ok=True)
return path
def get_default_db_path(*, create: bool = True) -> Path:
"""Default shared SQLite path (``openspace.db`` under the data home)."""
db_dir = get_data_home(create=create)
return db_dir / "openspace.db"
def get_cache_dir(name: str, *, create: bool = False) -> Path:
"""Return a named cache directory under the data home."""
path = get_data_home(create=create) / name
if create:
path.mkdir(parents=True, exist_ok=True)
return path
def get_log_dir(*, create: bool = True) -> Path:
"""Writable log directory (never under site-packages)."""
path = get_data_home(create=create) / "logs"
if create:
path.mkdir(parents=True, exist_ok=True)
return path
__all__ = [
@ -20,4 +102,10 @@ __all__ = [
"CONFIG_AGENTS",
"LOG_LEVELS",
"PROJECT_ROOT",
]
"PACKAGE_ROOT",
"is_source_checkout",
"get_data_home",
"get_default_db_path",
"get_cache_dir",
"get_log_dir",
]

View file

@ -82,8 +82,14 @@ class _MCPSafeStdout:
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
_LOG_DIR = _PROJECT_ROOT / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
try:
from openspace.config.constants import get_log_dir
_LOG_DIR = get_log_dir(create=True)
except Exception:
_LOG_DIR = Path.home() / ".openspace" / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_real_stdout = sys.stdout

View file

@ -145,7 +145,7 @@ class GroundingClient:
from .quality import ToolQualityManager, set_quality_manager
from pathlib import Path
from openspace.config.constants import PROJECT_ROOT
from openspace.config.constants import get_default_db_path
# Shared DB path
db_path = getattr(quality_config, 'db_path', None)
@ -153,9 +153,7 @@ class GroundingClient:
db_path = Path(db_path)
else:
# Default: same location as SkillStore
db_dir = PROJECT_ROOT / ".openspace"
db_dir.mkdir(parents=True, exist_ok=True)
db_path = db_dir / "openspace.db"
db_path = get_default_db_path(create=True)
manager = ToolQualityManager(
db_path=db_path,

View file

@ -2,7 +2,7 @@
SQLite-backed persistence for tool quality data. Shares the same database file as SkillStore.
Storage location (default):
<project_root>/.openspace/openspace.db
<data_home>/openspace.db (see openspace.config.constants.get_data_home)
Tables managed by this module:
tool_quality_records one row per tool (aggregate stats)
@ -18,7 +18,7 @@ from typing import Dict, Optional, Tuple
from .types import ToolQualityRecord, ExecutionRecord
from openspace.utils.logging import Logger
from openspace.config.constants import PROJECT_ROOT
from openspace.config.constants import get_default_db_path
logger = Logger.get_logger(__name__)
@ -60,15 +60,13 @@ class QualityStore:
"""SQLite-backed persistence for tool quality data.
By default uses the same ``.db`` file as ``SkillStore``
(``<project_root>/.openspace/openspace.db``).
(``get_default_db_path()``).
Each subsystem creates its own tables independently.
"""
def __init__(self, db_path: Optional[Path] = None):
if db_path is None:
db_dir = PROJECT_ROOT / ".openspace"
db_dir.mkdir(parents=True, exist_ok=True)
db_path = db_dir / "openspace.db"
db_path = get_default_db_path(create=True)
self._db_path = Path(db_path)
self._mu = threading.Lock()

View file

@ -15,7 +15,7 @@ from .tool import BaseTool
from .types import BackendType
from .tool_discovery import rank_tools_by_keyword
from openspace.utils.logging import Logger
from openspace.config.constants import PROJECT_ROOT
from openspace.config.constants import get_cache_dir
if TYPE_CHECKING:
from .quality import ToolQualityManager
@ -89,7 +89,7 @@ class ToolRanker:
# Persistent cache settings
self._enable_cache_persistence = enable_cache_persistence
if cache_dir is None:
cache_dir = PROJECT_ROOT / ".openspace" / "embedding_cache"
cache_dir = get_cache_dir("embedding_cache", create=False)
self._cache_dir = Path(cache_dir)
# Log cache settings

View file

@ -86,10 +86,10 @@ class SkillRanker:
if cache_dir is None:
try:
from openspace.config.constants import PROJECT_ROOT
cache_dir = PROJECT_ROOT / ".openspace" / "skill_embedding_cache"
from openspace.config.constants import get_cache_dir
cache_dir = get_cache_dir("skill_embedding_cache", create=False)
except Exception:
cache_dir = Path(".openspace") / "skill_embedding_cache"
cache_dir = Path.home() / ".openspace" / "skill_embedding_cache"
self._cache_dir = Path(cache_dir)
if self._enable_cache:

View file

@ -37,7 +37,7 @@ from .types import (
SkillVisibility,
)
from openspace.utils.logging import Logger
from openspace.config.constants import PROJECT_ROOT
from openspace.config.constants import get_default_db_path
from openspace.grounding.core.permissions.types import parse_rule_value
logger = Logger.get_logger(__name__)
@ -453,9 +453,7 @@ class SkillStore:
trust_promotion_min_independent_successes: int = 2,
) -> None:
if db_path is None:
db_dir = PROJECT_ROOT / ".openspace"
db_dir.mkdir(parents=True, exist_ok=True)
db_path = db_dir / "openspace.db"
db_path = get_default_db_path(create=True)
self._db_path = Path(db_path).expanduser()
self._db_path.parent.mkdir(parents=True, exist_ok=True)

View file

@ -0,0 +1,124 @@
"""Tests for writable OpenSpace data-home resolution."""
from __future__ import annotations
from pathlib import Path
import openspace.config.constants as constants
def test_is_source_checkout_true_for_repo_layout(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text('[project]\nname="openspace"\n', encoding="utf-8")
pkg = tmp_path / "openspace"
pkg.mkdir()
(pkg / "__init__.py").write_text("__version__ = '2.0.0'\n", encoding="utf-8")
assert constants.is_source_checkout(tmp_path) is True
def test_is_source_checkout_false_for_site_packages_layout(tmp_path: Path) -> None:
pkg = tmp_path / "openspace"
pkg.mkdir()
(pkg / "__init__.py").write_text("__version__ = '2.0.0'\n", encoding="utf-8")
assert constants.is_source_checkout(tmp_path) is False
def test_get_data_home_respects_openspace_home(
tmp_path: Path, monkeypatch
) -> None:
home = tmp_path / "custom-home"
monkeypatch.setenv("OPENSPACE_HOME", str(home))
monkeypatch.delenv("OPENSPACE_DATA_DIR", raising=False)
resolved = constants.get_data_home(create=True)
assert resolved == home.resolve()
assert home.is_dir()
def test_get_data_home_respects_openspace_data_dir_alias(
tmp_path: Path, monkeypatch
) -> None:
home = tmp_path / "alias-home"
monkeypatch.delenv("OPENSPACE_HOME", raising=False)
monkeypatch.setenv("OPENSPACE_DATA_DIR", str(home))
resolved = constants.get_data_home(create=True)
assert resolved == home.resolve()
def test_get_data_home_uses_user_home_when_not_source_checkout(
tmp_path: Path, monkeypatch
) -> None:
fake_home = tmp_path / "user"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.delenv("OPENSPACE_HOME", raising=False)
monkeypatch.delenv("OPENSPACE_DATA_DIR", raising=False)
monkeypatch.setattr(constants, "PROJECT_ROOT", tmp_path / "site-packages")
monkeypatch.setattr(constants, "is_source_checkout", lambda root=None: False)
resolved = constants.get_data_home(create=True)
assert resolved == (fake_home / ".openspace").resolve()
assert resolved.is_dir()
def test_get_data_home_uses_repo_dot_openspace_in_checkout(
tmp_path: Path, monkeypatch
) -> None:
(tmp_path / "pyproject.toml").write_text('[project]\nname="openspace"\n', encoding="utf-8")
pkg = tmp_path / "openspace"
pkg.mkdir()
(pkg / "__init__.py").write_text("", encoding="utf-8")
monkeypatch.delenv("OPENSPACE_HOME", raising=False)
monkeypatch.delenv("OPENSPACE_DATA_DIR", raising=False)
monkeypatch.setattr(constants, "PROJECT_ROOT", tmp_path)
resolved = constants.get_data_home(create=True)
assert resolved == (tmp_path / ".openspace").resolve()
def test_get_default_db_path_and_cache_dir(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("OPENSPACE_HOME", str(tmp_path / "data"))
monkeypatch.delenv("OPENSPACE_DATA_DIR", raising=False)
db_path = constants.get_default_db_path(create=True)
cache = constants.get_cache_dir("embedding_cache", create=True)
logs = constants.get_log_dir(create=True)
assert db_path == (tmp_path / "data" / "openspace.db").resolve()
assert cache == (tmp_path / "data" / "embedding_cache").resolve()
assert logs == (tmp_path / "data" / "logs").resolve()
assert cache.is_dir()
assert logs.is_dir()
def test_skill_store_default_path_uses_data_home(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.setenv("OPENSPACE_HOME", str(tmp_path / "runtime"))
monkeypatch.delenv("OPENSPACE_DATA_DIR", raising=False)
from openspace.skill_engine.store import SkillStore
store = SkillStore()
try:
expected = (tmp_path / "runtime" / "openspace.db").resolve()
assert store.db_path.resolve() == expected
assert expected.is_file()
finally:
store.close()
def test_package_version_matches_pyproject() -> None:
import openspace
from pathlib import Path
import re
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
text = pyproject.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
assert match is not None
assert openspace.__version__ == match.group(1)