the rendered meta flake pins each agent config by forge URL at its forge main
This commit is contained in:
parent
fc72bea151
commit
09cabf8398
8 changed files with 432 additions and 163 deletions
|
|
@ -45,6 +45,21 @@ pub struct AgentSpec {
|
|||
/// The archetype the agent is built from (`crate::archetypes`), `None`
|
||||
/// for a custom agent built from its own config repo.
|
||||
pub archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
|
||||
/// The pin of a custom agent's config input. `None` until [`sync_agents`]
|
||||
/// resolves it; an archetype agent has no config input and keeps `None`.
|
||||
pub pin: Option<AgentPin>,
|
||||
}
|
||||
|
||||
/// What the rendered flake needs to know about one agent's config input, all
|
||||
/// of it read from the agent's config repo at one commit. The rendered text
|
||||
/// is a function of this and nothing on the host.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentPin {
|
||||
/// `git+<forge>/agent-configs/<n>.git?rev=<rev>`, `<rev>` being the head of
|
||||
/// `main` on the forge when the flake was rendered.
|
||||
pub url: String,
|
||||
/// The `CANONICAL_INPUTS` the agent's `flake.lock` declares at that commit.
|
||||
pub canonical_inputs: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// Stage topology.json if it exists: it is regenerated by `reconcile`
|
||||
|
|
@ -74,6 +89,12 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
let dir = crate::paths::meta_root();
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
|
||||
// One agent whose forge main cannot be read fails the whole
|
||||
// sync, like a missing forge URL or hyperhive rev above: rendering the
|
||||
// fleet without it would drop a running agent from the flake.
|
||||
let pinned = pin_agents(agents).await?;
|
||||
let agents = pinned.as_slice();
|
||||
|
||||
let new_flake = render_hive_flake(hive, agents)?;
|
||||
let flake_path = dir.join("flake.nix");
|
||||
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
||||
|
|
@ -203,26 +224,12 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
/// One-shot used by the manual-rebuild path: relock just one
|
||||
/// agent's input and commit the lock change if any. Single-phase
|
||||
/// (no separate finalize) because rebuild has no failure-revert
|
||||
/// semantics — it always wants the latest main.
|
||||
/// semantics. The input follows the url `sync_agents` rendered just before.
|
||||
pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
let dir = crate::paths::meta_root();
|
||||
let input = format!("agent-{name}");
|
||||
// Re-lock against `applied/<n>/main` explicitly, so the lock is the
|
||||
// exact applied head whatever the declared input url says.
|
||||
let applied = applied_override_url(&crate::paths::applied_dir(name));
|
||||
nix(
|
||||
&dir,
|
||||
&[
|
||||
"flake",
|
||||
"update",
|
||||
&input,
|
||||
"--override-input",
|
||||
&input,
|
||||
&applied,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
nix(&dir, &rebuild_relock_args(&input)).await?;
|
||||
if !paths_dirty(&dir, &["flake.lock"]).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -235,15 +242,11 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
|
|||
.await
|
||||
}
|
||||
|
||||
/// `--override-input` URL re-locking an agent's config input against its
|
||||
/// LOCAL applied mirror (`git+file://<applied_dir>`, current `main` head),
|
||||
/// the same url the meta flake declares. The deploy + rebuild paths re-lock
|
||||
/// from here: the applied tree was already fast-forwarded to the reviewed
|
||||
/// head, so this builds exactly that config with no forge access. No
|
||||
/// `?rev` — `main` head is the reviewed head at deploy time. Pure so it's
|
||||
/// unit-testable.
|
||||
fn applied_override_url(applied_dir: &Path) -> String {
|
||||
format!("git+file://{}", applied_dir.display())
|
||||
/// `nix` arguments re-locking one agent's config input. The input is updated
|
||||
/// against the url `sync_agents` rendered, the forge url pinned by `?rev=`:
|
||||
/// there is no `--override-input`, so the lock never records another source.
|
||||
fn rebuild_relock_args(input: &str) -> [&str; 3] {
|
||||
["flake", "update", input]
|
||||
}
|
||||
|
||||
/// Update one or more named inputs in the meta flake and commit
|
||||
|
|
@ -348,14 +351,14 @@ fn pinned_git_url(forge_git_url: &str, rev: Option<&str>) -> Result<String> {
|
|||
/// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`]
|
||||
/// renders it.
|
||||
fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<String> {
|
||||
Ok(render_hive_flake_at(
|
||||
hive,
|
||||
&hyperhive_input_url(hive)?,
|
||||
agents,
|
||||
))
|
||||
render_hive_flake_at(hive, &hyperhive_input_url(hive)?, agents)
|
||||
}
|
||||
|
||||
fn render_hive_flake_at(hive: &HiveEnv, hyperhive_url: &str, agents: &[AgentSpec]) -> String {
|
||||
fn render_hive_flake_at(
|
||||
hive: &HiveEnv,
|
||||
hyperhive_url: &str,
|
||||
agents: &[AgentSpec],
|
||||
) -> Result<String> {
|
||||
render_flake(
|
||||
hyperhive_url,
|
||||
hive.claude_code_path.as_deref(),
|
||||
|
|
@ -364,21 +367,6 @@ fn render_hive_flake_at(hive: &HiveEnv, hyperhive_url: &str, agents: &[AgentSpec
|
|||
)
|
||||
}
|
||||
|
||||
fn render_flake(
|
||||
hyperhive_url: &str,
|
||||
claude_code_path: Option<&str>,
|
||||
dashboard_port: u16,
|
||||
agents: &[AgentSpec],
|
||||
) -> String {
|
||||
render_flake_with_lookup(
|
||||
hyperhive_url,
|
||||
claude_code_path,
|
||||
dashboard_port,
|
||||
agents,
|
||||
agent_canonical_inputs,
|
||||
)
|
||||
}
|
||||
|
||||
/// Canonical inputs meta knows how to dedup. An agent that declares one
|
||||
/// of these as a top-level input in its own `flake.nix` will get a
|
||||
/// `follows = "<name>"` line emitted in meta — collapsing the
|
||||
|
|
@ -712,16 +700,72 @@ fn ca_embed_state(dir: &std::path::Path) -> (Vec<(String, String)>, bool) {
|
|||
(desired, changed)
|
||||
}
|
||||
|
||||
/// 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 —
|
||||
/// safe degradation, the worst case is no dedup for that agent.
|
||||
fn agent_canonical_inputs(name: &str) -> Vec<&'static str> {
|
||||
let path = crate::paths::applied_dir(name).join("flake.lock");
|
||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
||||
/// Resolve every custom agent's [`AgentPin`] and return `agents` with it set.
|
||||
async fn pin_agents(agents: &[AgentSpec]) -> Result<Vec<AgentSpec>> {
|
||||
let mut out = Vec::with_capacity(agents.len());
|
||||
for spec in agents {
|
||||
let mut spec = spec.clone();
|
||||
spec.pin = if spec.archetype.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
pin_agent(
|
||||
&spec.name,
|
||||
&crate::paths::applied_dir(&spec.name),
|
||||
&crate::forge::forge_git_url(&format!(
|
||||
"{}/{}",
|
||||
crate::forge::CONFIG_ORG,
|
||||
spec.name
|
||||
)),
|
||||
crate::forge::FORGE_MAIN_REF,
|
||||
crate::forge::fetch_forge_main(&spec.name),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
out.push(spec);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pin the config input of agent `name` to the head of `agent-configs/<name>`
|
||||
/// `main` on the forge. `fetch_forge` fetches that branch into `repo` as
|
||||
/// `forge_ref` ([`crate::forge::fetch_forge_main`]); `repo` is only the object
|
||||
/// store that fetch lands in, its own branches and files are never read. The
|
||||
/// pin is a commit the forge has, so a flake rendered from it resolves off this
|
||||
/// host. Errors, with no fallback to a local path, when the forge cannot be
|
||||
/// reached or has no `main`.
|
||||
async fn pin_agent<F>(
|
||||
name: &str,
|
||||
repo: &Path,
|
||||
forge_git_url: &str,
|
||||
forge_ref: &str,
|
||||
fetch_forge: F,
|
||||
) -> Result<AgentPin>
|
||||
where
|
||||
F: std::future::Future<Output = Result<std::path::PathBuf>>,
|
||||
{
|
||||
fetch_forge
|
||||
.await
|
||||
.with_context(|| format!("agent {name}: read agent-configs/{name} main from the forge"))?;
|
||||
let rev = lifecycle::git_rev_parse(repo, forge_ref)
|
||||
.await
|
||||
.with_context(|| format!("agent {name}: agent-configs/{name} has no main on the forge"))?;
|
||||
let lock = lifecycle::git_show_file(repo, &rev, "flake.lock").await?;
|
||||
Ok(AgentPin {
|
||||
url: pinned_git_url(forge_git_url, Some(&rev))?,
|
||||
canonical_inputs: lock
|
||||
.as_deref()
|
||||
.map(canonical_inputs_in_lock)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The subset of `CANONICAL_INPUTS` that the `flake.lock` text `raw` declares
|
||||
/// as direct (root-level) inputs. Empty when the lock is unparsable — safe
|
||||
/// degradation, the worst case is no dedup for that agent.
|
||||
fn canonical_inputs_in_lock(raw: &str) -> Vec<&'static str> {
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
|
||||
|
|
@ -744,23 +788,18 @@ fn agent_canonical_inputs(name: &str) -> Vec<&'static str> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Inner render helper accepting a lookup fn so tests can stub the
|
||||
/// agent flake-lock introspection.
|
||||
/// Render the meta flake. Errors on a custom agent without a [`AgentPin`].
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "templated string-builder for the meta flake — the length is one \
|
||||
contiguous fmt block, splitting it would just hide the shape"
|
||||
)]
|
||||
fn render_flake_with_lookup<F>(
|
||||
fn render_flake(
|
||||
hyperhive_url: &str,
|
||||
claude_code_path: Option<&str>,
|
||||
dashboard_port: u16,
|
||||
agents: &[AgentSpec],
|
||||
lookup: F,
|
||||
) -> String
|
||||
where
|
||||
F: Fn(&str) -> Vec<&'static str>,
|
||||
{
|
||||
) -> Result<String> {
|
||||
use std::fmt::Write as _;
|
||||
let mut out = String::new();
|
||||
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
|
||||
|
|
@ -770,33 +809,36 @@ where
|
|||
// `agent-<n>.inputs.nixpkgs.follows = "nixpkgs"` resolves to this node.
|
||||
let _ = writeln!(out, " hyperhive.url = \"{hyperhive_url}\";");
|
||||
out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n");
|
||||
// Each agent's config input is its LOCAL applied mirror
|
||||
// (`git+file://<applied>`), so the meta flake resolves entirely from
|
||||
// on-disk state and boot never depends on the forge being reachable.
|
||||
// The forge `agent-configs/<name>` repos stay the review/audit surface
|
||||
// (config PRs land there) but are not the flake's build input. deploy +
|
||||
// rebuild re-lock this input to `applied/<name>`'s current `main` head.
|
||||
// Each agent's config input is its forge repo at the pinned commit, so
|
||||
// the flake text depends only on (agent, rev) and resolves anywhere that
|
||||
// can reach the forge.
|
||||
for spec in agents {
|
||||
// An archetype agent has no config repo, so no input: `mkAgent`
|
||||
// takes its module from the archetype.
|
||||
if spec.archetype.is_some() {
|
||||
continue;
|
||||
}
|
||||
let pin = spec.pin.as_ref().with_context(|| {
|
||||
format!(
|
||||
"agent {}: no pinned rev, refusing to render an unpinned config input",
|
||||
spec.name
|
||||
)
|
||||
})?;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" agent-{name}.url = \"git+file://{applied}\";",
|
||||
" agent-{name}.url = \"{url}\";",
|
||||
name = spec.name,
|
||||
applied = crate::paths::applied_dir(&spec.name).display(),
|
||||
url = pin.url,
|
||||
);
|
||||
// For each canonical input the agent declares in its own
|
||||
// `flake.nix` (detected by reading its applied `flake.lock`),
|
||||
// emit `inputs.agent-<name>.inputs.<canon>.follows = "<canon>"`.
|
||||
// For each canonical input the agent's `flake.lock` declares at the
|
||||
// pinned commit, emit
|
||||
// `inputs.agent-<name>.inputs.<canon>.follows = "<canon>"`.
|
||||
// Collapses otherwise-separate-but-identical nixpkgs nodes
|
||||
// (root + every agent's own nixpkgs) into one. Skipped
|
||||
// silently for agents that don't declare the input — emitting
|
||||
// follows on a non-existent input would error at
|
||||
// `nix flake lock` time.
|
||||
for canon in lookup(&spec.name) {
|
||||
for canon in &pin.canonical_inputs {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" agent-{}.inputs.{canon}.follows = \"{canon}\";",
|
||||
|
|
@ -1032,7 +1074,7 @@ where
|
|||
);
|
||||
}
|
||||
out.push_str(" };\n };\n}\n");
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Return the list of file names that are currently staged (index differs
|
||||
|
|
@ -1240,28 +1282,66 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
const SAMPLE_REV: &str = "1111111111111111111111111111111111111111";
|
||||
|
||||
fn sample_pin(name: &str, rev: &str, canonical_inputs: Vec<&'static str>) -> AgentPin {
|
||||
AgentPin {
|
||||
url: format!("git+https://forge.example/agent-configs/{name}.git?rev={rev}"),
|
||||
canonical_inputs,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_spec(name: &str, port: u16) -> AgentSpec {
|
||||
AgentSpec {
|
||||
name: name.to_owned(),
|
||||
port,
|
||||
archetype: None,
|
||||
pin: Some(sample_pin(name, SAMPLE_REV, Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
hyperhive_url: &str,
|
||||
claude_code_path: Option<&str>,
|
||||
dashboard_port: u16,
|
||||
agents: &[AgentSpec],
|
||||
) -> String {
|
||||
render_flake(hyperhive_url, claude_code_path, dashboard_port, agents).expect("render")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_override_url_targets_local_mirror_main_head() {
|
||||
// Deploy + rebuild re-lock against the local applied mirror's `main`
|
||||
// head (no `?rev`) — so a rebuild survives forge unreachability and
|
||||
// builds the fast-forwarded config.
|
||||
let p = Path::new("/var/lib/hyperhive/agents/iris/applied");
|
||||
let url = applied_override_url(p);
|
||||
assert_eq!(url, "git+file:///var/lib/hyperhive/agents/iris/applied");
|
||||
fn rebuild_relock_updates_the_input_without_an_override() {
|
||||
let args = rebuild_relock_args("agent-iris");
|
||||
assert_eq!(args, ["flake", "update", "agent-iris"]);
|
||||
assert!(
|
||||
!url.contains("?rev="),
|
||||
"must lock main head, not a pinned rev: {url}"
|
||||
!args
|
||||
.iter()
|
||||
.any(|a| *a == "--override-input" || a.contains("git+file")),
|
||||
"a rebuild must not lock a local path: {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The url a rebuild relocks is the one `sync_agents` rendered: the forge,
|
||||
/// pinned by rev.
|
||||
#[test]
|
||||
fn the_input_a_rebuild_relocks_is_a_pinned_forge_url() {
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
&[sample_spec("iris", 9001)],
|
||||
);
|
||||
let url = out
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("agent-iris.url = \""))
|
||||
.expect("agent-iris input is rendered");
|
||||
assert!(
|
||||
url.starts_with("git+https://forge.example/agent-configs/iris.git?rev="),
|
||||
"{url}"
|
||||
);
|
||||
assert!(!url.contains("git+file"), "{url}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_git_url_carries_the_rev_and_refuses_without_one() {
|
||||
assert_eq!(
|
||||
|
|
@ -1280,7 +1360,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn render_flake_pins_hyperhive_by_url_and_follows_its_nixpkgs() {
|
||||
let out = render_flake(
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1310,7 +1390,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn render_flake_names_each_agent_container_after_the_agent() {
|
||||
let out = render_flake(
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1346,7 +1426,7 @@ mod tests {
|
|||
// the host store, so the build is already reachable; making it an
|
||||
// input would re-copy it as a reference-less `-source` and strip
|
||||
// the closure the binary actually needs.
|
||||
let out = render_flake(
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
Some("/nix/store/cccc-claude-code-2.1.220"),
|
||||
8000,
|
||||
|
|
@ -1370,7 +1450,7 @@ mod tests {
|
|||
// agent module keeps its own nixpkgs' `claude-code` (and keeps it
|
||||
// in `environment.systemPackages`, which is what makes the
|
||||
// unpinned case self-contained).
|
||||
let out = render_flake(
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1384,24 +1464,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn render_flake_emits_follows_for_agents_declaring_nixpkgs() {
|
||||
// Stub lookup: pretend `bitburner` + `dmatrix` declare `nixpkgs`
|
||||
// at their root, while `argus` has no canonical inputs at all.
|
||||
let lookup = |name: &str| -> Vec<&'static str> {
|
||||
match name {
|
||||
"bitburner" | "dmatrix" => vec!["nixpkgs"],
|
||||
_ => vec![],
|
||||
}
|
||||
// `bitburner` + `dmatrix` declare `nixpkgs` at their root, while
|
||||
// `argus` has no canonical inputs at all.
|
||||
let with_nixpkgs = |name, port| AgentSpec {
|
||||
pin: Some(sample_pin(name, SAMPLE_REV, vec!["nixpkgs"])),
|
||||
..sample_spec(name, port)
|
||||
};
|
||||
let out = render_flake_with_lookup(
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
&[
|
||||
sample_spec("argus", 9001),
|
||||
sample_spec("bitburner", 9002),
|
||||
sample_spec("dmatrix", 9003),
|
||||
with_nixpkgs("bitburner", 9002),
|
||||
with_nixpkgs("dmatrix", 9003),
|
||||
],
|
||||
lookup,
|
||||
);
|
||||
// bitburner declares nixpkgs → follows emitted.
|
||||
assert!(
|
||||
|
|
@ -1421,7 +1498,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn render_flake_builds_every_agent_from_agent_base() {
|
||||
let out = render_flake(
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1445,15 +1522,14 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn render_flake_skips_canonical_follows_when_lookup_returns_empty() {
|
||||
let out = render_flake_with_lookup(
|
||||
fn render_flake_skips_canonical_follows_when_the_lock_declares_none() {
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
&[sample_spec("alice", 9001)],
|
||||
|_| Vec::new(),
|
||||
);
|
||||
// No agent-side follows when the lookup reports nothing
|
||||
// No agent-side follows when the lock declares nothing
|
||||
// declared — protects agents whose flake.lock can't be read
|
||||
// (missing / unparsable) from being broken by a follows on a
|
||||
// non-existent input.
|
||||
|
|
@ -1474,12 +1550,15 @@ mod tests {
|
|||
}),
|
||||
..sample_spec("droid", 9002)
|
||||
};
|
||||
let out = render_flake_with_lookup(
|
||||
let alice = AgentSpec {
|
||||
pin: Some(sample_pin("alice", SAMPLE_REV, vec!["nixpkgs"])),
|
||||
..sample_spec("alice", 9001)
|
||||
};
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
&[sample_spec("alice", 9001), android],
|
||||
|_| vec!["nixpkgs"],
|
||||
&[alice, android],
|
||||
);
|
||||
assert!(
|
||||
!out.contains("agent-droid"),
|
||||
|
|
@ -1492,7 +1571,7 @@ mod tests {
|
|||
),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("agent-alice.url = \"git+file://"), "{out}");
|
||||
assert!(out.contains("agent-alice.url = \"git+https://"), "{out}");
|
||||
assert!(out.contains("agent-alice.inputs.nixpkgs.follows"), "{out}");
|
||||
assert!(
|
||||
out.contains("alice = mkAgent { name = \"alice\"; port = 9001; };"),
|
||||
|
|
@ -1525,7 +1604,7 @@ mod tests {
|
|||
unsafe {
|
||||
std::env::set_var("HIVE_FORGE_URL", "http://forge.example.test");
|
||||
}
|
||||
let out = render_flake(
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1566,7 +1645,7 @@ mod tests {
|
|||
std::env::set_var(VARS[0], name);
|
||||
std::env::set_var(VARS[1], domain);
|
||||
}
|
||||
render_flake(
|
||||
render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1761,7 +1840,7 @@ mod tests {
|
|||
std::env::set_var("HIVE_FORGE_URL", "http://forge.example.test");
|
||||
std::env::set_var("HIVE_MATRIX_URL", "http://matrix.example.test");
|
||||
}
|
||||
let out = render_flake(
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1787,30 +1866,183 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn render_flake_agent_input_points_at_local_applied_mirror() {
|
||||
// The agent config input references the LOCAL applied mirror
|
||||
// (`git+file://<applied>`), NOT the forge — so the meta flake
|
||||
// resolves entirely from on-disk state and boot never depends on the
|
||||
// forge being reachable. The forge `agent-configs/<n>` repos stay the
|
||||
// review surface, but they are not the flake's build input.
|
||||
let out = render_flake(
|
||||
fn render_flake_pins_each_agent_input_to_its_own_forge_rev() {
|
||||
let at = |name, port, rev: &str| AgentSpec {
|
||||
pin: Some(sample_pin(name, rev, Vec::new())),
|
||||
..sample_spec(name, port)
|
||||
};
|
||||
let out = render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
&[sample_spec("alice", 9001)],
|
||||
);
|
||||
let want = format!(
|
||||
"agent-alice.url = \"git+file://{}\"",
|
||||
crate::paths::applied_dir("alice").display()
|
||||
&[at("alice", 9001, "aaaa"), at("bob", 9002, "bbbb")],
|
||||
);
|
||||
assert!(
|
||||
out.contains(&want),
|
||||
"expected the agent input to point at the local applied mirror ({want}):\n{out}"
|
||||
out.contains(
|
||||
"agent-alice.url = \"git+https://forge.example/agent-configs/alice.git?rev=aaaa\";"
|
||||
),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("agent-alice.url = \"git+http"),
|
||||
"no forge git+http URL must remain for the agent input:\n{out}"
|
||||
out.contains(
|
||||
"agent-bob.url = \"git+https://forge.example/agent-configs/bob.git?rev=bbbb\";"
|
||||
),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("git+file://"),
|
||||
"no host-local path may remain in the flake:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_flake_refuses_a_custom_agent_without_a_pin() {
|
||||
let args = |agents: &[AgentSpec]| {
|
||||
render_flake(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
agents,
|
||||
)
|
||||
};
|
||||
let unpinned = AgentSpec {
|
||||
pin: None,
|
||||
..sample_spec("alice", 9001)
|
||||
};
|
||||
let err = args(&[unpinned]).expect_err("an unpinned agent must not render");
|
||||
assert!(
|
||||
format!("{err:#}").contains("agent alice: no pinned rev"),
|
||||
"{err:#}"
|
||||
);
|
||||
// Presence control: the same agent with a pin renders.
|
||||
assert!(args(&[sample_spec("alice", 9001)]).is_ok());
|
||||
// An archetype agent has no config input and needs no pin.
|
||||
let android = AgentSpec {
|
||||
archetype: Some(swarm_queue_client::agent_archetype::ArchetypeRef {
|
||||
source: "hyperhive".to_owned(),
|
||||
name: "android-dev".to_owned(),
|
||||
}),
|
||||
pin: None,
|
||||
..sample_spec("droid", 9002)
|
||||
};
|
||||
assert!(args(&[android]).is_ok());
|
||||
}
|
||||
|
||||
/// A repo whose commits carry a `flake.lock`; `lock` is its content, `None`
|
||||
/// for a commit without one. Returns the dir and the commit.
|
||||
async fn commit_lock(dir: &Path, lock: Option<&str>) -> String {
|
||||
let _ = std::fs::remove_file(dir.join("flake.lock"));
|
||||
if let Some(lock) = lock {
|
||||
std::fs::write(dir.join("flake.lock"), lock).expect("write lock");
|
||||
}
|
||||
git(dir, &["add", "-A"]).await.expect("add");
|
||||
git(
|
||||
dir,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"c",
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("commit");
|
||||
lifecycle::git_rev_parse(dir, "HEAD").await.expect("HEAD")
|
||||
}
|
||||
|
||||
async fn repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
git(dir.path(), &["init", "-q", "--initial-branch=main"])
|
||||
.await
|
||||
.expect("init");
|
||||
dir
|
||||
}
|
||||
|
||||
const NIXPKGS_LOCK: &str = r#"{"nodes":{"root":{"inputs":{"nixpkgs":"nixpkgs"}},"nixpkgs":{}},"root":"root","version":7}"#;
|
||||
const NO_INPUTS_LOCK: &str = r#"{"nodes":{"root":{"inputs":{}}},"root":"root","version":7}"#;
|
||||
const TEST_FORGE_REF: &str = "refs/test/forge-main";
|
||||
const TEST_URL: &str = "https://forge.example/agent-configs/alice.git";
|
||||
|
||||
/// `pin_agent` where the forge fetch leaves `forge_ref` at `forge_sha`.
|
||||
async fn pin_with_forge_at(repo: &Path, forge_sha: Option<&str>) -> Result<AgentPin> {
|
||||
pin_agent("alice", repo, TEST_URL, TEST_FORGE_REF, async {
|
||||
if let Some(sha) = forge_sha {
|
||||
lifecycle::git_update_ref(repo, TEST_FORGE_REF, sha).await?;
|
||||
}
|
||||
Ok(repo.to_path_buf())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pin_agent_pins_forge_main_not_the_local_main() {
|
||||
let dir = repo().await;
|
||||
let forge = commit_lock(dir.path(), Some(NIXPKGS_LOCK)).await;
|
||||
// Local main moves on past the forge's: its commit and lock are never read.
|
||||
let local = commit_lock(dir.path(), Some(NO_INPUTS_LOCK)).await;
|
||||
std::fs::write(dir.path().join("flake.lock"), NO_INPUTS_LOCK).expect("edit");
|
||||
assert_ne!(forge, local);
|
||||
|
||||
let pin = pin_with_forge_at(dir.path(), Some(&forge))
|
||||
.await
|
||||
.expect("pinned");
|
||||
assert_eq!(pin.url, format!("git+{TEST_URL}?rev={forge}"));
|
||||
// The lock at the forge commit declares nixpkgs; local main's does not.
|
||||
assert_eq!(pin.canonical_inputs, vec!["nixpkgs"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_agents_at_different_forge_mains_get_distinct_pins() {
|
||||
let a = repo().await;
|
||||
let b = repo().await;
|
||||
let rev_a = commit_lock(a.path(), None).await;
|
||||
let rev_b = commit_lock(b.path(), Some(NIXPKGS_LOCK)).await;
|
||||
assert_ne!(rev_a, rev_b);
|
||||
let pin_a = pin_with_forge_at(a.path(), Some(&rev_a)).await.expect("a");
|
||||
let pin_b = pin_with_forge_at(b.path(), Some(&rev_b)).await.expect("b");
|
||||
assert_ne!(pin_a.url, pin_b.url);
|
||||
assert!(pin_a.url.ends_with(&rev_a) && pin_b.url.ends_with(&rev_b));
|
||||
// No lock at a's commit, so no follows; b's lock declares nixpkgs.
|
||||
assert!(pin_a.canonical_inputs.is_empty());
|
||||
assert_eq!(pin_b.canonical_inputs, vec!["nixpkgs"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pin_agent_errors_when_the_forge_is_unreachable_or_has_no_main() {
|
||||
let dir = repo().await;
|
||||
let rev = commit_lock(dir.path(), None).await;
|
||||
|
||||
let unreachable = pin_agent("alice", dir.path(), TEST_URL, TEST_FORGE_REF, async {
|
||||
anyhow::bail!("forge down")
|
||||
})
|
||||
.await
|
||||
.expect_err("unreachable forge");
|
||||
let msg = format!("{unreachable:#}");
|
||||
assert!(
|
||||
msg.contains("agent alice") && msg.contains("forge down"),
|
||||
"{msg}"
|
||||
);
|
||||
|
||||
// The fetch succeeds but leaves no forge main.
|
||||
let no_main = pin_with_forge_at(dir.path(), None)
|
||||
.await
|
||||
.expect_err("no main on the forge");
|
||||
let msg = format!("{no_main:#}");
|
||||
assert!(
|
||||
msg.contains("agent alice") && msg.contains("no main"),
|
||||
"{msg}"
|
||||
);
|
||||
|
||||
// Presence control: with a forge main, the same call pins it.
|
||||
let pin = pin_with_forge_at(dir.path(), Some(&rev))
|
||||
.await
|
||||
.expect("pinned");
|
||||
assert!(pin.url.ends_with(&rev));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1833,7 +2065,7 @@ mod tests {
|
|||
.expect("write temp CA");
|
||||
|
||||
let render = || {
|
||||
render_flake(
|
||||
render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1921,7 +2153,7 @@ mod tests {
|
|||
let _env = crate::test_env::lock();
|
||||
// SAFETY: serialised by the guard above; restored before returning.
|
||||
let render = || {
|
||||
render_flake(
|
||||
render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -1991,7 +2223,7 @@ mod tests {
|
|||
let _env = crate::test_env::lock();
|
||||
// SAFETY: serialised by the guard above; restored before returning.
|
||||
let render = || {
|
||||
render_flake(
|
||||
render(
|
||||
"git+https://forge.example/hyperhive/hyperhive.git?rev=0123abcd",
|
||||
None,
|
||||
8000,
|
||||
|
|
@ -2032,12 +2264,14 @@ mod tests {
|
|||
&hive("4G"),
|
||||
"git+https://forge.example/h.git?rev=abc",
|
||||
&agents,
|
||||
);
|
||||
)
|
||||
.expect("render");
|
||||
let large = render_hive_flake_at(
|
||||
&hive("16G"),
|
||||
"git+https://forge.example/h.git?rev=abc",
|
||||
&agents,
|
||||
);
|
||||
)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
small, large,
|
||||
"the rendered flake must not depend on the memory cap"
|
||||
|
|
|
|||
Loading…
Reference in a new issue