From c5126b300ccba46a0a40c47eddb8a34897194bc1 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Sun, 1 Mar 2026 22:56:24 -0800 Subject: [PATCH] feat(paths): add aipass-paths root resolution module v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation package with zero external dependencies. Implements 3-tier AIPASS_ROOT resolution: env var → .aipass/ marker walk → ~/.aipass/ default. Includes derived path helpers (system_logs_dir, branch_registry_path, branch_path, branch_logs_dir). 32 tests, full coverage. Also positions placeholder modules (cortex, mail, prax) with documented intent for upcoming implementation. Version bumped to 1.0.0. Co-Authored-By: Claude Opus 4.6 --- src/aipass/__init__.py | 17 +- src/aipass/cortex/__init__.py | 22 ++ src/aipass/mail/__init__.py | 21 ++ src/aipass/paths/__init__.py | 39 ++++ src/aipass/paths/derived.py | 80 +++++++ src/aipass/paths/exceptions.py | 13 ++ src/aipass/paths/resolver.py | 100 +++++++++ src/aipass/prax/__init__.py | 24 ++ tests/test_paths.py | 385 +++++++++++++++++++++++++++++++++ tests/test_version.py | 2 +- 10 files changed, 701 insertions(+), 2 deletions(-) create mode 100644 src/aipass/cortex/__init__.py create mode 100644 src/aipass/mail/__init__.py create mode 100644 src/aipass/paths/__init__.py create mode 100644 src/aipass/paths/derived.py create mode 100644 src/aipass/paths/exceptions.py create mode 100644 src/aipass/paths/resolver.py create mode 100644 src/aipass/prax/__init__.py create mode 100644 tests/test_paths.py diff --git a/src/aipass/__init__.py b/src/aipass/__init__.py index 607f7a49..f5f40488 100644 --- a/src/aipass/__init__.py +++ b/src/aipass/__init__.py @@ -1 +1,16 @@ -__version__ = "0.1.0-dev" +"""AIPass — Orchestration framework for autonomous AI agent ecosystems. + +Modules: + aipass.routing — Command routing & discovery (v1.0.0 — BUILT) + aipass.paths — Root resolution, AIPASS_ROOT (PLACEHOLDER) + aipass.prax — Visibility & monitoring layer (PLACEHOLDER) + aipass.cortex — Branch creation & templates (PLACEHOLDER) + aipass.mail — Inter-branch messaging (PLACEHOLDER) + +Standalone packages: + seedgo — Code standards framework (v1.0.0 — BUILT) + +Architecture reference: vera/projects/framework/architecture_reference.md +""" + +__version__ = "1.0.0" diff --git a/src/aipass/cortex/__init__.py b/src/aipass/cortex/__init__.py new file mode 100644 index 00000000..d442d256 --- /dev/null +++ b/src/aipass/cortex/__init__.py @@ -0,0 +1,22 @@ +"""AIPass Cortex — Branch Creation & Template Engine. + +Creates new branches (agent workspaces) following a 7-step flow: + 1. Validate template — verify template directory exists + 2. Extract metadata — branch name, profile, git repo from path + 3. Build placeholders — replacement dict: {{BRANCHNAME}}, {{CWD}}, {{DATE}}, etc. + 4. Smart file rename — rename existing memory files to standard convention + 5. Copy template — copy entire template, replace placeholders in text files + 6. File renaming — LOCAL.json -> BRANCHNAME.local.json, etc. + 7. Registry entry — add to BRANCH_REGISTRY.json + fire trigger event + +Three template types: + - branch_template/ — standard dev branch (apps/, modules/, handlers/, tests/) + - business_branch_template/ — business branch (playbooks/, strategy/, research/) + - team_template/ — think-tank manager (research/, ideas/, decisions/) + +Accessed through drone: `drone @cortex create`, NOT standalone. + +Architecture reference: vera/projects/framework/architecture_reference.md §5 + +Status: PLACEHOLDER — not yet implemented. +""" diff --git a/src/aipass/mail/__init__.py b/src/aipass/mail/__init__.py new file mode 100644 index 00000000..3c603aee --- /dev/null +++ b/src/aipass/mail/__init__.py @@ -0,0 +1,21 @@ +"""AIPass Mail — Inter-Branch Messaging. + +Communication layer for AI agent ecosystems. Branches coordinate through +email, not direct file access. This is how 10+ agents work simultaneously +without stepping on each other. + +Each branch has a mailbox (BRANCHNAME.ai_mail.json). Mail supports: + - send/receive between branches + - dispatch flags (--dispatch marks for autonomous execution) + - inbox/outbox management + - reply chains with auto-close and archival + +Integration requirement: branches register in BRANCH_REGISTRY.json +to be addressable. Seed enforces this. + +Depends on: aipass-paths. + +Architecture reference: vera/projects/framework/architecture_reference.md §8 + +Status: PLACEHOLDER — not yet implemented. +""" diff --git a/src/aipass/paths/__init__.py b/src/aipass/paths/__init__.py new file mode 100644 index 00000000..b7a6ee17 --- /dev/null +++ b/src/aipass/paths/__init__.py @@ -0,0 +1,39 @@ +""" +AIPass Paths — Root Resolution Module. + +Foundation package. ZERO external dependencies. + +Resolves the AIPass root directory using a 3-tier priority: + 1. AIPASS_ROOT environment variable (explicit — deployment/CI) + 2. .aipass/ marker directory walk (auto-detect — like git finding .git/) + 3. ~/.aipass/ default fallback (fresh install — XDG pattern) + +All other modules derive paths from get_root(): + - system_logs_dir() -> get_root() / "system_logs" + - branch_registry_path() -> get_root() / "BRANCH_REGISTRY.json" + - branch_path(name) -> registry lookup, returns None if not found + - branch_logs_dir(dir) -> dir / "logs" + +Exceptions: + - PathResolutionError -> raised when root cannot be resolved +""" + +from __future__ import annotations + +from .derived import branch_logs_dir, branch_path, branch_registry_path, system_logs_dir +from .exceptions import PathResolutionError +from .resolver import get_root + +__version__ = "1.0.0" + +__all__ = [ + # Core + "get_root", + # Derived paths + "system_logs_dir", + "branch_registry_path", + "branch_path", + "branch_logs_dir", + # Exceptions + "PathResolutionError", +] diff --git a/src/aipass/paths/derived.py b/src/aipass/paths/derived.py new file mode 100644 index 00000000..04dc743c --- /dev/null +++ b/src/aipass/paths/derived.py @@ -0,0 +1,80 @@ +""" +Derived path functions for AIPass. + +All functions derive their paths from get_root() and return Path objects. +No side effects beyond reading the filesystem. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .resolver import get_root + + +def system_logs_dir() -> Path: + """ + Return the system logs directory. + + Returns: + get_root() / "system_logs" + """ + return get_root() / "system_logs" + + +def branch_registry_path() -> Path: + """ + Return the path to BRANCH_REGISTRY.json. + + Returns: + get_root() / "BRANCH_REGISTRY.json" + """ + return get_root() / "BRANCH_REGISTRY.json" + + +def branch_path(name: str) -> Path | None: + """ + Look up a branch by name in BRANCH_REGISTRY.json and return its path. + + Args: + name: Branch name (without @ prefix). + + Returns: + Absolute Path to the branch directory, or None if the branch is not + found or the registry file does not exist. + """ + registry_file = branch_registry_path() + + if not registry_file.exists(): + return None + + try: + with open(registry_file, encoding="utf-8") as fh: + data = json.load(fh) + except (json.JSONDecodeError, OSError): + return None + + branches = data.get("branches", {}) + entry = branches.get(name) + if entry is None: + return None + + raw_path = entry.get("path") + if raw_path is None: + return None + + return Path(raw_path) + + +def branch_logs_dir(branch_dir: Path) -> Path: + """ + Return the logs directory for a given branch directory. + + Args: + branch_dir: Absolute path to a branch directory. + + Returns: + branch_dir / "logs" + """ + return branch_dir / "logs" diff --git a/src/aipass/paths/exceptions.py b/src/aipass/paths/exceptions.py new file mode 100644 index 00000000..0022f658 --- /dev/null +++ b/src/aipass/paths/exceptions.py @@ -0,0 +1,13 @@ +""" +Paths module custom exceptions. + +Defines the exception hierarchy for path resolution errors. +""" + +from __future__ import annotations + + +class PathResolutionError(Exception): + """Raised when the AIPass root directory cannot be resolved.""" + + pass diff --git a/src/aipass/paths/resolver.py b/src/aipass/paths/resolver.py new file mode 100644 index 00000000..c5da0705 --- /dev/null +++ b/src/aipass/paths/resolver.py @@ -0,0 +1,100 @@ +""" +Root resolution logic for AIPass paths. + +Resolves the AIPass root directory using a 3-tier priority: + 1. AIPASS_ROOT environment variable (if set and valid directory) + 2. .aipass/ marker directory walk (like git finding .git/) + 3. ~/.aipass/ default fallback (created if it doesn't exist) +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from .exceptions import PathResolutionError + + +def _resolve_from_env() -> Path | None: + """ + Attempt to resolve root from AIPASS_ROOT environment variable. + + Returns: + Path if env var is set and points to a valid directory, None otherwise. + + Raises: + PathResolutionError: If env var is set but points to a non-existent directory. + """ + env_val = os.environ.get("AIPASS_ROOT") + if not env_val: # None or empty string → not set + return None + + candidate = Path(env_val) + if not candidate.is_dir(): + raise PathResolutionError( + f"AIPASS_ROOT environment variable points to a non-existent directory: {env_val!r}" + ) + return candidate + + +def _resolve_from_marker(start: Path | None = None) -> Path | None: + """ + Walk up the directory tree from start looking for a directory containing .aipass/. + + Args: + start: Directory to start from. Defaults to Path.cwd(). + + Returns: + The directory that contains a .aipass/ subdirectory, or None if not found. + """ + current = (start or Path.cwd()).resolve() + + for candidate in [current, *current.parents]: + if (candidate / ".aipass").is_dir(): + return candidate + + return None + + +def _resolve_default() -> Path: + """ + Return the default fallback root (~/.aipass/), creating it if needed. + + Returns: + Path to ~/.aipass/, guaranteed to exist. + """ + default = Path.home() / ".aipass" + default.mkdir(parents=True, exist_ok=True) + return default + + +def get_root(start: Path | None = None) -> Path: + """ + Resolve the AIPass root directory using 3-tier priority. + + Priority order: + 1. AIPASS_ROOT environment variable (if set and valid) + 2. .aipass/ marker directory walk (starting from CWD or start) + 3. ~/.aipass/ default fallback (created if missing) + + Args: + start: Optional starting directory for marker walk. Defaults to Path.cwd(). + + Returns: + Resolved AIPass root directory as an absolute Path. + + Raises: + PathResolutionError: If AIPASS_ROOT env var is set but points to a non-existent directory. + """ + # Tier 1: environment variable + env_root = _resolve_from_env() + if env_root is not None: + return env_root + + # Tier 2: marker walk + marker_root = _resolve_from_marker(start) + if marker_root is not None: + return marker_root + + # Tier 3: default fallback + return _resolve_default() diff --git a/src/aipass/prax/__init__.py b/src/aipass/prax/__init__.py new file mode 100644 index 00000000..9f01453c --- /dev/null +++ b/src/aipass/prax/__init__.py @@ -0,0 +1,24 @@ +"""AIPass Prax — Visibility & Monitoring Layer. + +Project-scoped logging, monitoring, and observability for AI agent ecosystems. +Depends on: aipass-paths, watchdog. + +Internal Prax has 10 subsystems. The public version ships with 4 non-negotiable +trust changes: + 1. No global logging.getLogger() override — users call prax.logger() explicitly + 2. Project-scoped only — never operates outside the project directory + 3. All external communication opt-in — zero external by default + 4. Consent layer — prax init / prax status / prax stop (through drone) + +Three modes: + - Public: Safe defaults. Minimal, transparent, project-scoped. + - Full: Opt-in power features (file watcher, log tailing, Telegram relay). + - AIPass: Internal unchanged (not shipped publicly). + +Other modules don't configure Prax — they import correctly and use +warning(), info(), error() methods. Prax manages himself internally. + +Architecture reference: vera/projects/framework/architecture_reference.md §6 + +Status: PLACEHOLDER — not yet implemented. +""" diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 00000000..a78c290a --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,385 @@ +""" +Tests for aipass.paths — root resolution and derived path functions. + +Covers: + - get_root() env var resolution + - get_root() marker walk resolution + - get_root() default fallback + - Priority order (env var beats marker walk, marker walk beats default) + - Invalid env var raises PathResolutionError + - All derived path functions + - branch_path() with a mock registry + - branch_path() returns None for missing branch / missing file +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from aipass.paths import ( + PathResolutionError, + branch_logs_dir, + branch_path, + branch_registry_path, + get_root, + system_logs_dir, +) +from aipass.paths.resolver import _resolve_from_marker, get_root as _get_root_direct + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_registry(path: Path, branches: dict) -> None: + """Write a minimal BRANCH_REGISTRY.json to *path*.""" + path.write_text( + json.dumps({"version": "1.0", "branches": branches}), + encoding="utf-8", + ) + + +# --------------------------------------------------------------------------- +# get_root() — environment variable resolution +# --------------------------------------------------------------------------- + + +class TestEnvVarResolution: + """Tier 1: AIPASS_ROOT environment variable.""" + + def test_env_var_valid_directory_is_returned(self, tmp_path, monkeypatch): + """When AIPASS_ROOT points to an existing directory it is returned.""" + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + assert get_root() == tmp_path + + def test_env_var_takes_priority_over_marker(self, tmp_path, monkeypatch): + """Env var wins even when a .aipass/ marker exists in CWD.""" + env_root = tmp_path / "env_root" + env_root.mkdir() + + marker_root = tmp_path / "marker_root" + marker_root.mkdir() + (marker_root / ".aipass").mkdir() + + monkeypatch.setenv("AIPASS_ROOT", str(env_root)) + monkeypatch.chdir(marker_root) + + assert get_root() == env_root + + def test_env_var_nonexistent_raises_path_resolution_error(self, monkeypatch): + """AIPASS_ROOT pointing to a non-existent path raises PathResolutionError.""" + monkeypatch.setenv("AIPASS_ROOT", "/does/not/exist/at/all") + with pytest.raises(PathResolutionError): + get_root() + + def test_env_var_empty_string_falls_through(self, tmp_path, monkeypatch): + """An empty AIPASS_ROOT string is treated as unset (falls through to next tier).""" + # Make a marker so tier 2 picks it up (avoids writing to real home) + marker_root = tmp_path / "marker" + marker_root.mkdir() + (marker_root / ".aipass").mkdir() + + monkeypatch.setenv("AIPASS_ROOT", "") + monkeypatch.chdir(marker_root) + + # Empty string → env var absent → should find marker + result = get_root() + assert result == marker_root + + def test_env_var_unset_falls_through(self, tmp_path, monkeypatch): + """When AIPASS_ROOT is not set at all, env tier returns None and we fall through.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + + marker_root = tmp_path / "marker" + marker_root.mkdir() + (marker_root / ".aipass").mkdir() + monkeypatch.chdir(marker_root) + + result = get_root() + assert result == marker_root + + +# --------------------------------------------------------------------------- +# get_root() — marker walk resolution +# --------------------------------------------------------------------------- + + +class TestMarkerWalkResolution: + """Tier 2: .aipass/ marker directory walk.""" + + def test_marker_in_cwd_is_found(self, tmp_path, monkeypatch): + """A .aipass/ directory in CWD is resolved to CWD.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + (tmp_path / ".aipass").mkdir() + monkeypatch.chdir(tmp_path) + + assert get_root() == tmp_path + + def test_marker_in_parent_is_found(self, tmp_path, monkeypatch): + """A .aipass/ in a parent directory is discovered by walking up.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + + root = tmp_path / "project" + root.mkdir() + (root / ".aipass").mkdir() + + deep = root / "a" / "b" / "c" + deep.mkdir(parents=True) + monkeypatch.chdir(deep) + + assert get_root() == root + + def test_marker_in_grandparent_is_found(self, tmp_path, monkeypatch): + """Walk stops at the nearest ancestor that contains .aipass/.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + + grandparent = tmp_path / "gp" + grandparent.mkdir() + (grandparent / ".aipass").mkdir() + + child = grandparent / "child" / "grandchild" + child.mkdir(parents=True) + monkeypatch.chdir(child) + + assert get_root() == grandparent + + def test_marker_file_not_directory_is_ignored(self, tmp_path, monkeypatch): + """If .aipass exists but is a file (not dir), it should not match.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + + # .aipass as a file, not a directory + (tmp_path / ".aipass").write_text("not a dir") + + # Provide a parent with the real marker so the walk finds something + parent = tmp_path.parent + # We cannot rely on parent having .aipass, so use custom start to + # test the helper directly + result = _resolve_from_marker(start=tmp_path) + # The file should not match; result will be None or a higher ancestor + # that may or may not have .aipass — what matters is tmp_path itself + # is NOT returned because .aipass there is a file. + if result is not None: + assert result != tmp_path + + def test_explicit_start_directory(self, tmp_path, monkeypatch): + """_resolve_from_marker accepts an explicit start directory.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + + root = tmp_path / "root" + root.mkdir() + (root / ".aipass").mkdir() + + start = root / "sub" + start.mkdir() + + result = _resolve_from_marker(start=start) + assert result == root + + def test_no_marker_returns_none(self, tmp_path, monkeypatch): + """_resolve_from_marker returns None when no .aipass/ is found.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + # tmp_path has no .aipass and neither do its parents (almost certainly) + result = _resolve_from_marker(start=tmp_path) + # We can only assert it does not match tmp_path itself + if result is not None: + assert (result / ".aipass").is_dir() + + +# --------------------------------------------------------------------------- +# get_root() — default fallback +# --------------------------------------------------------------------------- + + +class TestDefaultFallback: + """Tier 3: ~/.aipass/ default fallback.""" + + def test_default_fallback_returns_home_aipass(self, tmp_path, monkeypatch): + """When env var absent and no marker found, default is ~/.aipass/.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + + # Use a temp home with no .aipass marker anywhere in the tree + fake_home = tmp_path / "home" / "user" + fake_home.mkdir(parents=True) + monkeypatch.setenv("HOME", str(fake_home)) + # Patch Path.home() by redirecting the env; also chdir to a location + # with no .aipass marker + isolated = tmp_path / "isolated" + isolated.mkdir() + monkeypatch.chdir(isolated) + + result = get_root() + expected = Path.home() / ".aipass" + assert result == expected + + def test_default_fallback_creates_directory(self, tmp_path, monkeypatch): + """The default fallback creates ~/.aipass/ if it does not exist.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + + fake_home = tmp_path / "fresh_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + isolated = tmp_path / "isolated2" + isolated.mkdir() + monkeypatch.chdir(isolated) + + result = get_root() + assert result.is_dir() + + +# --------------------------------------------------------------------------- +# Priority order +# --------------------------------------------------------------------------- + + +class TestPriorityOrder: + """Verify env var > marker walk > default.""" + + def test_env_var_beats_marker_walk(self, tmp_path, monkeypatch): + """Env var takes priority over marker walk.""" + env_root = tmp_path / "env" + env_root.mkdir() + + cwd_with_marker = tmp_path / "cwd" + cwd_with_marker.mkdir() + (cwd_with_marker / ".aipass").mkdir() + + monkeypatch.setenv("AIPASS_ROOT", str(env_root)) + monkeypatch.chdir(cwd_with_marker) + + assert get_root() == env_root + + def test_marker_walk_beats_default(self, tmp_path, monkeypatch): + """Marker walk takes priority over default fallback.""" + monkeypatch.delenv("AIPASS_ROOT", raising=False) + + marker_root = tmp_path / "project" + marker_root.mkdir() + (marker_root / ".aipass").mkdir() + monkeypatch.chdir(marker_root) + + result = get_root() + assert result == marker_root + + +# --------------------------------------------------------------------------- +# Derived path functions +# --------------------------------------------------------------------------- + + +class TestSystemLogsDir: + def test_returns_system_logs_under_root(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + assert system_logs_dir() == tmp_path / "system_logs" + + def test_returns_path_object(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + assert isinstance(system_logs_dir(), Path) + + +class TestBranchRegistryPath: + def test_returns_registry_under_root(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + assert branch_registry_path() == tmp_path / "BRANCH_REGISTRY.json" + + def test_returns_path_object(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + assert isinstance(branch_registry_path(), Path) + + +class TestBranchPath: + """branch_path(name) reads BRANCH_REGISTRY.json and returns a Path or None.""" + + def test_known_branch_returns_path(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + _write_registry( + tmp_path / "BRANCH_REGISTRY.json", + {"vera": {"name": "vera", "path": "/home/aipass/vera", "type": "agent", "status": "active"}}, + ) + result = branch_path("vera") + assert result == Path("/home/aipass/vera") + + def test_unknown_branch_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + _write_registry(tmp_path / "BRANCH_REGISTRY.json", {}) + assert branch_path("nonexistent") is None + + def test_missing_registry_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + # No registry file written + assert branch_path("vera") is None + + def test_corrupt_registry_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + (tmp_path / "BRANCH_REGISTRY.json").write_text("not valid json {{{{", encoding="utf-8") + assert branch_path("vera") is None + + def test_branch_entry_missing_path_key_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + _write_registry( + tmp_path / "BRANCH_REGISTRY.json", + {"vera": {"name": "vera", "type": "agent"}}, # no "path" key + ) + assert branch_path("vera") is None + + def test_returns_path_object(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + branch_dir = tmp_path / "branches" / "flow" + branch_dir.mkdir(parents=True) + _write_registry( + tmp_path / "BRANCH_REGISTRY.json", + {"flow": {"name": "flow", "path": str(branch_dir), "type": "agent", "status": "active"}}, + ) + result = branch_path("flow") + assert isinstance(result, Path) + assert result == branch_dir + + def test_multiple_branches_correct_one_returned(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", str(tmp_path)) + _write_registry( + tmp_path / "BRANCH_REGISTRY.json", + { + "alpha": {"name": "alpha", "path": "/agents/alpha", "type": "agent", "status": "active"}, + "beta": {"name": "beta", "path": "/agents/beta", "type": "agent", "status": "active"}, + }, + ) + assert branch_path("alpha") == Path("/agents/alpha") + assert branch_path("beta") == Path("/agents/beta") + assert branch_path("gamma") is None + + +class TestBranchLogsDir: + def test_returns_logs_under_branch_dir(self, tmp_path): + result = branch_logs_dir(tmp_path) + assert result == tmp_path / "logs" + + def test_returns_path_object(self, tmp_path): + assert isinstance(branch_logs_dir(tmp_path), Path) + + def test_arbitrary_path(self): + p = Path("/some/branch/dir") + assert branch_logs_dir(p) == Path("/some/branch/dir/logs") + + +# --------------------------------------------------------------------------- +# PathResolutionError +# --------------------------------------------------------------------------- + + +class TestPathResolutionError: + def test_is_exception_subclass(self): + assert issubclass(PathResolutionError, Exception) + + def test_carries_message(self): + err = PathResolutionError("bad path") + assert "bad path" in str(err) + + def test_raised_on_invalid_env_var(self, monkeypatch): + monkeypatch.setenv("AIPASS_ROOT", "/totally/nonexistent/12345") + with pytest.raises(PathResolutionError, match="AIPASS_ROOT"): + get_root() diff --git a/tests/test_version.py b/tests/test_version.py index ab93753a..82da2f04 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -2,4 +2,4 @@ from aipass import __version__ def test_version(): - assert __version__ == "0.1.0-dev" + assert __version__ == "1.0.0"