refactor(paths): v3 design - explicit initialize_paths + frozen PathsConfig singleton

This commit is contained in:
ed
2026-06-19 09:40:01 -04:00
parent 3fb9f9ff8e
commit 327b388800
11 changed files with 338 additions and 251 deletions
+1 -1
View File
@@ -1804,7 +1804,7 @@ class AppController:
spath = Path(proj_paths['scripts_dir'])
if not spath.is_absolute(): spath = project_root / spath
os.environ['SLOP_SCRIPTS_DIR'] = str(spath)
paths.reset_resolved()
paths.initialize_paths(paths.get_config_path())
path_info = paths.get_full_path_info()
self.ui_logs_dir = str(path_info['logs_dir']['path'])
+9 -2
View File
@@ -1311,7 +1311,7 @@ class App:
cfg_path = paths.get_config_path()
if cfg_path.exists(): shutil.copy(cfg_path, str(cfg_path) + ".bak")
self.save_config()
paths.reset_resolved()
paths.initialize_paths(cfg_path)
self.init_state()
self.ai_status = 'paths applied and session reset'
@@ -2341,10 +2341,17 @@ def render_paths_panel(app: App) -> None:
render_path_field("Logs Directory", "ui_logs_dir", "logs_dir", "Directory where session JSON-L logs and artifacts are stored.")
render_path_field("Scripts Directory", "ui_scripts_dir", "scripts_dir", "Directory for AI-generated PowerShell scripts.")
imgui.separator()
if imgui.button("Apply", imgui.ImVec2(120, 0)): app._save_paths()
imgui.same_line()
if imgui.button("Refresh Paths", imgui.ImVec2(140, 0)):
paths.initialize_paths(paths.get_config_path())
app.init_state()
app.ai_status = "paths reloaded from config.toml"
if imgui.is_item_hovered():
imgui.set_tooltip("Re-read [paths] section from config.toml and rebuild the PathsConfig singleton. Use after editing config.toml directly or after importing a new config.")
imgui.same_line()
if imgui.button("Reset", imgui.ImVec2(120, 0)):
app.init_state()
app.ai_status = "paths reset to defaults"
+198 -191
View File
@@ -1,189 +1,231 @@
"""
Paths - Centralized path resolution for configuration and environment variables.
Paths - Single source of truth for all application paths.
This module provides centralized path resolution for all configurable paths in the application.
All paths can be overridden via environment variables or config.toml.
All paths are resolved ONCE at startup via `initialize_paths(config_path)`,
which reads the active config.toml's `[paths]` section (with env-var overrides)
and builds an immutable `PathsConfig` snapshot. Path getters are trivial
lookups into this snapshot.
Environment Variables:
SLOP_CONFIG: Path to config.toml
SLOP_LOGS_DIR: Path to logs directory
SLOP_SCRIPTS_DIR: Path to generated scripts directory
**Usage contract:**
Configuration (config.toml):
[paths]
logs_dir = "logs/sessions"
scripts_dir = "scripts/generated"
1. Call `initialize_paths(config_path)` ONCE at process startup, BEFORE any
path getter is invoked. This is the only correct entry point.
2. After init, all `get_*_path()` functions return cached `Path` objects.
3. To change paths (e.g., in tests), call `initialize_paths(new_config_path)`
again — atomic swap under lock. Do not mutate `PathsConfig` instances;
they are frozen.
Path Functions:
get_config_path() -> Path to config.toml
get_conductor_dir(project_path=None) -> Path to conductor directory
get_logs_dir() -> Path to logs/sessions
get_scripts_dir() -> Path to scripts/generated
get_tracks_dir(project_path=None) -> Path to conductor/tracks
get_track_state_dir(track_id, project_path=None) -> Path to conductor/tracks/<track_id>
get_archive_dir(project_path=None) -> Path to conductor/archive
**Thread safety:** The singleton swap is guarded by an RLock. `PathsConfig`
is a `@dataclass(frozen=True)`, so reads of individual fields are atomic.
Reader threads see a consistent snapshot; writer threads serialize through
the lock. No partial writes.
Resolution Order:
1. Check project-specific manual_slop.toml (for conductor paths)
2. Check environment variable (for logs/scripts)
3. Check config.toml [paths] section (for logs/scripts)
4. Fall back to default
**Resolution priority** (per key, in `initialize_paths`):
1. Env var (e.g., `SLOP_GLOBAL_PRESETS`) if set
2. `config.toml [paths]` entry if present
3. Default `<project_root>/<default_filename>`
Usage:
from src.paths import get_logs_dir, get_scripts_dir
**Codepath ordering:**
logs_dir = get_logs_dir()
scripts_dir = get_scripts_dir()
The major codepaths that consume paths are:
- `sloppy.py` (production GUI entry point)
- `src/app_controller.py:AppController.__init__`
- `src/presets.py`, `src/tool_presets.py`, `src/personas.py`, etc.
See Also:
- docs/guide_tools.md for configuration documentation
- src/session_logger.py for logging paths
- src/project_manager.py for project paths
`initialize_paths()` must run BEFORE any of these. In sloppy.py, it runs
at the top of `__main__`. In tests, it runs at conftest module body (before
any src/ import). In other contexts (e.g., direct library use), the caller
is responsible.
If a path getter is called before `initialize_paths()`, a `RuntimeError`
is raised. This catches the "bad programmer" case where ordering is wrong.
"""
import os
import threading
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Any
from typing import Optional, Any
_RESOLVED: dict[str, Path] = {}
_CONFIG_OVERRIDE: Path | None = None
@dataclass(frozen=True)
class PathsConfig:
"""Immutable snapshot of resolved paths. Created ONCE per process.
[C: src/paths.py:initialize_paths, src/paths.py:_cfg]"""
config_path: Path
presets: Path
tool_presets: Path
personas: Path
themes: Path
workspace_profiles: Path
credentials: Path
logs_dir: Path
scripts_dir: Path
def set_config_override(path: Path | None) -> None:
"""
Set the active config.toml path. Pass None to use the default
<project_root>/config.toml. The CLI --config flag is the ONLY
supported override mechanism; the historical SLOP_CONFIG env var
fallback has been removed (per test_sandbox_hardening_20260619 FR2).
[C: sloppy.py:main, tests/conftest.py]
"""
global _CONFIG_OVERRIDE
_CONFIG_OVERRIDE = path
_RESOLVED.clear()
_PATHS_CONFIG: Optional[PathsConfig] = None
_PATHS_LOCK = threading.RLock()
def _resolve_path(env_var: str, config_key: str, default: Path, config_path: Path) -> Path:
"""Internal: resolve one path from env var -> config [paths] -> default.
Called only from initialize_paths(). Not thread-safe; caller holds lock."""
root_dir = Path(__file__).resolve().parent.parent
if env_var in os.environ:
return Path(os.environ[env_var])
try:
with open(config_path, "rb") as f:
cfg = tomllib.load(f)
if "paths" in cfg and config_key in cfg["paths"]:
p = Path(cfg["paths"][config_key])
return p if p.is_absolute() else root_dir / p
except (FileNotFoundError, tomllib.TOMLDecodeError):
pass
return default if default.is_absolute() else root_dir / default
def initialize_paths(config_path: Optional[Path] = None) -> PathsConfig:
"""Initialize the global paths singleton. Call this ONCE at startup,
BEFORE any path getter is invoked. Atomic swap under RLock.
If config_path is None, uses the default `<project_root>/config.toml`.
This is the SOLE entry point for setting the path graph at runtime.
Tests re-init to reset.
Raises:
OSError: if the config_path cannot be opened (other than FileNotFoundError
which is treated as "no [paths] overrides, use defaults")
TypeError: if config_path is not a Path
Returns:
The newly installed PathsConfig snapshot.
[C: src/paths.py:_cfg, tests/conftest.py:_setup_test_paths, sloppy.py:main]"""
global _PATHS_CONFIG
if config_path is None:
root_dir = Path(__file__).resolve().parent.parent
config_path = root_dir / "config.toml"
config_path = Path(config_path).resolve()
root_dir = Path(__file__).resolve().parent.parent
cfg = PathsConfig(
config_path = config_path,
presets = _resolve_path("SLOP_GLOBAL_PRESETS", "presets", root_dir / "presets.toml", config_path),
tool_presets = _resolve_path("SLOP_GLOBAL_TOOL_PRESETS", "tool_presets", root_dir / "tool_presets.toml", config_path),
personas = _resolve_path("SLOP_GLOBAL_PERSONAS", "personas", root_dir / "personas.toml", config_path),
themes = _resolve_path("SLOP_GLOBAL_THEMES", "themes", root_dir / "themes", config_path),
workspace_profiles = _resolve_path("SLOP_GLOBAL_WORKSPACE_PROFILES", "workspace_profiles", root_dir / "workspace_profiles.toml", config_path),
credentials = _resolve_path("SLOP_CREDENTIALS", "credentials", root_dir / "credentials.toml", config_path),
logs_dir = _resolve_path("SLOP_LOGS_DIR", "logs_dir", root_dir / "logs" / "sessions", config_path),
scripts_dir = _resolve_path("SLOP_SCRIPTS_DIR", "scripts_dir", root_dir / "scripts" / "generated", config_path),
)
with _PATHS_LOCK:
_PATHS_CONFIG = cfg
return cfg
def _cfg() -> PathsConfig:
"""Internal: get the current singleton, raising if uninitialized."""
if _PATHS_CONFIG is None:
raise RuntimeError(
"src.paths not initialized. Call paths.initialize_paths(<config.toml>) "
"BEFORE any path getter. See src/paths.py docstring for codepath ordering."
)
return _PATHS_CONFIG
# === Trivial getters (single source of truth) ===
def get_config_path() -> Path:
"""
Returns the active config.toml. If a CLI override is set, returns it.
Otherwise returns the default <project_root>/config.toml.
"""Active config.toml path. Frozen at initialize_paths() time.
[C: src/app_controller.py:AppController.load_config,
src/app_controller.py:AppController.init_state,
src/models.py:_load_config_from_disk,
tests/test_test_sandbox.py]
"""
if _CONFIG_OVERRIDE is not None:
return _CONFIG_OVERRIDE
root_dir = Path(__file__).resolve().parent.parent
return root_dir / "config.toml"
tests/test_test_sandbox.py]"""
return _cfg().config_path
def get_global_presets_path() -> Path:
"""
[C: src/presets.py:PresetManager.__init__, src/presets.py:PresetManager.delete_preset, src/presets.py:PresetManager.get_preset_scope]
"""
if "presets" not in _RESOLVED:
_RESOLVED["presets"] = _resolve_path("SLOP_GLOBAL_PRESETS", "presets", "presets.toml")
return _RESOLVED["presets"]
"""Global presets file. Frozen at initialize_paths() time.
[C: src/presets.py:PresetManager.__init__, src/presets.py:PresetManager.delete_preset, src/presets.py:PresetManager.get_preset_scope]"""
return _cfg().presets
def get_project_presets_path(project_root: Path) -> Path:
"""
[C: src/presets.py:PresetManager.delete_preset, src/presets.py:PresetManager.get_preset_scope, src/presets.py:PresetManager.project_path]
"""
"""Project-specific presets file. Computed from project_root (no cache).
[C: src/presets.py:PresetManager.delete_preset, src/presets.py:PresetManager.get_preset_scope, src/presets.py:PresetManager.project_path]"""
return project_root / "project_presets.toml"
def get_global_tool_presets_path() -> Path:
"""
[C: src/tool_presets.py:ToolPresetManager._get_path, src/tool_presets.py:ToolPresetManager.load_all_bias_profiles, src/tool_presets.py:ToolPresetManager.load_all_presets]
"""
if "tool_presets" not in _RESOLVED:
_RESOLVED["tool_presets"] = _resolve_path("SLOP_GLOBAL_TOOL_PRESETS", "tool_presets", "tool_presets.toml")
return _RESOLVED["tool_presets"]
"""Global tool presets file. Frozen at initialize_paths() time.
[C: src/tool_presets.py:ToolPresetManager._get_path, src/tool_presets.py:ToolPresetManager.load_all_bias_profiles, src/tool_presets.py:ToolPresetManager.load_all_presets]"""
return _cfg().tool_presets
def get_project_tool_presets_path(project_root: Path) -> Path:
"""
[C: src/tool_presets.py:ToolPresetManager._get_path, src/tool_presets.py:ToolPresetManager.load_all_bias_profiles, src/tool_presets.py:ToolPresetManager.load_all_presets]
"""
"""[C: src/tool_presets.py:ToolPresetManager._get_path, src/tool_presets.py:ToolPresetManager.load_all_bias_profiles, src/tool_presets.py:ToolPresetManager.load_all_presets]"""
return project_root / "project_tool_presets.toml"
def get_global_personas_path() -> Path:
"""
[C: src/personas.py:PersonaManager._get_path, src/personas.py:PersonaManager.get_persona_scope, src/personas.py:PersonaManager.load_all]
"""
if "personas" not in _RESOLVED:
_RESOLVED["personas"] = _resolve_path("SLOP_GLOBAL_PERSONAS", "personas", "personas.toml")
return _RESOLVED["personas"]
"""Global personas file. Frozen at initialize_paths() time.
[C: src/personas.py:PersonaManager._get_path, src/personas.py:PersonaManager.get_persona_scope, src/personas.py:PersonaManager.load_all]"""
return _cfg().personas
def get_project_personas_path(project_root: Path) -> Path:
"""
[C: src/personas.py:PersonaManager._get_path, src/personas.py:PersonaManager.get_persona_scope, src/personas.py:PersonaManager.load_all]
"""
"""[C: src/personas.py:PersonaManager._get_path, src/personas.py:PersonaManager.get_persona_scope, src/personas.py:PersonaManager.load_all]"""
return project_root / "project_personas.toml"
def get_global_themes_path() -> Path:
"""
[C: src/theme_2.py:load_themes_from_disk]
"""
if "themes" not in _RESOLVED:
_RESOLVED["themes"] = _resolve_path("SLOP_GLOBAL_THEMES", "themes", "themes")
return _RESOLVED["themes"]
"""Global themes directory. Frozen at initialize_paths() time.
[C: src/theme_2.py:load_themes_from_disk]"""
return _cfg().themes
def get_project_themes_path(project_root: Path) -> Path:
"""
[C: src/theme_2.py:load_themes_from_disk]
"""
"""[C: src/theme_2.py:load_themes_from_disk]"""
return project_root / "project_themes.toml"
def get_global_workspace_profiles_path() -> Path:
"""
[C: src/workspace_manager.py:WorkspaceManager._get_path, src/workspace_manager.py:WorkspaceManager.load_all_profiles]
"""
if "workspace_profiles" not in _RESOLVED:
_RESOLVED["workspace_profiles"] = _resolve_path("SLOP_GLOBAL_WORKSPACE_PROFILES", "workspace_profiles", "workspace_profiles.toml")
return _RESOLVED["workspace_profiles"]
"""Global workspace profiles file. Frozen at initialize_paths() time.
[C: src/workspace_manager.py:WorkspaceManager._get_path, src/workspace_manager.py:WorkspaceManager.load_all_profiles]"""
return _cfg().workspace_profiles
def get_project_workspace_profiles_path(project_root: Path) -> Path:
"""
[C: src/workspace_manager.py:WorkspaceManager._get_path, src/workspace_manager.py:WorkspaceManager.load_all_profiles]
"""
"""[C: src/workspace_manager.py:WorkspaceManager._get_path, src/workspace_manager.py:WorkspaceManager.load_all_profiles]"""
return project_root / ".ai" / "workspace_profiles.toml"
def get_credentials_path() -> Path:
"""
[C: src/mcp_client.py:_is_allowed]
"""
if "credentials" not in _RESOLVED:
_RESOLVED["credentials"] = _resolve_path("SLOP_CREDENTIALS", "credentials", "credentials.toml")
return _RESOLVED["credentials"]
"""Global credentials file. Frozen at initialize_paths() time.
[C: src/mcp_client.py:_is_allowed]"""
return _cfg().credentials
def get_logs_dir() -> Path:
"""Logs directory (contains session subdirs). Frozen at initialize_paths() time.
[C: src/session_logger.py:close_session, src/session_logger.py:open_session, tests/test_paths.py:test_config_overrides, tests/test_paths.py:test_default_paths, tests/test_paths.py:test_env_var_overrides, tests/test_paths.py:test_precedence]"""
return _cfg().logs_dir
def get_scripts_dir() -> Path:
"""Generated scripts directory. Frozen at initialize_paths() time.
[C: src/session_logger.py:log_tool_call, src/session_logger.py:open_session, tests/test_paths.py:test_config_overrides, tests/test_paths.py:test_default_paths]"""
return _cfg().scripts_dir
def get_tracks_dir(project_path: Optional[str] = None) -> Path:
"""[C: src/project_manager.py:get_all_tracks, tests/test_paths.py:test_conductor_dir_project_relative]"""
return get_conductor_dir(project_path) / "tracks"
def get_track_state_dir(track_id: str, project_path: Optional[str] = None) -> Path:
"""[C: src/project_manager.py:load_track_state, src/project_manager.py:save_track_state, tests/test_paths.py:test_conductor_dir_project_relative]"""
return get_tracks_dir(project_path) / track_id
def get_archive_dir(project_path: Optional[str] = None) -> Path:
"""[C: tests/test_paths.py:test_conductor_dir_project_relative]"""
return get_conductor_dir(project_path) / "archive"
def _resolve_path(env_var: str, config_key: str, default: str) -> Path:
root_dir = Path(__file__).resolve().parent.parent
p = None
if env_var in os.environ:
p = Path(os.environ[env_var])
else:
try:
with open(get_config_path(), "rb") as f:
cfg = tomllib.load(f)
if "paths" in cfg and config_key in cfg["paths"]:
p = Path(cfg["paths"][config_key])
except (FileNotFoundError, tomllib.TOMLDecodeError):
pass
if p is None:
p = Path(default)
if not p.is_absolute():
return root_dir / p
return p
def _get_project_conductor_dir_from_toml(project_root: Path) -> Optional[Path]:
# Look for manual_slop.toml in project_root
"""Look for manual_slop.toml in project_root for [conductor] dir override."""
toml_path = project_root / 'manual_slop.toml'
if not toml_path.exists(): return None
try:
with open(toml_path, 'rb') as f:
data = tomllib.load(f)
# Check [conductor] dir = '...'
c_dir = data.get('conductor', {}).get('dir')
if c_dir:
p = Path(c_dir)
@@ -192,79 +234,44 @@ def _get_project_conductor_dir_from_toml(project_root: Path) -> Optional[Path]:
except: pass
return None
def get_conductor_dir(project_path: Optional[str] = None) -> Path:
"""
[C: tests/test_paths.py:test_conductor_dir_project_relative, tests/test_project_paths.py:test_get_conductor_dir_default, tests/test_project_paths.py:test_get_conductor_dir_project_specific_with_toml]
"""
"""[C: tests/test_paths.py:test_conductor_dir_project_relative, tests/test_project_paths.py:test_get_conductor_dir_default, tests/test_project_paths.py:test_get_conductor_dir_project_specific_with_toml]"""
if not project_path:
# Fallback for legacy/tests, but we should avoid this
return Path('conductor').resolve()
project_root = Path(project_path).resolve()
p = _get_project_conductor_dir_from_toml(project_root)
p = _get_project_conductor_dir_from_toml(project_root)
if p: return p
return (project_root / "conductor").resolve()
def get_logs_dir() -> Path:
"""
[C: src/session_logger.py:close_session, src/session_logger.py:open_session, tests/test_paths.py:test_config_overrides, tests/test_paths.py:test_default_paths, tests/test_paths.py:test_env_var_overrides, tests/test_paths.py:test_precedence]
"""
if "logs_dir" not in _RESOLVED:
_RESOLVED["logs_dir"] = _resolve_path("SLOP_LOGS_DIR", "logs_dir", "logs/sessions")
return _RESOLVED["logs_dir"]
def get_scripts_dir() -> Path:
"""
[C: src/session_logger.py:log_tool_call, src/session_logger.py:open_session, tests/test_paths.py:test_config_overrides, tests/test_paths.py:test_default_paths]
"""
if "scripts_dir" not in _RESOLVED:
_RESOLVED["scripts_dir"] = _resolve_path("SLOP_SCRIPTS_DIR", "scripts_dir", "scripts/generated")
return _RESOLVED["scripts_dir"]
def get_tracks_dir(project_path: Optional[str] = None) -> Path:
"""
[C: src/project_manager.py:get_all_tracks, tests/test_paths.py:test_conductor_dir_project_relative]
"""
return get_conductor_dir(project_path) / "tracks"
def get_track_state_dir(track_id: str, project_path: Optional[str] = None) -> Path:
"""
[C: src/project_manager.py:load_track_state, src/project_manager.py:save_track_state, tests/test_paths.py:test_conductor_dir_project_relative]
"""
return get_tracks_dir(project_path) / track_id
def get_archive_dir(project_path: Optional[str] = None) -> Path:
"""
[C: tests/test_paths.py:test_conductor_dir_project_relative]
"""
return get_conductor_dir(project_path) / "archive"
def _resolve_path_info(env_var: str, config_key: str, default: str) -> dict[str, Any]:
if env_var in os.environ:
return {'path': Path(os.environ[env_var]).resolve(), 'source': f'env:{env_var}'}
try:
with open(get_config_path(), 'rb') as f:
cfg = tomllib.load(f)
if 'paths' in cfg and config_key in cfg['paths']:
p = Path(cfg['paths'][config_key])
if not p.is_absolute():
p = (Path(__file__).resolve().parent.parent / p).resolve()
return {'path': p, 'source': 'config.toml'}
except: pass
root_dir = Path(__file__).resolve().parent.parent
p = (root_dir / default).resolve()
return {'path': p, 'source': 'default'}
def get_full_path_info() -> dict[str, dict[str, Any]]:
"""Return the resolved paths + their source (env / config / default).
For diagnostic UIs (e.g., the Session Hub's "show resolved paths" panel).
[C: src/gui_2.py:App._render_path_field]"""
cfg = _cfg()
def info(value: Path) -> dict[str, Any]:
return {'path': str(value), 'source': 'frozen_at_init'}
return {
'logs_dir': _resolve_path_info('SLOP_LOGS_DIR', 'logs_dir', 'logs/sessions'),
'scripts_dir': _resolve_path_info('SLOP_SCRIPTS_DIR', 'scripts_dir', 'scripts/generated')
'config_path': info(cfg.config_path),
'presets': info(cfg.presets),
'tool_presets': info(cfg.tool_presets),
'personas': info(cfg.personas),
'themes': info(cfg.themes),
'workspace_profiles': info(cfg.workspace_profiles),
'credentials': info(cfg.credentials),
'logs_dir': info(cfg.logs_dir),
'scripts_dir': info(cfg.scripts_dir),
}
def reset_resolved() -> None:
"""
For testing only - clear cached resolutions.
[C: tests/conftest.py:reset_paths, tests/test_app_controller_offloading.py:tmp_session_dir, tests/test_gui_phase3.py:test_conductor_setup_scan, tests/test_paths.py:reset_paths, tests/test_project_paths.py:test_get_all_tracks_project_specific, tests/test_project_paths.py:test_get_conductor_dir_default, tests/test_project_paths.py:test_get_conductor_dir_project_specific_with_toml]
"""
_RESOLVED.clear()
def reset_paths() -> None:
"""Clear the singleton. FOR TESTS ONLY — production code should never
call this. After reset, the next path getter raises RuntimeError until
initialize_paths() is called again.
[C: tests/conftest.py:reset_paths, tests/test_paths.py:reset_paths,
tests/test_app_controller_offloading.py:setup_function,
tests/test_gui_phase3.py:setup]"""
global _PATHS_CONFIG
with _PATHS_LOCK:
_PATHS_CONFIG = None