Adds host_agent/skill_sync.py (HostAgentSkillSync) which constructs the synced + local skill stores, the Cloud API sync client, and the runner, then drives them on the host-agent lifecycle: incremental per-host pull into the synced catalog, fork-on-revocation, and a best-effort local- skill inventory report to the Cloud (design D7). Wired into create_application + run_async start/stop. Host-agent suite green (219 passed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
105 lines
4.0 KiB
Python
105 lines
4.0 KiB
Python
"""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()
|