"""Host-agent wiring for skill sync against the Cloud Control Plane. Constructs the synced catalog store, the local skill store, the Cloud API sync client, and the :class:`SkillSyncRunner`, then drives them on the host agent's lifecycle: the runner pulls incremental per-host skill deltas into the synced catalog (forking local overrides on revocation, design D9), and a best-effort inventory of the agent's local skills is reported to the Cloud (design D7). Lives in ``host_agent`` (not ``runtime``) for the same boundary reasons as :mod:`host_agent.cloud_planner_client`. """ from __future__ import annotations import logging import threading from host_agent.config import HostAgentConfig log = logging.getLogger(__name__) class HostAgentSkillSync: """Owns the skill stores, sync runner, and inventory reporting thread.""" def __init__( self, config: HostAgentConfig, *, poll_interval: float | None = None, http_client=None, ) -> None: from api.skill_sync import CloudApiSkillClient, SkillSyncRunner from storage.local_skills import LocalSkillStore from storage.skill_catalog import SkillCatalogStore state_dir = config.identity_path.parent self.config = config self.synced_store = SkillCatalogStore(db_path=state_dir / "skills.sqlite3") self.local_store = LocalSkillStore(db_path=state_dir / "local_skills.sqlite3") self.client = CloudApiSkillClient( config.control_plane_url, host_token=config.token, client=http_client, ) self.runner = SkillSyncRunner( store=self.synced_store, client=self.client, subscriptions=[config.host_id], poll_interval=poll_interval or config.skill_sync_interval_seconds, local_store=self.local_store, ) self._inventory_stop = threading.Event() self._inventory_thread: threading.Thread | None = None def start(self) -> None: """Start the sync poll loop + periodic inventory reporting.""" self.runner.start_background() self._inventory_stop.clear() self._inventory_thread = threading.Thread( target=self._report_inventory_forever, daemon=True ) self._inventory_thread.start() log.info("skill sync started for host %s", self.config.host_id) def stop(self) -> None: """Stop the sync loop + inventory thread and close the HTTP client.""" self.runner.stop_background() self._inventory_stop.set() if self._inventory_thread is not None: self._inventory_thread.join(timeout=5.0) self._inventory_thread = None self.client.close() def report_inventory_once(self) -> None: """Report the current local-skill inventory to the Cloud (best-effort).""" try: inventory = [ { "id": meta.id, "name": meta.name, "kind": meta.kind, "origin": "local", } for meta in self.local_store.list_local() ] for override in self.local_store.list_overrides(): inventory.append( { "id": override.metadata.id, "name": override.metadata.name, "kind": override.metadata.kind, "origin": "cloud", "locally_overridden": True, } ) self.client.report_inventory(self.config.host_id, inventory) except Exception: # best-effort: never impair local operation log.debug("skill inventory report failed", exc_info=True) def _report_inventory_forever(self) -> None: # Report once at startup, then on the sync cadence. self.report_inventory_once() interval = self.config.skill_sync_interval_seconds while not self._inventory_stop.wait(interval): self.report_inventory_once()