feat(host-agent): wire skill sync + inventory report into app lifecycle
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>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""Tests for the host-agent skill sync wiring (§7.2/7.4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.skill_sync import HostAgentSkillSync
|
||||
|
||||
|
||||
def _config(tmp_path) -> HostAgentConfig:
|
||||
return HostAgentConfig(
|
||||
control_plane_url="https://cloud.example",
|
||||
host_id="host-1",
|
||||
token="host-token",
|
||||
identity_path=tmp_path / "identity.json",
|
||||
skill_sync_interval_seconds=0.01,
|
||||
)
|
||||
|
||||
|
||||
def test_skill_sync_pulls_delta_and_reports_inventory(tmp_path):
|
||||
seen_inventory = {"host": None, "body": None}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if path.endswith("/skills/sync"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"skills": [
|
||||
{
|
||||
"id": "c1",
|
||||
"name": "Cloud Skill",
|
||||
"kind": "knowledge",
|
||||
"description": "",
|
||||
"tags": [],
|
||||
"revision": 1,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"content": "body",
|
||||
"steps": [],
|
||||
"parameters": {},
|
||||
}
|
||||
],
|
||||
"removed_ids": [],
|
||||
"latest_version": 1,
|
||||
"is_full_replace": True,
|
||||
},
|
||||
)
|
||||
if path.endswith("/skills/inventory"):
|
||||
seen_inventory["host"] = request.url.path.split("/")[4]
|
||||
seen_inventory["body"] = request.read()
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(404)
|
||||
|
||||
sync = HostAgentSkillSync(
|
||||
_config(tmp_path),
|
||||
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
# One manual tick applies the cloud skill to the synced store.
|
||||
outcomes = sync.runner.tick()
|
||||
assert outcomes["host-1"].success is True
|
||||
visible = sync.synced_store.list_skills({"host-1"})
|
||||
assert [m.name for m in visible] == ["Cloud Skill"]
|
||||
|
||||
# Inventory report of an authored local skill is best-effort and payload-shaped.
|
||||
from skills_learning.models import KnowledgeSkill, SkillMetadata
|
||||
|
||||
sync.local_store.create_local(
|
||||
KnowledgeSkill(metadata=SkillMetadata(name="Local Note", kind="knowledge"), content="x")
|
||||
)
|
||||
sync.report_inventory_once()
|
||||
assert seen_inventory["host"] == "host-1"
|
||||
assert b"Local Note" in seen_inventory["body"]
|
||||
|
||||
# start/stop lifecycle does not raise.
|
||||
sync.start()
|
||||
sync.stop()
|
||||
|
||||
|
||||
def test_skill_sync_inventory_failure_is_isolated(tmp_path):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/skills/inventory"):
|
||||
return httpx.Response(500)
|
||||
return httpx.Response(200, json={"skills": [], "removed_ids": [], "latest_version": 0, "is_full_replace": True})
|
||||
|
||||
sync = HostAgentSkillSync(
|
||||
_config(tmp_path),
|
||||
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
# A failed inventory report must not raise.
|
||||
sync.report_inventory_once()
|
||||
sync.client.close()
|
||||
Reference in New Issue
Block a user