diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..49168e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: plugin-ci + +on: + pull_request: + push: + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: validate (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Run plugin validation and lifecycle smoke + shell: bash + run: PYTHON=python bash scripts/ci-local.sh diff --git a/README.md b/README.md index b4bd4b6..45cb035 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ The validation script compiles the plugin, loads the Hermes registration surface, exercises the original and 0.2 methods, checks lifecycle hooks, and checks the public distribution surface. +GitHub Actions runs this same validation on current Ubuntu and Windows runners. +The matrix protects the POSIX `fcntl` backend used on macOS and Linux and the +Windows `msvcrt` backend used by the installed Hermes plugin. + ## Documentation - [Hermes plugin operation](docs/HERMES_PLUGIN.md) diff --git a/aem_store.py b/aem_store.py index 9022b78..8321907 100644 --- a/aem_store.py +++ b/aem_store.py @@ -64,7 +64,7 @@ def _atomic_replace_text(path: Path, text: str) -> None: handle, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(handle, "w", encoding="utf-8") as temporary: - if path.exists(): + if path.exists() and hasattr(os, "fchmod"): os.fchmod(temporary.fileno(), path.stat().st_mode & 0o777) temporary.write(text) temporary.flush() diff --git a/aethermind_core.py b/aethermind_core.py index d639691..472c6e7 100644 --- a/aethermind_core.py +++ b/aethermind_core.py @@ -250,13 +250,30 @@ def _layer_hash(layer: "AetherLayer") -> str: except ImportError: # pragma: no cover - Windows or unsupported host fcntl = None # type: ignore[assignment] +try: + import msvcrt +except ImportError: # pragma: no cover - POSIX or unsupported host + msvcrt = None # type: ignore[assignment] + def _lock_backend() -> str: - return "fcntl" if fcntl is not None else "unavailable" + if fcntl is not None: + return "fcntl" + if msvcrt is not None: + return "msvcrt" + return "unavailable" + + +def _locking_scope() -> str: + if fcntl is not None: + return "local-posix-advisory" + if msvcrt is not None: + return "local-windows-byte-range" + return "unavailable" def _require_lock_backend() -> None: - if fcntl is None: + if _lock_backend() == "unavailable": raise LockUnavailableError( "no safe local file locking backend is available; refusing to write" ) @@ -265,16 +282,55 @@ def _require_lock_backend() -> None: class _FileLock: def __init__(self, file_obj): self.file_obj = file_obj + self._windows_lock_file = None + + def _windows_lock_path(self) -> Path: + path = Path(self.file_obj.name) + return path.with_name(f"{path.name}.lock") + + def _lock_with_msvcrt(self) -> None: + """Lock a dedicated byte so ledger data files remain readable on Windows. + + ``msvcrt.locking`` operates on byte ranges. Locking a sibling file keeps + the data ledger free of mandatory byte-range locks while serializing all + cooperating AetherMind writers for that ledger. + """ + assert msvcrt is not None + lock_file = self._windows_lock_path().open("a+b") + self._windows_lock_file = lock_file + try: + lock_file.seek(0, io.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + os.fsync(lock_file.fileno()) + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + except BaseException: + lock_file.close() + self._windows_lock_file = None + raise def __enter__(self): _require_lock_backend() - assert fcntl is not None - fcntl.flock(self.file_obj.fileno(), fcntl.LOCK_EX) + if fcntl is not None: + fcntl.flock(self.file_obj.fileno(), fcntl.LOCK_EX) + else: + self._lock_with_msvcrt() return self.file_obj def __exit__(self, _exc_type, _exc, _tb): - assert fcntl is not None - fcntl.flock(self.file_obj.fileno(), fcntl.LOCK_UN) + if fcntl is not None: + fcntl.flock(self.file_obj.fileno(), fcntl.LOCK_UN) + return + assert msvcrt is not None + assert self._windows_lock_file is not None + try: + self._windows_lock_file.seek(0) + msvcrt.locking(self._windows_lock_file.fileno(), msvcrt.LK_UNLCK, 1) + finally: + self._windows_lock_file.close() + self._windows_lock_file = None # --- Data Models --- @@ -1830,7 +1886,7 @@ def runtime_capabilities( "implementation_sha256": hashlib.sha256(source_path.read_bytes()).hexdigest(), "safe_default_create": True, "lock_backend": _lock_backend(), - "locking_scope": "local-posix-advisory" if fcntl is not None else "unavailable", + "locking_scope": _locking_scope(), "capabilities": [ "acknowledged-layer-append", "explicit-store-init", diff --git a/plugin.yaml b/plugin.yaml index 3937bfe..898f40f 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,6 +1,9 @@ name: aethermind version: 0.2.0 description: Project-local continuity layers for Hermes agents. +provides_hooks: + - on_session_start + - pre_llm_call provides_tools: - aethermind_init_store - aethermind_write_layer diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 82bb6e2..f6e85d9 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -53,7 +53,14 @@ for forbidden in ("src", "tests", "tools", "plugins", "pyproject.toml"): raise SystemExit(f"forbidden publish surface still present: {forbidden}") manifest = (root / "plugin.yaml").read_text(encoding="utf-8") -for expected in ("name: aethermind", "aethermind_write_layer", "aethermind_reorient"): +for expected in ( + "name: aethermind", + "aethermind_write_layer", + "aethermind_reorient", + "provides_hooks:", + "on_session_start", + "pre_llm_call", +): if expected not in manifest: raise SystemExit(f"plugin.yaml missing {expected!r}") @@ -186,6 +193,8 @@ if not ( and imported["ok"] and imported_report["valid"] and capabilities["runtime_version"] == "0.2.0" + and capabilities["lock_backend"] in {"fcntl", "msvcrt"} + and capabilities["locking_scope"] in {"local-posix-advisory", "local-windows-byte-range"} and len(currentness["active_heads"]) == 1 and event["event_id"] == "0001" and len(events["events"]) == 1