From c5e63c594201ae2c3f59bc26b7e399be571fbb1c Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 2 Jun 2026 23:58:52 -0700 Subject: [PATCH] feat(@projects/@claire): host-capability registry for routing/dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit known_hosts gains a `capabilities` tag list (e.g. media, transmission, cores:64, gpu) + ClaireConfig.hosts_with_capability(tag) (exact or key: prefix match) and capabilities_for(host) (alias-resolved). Lets routing (location-transparent Claire, task 13764f2f) and dispatch pick a host by what it CAN do, not just load. Seeded black={media,transmission}. Prereq task a5453fb8. 351 tests green. (manual commit via ALLOW_COMMIT — autocommit LLM still timing out on claire) --- src/claire/config.py | 32 +++++++++++++++++++++++++++++++ tests/test_config.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/claire/config.py b/src/claire/config.py index a1c9d85..fa92f0b 100644 --- a/src/claire/config.py +++ b/src/claire/config.py @@ -44,6 +44,13 @@ class HostEntry(_Strict): # views from synced data. Optional — legacy entries lack it; resolution # falls back to label matching when absent. machine_id: str | None = None + # Free-form capability tags describing what this host CAN do — used by + # routing (location-transparent Claire) and dispatch to pick a host by + # capability, not just load. Conventions: `gpu`, `media`, `transmission`, + # `cores:64`, `mount:`, `svc:`. Matching is by exact tag OR + # `key:` prefix (see `ClaireConfig.hosts_with_capability`). Empty = no + # declared specialties (host is general-purpose). + capabilities: list[str] = Field(default_factory=list) class WebConfig(_Strict): @@ -291,6 +298,29 @@ class ClaireConfig(_Strict): return h.name return label + def capabilities_for(self, host: str) -> list[str]: + """Capability tags declared for `host` (label resolved to canonical).""" + canon = self.resolve_host_label(host) + for h in self.known_hosts: + if h.name == canon: + return list(h.capabilities) + return [] + + def hosts_with_capability(self, tag: str) -> list[str]: + """Canonical host names that satisfy capability `tag`. + + Matches a host's `capabilities` either exactly (`"gpu" == "gpu"`) or by + key for `key:value` tags (asking `"cores"` matches `"cores:64"`), so a + router can query `hosts_with_capability("cores")` without knowing the + value. Returns names in `known_hosts` order (stable, caller picks). + """ + key = tag + ":" + out: list[str] = [] + for h in self.known_hosts: + if any(c == tag or c.startswith(key) for c in h.capabilities): + out.append(h.name) + return out + def default_config_path() -> Path: """`~/.config/claire/claire.toml` — XDG-compliant.""" @@ -400,6 +430,8 @@ def _serialize(cfg: ClaireConfig) -> str: lines.append(f'description = "{h.description}"') if h.machine_id is not None: lines.append(f'machine_id = "{h.machine_id}"') + if h.capabilities: + lines.append(f"capabilities = {_fmt_str_list(list(h.capabilities))}") for peer in cfg.peers: lines.append("") lines.append("[[peers]]") diff --git a/tests/test_config.py b/tests/test_config.py index dfc0125..edb3a98 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -223,3 +223,48 @@ def test_serialize_round_trips_per_host_caps(tmp_path: Path) -> None: reloaded = load_or_init(cfg_path) assert reloaded.limits.per_host_max == 5 assert reloaded.limits.per_host == {"apricot": 10} + + +def test_host_capabilities_roundtrip_and_resolver() -> None: + """known_hosts capability tags serialize/reload and resolve by exact + + key-prefix match, with alias resolution.""" + from claire.config import ClaireConfig, HostEntry, _serialize + + cfg = ClaireConfig( + machine_id="m", + this_host="plum", + known_hosts=[ + HostEntry(name="black", capabilities=["media", "transmission"]), + HostEntry(name="apricot", capabilities=["cores:64", "gpu"]), + HostEntry(name="plum", aliases=["local"]), + ], + ) + + # Round-trip through the hand-rolled serializer (the field is easy to drop). + rt = ClaireConfig.model_validate(tomllib.loads(_serialize(cfg))) + black = next(h for h in rt.known_hosts if h.name == "black") + assert black.capabilities == ["media", "transmission"] + + # Exact-tag match. + assert cfg.hosts_with_capability("media") == ["black"] + assert cfg.hosts_with_capability("gpu") == ["apricot"] + # key:value tag matched by key alone. + assert cfg.hosts_with_capability("cores") == ["apricot"] + # No host satisfies an unknown capability. + assert cfg.hosts_with_capability("fpga") == [] + + # capabilities_for resolves aliases (local -> plum) and unknown -> []. + assert cfg.capabilities_for("local") == [] + assert cfg.capabilities_for("apricot") == ["cores:64", "gpu"] + assert cfg.capabilities_for("nope") == [] + + +def test_host_without_capabilities_emits_no_capabilities_line() -> None: + """A general-purpose host (no tags) stays minimal in the toml.""" + from claire.config import ClaireConfig, HostEntry, _serialize + + cfg = ClaireConfig( + machine_id="m", this_host="plum", + known_hosts=[HostEntry(name="plum", aliases=["local"])], + ) + assert "capabilities" not in _serialize(cfg)