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:
parent
2d3777a7d0
commit
c5e63c5942
2 changed files with 77 additions and 0 deletions
|
|
@ -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]]")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue