Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a33697cda | ||
|
|
967ec7c9d7 | ||
|
|
2fd80dbd68 | ||
|
|
f6d681c2e2 | ||
|
|
da655d31c8 | ||
|
|
3c702cf43f | ||
|
|
433c0d212e |
8 changed files with 319 additions and 27 deletions
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -140,11 +140,62 @@ in.
|
||||||
hot-reload works under the patch stack (validated empirically on muede-lpt2)
|
hot-reload works under the patch stack (validated empirically on muede-lpt2)
|
||||||
- ✅ Phase 2 — per-agent sockets, in-memory broker, agent harness round-trips messages
|
- ✅ Phase 2 — per-agent sockets, in-memory broker, agent harness round-trips messages
|
||||||
- ✅ Phase 3 — sqlite broker (durable across restart) + claude-or-echo turn loop
|
- ✅ Phase 3 — sqlite broker (durable across restart) + claude-or-echo turn loop
|
||||||
- 🔜 Phase 4 — `hm1nd` manager binary with privileged tool surface
|
- ✅ Phase 4 — `hm1nd` manager binary + manager socket + declarative `containers.hm1nd`
|
||||||
- 🔜 Phase 5 — git-commit approval flow (`state-repo` + per-agent config flakes)
|
- ✅ Phase 5 — git-commit approval flow:
|
||||||
|
- 5a — sqlite approval queue (`request_apply_commit` / `pending` / `approve` / `deny`)
|
||||||
|
- 5b — per-agent config flakes (proposed + applied repos)
|
||||||
|
- 5c — split: manager edits `proposed`, hive-c0re writes-only `applied`; the
|
||||||
|
container builds from `applied`. Approve = read `agent.nix` at the
|
||||||
|
approved commit from `proposed`, copy into `applied`, commit + rebuild.
|
||||||
|
Manager cannot move `main` on its own.
|
||||||
- 🔜 Phase 6 — per-agent web UI + dashboard MVP
|
- 🔜 Phase 6 — per-agent web UI + dashboard MVP
|
||||||
- 🔜 Phase 7 — dashboard commit-view + polish
|
- 🔜 Phase 7 — dashboard commit-view + polish
|
||||||
|
|
||||||
|
## Approval flow (Phase 5)
|
||||||
|
|
||||||
|
End-to-end: manager edits per-agent config repo → commits → submits commit sha
|
||||||
|
for approval → user approves on host CLI → `hive-c0re` advances `main` + rebuilds.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Inside the hm1nd container (manager has /agents bind-mounted RW):
|
||||||
|
cd /agents/alice/config
|
||||||
|
$EDITOR agent.nix # add `environment.systemPackages = [ pkgs.htop ];`
|
||||||
|
git commit -am "add htop"
|
||||||
|
SHA=$(git rev-parse HEAD)
|
||||||
|
hive-m1nd request-apply-commit alice $SHA
|
||||||
|
exit
|
||||||
|
|
||||||
|
# On the host:
|
||||||
|
sudo hive-c0re pending # shows the queued approval with id N
|
||||||
|
sudo hive-c0re approve N # validates, advances main, rebuilds h-alice
|
||||||
|
sudo nixos-container run h-alice -- which htop # /run/current-system/sw/bin/htop
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-agent layout — two separate git repos:
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/lib/hyperhive/agents/<name>/config/ # proposed — manager edits, hive-c0re reads only
|
||||||
|
├── .git/
|
||||||
|
└── agent.nix # the only file the manager can change
|
||||||
|
# (initial commit by hive-c0re on first spawn,
|
||||||
|
# never touched by hive-c0re again)
|
||||||
|
|
||||||
|
/var/lib/hyperhive/applied/<name>/ # applied — hive-c0re-only; container builds here
|
||||||
|
├── .git/
|
||||||
|
├── flake.nix # hive-c0re-managed; references hyperhive_flake
|
||||||
|
└── agent.nix # overwritten by approve from the proposed commit
|
||||||
|
```
|
||||||
|
|
||||||
|
The container's `--flake` ref is `<applied_dir>#default`. The flake's
|
||||||
|
`nixosConfigurations.default` extends `hyperhive.nixosConfigurations.agent-base`
|
||||||
|
with `./agent.nix` plus an inline module setting `environment.etc."gitconfig".text`
|
||||||
|
with the agent's name as the git committer identity.
|
||||||
|
|
||||||
|
On approve: `git show <commit>:agent.nix` from `proposed/<name>`, write the bytes
|
||||||
|
into `applied/<name>/agent.nix`, commit there as `hive-c0re`, then
|
||||||
|
`nixos-container update`. The manager can only propose; only hive-c0re advances
|
||||||
|
`applied`'s `main`.
|
||||||
|
|
||||||
See PLAN.md for the full design and the deferred-out-of-scope list.
|
See PLAN.md for the full design and the deferred-out-of-scope list.
|
||||||
|
|
||||||
## Inspirations
|
## Inspirations
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,32 @@ use crate::broker::Broker;
|
||||||
|
|
||||||
const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
||||||
const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager";
|
const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager";
|
||||||
|
/// Manager-editable per-agent config repos. Bind-mounted RW into the manager
|
||||||
|
/// container as `/agents/<name>/`. Hive-c0re only writes to these on first
|
||||||
|
/// spawn (initial commit); after that it's manager-only.
|
||||||
|
const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
|
||||||
|
/// Hive-c0re-only authoritative per-agent config repos. Containers build from
|
||||||
|
/// these. Manager has no filesystem access; the only way to update is via
|
||||||
|
/// `request_apply_commit` + user approval.
|
||||||
|
const APPLIED_STATE_ROOT: &str = "/var/lib/hyperhive/applied";
|
||||||
|
|
||||||
pub struct Coordinator {
|
pub struct Coordinator {
|
||||||
pub broker: Arc<Broker>,
|
pub broker: Arc<Broker>,
|
||||||
pub approvals: Arc<Approvals>,
|
pub approvals: Arc<Approvals>,
|
||||||
pub agent_flake: String,
|
/// URL of the hyperhive flake (no fragment). Inlined into per-agent
|
||||||
|
/// `flake.nix` files as `inputs.hyperhive.url`.
|
||||||
|
pub hyperhive_flake: String,
|
||||||
agents: Mutex<HashMap<String, AgentSocket>>,
|
agents: Mutex<HashMap<String, AgentSocket>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Coordinator {
|
impl Coordinator {
|
||||||
pub fn open(db_path: &Path, agent_flake: String) -> Result<Self> {
|
pub fn open(db_path: &Path, hyperhive_flake: String) -> Result<Self> {
|
||||||
let broker = Broker::open(db_path).context("open broker")?;
|
let broker = Broker::open(db_path).context("open broker")?;
|
||||||
let approvals = Approvals::open(db_path).context("open approvals")?;
|
let approvals = Approvals::open(db_path).context("open approvals")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
broker: Arc::new(broker),
|
broker: Arc::new(broker),
|
||||||
approvals: Arc::new(approvals),
|
approvals: Arc::new(approvals),
|
||||||
agent_flake,
|
hyperhive_flake,
|
||||||
agents: Mutex::new(HashMap::new()),
|
agents: Mutex::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -69,4 +79,15 @@ impl Coordinator {
|
||||||
pub fn manager_socket_path() -> PathBuf {
|
pub fn manager_socket_path() -> PathBuf {
|
||||||
Self::manager_dir().join("mcp.sock")
|
Self::manager_dir().join("mcp.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manager-editable proposed config repo. Bind-mounted into the manager
|
||||||
|
/// container as `/agents/<name>/config/`.
|
||||||
|
pub fn agent_proposed_dir(name: &str) -> PathBuf {
|
||||||
|
PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authoritative applied config repo. Hive-c0re-only.
|
||||||
|
pub fn agent_applied_dir(name: &str) -> PathBuf {
|
||||||
|
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Thin async wrappers over `nixos-container`.
|
//! `nixos-container` lifecycle + per-agent config flake generation.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -16,6 +16,9 @@ pub const MANAGER_NAME: &str = "hm1nd";
|
||||||
/// Mount point of the per-agent runtime directory inside the container.
|
/// Mount point of the per-agent runtime directory inside the container.
|
||||||
pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive";
|
pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive";
|
||||||
|
|
||||||
|
const GIT_NAME: &str = "hive-c0re";
|
||||||
|
const GIT_EMAIL: &str = "hive-c0re@hyperhive";
|
||||||
|
|
||||||
pub fn container_name(name: &str) -> String {
|
pub fn container_name(name: &str) -> String {
|
||||||
format!("{AGENT_PREFIX}{name}")
|
format!("{AGENT_PREFIX}{name}")
|
||||||
}
|
}
|
||||||
|
|
@ -33,10 +36,19 @@ fn validate(name: &str) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn spawn(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> {
|
pub async fn spawn(
|
||||||
|
name: &str,
|
||||||
|
hyperhive_flake: &str,
|
||||||
|
agent_dir: &Path,
|
||||||
|
proposed_dir: &Path,
|
||||||
|
applied_dir: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
validate(name)?;
|
validate(name)?;
|
||||||
|
setup_proposed(proposed_dir, name).await?;
|
||||||
|
setup_applied(applied_dir, name, hyperhive_flake).await?;
|
||||||
let container = container_name(name);
|
let container = container_name(name);
|
||||||
run(&["create", &container, "--flake", agent_flake]).await?;
|
let flake_ref = format!("{}#default", applied_dir.display());
|
||||||
|
run(&["create", &container, "--flake", &flake_ref]).await?;
|
||||||
set_nspawn_flags(&container, agent_dir)?;
|
set_nspawn_flags(&container, agent_dir)?;
|
||||||
run(&["start", &container]).await
|
run(&["start", &container]).await
|
||||||
}
|
}
|
||||||
|
|
@ -47,11 +59,18 @@ pub async fn kill(name: &str) -> Result<()> {
|
||||||
run(&["stop", &container]).await
|
run(&["stop", &container]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rebuild(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> {
|
pub async fn rebuild(
|
||||||
|
name: &str,
|
||||||
|
hyperhive_flake: &str,
|
||||||
|
agent_dir: &Path,
|
||||||
|
applied_dir: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
validate(name)?;
|
validate(name)?;
|
||||||
|
setup_applied(applied_dir, name, hyperhive_flake).await?;
|
||||||
let container = container_name(name);
|
let container = container_name(name);
|
||||||
|
let flake_ref = format!("{}#default", applied_dir.display());
|
||||||
set_nspawn_flags(&container, agent_dir)?;
|
set_nspawn_flags(&container, agent_dir)?;
|
||||||
run(&["update", &container, "--flake", agent_flake]).await?;
|
run(&["update", &container, "--flake", &flake_ref]).await?;
|
||||||
// Restart so any nspawn-level changes (bind mounts, networking, etc.) apply.
|
// Restart so any nspawn-level changes (bind mounts, networking, etc.) apply.
|
||||||
run(&["stop", &container]).await?;
|
run(&["stop", &container]).await?;
|
||||||
run(&["start", &container]).await
|
run(&["start", &container]).await
|
||||||
|
|
@ -78,6 +97,157 @@ pub async fn list() -> Result<Vec<String>> {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialize the manager-editable proposed repo. Contains only `agent.nix`
|
||||||
|
/// (the file the manager edits). Touched by hive-c0re only on first spawn —
|
||||||
|
/// never again — so the manager can't be surprised by hive-c0re commits or
|
||||||
|
/// working-tree resets.
|
||||||
|
pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
|
||||||
|
if proposed_dir.join(".git").exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(proposed_dir)
|
||||||
|
.with_context(|| format!("create {}", proposed_dir.display()))?;
|
||||||
|
let agent_path = proposed_dir.join("agent.nix");
|
||||||
|
if !agent_path.exists() {
|
||||||
|
std::fs::write(&agent_path, initial_agent_nix(name))
|
||||||
|
.with_context(|| format!("write {}", agent_path.display()))?;
|
||||||
|
}
|
||||||
|
git(proposed_dir, &["init", "--initial-branch=main"]).await?;
|
||||||
|
git(proposed_dir, &["add", "agent.nix"]).await?;
|
||||||
|
git_commit(proposed_dir, "hive-c0re init").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maintain the authoritative applied repo. Rewrites `flake.nix` every call
|
||||||
|
/// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix`
|
||||||
|
/// only on first call. `apply_commit` overwrites `agent.nix` later.
|
||||||
|
pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> {
|
||||||
|
std::fs::create_dir_all(applied_dir)
|
||||||
|
.with_context(|| format!("create {}", applied_dir.display()))?;
|
||||||
|
|
||||||
|
let flake_body = format!(
|
||||||
|
r#"{{
|
||||||
|
description = "hyperhive sub-agent {name}";
|
||||||
|
inputs.hyperhive.url = "{hyperhive_flake}";
|
||||||
|
outputs =
|
||||||
|
{{ hyperhive, ... }}:
|
||||||
|
{{
|
||||||
|
nixosConfigurations.default = hyperhive.nixosConfigurations.agent-base.extendModules {{
|
||||||
|
modules = [
|
||||||
|
./agent.nix
|
||||||
|
{{
|
||||||
|
environment.etc."gitconfig".text = ''
|
||||||
|
[user]
|
||||||
|
name = {name}
|
||||||
|
email = {name}@hyperhive
|
||||||
|
[init]
|
||||||
|
defaultBranch = main
|
||||||
|
'';
|
||||||
|
}}
|
||||||
|
];
|
||||||
|
}};
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
std::fs::write(applied_dir.join("flake.nix"), flake_body)
|
||||||
|
.with_context(|| format!("write {}/flake.nix", applied_dir.display()))?;
|
||||||
|
|
||||||
|
let agent_path = applied_dir.join("agent.nix");
|
||||||
|
if !agent_path.exists() {
|
||||||
|
std::fs::write(&agent_path, initial_agent_nix(name))
|
||||||
|
.with_context(|| format!("write {}", agent_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !applied_dir.join(".git").exists() {
|
||||||
|
git(applied_dir, &["init", "--initial-branch=main"]).await?;
|
||||||
|
}
|
||||||
|
git(applied_dir, &["add", "-A"]).await?;
|
||||||
|
let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?;
|
||||||
|
if !clean {
|
||||||
|
git_commit(applied_dir, "hive-c0re sync").await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a manager-proposed commit: read `agent.nix` at `commit_ref` from the
|
||||||
|
/// proposed repo, write it into the applied repo, commit. Hive-c0re alone
|
||||||
|
/// advances `applied`'s `main`; the manager only sees `proposed/`.
|
||||||
|
pub async fn apply_commit(applied_dir: &Path, proposed_dir: &Path, commit_ref: &str) -> Result<()> {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.current_dir(proposed_dir)
|
||||||
|
.args(["show", &format!("{commit_ref}:agent.nix")])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("git show in {}", proposed_dir.display()))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"agent.nix at commit {commit_ref} not found in {}: {}",
|
||||||
|
proposed_dir.display(),
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::write(applied_dir.join("agent.nix"), &out.stdout)
|
||||||
|
.with_context(|| format!("write {}/agent.nix", applied_dir.display()))?;
|
||||||
|
git(applied_dir, &["add", "agent.nix"]).await?;
|
||||||
|
let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?;
|
||||||
|
if !clean {
|
||||||
|
git_commit(applied_dir, &format!("apply {commit_ref}")).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_agent_nix(name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||||
|
git(
|
||||||
|
dir,
|
||||||
|
&[
|
||||||
|
"-c",
|
||||||
|
&format!("user.name={GIT_NAME}"),
|
||||||
|
"-c",
|
||||||
|
&format!("user.email={GIT_EMAIL}"),
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
message,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git(dir: &Path, args: &[&str]) -> Result<()> {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.current_dir(dir)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"git {} failed ({}): {}",
|
||||||
|
args.join(" "),
|
||||||
|
out.status,
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the command exits 0.
|
||||||
|
async fn git_status(dir: &Path, args: &[&str]) -> Result<bool> {
|
||||||
|
let st = Command::new("git")
|
||||||
|
.current_dir(dir)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
|
||||||
|
Ok(st.success())
|
||||||
|
}
|
||||||
|
|
||||||
/// Idempotently rewrite the `EXTRA_NSPAWN_FLAGS` line in
|
/// Idempotently rewrite the `EXTRA_NSPAWN_FLAGS` line in
|
||||||
/// `/etc/nixos-containers/<container>.conf`. The start script expands this
|
/// `/etc/nixos-containers/<container>.conf`. The start script expands this
|
||||||
/// variable unquoted into the `systemd-nspawn` command.
|
/// variable unquoted into the `systemd-nspawn` command.
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ struct Cli {
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
/// Run the coordinator daemon.
|
/// Run the coordinator daemon.
|
||||||
Serve {
|
Serve {
|
||||||
/// Flake reference for the agent base template.
|
/// URL of the hyperhive flake. Inlined into each per-agent
|
||||||
#[arg(long, default_value = "/etc/hyperhive#agent-base")]
|
/// `flake.nix` as the `hyperhive` input.
|
||||||
agent_flake: String,
|
#[arg(long, default_value = "/etc/hyperhive")]
|
||||||
|
hyperhive_flake: String,
|
||||||
/// Path to the sqlite message store.
|
/// Path to the sqlite message store.
|
||||||
#[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")]
|
#[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")]
|
||||||
db: PathBuf,
|
db: PathBuf,
|
||||||
|
|
@ -65,8 +66,11 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.cmd {
|
match cli.cmd {
|
||||||
Cmd::Serve { agent_flake, db } => {
|
Cmd::Serve {
|
||||||
let coord = Arc::new(Coordinator::open(&db, agent_flake)?);
|
hyperhive_flake,
|
||||||
|
db,
|
||||||
|
} => {
|
||||||
|
let coord = Arc::new(Coordinator::open(&db, hyperhive_flake)?);
|
||||||
manager_server::start(coord.clone())?;
|
manager_server::start(coord.clone())?;
|
||||||
server::serve(&cli.socket, coord).await
|
server::serve(&cli.socket, coord).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,17 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
||||||
tracing::info!(%name, "manager: spawn");
|
tracing::info!(%name, "manager: spawn");
|
||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
let agent_dir = coord.register_agent(name)?;
|
let agent_dir = coord.register_agent(name)?;
|
||||||
if let Err(e) = lifecycle::spawn(name, &coord.agent_flake, &agent_dir).await {
|
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||||
|
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||||
|
if let Err(e) = lifecycle::spawn(
|
||||||
|
name,
|
||||||
|
&coord.hyperhive_flake,
|
||||||
|
&agent_dir,
|
||||||
|
&proposed_dir,
|
||||||
|
&applied_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
coord.unregister_agent(name);
|
coord.unregister_agent(name);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ pub async fn serve(socket: &Path, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
|
|
||||||
let listener = UnixListener::bind(socket)
|
let listener = UnixListener::bind(socket)
|
||||||
.with_context(|| format!("bind admin socket {}", socket.display()))?;
|
.with_context(|| format!("bind admin socket {}", socket.display()))?;
|
||||||
tracing::info!(socket = %socket.display(), agent_flake = %coord.agent_flake, "hive-c0re admin listening");
|
tracing::info!(socket = %socket.display(), hyperhive_flake = %coord.hyperhive_flake, "hive-c0re admin listening");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (stream, _) = listener.accept().await.context("accept connection")?;
|
let (stream, _) = listener.accept().await.context("accept connection")?;
|
||||||
|
|
@ -61,7 +61,17 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse {
|
||||||
HostRequest::Spawn { name } => {
|
HostRequest::Spawn { name } => {
|
||||||
tracing::info!(%name, "spawn");
|
tracing::info!(%name, "spawn");
|
||||||
let agent_dir = coord.register_agent(name)?;
|
let agent_dir = coord.register_agent(name)?;
|
||||||
if let Err(e) = lifecycle::spawn(name, &coord.agent_flake, &agent_dir).await {
|
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||||
|
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||||
|
if let Err(e) = lifecycle::spawn(
|
||||||
|
name,
|
||||||
|
&coord.hyperhive_flake,
|
||||||
|
&agent_dir,
|
||||||
|
&proposed_dir,
|
||||||
|
&applied_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
// Roll back socket registration if container creation failed.
|
// Roll back socket registration if container creation failed.
|
||||||
coord.unregister_agent(name);
|
coord.unregister_agent(name);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
|
|
@ -77,18 +87,31 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse {
|
||||||
HostRequest::Rebuild { name } => {
|
HostRequest::Rebuild { name } => {
|
||||||
tracing::info!(%name, "rebuild");
|
tracing::info!(%name, "rebuild");
|
||||||
let agent_dir = coord.register_agent(name)?;
|
let agent_dir = coord.register_agent(name)?;
|
||||||
lifecycle::rebuild(name, &coord.agent_flake, &agent_dir).await?;
|
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||||
|
lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &applied_dir).await?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::List => HostResponse::list(lifecycle::list().await?),
|
HostRequest::List => HostResponse::list(lifecycle::list().await?),
|
||||||
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
||||||
HostRequest::Approve { id } => {
|
HostRequest::Approve { id } => {
|
||||||
let approval = coord.approvals.mark_approved(*id)?;
|
let approval = coord.approvals.mark_approved(*id)?;
|
||||||
tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval applied: rebuilding agent");
|
tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding");
|
||||||
let agent_dir = coord.register_agent(&approval.agent)?;
|
let agent_dir = coord.register_agent(&approval.agent)?;
|
||||||
if let Err(e) =
|
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
|
||||||
lifecycle::rebuild(&approval.agent, &coord.agent_flake, &agent_dir).await
|
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
|
||||||
{
|
let result: anyhow::Result<()> = async {
|
||||||
|
lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref)
|
||||||
|
.await?;
|
||||||
|
lifecycle::rebuild(
|
||||||
|
&approval.agent,
|
||||||
|
&coord.hyperhive_flake,
|
||||||
|
&agent_dir,
|
||||||
|
&applied_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(e) = result {
|
||||||
let note = format!("{e:#}");
|
let note = format!("{e:#}");
|
||||||
let _ = coord.approvals.mark_failed(approval.id, ¬e);
|
let _ = coord.approvals.mark_failed(approval.id, ¬e);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,15 @@ in
|
||||||
defaultText = lib.literalExpression "pkgs.hyperhive";
|
defaultText = lib.literalExpression "pkgs.hyperhive";
|
||||||
description = "Package that provides /bin/hive-c0re.";
|
description = "Package that provides /bin/hive-c0re.";
|
||||||
};
|
};
|
||||||
agentFlake = lib.mkOption {
|
hyperhiveFlake = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "/etc/hyperhive#agent-base";
|
default = "/etc/hyperhive";
|
||||||
description = "Flake reference passed to `nixos-container create --flake` when spawning sub-agents.";
|
description = ''
|
||||||
|
URL of the hyperhive flake (no fragment). Inlined into each
|
||||||
|
per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent
|
||||||
|
flake then pulls `hyperhive.nixosConfigurations.agent-base` to
|
||||||
|
build the container.
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -31,7 +36,7 @@ in
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
path = [ "/run/current-system/sw" ];
|
path = [ "/run/current-system/sw" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --agent-flake ${cfg.agentFlake}";
|
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake}";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 2;
|
RestartSec = 2;
|
||||||
RuntimeDirectory = "hyperhive";
|
RuntimeDirectory = "hyperhive";
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@
|
||||||
coreutils-full
|
coreutils-full
|
||||||
];
|
];
|
||||||
|
|
||||||
|
environment.etc."gitconfig".text = ''
|
||||||
|
[user]
|
||||||
|
name = hm1nd
|
||||||
|
email = hm1nd@hyperhive
|
||||||
|
[init]
|
||||||
|
defaultBranch = main
|
||||||
|
'';
|
||||||
|
|
||||||
systemd.services.hive-m1nd = {
|
systemd.services.hive-m1nd = {
|
||||||
description = "hive-m1nd manager harness";
|
description = "hive-m1nd manager harness";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue