Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Normalize line endings. Shell scripts and Python with CRLF break on
# checkout under Git Bash (bad interpreter) and trip py_compile diffs.
# source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §7.2
* text=auto eol=lf

*.sh text eol=lf
*.py text eol=lf
*.toml text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.md text eol=lf

# Binary assets — never normalize.
*.png binary
*.jpg binary
*.gif binary
*.pdf binary
*.onnx binary
*.so binary
*.dylib binary
*.dll binary
*.exe binary
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,62 @@ jobs:
# on SQLite is tracked separately (full-parity effort).
run: pytest tests_py/infrastructure/test_sqlite_backend.py --tb=short -q

test-windows:
name: Test (Windows, SQLite backend)
runs-on: windows-latest

# Real NT proof for the cross-platform fixes (fcntl→msvcrt, sys.executable,
# NTFS exec bits, $HOME override, path separators). We use the SQLite
# fallback so no PostgreSQL has to be provisioned on the Windows runner —
# the conftest selects it when PG is unreachable; the env var makes it
# explicit. source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §10 (CI Windows)
env:
CORTEX_MEMORY_STORE_BACKEND: sqlite

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-hf-all-MiniLM-L6-v2

- name: Install dependencies (no postgresql extra)
run: pip install -e ".[dev,sqlite]"

# Import smoke: the modules that previously crashed at load on Windows
# (fcntl import) or silently misbehaved. If any fails to import, the
# platform branches are wrong — fail fast before the suite.
- name: Import smoke (formerly Windows-broken modules)
run: >-
python -c "import mcp_server.shared.platform,
mcp_server.infrastructure.pipeline_install_lock,
mcp_server.infrastructure.pipeline_discovery,
mcp_server.core.staleness, mcp_server.doctor, mcp_server.doctor_mcp;
print('windows import smoke OK')"

- name: Pre-download embedding model
run: python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2', device='cpu')"
continue-on-error: true

# Scope (explicit, not silent): the portability tests plus the modules
# carrying Windows-specific branches and the SQLite backend suite. The
# full PG suite is not run here — it is covered by the ubuntu `test` job.
- name: Run portability + backend tests
run: >-
pytest --tb=short -q
tests_py/shared/test_platform.py
tests_py/infrastructure/test_pipeline_discovery.py
tests_py/infrastructure/test_pipeline_install_lock.py
tests_py/core/test_staleness.py
tests_py/infrastructure/test_sqlite_backend.py

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
21 changes: 21 additions & 0 deletions mcp_server/core/staleness.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@
re.MULTILINE,
)

# Matches backslash-separated paths — Windows relative (``src\core\x.py``)
# and drive-absolute (``C:\Users\me\x.py``). Without this, refs stored with
# backslashes are invisible to staleness detection on Windows.
# source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.6
_WIN_PATH_RE = re.compile(
r"(?:^|[\s\"'`(,])("
r"(?:[A-Za-z]:\\?)?" # optional drive letter, consuming its backslash
r"(?:[\w@.-]+\\)+" # one or more backslash-separated segments
r"[\w@.-]+\.\w{1,10}" # filename.ext
r")(?:[\s\"'`:),]|$)",
re.MULTILINE,
)

# Matches imports/requires that imply a local path
_IMPORT_PATH_RE = re.compile(
r"""(?:import|from|require)\s+['"]([./][^'"]+)['"]""",
Expand Down Expand Up @@ -58,6 +71,14 @@ def extract_file_references(content: str) -> list[str]:
if path and not _EXCLUDE_RE.search(path) and len(path) < 256:
refs.add(path)

# Backslash paths are normalized to '/' so a single ref is stored
# regardless of the separator the author used; resolution works with
# forward slashes on every OS.
for m in _WIN_PATH_RE.finditer(content):
path = m.group(1).strip().replace("\\", "/")
if path and not _EXCLUDE_RE.search(path) and len(path) < 256:
refs.add(path)

for m in _IMPORT_PATH_RE.finditer(content):
path = m.group(1).strip()
if path and len(path) < 256:
Expand Down
6 changes: 5 additions & 1 deletion mcp_server/core/wiki_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
from dataclasses import dataclass, field
from typing import Final

from mcp_server.shared.platform import to_posix


# Minimum useful page size in bytes. Below this, a page is a stub —
# the scope is not really covered.
Expand Down Expand Up @@ -1266,7 +1268,9 @@ def list_source_files(root: str) -> list[str]:
if ext not in _SOURCE_EXTENSIONS:
continue
full = os.path.join(dirpath, f)
rel = os.path.relpath(full, root)
# relpath emits backslashes on Windows; downstream indexing and
# comparisons assume '/'. source: REPORT_..._CORTEX_WINDOWS §5.3
rel = to_posix(os.path.relpath(full, root))
out.append(rel)
return out

Expand Down
6 changes: 5 additions & 1 deletion mcp_server/core/wiki_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
from dataclasses import dataclass, field
from typing import Final

from mcp_server.shared.platform import to_posix


@dataclass
class PageDrift:
Expand Down Expand Up @@ -340,7 +342,9 @@ def audit_wiki_drift(
if not f.endswith(".md"):
continue
full = os.path.join(dirpath, f)
rel = os.path.relpath(full, wiki_root)
# Normalize to '/' so _kind_and_domain_from_path's path parsing
# works on Windows. source: REPORT_..._CORTEX_WINDOWS §5.3
rel = to_posix(os.path.relpath(full, wiki_root))
kind, domain = _kind_and_domain_from_path(rel)
if not domain or not kind:
continue
Expand Down
6 changes: 5 additions & 1 deletion mcp_server/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
from pathlib import Path
from typing import Callable

from mcp_server.shared.platform import home_dir


class Check:
__slots__ = ("name", "ok", "detail", "fix", "optional")
Expand Down Expand Up @@ -165,7 +167,9 @@ def _pg_extensions() -> Check:


def _methodology_dir() -> Check:
path = Path("~/.claude/methodology").expanduser()
# home_dir() honors $HOME on every OS; Path.expanduser() ignores it on
# Windows. source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.1
path = home_dir() / ".claude" / "methodology"
try:
path.mkdir(parents=True, exist_ok=True)
probe = path / ".write_probe"
Expand Down
9 changes: 7 additions & 2 deletions mcp_server/doctor_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from dataclasses import asdict, dataclass, field
from pathlib import Path

from mcp_server.shared.platform import home_dir, python_executable
from mcp_server.shared.redaction import redact_url, scrub_secrets


Expand Down Expand Up @@ -148,7 +149,7 @@ def _check_python_interpreter() -> McpCheck:


def _installed_plugins_path() -> Path:
return Path.home() / ".claude" / "plugins" / "installed_plugins.json"
return home_dir() / ".claude" / "plugins" / "installed_plugins.json"


def _check_installed_plugins_json() -> tuple[McpCheck, dict | None]:
Expand Down Expand Up @@ -362,7 +363,11 @@ def _check_launcher_smoke(install_path: str | None) -> McpCheck:
detail="launcher.py missing",
attempted=str(launcher),
)
py = shutil.which("python3") or shutil.which("python") or sys.executable
# Never resolve "python3"/"python" by name: on Windows PATH those hit the
# Microsoft Store stub (exit 9009, no interpreter), making this smoke test
# spuriously fail. The launcher must run under THIS interpreter anyway.
# source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.2
py = python_executable()
cmd = [py, str(launcher)]
attempted = " ".join(cmd)
try:
Expand Down
23 changes: 18 additions & 5 deletions mcp_server/infrastructure/pipeline_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@
import logging
import os
import shutil
import sys
from pathlib import Path
from typing import Optional

from mcp_server.infrastructure.config import MCP_CONNECTIONS_PATH
from mcp_server.infrastructure.file_io import read_json, write_json
from mcp_server.shared.platform import home_dir

logger = logging.getLogger(__name__)

Expand All @@ -54,15 +56,21 @@
"../ai-automatised-pipeline",
)

_BUILT_RELATIVE = ("target/release/automatised-pipeline",)
# Windows builds emit an .exe; list it first so it wins on NT. The
# extension-less name is the Linux/macOS artifact.
# source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.5
_BUILT_RELATIVE = (
"target/release/automatised-pipeline.exe",
"target/release/automatised-pipeline",
)

# ── Marketplace install (canonical) ─────────────────────────────────────

# The AP plugin installed via its marketplace. This is the SAME source AP's
# own .mcp.json resolves from: installed_plugins.json -> installPath ->
# target/release/automatised-pipeline. Preferring it means Cortex spawns the
# exact plugin the user installed — no self-built symlink, no version drift.
_INSTALLED_PLUGINS_PATH = Path.home() / ".claude" / "plugins" / "installed_plugins.json"
_INSTALLED_PLUGINS_PATH = home_dir() / ".claude" / "plugins" / "installed_plugins.json"
_AP_PLUGIN_KEY = "automatised-pipeline@automatised-pipeline-marketplace"
_AP_BINARY_RELATIVE = Path("target") / "release" / "automatised-pipeline"

Expand Down Expand Up @@ -95,9 +103,9 @@ def _marketplace_pipeline_binary() -> Optional[str]:
# Where the silent installer clones and builds the upstream source.
# Living next to other methodology artefacts means cleanup is one rm -rf.
_INSTALL_SRC_DIR = (
Path.home() / ".claude" / "methodology" / "src" / "automatised-pipeline"
home_dir() / ".claude" / "methodology" / "src" / "automatised-pipeline"
)
_INSTALL_BIN_DIR = Path.home() / ".claude" / "methodology" / "bin"
_INSTALL_BIN_DIR = home_dir() / ".claude" / "methodology" / "bin"
_INSTALL_SYMLINK = _INSTALL_BIN_DIR / "mcp-server"


Expand Down Expand Up @@ -134,7 +142,12 @@ def discover_pipeline_command() -> Optional[list[str]]:
continue
for rel in _BUILT_RELATIVE:
built = root / rel
if built.is_file() and built.stat().st_mode & 0o111:
# NTFS doesn't store Unix exec bits — st_mode & 0o111 is always 0
# on Windows, so the bit check would reject every valid binary.
# source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.5
if built.is_file() and (
sys.platform == "win32" or built.stat().st_mode & 0o111
):
return [str(built)]

return None
Expand Down
70 changes: 51 additions & 19 deletions mcp_server/infrastructure/pipeline_install_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,28 @@
the user's manual setup) from corrupting the shared install state:
half-cloned src/, racy symlink swap, JSON config truncation.

Uses fcntl.flock (POSIX advisory lock). Non-blocking acquire — contended
runs return immediately so callers can surface a clear ``install_in_progress``
action rather than hanging the user's terminal for 6 minutes.
Non-blocking acquire — contended runs return immediately so callers can
surface a clear ``install_in_progress`` action rather than hanging the
user's terminal for 6 minutes.

POSIX uses ``fcntl.flock`` (advisory lock). Windows has no ``fcntl`` — its
import alone would crash this module at load on every Windows install — so
we use ``msvcrt.locking`` (mandatory byte-range lock) there. Both back the
same ``install_lock()`` contract; the only visible difference (advisory vs
mandatory) is immaterial because every caller goes through this function.

source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.4
"""

from __future__ import annotations

import fcntl
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator

_LOCK_FILE = Path.home() / ".claude" / "methodology" / ".install.lock"
from mcp_server.shared.platform import IS_WINDOWS, home_dir

_LOCK_FILE = home_dir() / ".claude" / "methodology" / ".install.lock"


class InstallLockBusy(RuntimeError):
Expand All @@ -32,28 +40,52 @@ def install_lock() -> Iterator[None]:
return a structured ``install_in_progress`` action instead of
blocking for the duration of someone else's 6-minute build.

The lock file is opened in append mode so concurrent acquires don't
truncate it; we never read or write content — only the lock metadata
matters. The fd stays open for the duration of the context to keep
the lock held.
We never read or write content — only the lock metadata matters. The fd
stays open for the duration of the context to keep the lock held.
"""
_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(_LOCK_FILE), os.O_RDWR | os.O_CREAT, 0o644)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
os.close(fd)
raise InstallLockBusy(str(_LOCK_FILE)) from exc
_acquire(fd)
try:
yield
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except Exception:
pass
_release(fd)
finally:
try:
os.close(fd)
except Exception:
pass


if IS_WINDOWS:
import msvcrt

def _acquire(fd: int) -> None:
try:
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
except OSError as exc:
os.close(fd)
raise InstallLockBusy(str(_LOCK_FILE)) from exc

def _release(fd: int) -> None:
try:
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except Exception:
pass

else:
import fcntl

def _acquire(fd: int) -> None:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
os.close(fd)
raise InstallLockBusy(str(_LOCK_FILE)) from exc

def _release(fd: int) -> None:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except Exception:
pass
Loading
Loading