feat(@projects/@claire): host-capability registry for routing/dispatch

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)
This commit is contained in:
Natalie 2026-06-02 23:58:52 -07:00
parent 2d3777a7d0
commit c5e63c5942
2 changed files with 77 additions and 0 deletions

View file

@ -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:<name>`, `svc:<name>`. 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]]")

View file

@ -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)