harness: surface hive + swarm display names to agents (#701)
This commit is contained in:
parent
04f69c1fe1
commit
c41bf1b562
5 changed files with 239 additions and 16 deletions
|
|
@ -184,7 +184,10 @@ hive-ag3nt/ in-container harness crate; produces ONE `hive`
|
|||
src/identity.rs hive-qualified agent label (#589 phase A):
|
||||
`label()` / `qualified_label()` / `qualify(label)`.
|
||||
Reads `HYPERHIVE_HIVE_DOMAIN`; falls back to short
|
||||
name when unset.
|
||||
name when unset. Display-name accessors (#701):
|
||||
`hive_name()` / `swarm_name()` read
|
||||
`HYPERHIVE_HIVE_NAME` / `HYPERHIVE_SWARM_NAME`,
|
||||
both `Option<String>`.
|
||||
src/login.rs probe $HOME/.claude/ (post-#658 `/home/<agent>/.claude`)
|
||||
for a valid session
|
||||
src/login_session.rs drives `claude auth login` over stdio pipes
|
||||
|
|
|
|||
|
|
@ -104,6 +104,15 @@ name. No default — subsystems that require it (currently:
|
|||
`services.hyperhive.matrix`) assert non-null at eval time with a clear
|
||||
error message if it is missing.
|
||||
|
||||
Optional: set `services.hyperhive.hiveName = "pr1ma";` and / or
|
||||
`services.hyperhive.swarmName = "constellat1on";` to give the hive and
|
||||
the wider swarm human-readable display labels. Distinct from
|
||||
`services.hyperhive.domain` (the DNS address): the names surface in
|
||||
the dashboard chrome and the per-agent system prompt; the domain is
|
||||
how things are addressed on the wire. Federated hives at different
|
||||
domains can share a swarm name. Both default to null — chrome falls
|
||||
back to the domain, the prompt simply doesn't mention them (#701).
|
||||
|
||||
Optional: set `services.hyperhive.matrix.enable = true;` to spin up a
|
||||
private [matrix-tuwunel](https://github.com/matrix-construct/tuwunel)
|
||||
homeserver in a nixos-container. Requires either
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Agent identity helpers — short label + hive-qualified long name.
|
||||
//! Agent identity helpers — short label + hive-qualified long name +
|
||||
//! human display names for the hive and swarm.
|
||||
//!
|
||||
//! `HIVE_LABEL` is the short, hive-local agent name (e.g. `iris`, `damocles`).
|
||||
//! `HYPERHIVE_HIVE_DOMAIN` is the hive's canonical DNS domain (e.g.
|
||||
|
|
@ -13,11 +14,19 @@
|
|||
//! `from` / `to` qualification + dashboard rendering of cross-hive
|
||||
//! identities are subsequent follow-ups inside #589.
|
||||
//!
|
||||
//! The hive name itself IS the operator's DNS domain — `hyperhive.hiveName`
|
||||
//! was deliberately dropped in #589 spec discussion (mara #6577 / iris
|
||||
//! #6582) so there's one source of truth. Matrix MXIDs already use the
|
||||
//! same convention (`@iris:darkest.space`), so federation lookups Just Work
|
||||
//! without a separate slug.
|
||||
//! `HYPERHIVE_HIVE_NAME` + `HYPERHIVE_SWARM_NAME` are human-readable
|
||||
//! display names for the local hive (`pr1ma`) and the wider swarm
|
||||
//! (`constellat1on`) — added in #701 after mara's
|
||||
//! `internal-requests#9` ("we want to persist this name somewhere in
|
||||
//! the hive"). They're **distinct** from the DNS domain above: the
|
||||
//! domain may carry the hive name as its leftmost label by
|
||||
//! convention, but the convention isn't machine-readable, and
|
||||
//! federated hives at different DNS domains can share a swarm name.
|
||||
//! Both reverse the earlier #589 spec decision (mara #6577 / iris
|
||||
//! #6582 dropped `hyperhive.hiveName` in favour of "the domain IS the
|
||||
//! name") — turns out humans want both: the address (`@darkest.space`)
|
||||
//! AND the prose name (`pr1ma`). Matrix MXIDs still use the
|
||||
//! domain-based convention untouched.
|
||||
|
||||
use std::env;
|
||||
|
||||
|
|
@ -40,6 +49,30 @@ pub fn hive_domain() -> Option<String> {
|
|||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Human display name of this hive (e.g. `pr1ma`). Distinct from
|
||||
/// [`hive_domain`] — the domain is the machine-readable DNS address;
|
||||
/// this is the prose label humans use in conversation. Returns None
|
||||
/// when the host-side `services.hyperhive.hiveName` option is unset,
|
||||
/// in which case callers fall back to the domain or the short label
|
||||
/// at their discretion (#701).
|
||||
#[must_use]
|
||||
pub fn hive_name() -> Option<String> {
|
||||
env::var("HYPERHIVE_HIVE_NAME")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Human display name of the wider swarm this hive belongs to (e.g.
|
||||
/// `constellat1on`). Federated hives at different DNS domains can
|
||||
/// share a swarm name. Returns None when the host-side
|
||||
/// `services.hyperhive.swarmName` option is unset (#701).
|
||||
#[must_use]
|
||||
pub fn swarm_name() -> Option<String> {
|
||||
env::var("HYPERHIVE_SWARM_NAME")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Hive-qualified agent identity. When the hive domain is configured, returns
|
||||
/// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just
|
||||
/// the short label so callers can render a single string regardless of
|
||||
|
|
@ -82,9 +115,24 @@ mod tests {
|
|||
/// to recover so a single test failure doesn't cascade through the
|
||||
/// whole module.
|
||||
fn with_env<F: FnOnce()>(label: Option<&str>, domain: Option<&str>, f: F) {
|
||||
with_full_env(label, domain, None, None, f);
|
||||
}
|
||||
|
||||
/// Extended form of [`with_env`] covering the #701 display-name env
|
||||
/// vars (hive name + swarm name) alongside label + domain. Same
|
||||
/// SAFETY contract — serialised on `ENV_LOCK`, restore in scope.
|
||||
fn with_full_env<F: FnOnce()>(
|
||||
label: Option<&str>,
|
||||
domain: Option<&str>,
|
||||
hive_name: Option<&str>,
|
||||
swarm_name: Option<&str>,
|
||||
f: F,
|
||||
) {
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let prev_label = env::var("HIVE_LABEL").ok();
|
||||
let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok();
|
||||
let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok();
|
||||
let prev_swarm_name = env::var("HYPERHIVE_SWARM_NAME").ok();
|
||||
// SAFETY: serialised by ENV_LOCK above; restore in the same scope.
|
||||
unsafe {
|
||||
match label {
|
||||
|
|
@ -95,6 +143,14 @@ mod tests {
|
|||
Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v),
|
||||
None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"),
|
||||
}
|
||||
match hive_name {
|
||||
Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v),
|
||||
None => env::remove_var("HYPERHIVE_HIVE_NAME"),
|
||||
}
|
||||
match swarm_name {
|
||||
Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v),
|
||||
None => env::remove_var("HYPERHIVE_SWARM_NAME"),
|
||||
}
|
||||
}
|
||||
f();
|
||||
unsafe {
|
||||
|
|
@ -106,6 +162,14 @@ mod tests {
|
|||
Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v),
|
||||
None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"),
|
||||
}
|
||||
match prev_hive_name {
|
||||
Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v),
|
||||
None => env::remove_var("HYPERHIVE_HIVE_NAME"),
|
||||
}
|
||||
match prev_swarm_name {
|
||||
Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v),
|
||||
None => env::remove_var("HYPERHIVE_SWARM_NAME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,4 +221,58 @@ mod tests {
|
|||
assert_eq!(label(), "");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hive_name_returns_some_when_env_set() {
|
||||
with_full_env(Some("iris"), None, Some("pr1ma"), None, || {
|
||||
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hive_name_returns_none_when_env_unset_or_empty() {
|
||||
with_full_env(Some("iris"), None, None, None, || {
|
||||
assert!(hive_name().is_none());
|
||||
});
|
||||
with_full_env(Some("iris"), None, Some(""), None, || {
|
||||
assert!(hive_name().is_none(), "empty string treated as unset");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swarm_name_returns_some_when_env_set() {
|
||||
with_full_env(Some("iris"), None, None, Some("constellat1on"), || {
|
||||
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swarm_name_returns_none_when_env_unset_or_empty() {
|
||||
with_full_env(Some("iris"), None, None, None, || {
|
||||
assert!(swarm_name().is_none());
|
||||
});
|
||||
with_full_env(Some("iris"), None, None, Some(""), || {
|
||||
assert!(swarm_name().is_none(), "empty string treated as unset");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_accessors_independent_from_domain() {
|
||||
// hive_name + swarm_name surface without HYPERHIVE_HIVE_DOMAIN
|
||||
// being set — the names are display labels, not derived from
|
||||
// the DNS domain (#701, mara on internal-requests#9).
|
||||
with_full_env(
|
||||
Some("iris"),
|
||||
None,
|
||||
Some("pr1ma"),
|
||||
Some("constellat1on"),
|
||||
|| {
|
||||
assert!(hive_domain().is_none());
|
||||
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
|
||||
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
|
||||
// qualified_label still degrades to short label without domain.
|
||||
assert_eq!(qualified_label(), "iris");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,6 +280,33 @@ fn render_flake(
|
|||
/// meta-level reference (#355).
|
||||
const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "nixpkgs-unstable"];
|
||||
|
||||
/// Env vars hive-c0re forwards from its own systemd unit env into every
|
||||
/// sub-agent's harness service env. Each entry is `(env_var_name,
|
||||
/// host_value)`. Empty / unset vars are filtered out so absent options
|
||||
/// don't render no-op `FOO = ""` lines into the meta flake.
|
||||
///
|
||||
/// Returns owned `String` values so the result is `'static`-friendly +
|
||||
/// trivial to stub from tests (which build their own slice instead of
|
||||
/// touching process-wide env).
|
||||
const FORWARDED_VARS: &[&str] = &[
|
||||
"HIVE_FORGE_URL",
|
||||
"HYPERHIVE_HIVE_DOMAIN",
|
||||
"HYPERHIVE_HIVE_NAME",
|
||||
"HYPERHIVE_SWARM_NAME",
|
||||
];
|
||||
|
||||
fn forwarded_env_vars() -> Vec<(&'static str, String)> {
|
||||
FORWARDED_VARS
|
||||
.iter()
|
||||
.filter_map(|&name| {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| (name, v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read an agent's applied `flake.lock` and return the subset of
|
||||
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
|
||||
/// Returns an empty vec when the lock is missing or unparsable —
|
||||
|
|
@ -446,14 +473,21 @@ where
|
|||
" HIVE_CONTEXT_WINDOW_TOKENS_{upper_key} = \"{val}\";"
|
||||
);
|
||||
}
|
||||
// Forge URL — injected when hive-c0re itself has HIVE_FORGE_URL set
|
||||
// (the NixOS module derives it from hyperhive.forge.{domain,httpPort}).
|
||||
// Agents use it in forge_notify to poll Forgejo for PR/review events.
|
||||
if let Ok(forge_url) = std::env::var("HIVE_FORGE_URL")
|
||||
&& !forge_url.is_empty()
|
||||
{
|
||||
let escaped = forge_url.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(out, " HIVE_FORGE_URL = \"{escaped}\";");
|
||||
// Forwarded env vars — picked up from hive-c0re's own systemd unit
|
||||
// env (`services.hyperhive.*` options flow through nix/modules/
|
||||
// hive-c0re.nix into the host process). We copy whatever's set into
|
||||
// each sub-agent's harness service env so the in-container surfaces
|
||||
// (`identity.rs`, `forge_notify`) see a consistent view across the
|
||||
// whole hive. Absent host-side env (option not set) → skip emission
|
||||
// → in-container accessors fall back to None / defaults gracefully.
|
||||
//
|
||||
// - HIVE_FORGE_URL: agents poll this for Forgejo notifications.
|
||||
// - HYPERHIVE_HIVE_DOMAIN: machine-readable hive DNS (#589).
|
||||
// - HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME: human display
|
||||
// names for hive + swarm (#701).
|
||||
for (var, val) in forwarded_env_vars() {
|
||||
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(out, " {var} = \"{escaped}\";");
|
||||
}
|
||||
out.push_str(
|
||||
r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
||||
|
|
|
|||
|
|
@ -48,7 +48,47 @@ in
|
|||
`matrix.''${services.hyperhive.domain}` when `serverName` is
|
||||
null). No default — subsystems that opt to require it assert
|
||||
non-null in their own config and fail eval with a helpful
|
||||
message if it's missing.
|
||||
message if it's missing. Exposed to agents as
|
||||
`HYPERHIVE_HIVE_DOMAIN`; consumed by
|
||||
`hive-ag3nt::identity::hive_domain()` for `<name>@<domain>`
|
||||
qualified labels (#589).
|
||||
'';
|
||||
};
|
||||
|
||||
# Display-name identities for the swarm + hive — distinct from the
|
||||
# DNS domain above, which is the machine-readable address. The
|
||||
# display names are how humans address the constellation in
|
||||
# conversation (`pr1ma`, `constellat1on`); the DNS subdomain may
|
||||
# carry the hive name as its leftmost label by convention but the
|
||||
# convention isn't machine-readable. Both nullable + default null
|
||||
# so existing deploys evaluate unchanged (#701).
|
||||
options.services.hyperhive.hiveName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "pr1ma";
|
||||
description = ''
|
||||
Human-readable name of this single-host hive instance.
|
||||
Distinct from `services.hyperhive.domain` (the machine-
|
||||
addressable DNS name): the domain may carry the hive name as
|
||||
its leftmost label by convention, but this option is the
|
||||
canonical readable identity. Exposed to agents as
|
||||
`HYPERHIVE_HIVE_NAME`; surfaced in the dashboard chrome and
|
||||
per-agent system prompt when set. Null falls back to the
|
||||
pre-#701 behaviour (chrome shows the domain, prompt doesn't
|
||||
mention a hive name).
|
||||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.swarmName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "constellat1on";
|
||||
description = ''
|
||||
Human-readable name of the wider swarm this hive belongs to.
|
||||
Hives at different DNS domains can share a swarm name when
|
||||
they federate together (#589). Exposed to agents as
|
||||
`HYPERHIVE_SWARM_NAME`; surfaced in the dashboard chrome and
|
||||
per-agent system prompt when set.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -248,6 +288,25 @@ in
|
|||
# `forge.rs` reads the avatar PNGs from here on startup.
|
||||
HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive";
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.domain != null) {
|
||||
# Canonical hive DNS domain — surfaced to identity.rs as
|
||||
# HYPERHIVE_HIVE_DOMAIN. meta.rs forwards the same env var
|
||||
# into every sub-agent's harness service env so they all see
|
||||
# a consistent qualified label (#589 + #701).
|
||||
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
|
||||
# Display name of this hive instance (#701). meta.rs
|
||||
# forwards into sub-agent harness env so identity.rs can
|
||||
# expose hive_name() to claude.
|
||||
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.swarmName != null) {
|
||||
# Display name of the wider swarm this hive belongs to (#701).
|
||||
# meta.rs forwards into sub-agent harness env so identity.rs
|
||||
# can expose swarm_name() to claude.
|
||||
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
|
||||
}
|
||||
// lib.optionalAttrs config.services.hyperhive.forge.enable {
|
||||
# Agents poll this URL for Forgejo notifications. Derived from
|
||||
# services.hyperhive.forge.{domain,httpPort} so it tracks forge config changes.
|
||||
|
|
|
|||
Loading…
Reference in a new issue