diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5960762..0e6a342 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,7 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: 1.97.1 + components: rustfmt, clippy - run: python -m pip install --upgrade pip - run: python -m pip install -e . - run: cargo test --workspace diff --git a/README.md b/README.md index 991e9dd..4a84632 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,10 @@ reconciliation, bundle, and replay/negative checks without dispatching selection or final material. `src/ravel/fabric_persistent.py` is the preferred live Fabric consumer path: it connects through the persistent controller, delegates bundle transport and worker placement to Fabric, and supports detached execution with -restart-safe RAVEL provenance metadata. The Rust crates under `crates/` and the +restart-safe RAVEL provenance metadata. `ravel-fabric-agent` adds a bounded +long-running consumer that checks live readiness, can submit one idempotent pair +of development bootstrap probes, and retains completed Fabric evidence +references without taking over fleet or evaluator authority. The Rust crates under `crates/` and the `ravel-rs` CLI are the future implementation home; `src/ravel/rust_bridge.py` and `tests/test_rust_parity.py` prove discrete C/Python/Rust agreement without making either side authoritative. See [`docs/RUST_FOUNDATION.md`](docs/RUST_FOUNDATION.md) diff --git a/config/ravel-fabric-persistent.example.toml b/config/ravel-fabric-persistent.example.toml index 775b134..ec9f006 100644 --- a/config/ravel-fabric-persistent.example.toml +++ b/config/ravel-fabric-persistent.example.toml @@ -5,6 +5,6 @@ # RAVEL receives only the controller consumer socket. [fabric] mode = "persistent-controller" -socket_path = "/run/mncs-fabric/controller.sock" +socket_path = "~/.local/state/mncs-fabric/controller.sock" client_identity = "ravel" timeout = 5.0 diff --git a/docs/FABRIC_INTEGRATION.md b/docs/FABRIC_INTEGRATION.md index b0e2111..ff2f2d1 100644 --- a/docs/FABRIC_INTEGRATION.md +++ b/docs/FABRIC_INTEGRATION.md @@ -35,7 +35,7 @@ A minimal configuration is: ```toml [fabric] mode = "persistent-controller" -socket_path = "/run/mncs-fabric/controller.sock" +socket_path = "~/.local/state/mncs-fabric/controller.sock" client_identity = "ravel" timeout = 5.0 ``` @@ -83,6 +83,39 @@ No model is hard-coded into the adapter. `submit_provider_parity()` accepts an optional Fabric `model` and `role`, while the default leaves model/worker selection to the surrounding MNCS policy and Fabric capability inventory. +### Live RAVEL consumer agent + +`ravel-fabric-agent` (or `python3 tools/ravel_fabric_agent.py` from a checkout) +provides the bounded long-running consumer process. It performs three jobs only: + +1. reports controller/fleet readiness through Fabric's public consumer API; +2. optionally submits one branching and one ring development bootstrap probe; and +3. watches RAVEL's own detached work and retains completed Fabric evidence + references under the RAVEL state directory. + +It does **not** poll controller ledgers directly, inspect worker secrets, ingest +arbitrary MNCS experiments, resubmit work on a timer, or grant evaluator status. +The bootstrap operation is idempotent with respect to RAVEL's retained provider +submissions, so restarting the agent does not create an endless stream of jobs. + +From the standard controller layout no config file is required: + +```bash +python3 tools/ravel_fabric_agent.py doctor +python3 tools/ravel_fabric_agent.py run --bootstrap --interval 30 +``` + +The default socket is `~/.local/state/mncs-fabric/controller.sock` and the +default RAVEL-owned state root is `~/.local/state/ravel/fabric-live`. A custom +config may still be supplied with `--config` or `RAVEL_FABRIC_CONFIG`. + +Because the current 0.6 provider probe bundles a binary compiled on the +controller, its Fabric workload is explicitly constrained to the producing OS +and architecture in addition to `python`. This prevents, for example, a Linux +ELF candidate from being placed on a Windows worker merely because both expose +Python. Cross-platform RAVEL probes require platform-native build artifacts; the +adapter does not pretend otherwise. + ### Authority and evidence boundary Fabric outcomes remain execution evidence. They are never promoted into a RAVEL diff --git a/mncs-forge.toml b/mncs-forge.toml index 75dbc5b..686077b 100644 --- a/mncs-forge.toml +++ b/mncs-forge.toml @@ -233,3 +233,12 @@ command = ["python3", "tools/ravel_forge_check.py", "knowledge-lifecycle"] provider_protocol = false subject = "project" disclosure = "compact" + +[[workflows]] +name = "fabric-live-doctor" +category = "inspection" +mode = "development" +command = ["python3", "tools/ravel_fabric_agent.py", "doctor"] +provider_protocol = false +subject = "project" +disclosure = "compact" diff --git a/pyproject.toml b/pyproject.toml index fa45615..a4deb21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,9 @@ requires-python = ">=3.11" authors = [{name = "RAVEL contributors"}] dependencies = [] +[project.scripts] +ravel-fabric-agent = "ravel.fabric_agent:main" + [tool.setuptools.packages.find] where = ["src"] @@ -32,3 +35,4 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/ravel/__init__.py b/src/ravel/__init__.py index 36348ac..2a21a71 100644 --- a/src/ravel/__init__.py +++ b/src/ravel/__init__.py @@ -2,18 +2,19 @@ __all__ = [ "adaptation", - "checkpoint", "c_observations", + "checkpoint", "development_evaluator", "experience", "fabric", + "fabric_agent", "fabric_persistent", "knowledge", "lifecycle", "matched_compute", - "mncs_receipts", - "memory", "mechanism_state", + "memory", + "mncs_receipts", "planning", "policy", "providers", diff --git a/src/ravel/fabric.py b/src/ravel/fabric.py index 207a706..11a5552 100644 --- a/src/ravel/fabric.py +++ b/src/ravel/fabric.py @@ -9,22 +9,23 @@ from __future__ import annotations -from dataclasses import dataclass, field -from enum import StrEnum import hashlib import json import math import os -from pathlib import Path import shutil import stat -import tempfile -from typing import Any, Mapping, Protocol +import subprocess +import sys +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any, Protocol from .mncs_bundles import BundleResult, build_execution_bundle from .siblings import ensure_sibling_src - WORKLOAD_SCHEMA = "ravel-fabric-workload/0.1" OBSERVATION_SCHEMA = "ravel-fabric-observation/0.1" REFERENCE_SCHEMA = "ravel-fabric-reference-report/0.1" @@ -322,6 +323,42 @@ def _write_bundle_source_manifest(source_root: Path, destination: Path) -> None: destination.write_text(json.dumps(value, sort_keys=True), encoding="utf-8") +def _build_provider_candidate(provider: str, output: Path) -> dict[str, Any]: + """Build development material without importing repository-only tools as a package.""" + + if provider not in {"branching", "ring"}: + raise FabricError("provider must be branching or ring") + project_root = Path(__file__).resolve().parents[2] + build_tool = project_root / "tools" / "ravel_0_6_build.py" + if not build_tool.is_file(): + raise FabricUnavailableError( + "RAVEL 0.6 development bootstrap requires a source checkout containing " + "tools/ravel_0_6_build.py" + ) + environment = dict(os.environ) + environment["RAVEL06_PROVIDER"] = provider + completed = subprocess.run( + [sys.executable, str(build_tool), "build", "--output-dir", str(output)], + cwd=project_root, + env=environment, + text=True, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout)[-4096:].strip() + raise FabricUnavailableError( + "RAVEL 0.6 development build failed" + (f": {detail}" if detail else "") + ) + try: + record = json.loads(completed.stdout) + except json.JSONDecodeError as error: + raise FabricUnavailableError("RAVEL 0.6 development build returned invalid JSON") from error + if not isinstance(record, dict): + raise FabricUnavailableError("RAVEL 0.6 development build returned a non-object record") + return record + + def _task_source(provider: str) -> str: return f'''import json from pathlib import Path @@ -368,7 +405,10 @@ def __init__(self, workspace: str | Path) -> None: ensure_sibling_src("mncs-fabric", "mncs_validator") try: from mncs_fabric.artifacts import build_manifest - from mncs_fabric.challenges import ChallengeReplayStore, challenge_for_receipt + from mncs_fabric.challenges import ( + ChallengeReplayStore, + challenge_for_receipt, + ) from mncs_fabric.controller import LocalController from mncs_fabric.receipts import build_execution_receipt from mncs_fabric.service import FabricService @@ -400,17 +440,7 @@ def reconcile(self, records: list[Mapping[str, Any]]) -> Mapping[str, Any]: return self._service.reconcile(list(records), require_distinct_nodes=True) def _build_provider(self, provider: str, output: Path) -> dict[str, Any]: - from tools.ravel_0_6_build import build - - prior = os.environ.get("RAVEL06_PROVIDER") - try: - os.environ["RAVEL06_PROVIDER"] = provider - return build(output) - finally: - if prior is None: - os.environ.pop("RAVEL06_PROVIDER", None) - else: - os.environ["RAVEL06_PROVIDER"] = prior + return _build_provider_candidate(provider, output) def _make_artifact(self, provider: str, root: Path) -> tuple[Path, dict[str, Any], BundleResult]: artifact = root / "artifact" @@ -540,7 +570,7 @@ def execute_provider_parity( "issues": list(binding.get("issues", [])), } ) - except Exception as error: + except Exception as error: # noqa: BLE001 receipt_bindings.append( {"status": "UNKNOWN", "issues": [type(error).__name__]} ) @@ -700,7 +730,7 @@ class FabricNetworkConfig: pre_staged_bundle_identity: str | None = None @classmethod - def load(cls, path: str | Path) -> "FabricNetworkConfig": + def load(cls, path: str | Path) -> FabricNetworkConfig: import tomllib source = Path(path).resolve(strict=True) diff --git a/src/ravel/fabric_agent.py b/src/ravel/fabric_agent.py new file mode 100644 index 0000000..936c71f --- /dev/null +++ b/src/ravel/fabric_agent.py @@ -0,0 +1,226 @@ +"""Bounded long-running RAVEL consumer for the persistent MNCS Fabric service.""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from .fabric import FabricError, _identity +from .fabric_persistent import ( + FabricPersistentBackend, + FabricPersistentConfig, + FabricPersistentSubmission, +) + +AGENT_SCHEMA = "ravel-fabric-agent-state/0.1" +TERMINAL_STATES = {"COMPLETED", "COMPLETE", "DONE", "FAILED", "CANCELLED", "CANCELED"} + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _state_root() -> Path: + return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) + + +def _atomic_json(path: Path, value: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(value, sort_keys=True, indent=2) + "\n", encoding="utf-8") + temporary.chmod(0o600) + os.replace(temporary, path) + path.chmod(0o600) + + +def _execution_state(status: Mapping[str, Any]) -> str: + for key in ("state", "status", "outcome"): + value = status.get(key) + if isinstance(value, str) and value: + return value.upper() + result = status.get("result") + if isinstance(result, Mapping): + return _execution_state(result) + return "UNKNOWN" + + +@dataclass(slots=True) +class FabricAgent: + backend: FabricPersistentBackend + workspace: Path + replication_count: int = 1 + report_root: Path = field(init=False) + heartbeat_path: Path = field(init=False) + + def __post_init__(self) -> None: + if self.replication_count < 1: + raise FabricError("agent replication_count must be at least one") + self.workspace.mkdir(parents=True, exist_ok=True) + self.report_root = self.workspace / "fabric-reports" + self.report_root.mkdir(parents=True, exist_ok=True) + self.heartbeat_path = self.workspace / "agent-heartbeat.json" + + def _report_path(self, work_id: str) -> Path: + digest = _identity({"fabric_work_id": work_id})[7:] + return self.report_root / f"{digest}.json" + + def _bootstrap(self) -> list[dict[str, Any]]: + existing = {submission.provider_identity for submission in self.backend.submissions()} + created: list[dict[str, Any]] = [] + for provider in ("branching", "ring"): + if provider in existing: + continue + submission = self.backend.submit_provider_parity( + provider, + replication_count=self.replication_count, + ) + created.append( + { + "provider": provider, + "work_id": submission.work_id, + "state": submission.accepted_state, + } + ) + return created + + def _observe_submission(self, submission: FabricPersistentSubmission) -> dict[str, Any]: + status = dict(self.backend.execution_status(submission)) + state = _execution_state(status) + observation: dict[str, Any] = { + "work_id": submission.work_id, + "provider": submission.provider_identity, + "state": state, + "report_written": False, + } + report_path = self._report_path(submission.work_id) + if state in TERMINAL_STATES and not report_path.exists(): + try: + report = self.backend.collect_submission(submission) + except Exception as error: # noqa: BLE001 + observation["collection"] = "UNKNOWN" + observation["collection_reason"] = type(error).__name__ + else: + _atomic_json( + report_path, + { + "schema": "ravel-fabric-agent-report/0.1", + "collected_at": _utc_now(), + "fabric_work_id": submission.work_id, + "report": report.to_dict(), + "authority": "development-only", + "semantics": "retained Fabric evidence reference; not evaluator authority", + }, + ) + observation["report_written"] = True + observation["fabric_status"] = report.fabric_status + elif report_path.exists(): + observation["report_written"] = True + return observation + + def tick(self, *, bootstrap: bool = False) -> dict[str, Any]: + health = dict(self.backend.health()) + created: list[dict[str, Any]] = [] + if bootstrap and health.get("eligible_workers"): + created = self._bootstrap() + submissions = [self._observe_submission(item) for item in self.backend.submissions()] + state = { + "schema": AGENT_SCHEMA, + "observed_at": _utc_now(), + "health": health, + "bootstrap_submissions": created, + "submissions": submissions, + "authority": "consumer-only", + "semantics": ( + "RAVEL watches only its own detached Fabric development work; " + "it does not own fleet state or infer evaluator authority" + ), + } + _atomic_json(self.heartbeat_path, state) + return state + + def run(self, *, interval_seconds: float, bootstrap: bool = False, once: bool = False) -> int: + if interval_seconds < 1 or interval_seconds > 3600: + raise FabricError("agent interval must be within [1, 3600] seconds") + stopping = False + + def stop(_signum: int, _frame: object) -> None: + nonlocal stopping + stopping = True + + prior_int = signal.signal(signal.SIGINT, stop) + prior_term = signal.signal(signal.SIGTERM, stop) + try: + first = True + while not stopping: + state = self.tick(bootstrap=bootstrap and first) + print(json.dumps(state, sort_keys=True), flush=True) + first = False + if once: + break + deadline = time.monotonic() + interval_seconds + while not stopping and time.monotonic() < deadline: + time.sleep(min(1.0, max(0.0, deadline - time.monotonic()))) + finally: + signal.signal(signal.SIGINT, prior_int) + signal.signal(signal.SIGTERM, prior_term) + self.backend.close() + return 0 + + +def _config(path: str | None) -> FabricPersistentConfig: + return FabricPersistentConfig.load(path) if path else FabricPersistentConfig.default() + + +def _backend(args: argparse.Namespace) -> FabricPersistentBackend: + workspace = Path(args.workspace).expanduser().resolve() + return FabricPersistentBackend(workspace, _config(args.config)) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", + default=os.environ.get("RAVEL_FABRIC_CONFIG"), + help="persistent Fabric TOML; defaults to the standard per-user controller socket", + ) + parser.add_argument( + "--workspace", + default=str(_state_root() / "ravel" / "fabric-live"), + help="RAVEL-owned local state directory", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("doctor", help="check the public persistent-controller boundary") + run = subparsers.add_parser("run", help="watch RAVEL's detached Fabric work") + run.add_argument("--bootstrap", action="store_true", help="submit one branching and one ring development probe if absent") + run.add_argument("--once", action="store_true", help="perform one bounded agent tick and exit") + run.add_argument("--interval", type=float, default=30.0, help="poll interval in seconds (1..3600)") + run.add_argument("--replicas", type=int, default=1, help="bootstrap replica count") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + backend = _backend(args) + try: + if args.command == "doctor": + health = dict(backend.health()) + print(json.dumps(health, sort_keys=True, indent=2)) + return 0 if health.get("outcome") == "PASS" else 2 + agent = FabricAgent(backend, Path(args.workspace).expanduser().resolve(), args.replicas) + return agent.run(interval_seconds=args.interval, bootstrap=args.bootstrap, once=args.once) + except (FabricError, OSError) as error: + print(json.dumps({"outcome": "UNKNOWN", "reason": type(error).__name__, "detail": str(error)})) + backend.close() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ravel/fabric_persistent.py b/src/ravel/fabric_persistent.py index bb26c42..8f7ee9a 100644 --- a/src/ravel/fabric_persistent.py +++ b/src/ravel/fabric_persistent.py @@ -11,26 +11,29 @@ from __future__ import annotations -from dataclasses import dataclass, field import json import os -from pathlib import Path +import platform import shutil import stat import tomllib -from typing import Any, Mapping +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any from .fabric import ( DEVELOPMENT_AUTHORITY, + MAX_OUTPUT_BYTES, + MAX_REPLICAS, FabricError, FabricExecutionObservation, FabricQuestion, FabricReferenceResult, FabricUnavailableError, FabricWorkload, - MAX_OUTPUT_BYTES, - MAX_REPLICAS, _aggregate, + _build_provider_candidate, _identity, _task_source, _write_bundle_source_manifest, @@ -38,7 +41,6 @@ from .mncs_bundles import BundleResult, build_execution_bundle from .siblings import ensure_sibling_src - PERSISTENT_CONFIG_SCHEMA = "ravel-fabric-persistent-config/0.1" PERSISTENT_SUBMISSION_SCHEMA = "ravel-fabric-persistent-submission/0.1" @@ -54,13 +56,22 @@ class FabricPersistentConfig: def __post_init__(self) -> None: if not str(self.socket_path): raise FabricError("persistent Fabric socket_path is required") + if not self.socket_path.is_absolute(): + raise FabricError("persistent Fabric socket_path must be absolute") if not self.client_identity or len(self.client_identity) > 128 or "\x00" in self.client_identity: raise FabricError("persistent Fabric client_identity must be bounded text") if self.timeout <= 0 or self.timeout > 300: raise FabricError("persistent Fabric timeout must be within (0, 300] seconds") @classmethod - def load(cls, path: str | Path) -> "FabricPersistentConfig": + def default(cls) -> FabricPersistentConfig: + """Use the controller's standard per-user state location without owning it.""" + + state_root = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) + return cls(socket_path=state_root / "mncs-fabric" / "controller.sock") + + @classmethod + def load(cls, path: str | Path) -> FabricPersistentConfig: """Load a deliberately narrow config that cannot smuggle worker trust state.""" candidate = Path(path) @@ -148,7 +159,7 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, value: Mapping[str, Any]) -> "FabricPersistentSubmission": + def from_dict(cls, value: Mapping[str, Any]) -> FabricPersistentSubmission: if value.get("schema") != PERSISTENT_SUBMISSION_SCHEMA: raise FabricError("unsupported persistent submission schema") workload_value = value.get("workload") @@ -239,7 +250,7 @@ def __init__( client_identity=config.client_identity, timeout=config.timeout, ) - except Exception as error: + except Exception as error: # noqa: BLE001 self.unavailable_reason = ( f"persistent Fabric controller unavailable: {type(error).__name__}" ) @@ -258,6 +269,8 @@ def _require(self) -> None: def close(self) -> None: if self.available and hasattr(self._client, "close"): self._client.close() + self.available = False + self.unavailable_reason = "persistent Fabric backend is closed" def _submission_path(self, work_id: str) -> Path: digest = _identity({"fabric_work_id": work_id})[7:] @@ -265,12 +278,31 @@ def _submission_path(self, work_id: str) -> Path: def _persist_submission(self, submission: FabricPersistentSubmission) -> None: path = self._submission_path(submission.work_id) - temporary = path.with_suffix(".tmp") + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") temporary.write_text( json.dumps(submission.to_dict(), sort_keys=True, indent=2) + "\n", encoding="utf-8", ) - temporary.replace(path) + temporary.chmod(0o600) + os.replace(temporary, path) + path.chmod(0o600) + + def submissions(self) -> tuple[FabricPersistentSubmission, ...]: + """Load all locally retained detached-work references, failing closed on corruption.""" + + items: list[FabricPersistentSubmission] = [] + for path in sorted(self.submission_root.glob("*.json")): + try: + value = json.loads(path.read_text(encoding="utf-8")) + submission = FabricPersistentSubmission.from_dict(value) + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error: + raise FabricError( + f"persistent submission metadata is corrupt: {path.name}: {type(error).__name__}" + ) from error + if self._submission_path(submission.work_id).name != path.name: + raise FabricError("persistent submission filename does not bind its work identity") + items.append(submission) + return tuple(items) def load_submission(self, work_id: str) -> FabricPersistentSubmission: path = self._submission_path(work_id) @@ -295,6 +327,68 @@ def contract(self) -> Mapping[str, Any]: } return dict(contract()) + @staticmethod + def artifact_required_capabilities() -> tuple[str, ...]: + """Bind precompiled candidate artifacts to the platform that produced them.""" + + system = platform.system().lower() + machine = platform.machine().lower() + os_capability = { + "linux": "os:linux", + "windows": "os:windows", + "darwin": "os:darwin", + }.get(system, f"os:{system or 'unknown'}") + architecture = machine or "unknown" + return ("python", os_capability, f"arch:{architecture}") + + def health(self) -> Mapping[str, Any]: + """Return a bounded live-readiness view from Fabric's public consumer surface.""" + + self._require() + controller_status: Mapping[str, Any] = {} + controller_doctor: Mapping[str, Any] = {} + status_fn = getattr(self._client, "controller_status", None) + doctor_fn = getattr(self._client, "controller_doctor", None) + if status_fn is not None: + controller_status = dict(status_fn()) + if doctor_fn is not None: + controller_doctor = dict(doctor_fn()) + required = set(self.artifact_required_capabilities()) + workers = self.workers() + summaries = [] + eligible = [] + for worker in workers: + capabilities = {str(item) for item in worker.get("capabilities", ())} + summary = { + "worker_id": worker.get("worker_id") or worker.get("worker_identity"), + "availability": worker.get("availability", "UNKNOWN"), + "available": bool(worker.get("available", False)), + "capabilities": sorted(capabilities), + "capability_inventory_status": worker.get("capability_inventory_status", "UNKNOWN"), + } + summaries.append(summary) + if summary["available"] and required.issubset(capabilities): + eligible.append(summary["worker_id"]) + checks = controller_doctor.get("checks", {}) if isinstance(controller_doctor, Mapping) else {} + controller_ok = not checks or all( + value in {"PASS", "CONTROLLER_MANAGED_ENDPOINTS", "NOT_CONFIGURED", "LOCAL_OPERATOR_SOCKET"} + for value in checks.values() + ) + return { + "schema": "ravel-fabric-persistent-health/0.1", + "backend": self.backend_identity, + "outcome": "PASS" if controller_ok and eligible else "UNKNOWN", + "controller_id": controller_status.get("controller_id"), + "fabric_version": controller_status.get("fabric_version"), + "configured": controller_status.get("configured"), + "required_capabilities": sorted(required), + "eligible_workers": eligible, + "workers": summaries, + "controller_checks": dict(checks) if isinstance(checks, Mapping) else {}, + "authority": "consumer-only", + "semantics": "live readiness only; not evaluator or conformance authority", + } + def workers(self) -> list[dict[str, Any]]: """Return controller-observed fleet state without reading worker secrets.""" @@ -328,17 +422,7 @@ def reconcile(self, records: list[Mapping[str, Any]]) -> Mapping[str, Any]: } def _build_provider(self, provider: str, output: Path) -> dict[str, Any]: - from tools.ravel_0_6_build import build - - prior = os.environ.get("RAVEL06_PROVIDER") - try: - os.environ["RAVEL06_PROVIDER"] = provider - return build(output) - finally: - if prior is None: - os.environ.pop("RAVEL06_PROVIDER", None) - else: - os.environ["RAVEL06_PROVIDER"] = prior + return _build_provider_candidate(provider, output) def _make_artifact( self, provider: str, root: Path @@ -420,7 +504,7 @@ def _workload( question_kind=FabricQuestion.PROVIDER_PARITY, bundle_identity=str(bundle.logical_identity), fabric_manifest_identity=str(manifest["manifest_identity"]), - required_capabilities=("python",), + required_capabilities=self.artifact_required_capabilities(), replication_count=replication_count, provider_identity=f"ravel-toy-{provider}-c/1", ) @@ -607,6 +691,12 @@ def _report( for result in results: record = result.get("record") if isinstance(result.get("record"), Mapping) else {} receipt = result.get("receipt") if isinstance(result.get("receipt"), Mapping) else {} + observed_manifest = record.get("artifact_manifest_identity") + if observed_manifest is not None and observed_manifest != workload.fabric_manifest_identity: + raise FabricError("persistent Fabric result does not bind the submitted manifest") + observed_bundle = result.get("bundle_identity") + if observed_bundle is not None and observed_bundle != bundle_identity: + raise FabricError("persistent Fabric result does not bind the submitted bundle") records.append(record) raw_status = record.get("outcome", "UNKNOWN") status = raw_status if raw_status in {"PASS", "FAIL", "UNKNOWN"} else "UNKNOWN" @@ -698,9 +788,9 @@ def _report( __all__ = [ + "PERSISTENT_CONFIG_SCHEMA", + "PERSISTENT_SUBMISSION_SCHEMA", "FabricPersistentBackend", "FabricPersistentConfig", "FabricPersistentSubmission", - "PERSISTENT_CONFIG_SCHEMA", - "PERSISTENT_SUBMISSION_SCHEMA", ] diff --git a/tests/test_fabric_agent.py b/tests/test_fabric_agent.py new file mode 100644 index 0000000..552c3ec --- /dev/null +++ b/tests/test_fabric_agent.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from ravel.fabric import FabricQuestion, FabricReferenceResult, FabricWorkload +from ravel.fabric_agent import FabricAgent, _execution_state +from ravel.fabric_persistent import FabricPersistentSubmission + + +class _Backend: + def __init__(self, root: Path) -> None: + self.root = root + self.items: list[FabricPersistentSubmission] = [] + self.submitted: list[str] = [] + self.closed = False + + def health(self): + return {"outcome": "PASS", "eligible_workers": ["fabric-worker-01"]} + + def submissions(self): + return tuple(self.items) + + def submit_provider_parity(self, provider: str, *, replication_count: int): + self.submitted.append(provider) + workload = FabricWorkload( + candidate_identity="ravel-0.6-candidate-001", + experiment_identity="sha256:" + "1" * 64, + question_kind=FabricQuestion.PROVIDER_PARITY, + bundle_identity="sha256:" + "2" * 64, + fabric_manifest_identity="sha256:" + "3" * 64, + required_capabilities=("python", "os:linux", "arch:x86_64"), + replication_count=replication_count, + provider_identity=f"ravel-toy-{provider}-c/1", + ) + item = FabricPersistentSubmission( + workload=workload, + work_id=f"work-{provider}", + accepted_state="QUEUED", + provider_identity=provider, + plan={"schema_version": "mncs-fabric.job-plan.v0.1"}, + manifest={"manifest_identity": "sha256:" + "3" * 64}, + bundle_identity="sha256:" + "2" * 64, + bundle_archive_identity="sha256:" + "4" * 64, + archive_path=self.root / f"{provider}.zip", + request_identity="sha256:" + ("5" if provider == "branching" else "6") * 64, + ) + self.items.append(item) + return item + + def execution_status(self, submission): + return {"state": "COMPLETED"} + + def collect_submission(self, submission): + return FabricReferenceResult( + workload=submission.workload, + observations=(), + reconciliation={"outcome": "UNKNOWN"}, + bundle={"executed": "PASS"}, + replay={"status": "UNKNOWN"}, + negative_cases={"status": "UNKNOWN"}, + fabric_status="PASS", + limitations=(), + ) + + def close(self): + self.closed = True + + +class FabricAgentTests(unittest.TestCase): + def test_execution_state_handles_nested_result(self) -> None: + self.assertEqual(_execution_state({"result": {"state": "completed"}}), "COMPLETED") + self.assertEqual(_execution_state({}), "UNKNOWN") + + def test_bootstrap_is_idempotent_and_collects_reports(self) -> None: + with tempfile.TemporaryDirectory(prefix="ravel-agent-") as directory: + root = Path(directory) + backend = _Backend(root) + agent = FabricAgent(backend, root, replication_count=1) + first = agent.tick(bootstrap=True) + self.assertEqual(backend.submitted, ["branching", "ring"]) + self.assertEqual(len(first["bootstrap_submissions"]), 2) + self.assertEqual(len(list((root / "fabric-reports").glob("*.json"))), 2) + second = agent.tick(bootstrap=True) + self.assertEqual(backend.submitted, ["branching", "ring"]) + self.assertEqual(second["bootstrap_submissions"], []) + heartbeat = json.loads((root / "agent-heartbeat.json").read_text()) + self.assertEqual(heartbeat["schema"], "ravel-fabric-agent-state/0.1") + self.assertEqual(len(heartbeat["submissions"]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fabric_persistent.py b/tests/test_fabric_persistent.py index 5e230ca..ef8f54c 100644 --- a/tests/test_fabric_persistent.py +++ b/tests/test_fabric_persistent.py @@ -127,6 +127,28 @@ def test_persistent_report_keeps_fabric_status_separate_from_evaluator_authority observation.resource_observations["fabric_work_id"], "work-123" ) + + def test_workload_pins_precompiled_artifact_platform(self) -> None: + backend = FabricPersistentBackend.__new__(FabricPersistentBackend) + capabilities = backend.artifact_required_capabilities() + self.assertIn("python", capabilities) + self.assertTrue(any(item.startswith("os:") for item in capabilities)) + self.assertTrue(any(item.startswith("arch:") for item in capabilities)) + + def test_report_rejects_manifest_binding_mismatch(self) -> None: + backend = FabricPersistentBackend.__new__(FabricPersistentBackend) + backend.available = True + backend.unavailable_reason = None + workload = self._workload() + with self.assertRaises(FabricError): + backend._report( + workload, + "branching", + "sha256:" + "2" * 64, + "sha256:" + "4" * 64, + [{"record": {"artifact_manifest_identity": "sha256:" + "f" * 64}}], + ) + def test_submission_round_trip_preserves_workload_identity(self) -> None: workload = self._workload() submission = FabricPersistentSubmission( diff --git a/tests/test_forge_integration.py b/tests/test_forge_integration.py index f5ca1c7..de290ac 100644 --- a/tests/test_forge_integration.py +++ b/tests/test_forge_integration.py @@ -1,11 +1,12 @@ from __future__ import annotations -from pathlib import Path +import shutil +import tempfile import unittest +from pathlib import Path from ravel.providers import EvidenceRequest, ForgeAdapter, ForgeCliProvider - ROOT = Path(__file__).resolve().parents[1] FORGE = ROOT.parent / "mncs-forge-mcp/.venv/bin/mncs-forge" FORGE_CONFIG = ROOT.parent / "mncs-forge-mcp/examples/minimal/mncs-forge.toml" @@ -14,24 +15,36 @@ class ForgeIntegrationTests(unittest.TestCase): @unittest.skipUnless(FORGE.is_file() and FORGE_CONFIG.is_file(), "local Forge checkout unavailable") def test_actual_forge_inventory_and_lifecycle_rejection(self) -> None: - provider = ForgeCliProvider(executable=str(FORGE), config=FORGE_CONFIG) - inventory = provider.verifier_inventory() - self.assertGreaterEqual(len(inventory.get("verifiers", [])), 1) - receipt = ForgeAdapter((provider,)).request( - EvidenceRequest( - "ravel-forge-integration-request", - "ravel-0.6-candidate-001", - "sha256:" + "a" * 64, - "ravel-development-contract/0.6", - "python.bounded-add-equivalence", - "does the declared bounded verifier exist?", - "diagnostic", + # The MNCS controller sandbox exposes sibling projects read-only. Copy + # Forge's minimal fixture so its own ledger remains Forge-owned but writable. + with tempfile.TemporaryDirectory(prefix="ravel-forge-integration-") as directory: + project = Path(directory) / "minimal" + shutil.copytree( + FORGE_CONFIG.parent, + project, + ignore=shutil.ignore_patterns(".mncs-forge"), + ) + provider = ForgeCliProvider( + executable=str(FORGE), + config=project / "mncs-forge.toml", + ) + inventory = provider.verifier_inventory() + self.assertGreaterEqual(len(inventory.get("verifiers", [])), 1) + receipt = ForgeAdapter((provider,)).request( + EvidenceRequest( + "ravel-forge-integration-request", + "ravel-0.6-candidate-001", + "sha256:" + "a" * 64, + "ravel-development-contract/0.6", + "python.bounded-add-equivalence", + "does the declared bounded verifier exist?", + "diagnostic", + ) ) - ) - # The minimal Forge project has no active candidate. Its lifecycle - # rejection is an actual Forge observation and must remain UNKNOWN. - self.assertEqual(receipt.status, "UNKNOWN") - self.assertEqual(receipt.raw.provider_id, provider.provider_id) + # The minimal Forge project has no active candidate. Its lifecycle + # rejection is an actual Forge observation and must remain UNKNOWN. + self.assertEqual(receipt.status, "UNKNOWN") + self.assertEqual(receipt.raw.provider_id, provider.provider_id) if __name__ == "__main__": diff --git a/tools/ravel_fabric_agent.py b/tools/ravel_fabric_agent.py new file mode 100755 index 0000000..0cad5de --- /dev/null +++ b/tools/ravel_fabric_agent.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Run the persistent-controller RAVEL Fabric agent from a source checkout.""" + +import sys +from importlib import import_module +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +main = import_module("ravel.fabric_agent").main + +if __name__ == "__main__": + raise SystemExit(main())