drop the proposed config repo: applied seeds from forge main, and the dead pending-init topology list goes with it
This commit is contained in:
parent
08b8eb3f73
commit
25295fd33f
26 changed files with 225 additions and 520 deletions
|
|
@ -102,7 +102,7 @@ body strings before calling it.
|
||||||
|
|
||||||
`HostRequest::Destroy { name, purge }` is the lifecycle tear-down.
|
`HostRequest::Destroy { name, purge }` is the lifecycle tear-down.
|
||||||
Stops + removes the nspawn container and drops the systemd drop-in.
|
Stops + removes the nspawn container and drops the systemd drop-in.
|
||||||
Persistent state (proposed/applied repos, claude credentials, `/state/`
|
Persistent state (the applied repo, claude credentials, `/state/`
|
||||||
notes) is
|
notes) is
|
||||||
**kept by default** — recreating the agent with the same name
|
**kept by default** — recreating the agent with the same name
|
||||||
reuses prior config + login. With `purge = true` the agent's
|
reuses prior config + login. With `purge = true` the agent's
|
||||||
|
|
@ -186,20 +186,10 @@ the swarm builder built the agent from the updated repo (the
|
||||||
reconcile ([below](#autoupdate-on-startup)) and the dashboard's update chip
|
reconcile ([below](#autoupdate-on-startup)) and the dashboard's update chip
|
||||||
read them.
|
read them.
|
||||||
|
|
||||||
## Two repos per agent
|
## The applied repo per agent
|
||||||
|
|
||||||
```
|
```
|
||||||
/var/lib/hyperhive/agents/<name>/config/ proposed — parent mount is RO
|
/var/lib/hyperhive/applied/<name>/ applied - core-only
|
||||||
└── <anything> # any files the submitting
|
|
||||||
# agent wants in the commit.
|
|
||||||
# agent.nix is the
|
|
||||||
# convention entry
|
|
||||||
# point; flake.nix is
|
|
||||||
# tracked boilerplate
|
|
||||||
# (submitting agent doesn't
|
|
||||||
# edit it).
|
|
||||||
|
|
||||||
/var/lib/hyperhive/applied/<name>/ applied — core-only
|
|
||||||
├── .git/ # tag-rich history
|
├── .git/ # tag-rich history
|
||||||
├── flake.nix # tracked, fixed
|
├── flake.nix # tracked, fixed
|
||||||
│ # boilerplate exporting
|
│ # boilerplate exporting
|
||||||
|
|
@ -207,16 +197,18 @@ the swarm builder built the agent from the updated repo (the
|
||||||
├── agent.nix # working tree of main
|
├── agent.nix # working tree of main
|
||||||
└── <other committed files> # also tracked
|
└── <other committed files> # also tracked
|
||||||
|
|
||||||
/var/lib/hyperhive/meta/ plain hive state — core
|
/var/lib/hyperhive/meta/ plain hive state - core
|
||||||
├── topology.json # the live agent set
|
├── topology.json # the live agent set
|
||||||
└── swarm-resource-limits.json # the swarm's per-agent caps
|
└── swarm-resource-limits.json # the swarm's per-agent caps
|
||||||
```
|
```
|
||||||
|
|
||||||
Why two physical repos: the submitting agent's `/agents/<n>/config/` is
|
hive-c0re seeds the applied repo at first spawn from the forge's
|
||||||
RW — a buggy or hostile agent can `git clean -fdx` its own
|
`agent-configs/<name>` `main`, or from a template when the forge has none yet,
|
||||||
proposed tree. The applied repo is never bind-mounted (except
|
and fast-forwards it to each merged config commit that deploys. An agent never
|
||||||
the read-only `.git` exposure described below) so a destructive
|
edits it: the agent sees it read-only at `/agents/<name>/config`, so it can
|
||||||
move inside the container can't reach it.
|
read what governs it, while a config change is a PR against its forge repo.
|
||||||
|
Older hives also kept a *proposed* clone at `agents/<name>/config`. Nothing
|
||||||
|
reads it any more, and you can delete it.
|
||||||
|
|
||||||
The container's `--flake` ref is `internal/meta` (see "Meta flake" above).
|
The container's `--flake` ref is `internal/meta` (see "Meta flake" above).
|
||||||
The agent's own `applied/<n>/flake.nix`
|
The agent's own `applied/<n>/flake.nix`
|
||||||
|
|
@ -240,9 +232,8 @@ Every agent created through the swarm has a repo in the private
|
||||||
`agent-configs` Forgejo org, created and seeded by swarm-controller.
|
`agent-configs` Forgejo org, created and seeded by swarm-controller.
|
||||||
hive-c0re pushes nothing to it: the applied repo's tags (`deployed/0`)
|
hive-c0re pushes nothing to it: the applied repo's tags (`deployed/0`)
|
||||||
exist only locally, and forge `main` advances only by an operator's merge.
|
exist only locally, and forge `main` advances only by an operator's merge.
|
||||||
hive-c0re reads from it — reseeding a purged `applied`
|
hive-c0re reads from it - reseeding a purged `applied`, catching `applied` up, and
|
||||||
(`forge::fetch_config_main_into_applied`), catching `applied` up, and
|
and seeding `applied` at first spawn (`forge::fetch_config_main_into_applied`).
|
||||||
seeding `proposed` at first spawn (`forge::clone_config_into_proposed`).
|
|
||||||
|
|
||||||
A legacy agent spawned by the hive alone has no swarm-seeded repo; if its
|
A legacy agent spawned by the hive alone has no swarm-seeded repo; if its
|
||||||
`agent-configs/<name>` has no `main`, a first spawn falls back to the local
|
`agent-configs/<name>` has no `main`, a first spawn falls back to the local
|
||||||
|
|
@ -284,9 +275,9 @@ the swarm's `agent-permissions` record; see
|
||||||
`docs/process/conventions.md#permissions`) get additional host-side bind
|
`docs/process/conventions.md#permissions`) get additional host-side bind
|
||||||
mounts via `set_nspawn_flags`:
|
mounts via `set_nspawn_flags`:
|
||||||
|
|
||||||
- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's
|
- `/var/lib/hyperhive/agents/<n>/state` → `/agents/<n>/state` (RW) for
|
||||||
proposed repo, not just direct children. The permission means "may
|
**every** agent, and `/agents/<n>/config` (RO, the applied repo). The
|
||||||
manage any agent", so the mounted set is every agent.
|
permission means "may manage any agent," so the mounted set is every agent.
|
||||||
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
|
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
|
||||||
authoritative applied repo, including `.git`.
|
authoritative applied repo, including `.git`.
|
||||||
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state
|
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state
|
||||||
|
|
@ -306,17 +297,13 @@ permissions were unset, swarm-controller writes the default plus
|
||||||
`manage_root_agent` into her key once at start, so she keeps her mounts;
|
`manage_root_agent` into her key once at start, so she keeps her mounts;
|
||||||
it never touches a key that's already set.
|
it never touches a key that's already set.
|
||||||
|
|
||||||
Each proposed repo (`/agents/<n>/config/`) is pre-configured
|
With the full `/applied` mount, an agent can read what hive-c0re deployed,
|
||||||
with `applied` as a git remote pointing at
|
failed or denied for any agent:
|
||||||
`/applied/<n>/.git`. Useful incantations from inside an agent with
|
|
||||||
the full `/applied` mount:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git -C /agents/<n>/config fetch applied
|
git -C /applied/<n> log main --oneline
|
||||||
git -C /agents/<n>/config log applied/main --oneline
|
git -C /applied/<n> show refs/tags/deployed/0 # the seed commit
|
||||||
git -C /agents/<n>/config show applied/refs/tags/deployed/0 # the seed commit
|
git -C /applied/<n> show refs/tags/denied/<id> # body = operator note
|
||||||
git -C /agents/<n>/config show applied/refs/tags/denied/<id> # body = operator note
|
|
||||||
git -C /agents/<n>/config rebase applied/main # base in-flight work on what's deployed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The RO binds block push at the kernel level — git plumbing inside the
|
The RO binds block push at the kernel level — git plumbing inside the
|
||||||
|
|
@ -333,17 +320,16 @@ once already applied:
|
||||||
`deployed/0` on `main` once. Non-destructive — it doesn't touch live
|
`deployed/0` on `main` once. Non-destructive — it doesn't touch live
|
||||||
containers, state dirs, or claude creds.
|
containers, state dirs, or claude creds.
|
||||||
- **Agent files**: rewrites each `applied/<n>/flake.nix` to the
|
- **Agent files**: rewrites each `applied/<n>/flake.nix` to the
|
||||||
module-only boilerplate, wires the `applied` remote in each proposed
|
module-only boilerplate and refreshes the files that follow the agent set
|
||||||
repo, and refreshes the files that follow the agent set
|
|
||||||
(`agent_set::refresh`) from the current agent list. Set
|
(`agent_set::refresh`) from the current agent list. Set
|
||||||
`HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase.
|
`HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase.
|
||||||
- **Legacy meta repo**: deletes the leftover `.git` in
|
- **Legacy meta repo**: deletes the leftover `.git` in
|
||||||
`/var/lib/hyperhive/meta/`, the `meta` remote in each proposed repo and the
|
`/var/lib/hyperhive/meta/` and the
|
||||||
unread `/var/lib/hyperhive/forge-core-token`. The files beside the `.git`
|
unread `/var/lib/hyperhive/forge-core-token`. The files beside the `.git`
|
||||||
stay. The forge-side `core/meta` repo and `core` account aren't touched.
|
stay. The forge-side `core/meta` repo and `core` account aren't touched.
|
||||||
|
|
||||||
No state loss in either migration: claude creds, `/state/` notes, the
|
No state loss in either migration: claude creds, `/state/` notes, the
|
||||||
events DB, and both proposed + applied history all survive. The root
|
events DB, and the applied history all survive. The root
|
||||||
agent keeps its session; sub-agents stay logged in.
|
agent keeps its session; sub-agents stay logged in.
|
||||||
|
|
||||||
## The root/bootstrap container
|
## The root/bootstrap container
|
||||||
|
|
@ -352,8 +338,7 @@ The root agent container runs through the **same lifecycle as
|
||||||
sub-agents**. Nothing creates it at hive boot: you create ruth from the
|
sub-agents**. Nothing creates it at hive boot: you create ruth from the
|
||||||
swarm dashboard like any agent, and an existing ruth container starts with
|
swarm dashboard like any agent, and an existing ruth container starts with
|
||||||
the hive like every other. The root agent's flake lives at
|
the hive like every other. The root agent's flake lives at
|
||||||
`/var/lib/hyperhive/applied/ruth/`; its proposed config at
|
`/var/lib/hyperhive/applied/ruth/`. The root agent can read its own
|
||||||
`/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own
|
|
||||||
`agent.nix` (visible inside the container at `/agents/ruth/config/`)
|
`agent.nix` (visible inside the container at `/agents/ruth/config/`)
|
||||||
and open a config PR on `agent-configs/ruth` for operator review,
|
and open a config PR on `agent-configs/ruth` for operator review,
|
||||||
same as any other agent.
|
same as any other agent.
|
||||||
|
|
@ -368,8 +353,7 @@ Differences from sub-agents:
|
||||||
FNV-1a hash as every other agent (8100..8999 range).
|
FNV-1a hash as every other agent (8100..8999 range).
|
||||||
- The root agent holds `manage_root_agent` (the swarm UI's setup
|
- The root agent holds `manage_root_agent` (the swarm UI's setup
|
||||||
assistant creates her with it), for which `set_nspawn_flags` adds two extra binds:
|
assistant creates her with it), for which `set_nspawn_flags` adds two extra binds:
|
||||||
`/var/lib/hyperhive/agents` → `/agents` (RW) so the root agent can edit
|
`/var/lib/hyperhive/agents` → `/agents` (RW) for every agent's state,
|
||||||
per-agent proposed repos,
|
|
||||||
and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent
|
and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent
|
||||||
can `git fetch` deployed/failed/denied tags from any agent's
|
can `git fetch` deployed/failed/denied tags from any agent's
|
||||||
authoritative applied repo (see "Root-agent view of applied" below).
|
authoritative applied repo (see "Root-agent view of applied" below).
|
||||||
|
|
|
||||||
|
|
@ -254,11 +254,9 @@ than an accident of storage.
|
||||||
|
|
||||||
Under `/var/lib/hyperhive/agents/<name>/`:
|
Under `/var/lib/hyperhive/agents/<name>/`:
|
||||||
|
|
||||||
- `config/` — the proposed nix repo (root-agent-editable). Bind-mounted
|
- `config/` — unused. Older hives kept a proposed nix repo here; nothing
|
||||||
**read-only** to `/agents/<name>/config` inside the sub-agent's own
|
reads it any more, and you can delete it. The agent's config mount at
|
||||||
container so the agent can inspect what defines it and request
|
`/agents/<name>/config` is the applied repo, read-only.
|
||||||
precise changes from the root agent; RW into the root agent via the
|
|
||||||
`/agents` tree bind.
|
|
||||||
- `claude/` — claude OAuth credentials, bind-mounted RW to
|
- `claude/` — claude OAuth credentials, bind-mounted RW to
|
||||||
`/home/<name>/.claude` inside the container.
|
`/home/<name>/.claude` inside the container.
|
||||||
- `state/` — durable notes and `hyperhive-harness.json`. Bind-mounted
|
- `state/` — durable notes and `hyperhive-harness.json`. Bind-mounted
|
||||||
|
|
@ -487,7 +485,7 @@ boundary a convention rather than a permission.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
⚠️ Don't confuse it with the config-repo seeding hive-c0re does at
|
⚠️ Don't confuse it with the config-repo seeding hive-c0re does at
|
||||||
spawn (`lifecycle::setup_proposed`): that writes the agent's initial
|
spawn (`lifecycle::setup_applied`): that writes the agent's initial
|
||||||
config repo as **hive-c0re, against the host path**, and `read_only` on a bind
|
config repo as **hive-c0re, against the host path**, and `read_only` on a bind
|
||||||
constrains writers *inside* a container only. The two are unrelated —
|
constrains writers *inside* a container only. The two are unrelated —
|
||||||
conflating them can lead you to reason your way into thinking this
|
conflating them can lead you to reason your way into thinking this
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ forge container:
|
||||||
- fetching a merged config commit for a swarm deploy, and
|
- fetching a merged config commit for a swarm deploy, and
|
||||||
`hivectl forge reconcile-config`
|
`hivectl forge reconcile-config`
|
||||||
- reseeding a purged `applied` repo
|
- reseeding a purged `applied` repo
|
||||||
- seeding `proposed` from `agent-configs/<agent>` on first deploy
|
- seeding `applied` from `agent-configs/<agent>` on first deploy
|
||||||
- cloning and pulling `/knowledge`
|
- cloning and pulling `/knowledge`
|
||||||
|
|
||||||
The hive builds nothing, so no nix process reads the forge as the hive's
|
The hive builds nothing, so no nix process reads the forge as the hive's
|
||||||
|
|
|
||||||
|
|
@ -402,8 +402,8 @@ sets `EXTRA_NSPAWN_FLAGS` — plus the systemd resource-limits drop-in)
|
||||||
into the `Swap` node, then runs `nixos-container update` + stop +
|
into the `Swap` node, then runs `nixos-container update` + stop +
|
||||||
start across the `StopForUpdate → Swap → RebuildBookkeeping`
|
start across the `StopForUpdate → Swap → RebuildBookkeeping`
|
||||||
brace and the tail `Reconcile` node. `flake.nix` itself lives in the agent's
|
brace and the tail `Reconcile` node. `flake.nix` itself lives in the agent's
|
||||||
proposed/applied repos and rides along on every fetch (see
|
the applied repo and rides along on every fetch (see
|
||||||
`docs/agent-lifecycle/config-changes.md::Two repos per agent`).
|
`docs/agent-lifecycle/config-changes.md::The applied repo per agent`).
|
||||||
|
|
||||||
Anything that changes per-container state on the host should be
|
Anything that changes per-container state on the host should be
|
||||||
re-applied here so a manual `↻ R3BU1LD` from the dashboard is
|
re-applied here so a manual `↻ R3BU1LD` from the dashboard is
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ Cheap — no build slot:
|
||||||
| Node | Behavior |
|
| Node | Behavior |
|
||||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `MetaSync` | the rebuild's preamble — rebuild-dir prep, the agent-set refresh (`agent_set::refresh`), and the `internal/meta` commit `Prebuild` and `Swap` expect the swarm's closure to be built from, resolved once and recorded; fails when the repo doesn't pin the agent's current config. Declares no resources |
|
| `MetaSync` | the rebuild's preamble — rebuild-dir prep, the agent-set refresh (`agent_set::refresh`), and the `internal/meta` commit `Prebuild` and `Swap` expect the swarm's closure to be built from, resolved once and recorded; fails when the repo doesn't pin the agent's current config. Declares no resources |
|
||||||
| `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, agent-set refresh (`agent_set::refresh`); runs ahead of `Create`. Store-only, no container yet |
|
| `Provision` | first-spawn pre-create provisioning — the applied repo, state subvolume, agent-set refresh (`agent_set::refresh`); runs ahead of `Create`. Store-only, no container yet |
|
||||||
| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
|
| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
|
||||||
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
|
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
|
||||||
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
|
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,8 @@ claude-creds badge). One action: `PURG3` (wipes state + applied
|
||||||
dirs; `POST /api/purge-tombstone/{name}`).
|
dirs; `POST /api/purge-tombstone/{name}`).
|
||||||
|
|
||||||
⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision`
|
⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision`
|
||||||
writes every definition-side artifact (state subvolume, proposed + applied
|
writes every definition-side artifact (state subvolume, applied
|
||||||
repos, meta registration, topology entry) *before* the container exists,
|
repo, meta registration, topology entry) *before* the container exists,
|
||||||
and that artifact set survives `lifecycle::destroy`. An agent
|
and that artifact set survives `lifecycle::destroy`. An agent
|
||||||
part-way through a spawn is byte-identical on disk to a tombstone, and
|
part-way through a spawn is byte-identical on disk to a tombstone, and
|
||||||
the pane lists both. The pane carries a standing warning to that effect, and
|
the pane lists both. The pane carries a standing warning to that effect, and
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ async fn ff_applied_main(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tear down a sub-agent container. By default this is non-destructive to
|
/// Tear down a sub-agent container. By default this is non-destructive to
|
||||||
/// persistent state: the proposed/applied config repos and the Claude
|
/// persistent state: the applied config repo and the Claude
|
||||||
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
|
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
|
||||||
/// kept, so recreating an agent of the same name reuses prior config + creds
|
/// kept, so recreating an agent of the same name reuses prior config + creds
|
||||||
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
|
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
|
||||||
|
|
|
||||||
|
|
@ -102,19 +102,14 @@ pub fn all_agents_in(topo: &BTreeSet<String>) -> Vec<String> {
|
||||||
/// caller. The write is atomic, as a sorted array (`BTreeSet` iterates in
|
/// caller. The write is atomic, as a sorted array (`BTreeSet` iterates in
|
||||||
/// key order) to keep git diffs minimal.
|
/// key order) to keep git diffs minimal.
|
||||||
///
|
///
|
||||||
/// `pending` lists agents that have a provisioned proposed config repo
|
|
||||||
/// but no container yet (provisioned, not yet spawned). They are KEPT
|
|
||||||
/// (not dropped) so an agent that exists on disk but has never booted is
|
|
||||||
/// still a name the hive knows about.
|
|
||||||
///
|
|
||||||
/// Fails without writing when the existing file can't be read or parsed:
|
/// Fails without writing when the existing file can't be read or parsed:
|
||||||
/// rebuilding it from the live set alone would drop every pending name.
|
/// rebuilding it from the live set alone would silently rewrite it.
|
||||||
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
|
pub fn reconcile(agent_names: &[String]) -> std::io::Result<bool> {
|
||||||
reconcile_at(&topology_path(), agent_names, pending)
|
reconcile_at(&topology_path(), agent_names)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reconcile_at(path: &Path, agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
|
fn reconcile_at(path: &Path, agent_names: &[String]) -> std::io::Result<bool> {
|
||||||
let (next, changed) = apply_reconcile(&read_from(path)?, agent_names, pending);
|
let (next, changed) = apply_reconcile(&read_from(path)?, agent_names);
|
||||||
if changed {
|
if changed {
|
||||||
super::write_map(path, &next)?;
|
super::write_map(path, &next)?;
|
||||||
}
|
}
|
||||||
|
|
@ -122,13 +117,12 @@ fn reconcile_at(path: &Path, agent_names: &[String], pending: &[String]) -> std:
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure form of [`reconcile`] for unit tests. Adds missing live agents,
|
/// Pure form of [`reconcile`] for unit tests. Adds missing live agents,
|
||||||
/// drops entries for agents that are neither live nor pending-init, and
|
/// drops entries for agents that are not live, and reports whether anything
|
||||||
/// reports whether anything changed.
|
/// changed.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn apply_reconcile(
|
pub fn apply_reconcile(
|
||||||
current: &BTreeSet<String>,
|
current: &BTreeSet<String>,
|
||||||
agent_names: &[String],
|
agent_names: &[String],
|
||||||
pending: &[String],
|
|
||||||
) -> (BTreeSet<String>, bool) {
|
) -> (BTreeSet<String>, bool) {
|
||||||
let mut next = current.clone();
|
let mut next = current.clone();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
|
|
@ -137,8 +131,7 @@ pub fn apply_reconcile(
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let known: std::collections::HashSet<&String> =
|
let known: std::collections::HashSet<&String> = agent_names.iter().collect();
|
||||||
agent_names.iter().chain(pending.iter()).collect();
|
|
||||||
next.retain(|name| {
|
next.retain(|name| {
|
||||||
let keep = known.contains(name);
|
let keep = known.contains(name);
|
||||||
if !keep {
|
if !keep {
|
||||||
|
|
@ -166,7 +159,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_reconcile_adds_missing_live_agent() {
|
fn apply_reconcile_adds_missing_live_agent() {
|
||||||
let live = vec!["alice".to_owned(), "newbie".to_owned()];
|
let live = vec!["alice".to_owned(), "newbie".to_owned()];
|
||||||
let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]);
|
let (next, changed) = apply_reconcile(&BTreeSet::new(), &live);
|
||||||
assert!(changed);
|
assert!(changed);
|
||||||
assert!(next.contains("newbie"));
|
assert!(next.contains("newbie"));
|
||||||
assert!(next.contains("alice"));
|
assert!(next.contains("alice"));
|
||||||
|
|
@ -175,30 +168,17 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_reconcile_drops_vanished_agent() {
|
fn apply_reconcile_drops_vanished_agent() {
|
||||||
let live = vec!["alice".to_owned()];
|
let live = vec!["alice".to_owned()];
|
||||||
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
|
let (next, changed) = apply_reconcile(&roster_three(), &live);
|
||||||
assert!(changed);
|
assert!(changed);
|
||||||
assert!(!next.contains("bob"));
|
assert!(!next.contains("bob"));
|
||||||
assert!(!next.contains("carol"));
|
assert!(!next.contains("carol"));
|
||||||
assert!(next.contains("alice"));
|
assert!(next.contains("alice"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apply_reconcile_keeps_pending_init_agent() {
|
|
||||||
// `dora` was provisioned but has no container yet, so it's absent
|
|
||||||
// from the live set. It must NOT be dropped.
|
|
||||||
let mut roster = roster_three();
|
|
||||||
roster.insert("dora".to_owned());
|
|
||||||
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
|
||||||
let pending = vec!["dora".to_owned()];
|
|
||||||
let (next, changed) = apply_reconcile(&roster, &live, &pending);
|
|
||||||
assert!(!changed, "no change expected: {next:?}");
|
|
||||||
assert!(next.contains("dora"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_reconcile_is_a_noop_when_already_in_sync() {
|
fn apply_reconcile_is_a_noop_when_already_in_sync() {
|
||||||
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
||||||
let (next, changed) = apply_reconcile(&roster_three(), &live, &[]);
|
let (next, changed) = apply_reconcile(&roster_three(), &live);
|
||||||
assert!(!changed);
|
assert!(!changed);
|
||||||
assert_eq!(next, roster_three());
|
assert_eq!(next, roster_three());
|
||||||
}
|
}
|
||||||
|
|
@ -255,8 +235,7 @@ mod tests {
|
||||||
fn reconcile_leaves_a_corrupt_file_untouched() {
|
fn reconcile_leaves_a_corrupt_file_untouched() {
|
||||||
let (_dir, path) = corrupt_file();
|
let (_dir, path) = corrupt_file();
|
||||||
let live = vec!["alice".to_owned(), "bob".to_owned()];
|
let live = vec!["alice".to_owned(), "bob".to_owned()];
|
||||||
let err =
|
let err = reconcile_at(&path, &live).expect_err("a corrupt file must not be rewritten");
|
||||||
reconcile_at(&path, &live, &[]).expect_err("a corrupt file must not be rewritten");
|
|
||||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||||
assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes());
|
assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes());
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +252,7 @@ mod tests {
|
||||||
let path = dir.path().join(TOPOLOGY_FILE);
|
let path = dir.path().join(TOPOLOGY_FILE);
|
||||||
assert!(read_from(&path).expect("missing file").is_empty());
|
assert!(read_from(&path).expect("missing file").is_empty());
|
||||||
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()];
|
||||||
assert!(reconcile_at(&path, &live, &[]).expect("reconcile"));
|
assert!(reconcile_at(&path, &live).expect("reconcile"));
|
||||||
assert_eq!(read_from(&path).expect("read"), roster_three());
|
assert_eq!(read_from(&path).expect("read"), roster_three());
|
||||||
let entries: Vec<_> = std::fs::read_dir(dir.path())
|
let entries: Vec<_> = std::fs::read_dir(dir.path())
|
||||||
.expect("read_dir")
|
.expect("read_dir")
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,7 @@ pub async fn refresh(agents: &[hive_meta_flake::AgentSpec]) -> Result<()> {
|
||||||
};
|
};
|
||||||
remove_stale_ca_files(&dir);
|
remove_stale_ca_files(&dir);
|
||||||
|
|
||||||
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
|
crate::topology::reconcile(&agent_names)
|
||||||
.into_iter()
|
|
||||||
.map(hive_types::Ident::into_string)
|
|
||||||
.collect();
|
|
||||||
crate::topology::reconcile(&agent_names, &pending)
|
|
||||||
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
||||||
|
|
||||||
// Every agent binds a unix socket (`HIVE_WEB_SOCKET`); the gateway routes
|
// Every agent binds a unix socket (`HIVE_WEB_SOCKET`); the gateway routes
|
||||||
|
|
|
||||||
|
|
@ -286,8 +286,6 @@ mod serve_config_tests {
|
||||||
pub struct AgentPaths {
|
pub struct AgentPaths {
|
||||||
/// Runtime socket dir (tmpfs, recreated per boot).
|
/// Runtime socket dir (tmpfs, recreated per boot).
|
||||||
pub agent: PathBuf,
|
pub agent: PathBuf,
|
||||||
/// Manager-editable proposed config repo.
|
|
||||||
pub proposed: PathBuf,
|
|
||||||
/// Hive-c0re-authoritative applied config repo.
|
/// Hive-c0re-authoritative applied config repo.
|
||||||
pub applied: PathBuf,
|
pub applied: PathBuf,
|
||||||
/// Claude OAuth credentials (survives purge boundary).
|
/// Claude OAuth credentials (survives purge boundary).
|
||||||
|
|
@ -438,7 +436,6 @@ impl Coordinator {
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||||
Ok(AgentPaths {
|
Ok(AgentPaths {
|
||||||
agent: agent_dir,
|
agent: agent_dir,
|
||||||
proposed: Self::agent_proposed_dir(&name),
|
|
||||||
applied: crate::paths::applied_dir(name.as_str()),
|
applied: crate::paths::applied_dir(name.as_str()),
|
||||||
claude: Self::agent_claude_dir(&name),
|
claude: Self::agent_claude_dir(&name),
|
||||||
notes: Self::agent_notes_dir(&name),
|
notes: Self::agent_notes_dir(&name),
|
||||||
|
|
@ -829,17 +826,6 @@ impl Coordinator {
|
||||||
crate::paths::agent_runtime_dir(name).join("mcp.sock")
|
crate::paths::agent_runtime_dir(name).join("mcp.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The *proposed* config repo: where a config change lands before it is
|
|
||||||
/// applied, and what an approved deploy promotes into `applied_dir`.
|
|
||||||
///
|
|
||||||
/// **Not bind-mounted into any container.** An agent that edits a config
|
|
||||||
/// clones it from the forge itself; `/agents/<name>/config` shows the
|
|
||||||
/// applied (deployed) tree instead — see `config_bind_source` in
|
|
||||||
/// `lifecycle/host_config.rs`.
|
|
||||||
pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf {
|
|
||||||
crate::paths::agent_state_dir(name).join("config")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-agent Claude credentials dir. Bind-mounted RW into the agent
|
/// Per-agent Claude credentials dir. Bind-mounted RW into the agent
|
||||||
/// container at `/root/.claude` so OAuth state survives container
|
/// container at `/root/.claude` so OAuth state survives container
|
||||||
/// destroy/recreate. Each agent owns its own token lineage — sharing
|
/// destroy/recreate. Each agent owns its own token lineage — sharing
|
||||||
|
|
@ -950,23 +936,6 @@ impl Coordinator {
|
||||||
out.sort();
|
out.sort();
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Agents that have an operator-approved proposed config repo but
|
|
||||||
/// were never deployed — proposed `.git` exists, applied `.git` does
|
|
||||||
/// not. Their `topology.json` parent edge (written before the first
|
|
||||||
/// spawn) must survive `topology::reconcile` until the first
|
|
||||||
/// apply-commit spawns the container. Distinct from tombstones,
|
|
||||||
/// which have an applied repo from a prior deploy.
|
|
||||||
#[must_use]
|
|
||||||
pub fn pending_init_names() -> Vec<hive_types::Ident> {
|
|
||||||
Self::kept_state_names()
|
|
||||||
.into_iter()
|
|
||||||
.filter(|n| {
|
|
||||||
Self::agent_proposed_dir(n).join(".git").exists()
|
|
||||||
&& !crate::paths::applied_dir(n.as_str()).join(".git").exists()
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `push_todo` summary text for a rebuild outcome, for
|
/// `push_todo` summary text for a rebuild outcome, for
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ pub struct TombstoneView {
|
||||||
/// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not
|
/// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not
|
||||||
/// yet made by `Create`) is indistinguishable from a tombstone here, because
|
/// yet made by `Create`) is indistinguishable from a tombstone here, because
|
||||||
/// *nothing records a destroy*. Every definition-side artifact — state
|
/// *nothing records a destroy*. Every definition-side artifact — state
|
||||||
/// subvolume, proposed + applied repos, `deployed/0`, meta registration,
|
/// subvolume, applied repo, `deployed/0`, meta registration,
|
||||||
/// topology entry — is written before the container exists and survives
|
/// topology entry — is written before the container exists and survives
|
||||||
/// `lifecycle::destroy`, which removes the container and leaves the rest.
|
/// `lifecycle::destroy`, which removes the container and leaves the rest.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ mod repos;
|
||||||
pub(crate) use internal_meta::internal_meta_source;
|
pub(crate) use internal_meta::internal_meta_source;
|
||||||
pub use reader::reader_token;
|
pub use reader::reader_token;
|
||||||
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
||||||
pub use repos::{clone_config_into_proposed, fetch_config_main_into_applied};
|
pub use repos::fetch_config_main_into_applied;
|
||||||
|
|
||||||
use std::future::IntoFuture;
|
use std::future::IntoFuture;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,23 @@
|
||||||
//! Repo plumbing on the local Forgejo: reseeding an agent's `applied` and
|
//! Repo plumbing on the local Forgejo: seeding an agent's `applied` repo from
|
||||||
//! `proposed` repos from `agent-configs/<name>`. The typed API-client
|
//! `agent-configs/<name>`. The typed API-client constructor + org-name
|
||||||
//! constructor + org-name constants live in the module root (`super`).
|
//! constants live in the module root (`super`).
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use super::{CONFIG_ORG, forge_git_url, reader_token};
|
use super::{CONFIG_ORG, forge_git_url, reader_token};
|
||||||
|
|
||||||
/// Reseed a revived agent's `applied` repo — missing its `.git` entirely,
|
/// Seed an agent's `applied` repo, missing its `.git` entirely (first spawn,
|
||||||
/// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'s
|
/// or a revival after `destroy --purge`), by fetching `agent-configs/<name>`'s
|
||||||
/// `main` from the forge. Unlike `proposed` (seeded once at first spawn and
|
/// `main` from the forge. Forge `main` advances with every operator-merged
|
||||||
/// never touched again — see `lifecycle::setup_proposed`'s own doc
|
/// config PR, so this reflects the last merged config. A config deployed
|
||||||
/// comment), forge `main` advances with every operator-merged config PR, so
|
/// through hive-c0re without a forge merge never reaches it.
|
||||||
/// this reflects the last merged config rather than a stale creation-time
|
|
||||||
/// snapshot. A config deployed through hive-c0re without a forge merge never
|
|
||||||
/// reaches it.
|
|
||||||
///
|
///
|
||||||
/// Best-effort: returns `false` (not an error) rather than bailing when there
|
/// Best-effort: returns `false` (not an error) rather than bailing when there
|
||||||
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), or
|
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), the
|
||||||
/// any git step fails — callers fall back to a less-current source or the
|
/// forge has no `main` for the agent yet (the normal case right after
|
||||||
/// original hard error. Only `true` once `applied` is
|
/// `swarm-controller` creates it, before it has pushed anything), or any git
|
||||||
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own
|
/// step fails. Callers fall back to the template or the original hard error. A
|
||||||
/// `setup_applied` seed shape exactly.
|
/// failed seed leaves no `.git` behind, so a later "is it seeded" check reads
|
||||||
|
/// the same as before the attempt. Only `true` once `applied` is seeded and
|
||||||
|
/// tagged `deployed/0`.
|
||||||
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
||||||
let Some(reader) = reader_token().await else {
|
let Some(reader) = reader_token().await else {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -29,111 +26,60 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
||||||
if applied.join(".git").exists() {
|
if applied.join(".git").exists() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::create_dir_all(&applied) {
|
let seeded = async {
|
||||||
tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed");
|
if let Err(e) = std::fs::create_dir_all(&applied) {
|
||||||
return false;
|
tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed");
|
||||||
}
|
|
||||||
if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await {
|
|
||||||
tracing::warn!(%name, error = ?e, "forge: applied reseed init failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
|
||||||
let auth = reader.auth_header();
|
|
||||||
let out = crate::lifecycle::git_command_authed(&auth)
|
|
||||||
.current_dir(&applied)
|
|
||||||
.args([
|
|
||||||
"fetch",
|
|
||||||
"--no-tags",
|
|
||||||
"--update-head-ok",
|
|
||||||
&url,
|
|
||||||
"refs/heads/main:refs/heads/main",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
match out {
|
|
||||||
Ok(o) if o.status.success() => {}
|
|
||||||
Ok(o) => {
|
|
||||||
tracing::warn!(
|
|
||||||
%name,
|
|
||||||
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
|
|
||||||
"forge: applied reseed fetch failed"
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await {
|
||||||
tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed");
|
tracing::warn!(%name, error = ?e, "forge: applied reseed init failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
||||||
|
let auth = reader.auth_header();
|
||||||
|
let out = crate::lifecycle::git_command_authed(&auth)
|
||||||
|
.current_dir(&applied)
|
||||||
|
.args([
|
||||||
|
"fetch",
|
||||||
|
"--no-tags",
|
||||||
|
"--update-head-ok",
|
||||||
|
&url,
|
||||||
|
"refs/heads/main:refs/heads/main",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match out {
|
||||||
|
Ok(o) if o.status.success() => {}
|
||||||
|
Ok(o) => {
|
||||||
|
// `info`, not `warn`: the normal case right after
|
||||||
|
// `swarm-controller` creates the agent, before it has pushed
|
||||||
|
// anything into the repo it just made, and nothing is wrong.
|
||||||
|
tracing::info!(
|
||||||
|
%name,
|
||||||
|
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
|
||||||
|
"forge: no config to fetch for this agent yet"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await {
|
||||||
|
tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await {
|
||||||
|
tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tracing::info!(%name, "forge: seeded applied repo from agent-configs main");
|
||||||
|
true
|
||||||
}
|
}
|
||||||
if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await {
|
.await;
|
||||||
tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed");
|
if !seeded {
|
||||||
return false;
|
let _ = std::fs::remove_dir_all(applied.join(".git"));
|
||||||
}
|
|
||||||
if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await {
|
|
||||||
tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
tracing::info!(%name, "forge: reseeded applied repo from agent-configs main");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seed an agent's `proposed` repo from `agent-configs/<name>` on the forge.
|
|
||||||
///
|
|
||||||
/// When the swarm creates an agent it writes that agent's config to the forge
|
|
||||||
/// before any hive is told to deploy it, so the hive's job here is to **take**
|
|
||||||
/// that config rather than author a second one beside it. The local template
|
|
||||||
/// is the fallback, not the default.
|
|
||||||
///
|
|
||||||
/// Best-effort like [`fetch_config_main_into_applied`], and `false` is an
|
|
||||||
/// ordinary answer rather than an error: there is no [`reader_token`], the
|
|
||||||
/// forge is unreachable, or `agent-configs/<name>` has no `main` — the last of
|
|
||||||
/// which is the *normal* case right after `swarm-controller` creates the
|
|
||||||
/// agent, before it has pushed anything into the repo it just made.
|
|
||||||
///
|
|
||||||
/// `clone` rather than `init` + `fetch`: git removes a directory it created
|
|
||||||
/// when the clone fails, and cloning into an existing empty dir leaves no
|
|
||||||
/// `.git` behind either — so the caller's "does this repo already exist"
|
|
||||||
/// check reads exactly the same before and after a failed attempt.
|
|
||||||
pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool {
|
|
||||||
let Some(reader) = reader_token().await else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
|
||||||
// `--branch main` fails outright against a repo with no commits, so an
|
|
||||||
// empty repo reads as "nothing to take" instead of cloning to an unborn
|
|
||||||
// HEAD that would then look like a seeded checkout.
|
|
||||||
let out = crate::lifecycle::git_command_authed(&reader.auth_header())
|
|
||||||
.args([
|
|
||||||
"clone",
|
|
||||||
"--branch",
|
|
||||||
"main",
|
|
||||||
&url,
|
|
||||||
&dir.display().to_string(),
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
match out {
|
|
||||||
Ok(o) if o.status.success() => {
|
|
||||||
tracing::info!(%name, "forge: seeded proposed repo from agent-configs main");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(o) => {
|
|
||||||
// `info`, not `warn`: the normal case right after
|
|
||||||
// `swarm-controller` creates the agent, before it has pushed
|
|
||||||
// anything into the repo it just made, and nothing is wrong.
|
|
||||||
// Logged at all because "where did this agent's config come
|
|
||||||
// from" is worth answering from the journal, and proposed is
|
|
||||||
// seeded exactly once.
|
|
||||||
tracing::info!(
|
|
||||||
%name,
|
|
||||||
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
|
|
||||||
"forge: no config to clone for this agent; seeding the template"
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(%name, error = ?e, "forge: proposed clone failed to run");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
seeded
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -731,7 +731,7 @@ async fn run_rebuild_bookkeeping(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First-spawn pre-create provisioning: proposed/applied repos, state
|
/// First-spawn pre-create provisioning: the applied repo, state
|
||||||
/// subvolume, and the agent-set refresh.
|
/// subvolume, and the agent-set refresh.
|
||||||
async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
|
async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
|
||||||
let agent_dir = crate::paths::agent_runtime_dir(name);
|
let agent_dir = crate::paths::agent_runtime_dir(name);
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,7 @@ pub fn rebuild(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First deploy of an agent this hive has never seen, asked for by the swarm:
|
/// First deploy of an agent this hive has never seen, asked for by the swarm:
|
||||||
/// `Provision` (proposed/applied repos, state subvolume, agent-set registration)
|
/// `Provision` (applied repo, state subvolume, agent-set registration)
|
||||||
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
|
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
|
||||||
/// starts the container (`wanted = Up`, seeded by
|
/// starts the container (`wanted = Up`, seeded by
|
||||||
/// `swarm_status::queue_first_deploy`). All-or-nothing: `Provision`
|
/// `swarm_status::queue_first_deploy`). All-or-nothing: `Provision`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Git shellout helpers for the per-agent proposed/applied repos: run
|
//! Git shellout helpers for the per-agent applied repo: run
|
||||||
//! `git` with the hive-c0re identity, resolve/plant refs and tags, and
|
//! `git` with the hive-c0re identity, resolve/plant refs and tags, and
|
||||||
//! fetch proposal commits into the applied repo.
|
//! fetch proposal commits into the applied repo.
|
||||||
|
|
||||||
|
|
@ -71,25 +71,6 @@ async fn drain<R: AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>
|
||||||
const GIT_NAME: &str = "c0re";
|
const GIT_NAME: &str = "c0re";
|
||||||
const GIT_EMAIL: &str = "c0re@hyperhive.local";
|
const GIT_EMAIL: &str = "c0re@hyperhive.local";
|
||||||
|
|
||||||
/// Return the SHA of the root (oldest, no-parent) commit in a repo.
|
|
||||||
/// Used to seed the applied repo at the template baseline rather than at
|
|
||||||
/// `main`, so `deployed/0` records the template, not the manager's first commit.
|
|
||||||
pub(super) async fn git_root_commit(dir: &Path) -> Result<String> {
|
|
||||||
let out = git_command()
|
|
||||||
.current_dir(dir)
|
|
||||||
.args(["rev-list", "--max-parents=0", "HEAD"])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?;
|
|
||||||
if !out.status.success() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"git rev-list --max-parents=0 failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||||
git(
|
git(
|
||||||
dir,
|
dir,
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,7 @@ fn config_bind_source(name: &str) -> PathBuf {
|
||||||
///
|
///
|
||||||
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
|
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
|
||||||
/// the `config` flag and must not be read as a reason to widen it: that runs
|
/// the `config` flag and must not be read as a reason to widen it: that runs
|
||||||
/// as hive-c0re against the host path (see `lifecycle::setup_proposed`), and
|
/// as hive-c0re against the host path (see `lifecycle::setup_applied`), and
|
||||||
/// `read_only` on a bind constrains writers *inside* the container only.
|
/// `read_only` on a bind constrains writers *inside* the container only.
|
||||||
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
||||||
let Ok(child) = hive_types::Ident::parse(child) else {
|
let Ok(child) = hive_types::Ident::parse(child) else {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ pub use git::{
|
||||||
pub use host_config::write_dropins;
|
pub use host_config::write_dropins;
|
||||||
pub use setup::{
|
pub use setup::{
|
||||||
ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix,
|
ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix,
|
||||||
setup_applied, setup_proposed,
|
setup_applied,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
@ -205,8 +205,8 @@ async fn port_collision(self_name: &str) -> Result<Option<String>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First-spawn pre-create provisioning — the job queue's `Provision`
|
/// First-spawn pre-create provisioning — the job queue's `Provision`
|
||||||
/// node. Fail fast on a port collision, set up the proposed/applied
|
/// node. Fail fast on a port collision, set up the applied
|
||||||
/// repos + state subvolume + claude/notes dirs, then register the new
|
/// repo + state subvolume + claude/notes dirs, then register the new
|
||||||
/// agent in the host-side files that follow the agent set
|
/// agent in the host-side files that follow the agent set
|
||||||
/// ([`crate::agent_set::refresh`]). Does NOT
|
/// ([`crate::agent_set::refresh`]). Does NOT
|
||||||
/// create the container — that's `create_only` / the `Create` node.
|
/// create the container — that's `create_only` / the `Create` node.
|
||||||
|
|
@ -222,8 +222,7 @@ pub async fn provision_container(name: &str, paths: &AgentPaths) -> Result<()> {
|
||||||
// repo, and the build takes it from the archetype.
|
// repo, and the build takes it from the archetype.
|
||||||
crate::archetypes::refresh(name).await?;
|
crate::archetypes::refresh(name).await?;
|
||||||
if crate::archetypes::of(name)?.is_none() {
|
if crate::archetypes::of(name)?.is_none() {
|
||||||
setup_proposed(&paths.proposed, name).await?;
|
setup_applied(&paths.applied, name).await?;
|
||||||
setup_applied(&paths.applied, Some(&paths.proposed), name).await?;
|
|
||||||
}
|
}
|
||||||
ensure_agent_state_subvolume(name).await?;
|
ensure_agent_state_subvolume(name).await?;
|
||||||
ensure_claude_dir(&paths.claude)?;
|
ensure_claude_dir(&paths.claude)?;
|
||||||
|
|
@ -250,12 +249,9 @@ pub async fn create_only(name: &str, toplevel: &str) -> Result<()> {
|
||||||
/// in `provision_container`. Recover from `agent-configs/<name>` on the
|
/// in `provision_container`. Recover from `agent-configs/<name>` on the
|
||||||
/// forge when that's the case (forge `main` is the last operator-merged
|
/// forge when that's the case (forge `main` is the last operator-merged
|
||||||
/// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
|
/// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
|
||||||
/// rather than bailing outright. Deliberately **no fallback to
|
/// rather than bailing outright. Deliberately **no template fallback**: a
|
||||||
/// `proposed`**: that repo is seeded once at first spawn and never touched
|
/// template would silently revive the *wrong* config rather than recovering
|
||||||
/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily
|
/// the right one, which is worse than the clear bail this replaces.
|
||||||
/// stale for a long-lived agent — restoring from it would silently revive
|
|
||||||
/// the *wrong* config rather than recovering the right one, which is worse
|
|
||||||
/// than the clear bail this replaces.
|
|
||||||
pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> {
|
pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> {
|
||||||
validate(name)?;
|
validate(name)?;
|
||||||
if let Some(other) = port_collision(name).await? {
|
if let Some(other) = port_collision(name).await? {
|
||||||
|
|
@ -276,7 +272,7 @@ pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()>
|
||||||
paths.applied.display()
|
paths.applied.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setup_applied(&paths.applied, None, name).await?;
|
setup_applied(&paths.applied, name).await?;
|
||||||
}
|
}
|
||||||
ensure_agent_state_subvolume(name).await?;
|
ensure_agent_state_subvolume(name).await?;
|
||||||
ensure_claude_dir(&paths.claude)?;
|
ensure_claude_dir(&paths.claude)?;
|
||||||
|
|
|
||||||
|
|
@ -1,159 +1,53 @@
|
||||||
//! First-spawn provisioning: seed the agent's proposed config repo and
|
//! First-spawn provisioning: seed the hive-c0re-owned applied repo, and
|
||||||
//! the hive-c0re-owned applied repo, and ensure the per-agent state /
|
//! ensure the per-agent state / claude-credentials dirs (btrfs subvolume when
|
||||||
//! claude-credentials dirs (btrfs subvolume when available) exist.
|
//! available) exist.
|
||||||
//!
|
//!
|
||||||
//! Seeding is the only write either repo gets from here. Later config
|
//! Seeding is the only write the repo gets from here. Later config changes
|
||||||
//! changes arrive as forge PRs authored in a clone — nothing edits a
|
//! arrive as forge PRs authored in a clone - nothing edits a working tree in
|
||||||
//! working tree in place.
|
//! place.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
use super::git::{
|
use super::git::{git, git_commit, git_rev_parse, git_tag};
|
||||||
git, git_command, git_commit, git_read_tree_reset, git_rev_parse, git_root_commit, git_tag,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Initialize an agent's config repo, preferring the one the forge
|
/// Seed an agent's applied repo on first spawn, preferring the config the
|
||||||
/// already has. `agent-configs/<name>` is authored by whoever created
|
/// forge already has. `agent-configs/<name>` is authored by whoever created
|
||||||
/// the agent — at swarm level that is the controller, which writes the
|
/// the agent (at swarm level the controller, which writes the config before
|
||||||
/// config before any hive is told to deploy it — so this **clones**
|
/// any hive is told to deploy it), so this takes the forge's `main` when
|
||||||
/// when there is something to clone and seeds a template only when
|
/// there is one and writes a template only when there is not. Two authors for
|
||||||
/// there is not. Two authors for one file is the failure mode being
|
/// one file is the failure mode being avoided: whichever wrote second would
|
||||||
/// avoided: whichever wrote second would win, and neither knows about
|
/// win, and neither knows about the other.
|
||||||
/// the other.
|
|
||||||
///
|
///
|
||||||
/// The template ([`seed_template`]) is what this still falls back to when
|
/// The template ([`seed_template`]) is the fallback when there is genuinely
|
||||||
/// there's genuinely nothing to clone yet — `swarm-controller` creates
|
/// nothing to take yet: `swarm-controller` creates `agent-configs/<name>`
|
||||||
/// `agent-configs/<name>` before telling a hive to deploy, but hasn't
|
/// before telling a hive to deploy, but hasn't necessarily pushed `main` to
|
||||||
/// necessarily pushed `main` to it yet, or the forge is unreachable, or
|
/// it, or the forge is unreachable, or this hive has no forge read token.
|
||||||
/// this hive has no forge read token.
|
|
||||||
///
|
///
|
||||||
/// **Seeding is the whole of hive-c0re's write.** Changes to the repo
|
/// Seeding is the only write hive-c0re makes here. Later config changes
|
||||||
/// arrive as PRs from a clone, via the forge, like any other code
|
/// arrive as forge PRs and are fetched from the forge's `main` (see
|
||||||
/// change — nothing edits a working tree in place, here or in the
|
/// `actions::advance_applied_to_rev`). `flake.nix` is tracked in the seed
|
||||||
/// agent's bind-mounted `config/` (which is a read-only copy).
|
/// and rides along on every fetch.
|
||||||
///
|
///
|
||||||
/// Touched by hive-c0re only on first spawn — never again — so a
|
/// A repo that is already there is only checked for its shape (a rebuild
|
||||||
/// hive-c0re commit or working-tree reset can't surprise anyone
|
/// finds it already seeded): a missing `deployed/0` tag means it predates the
|
||||||
/// holding a clone.
|
/// meta-flake layout and is an error.
|
||||||
pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
|
pub async fn setup_applied(applied_dir: &Path, name: &str) -> Result<()> {
|
||||||
let fresh = !proposed_dir.join(".git").exists();
|
|
||||||
if fresh {
|
|
||||||
std::fs::create_dir_all(proposed_dir)
|
|
||||||
.with_context(|| format!("create {}", proposed_dir.display()))?;
|
|
||||||
if !crate::forge::clone_config_into_proposed(proposed_dir, name).await {
|
|
||||||
seed_template(proposed_dir, name).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Idempotently wire the `applied` remote — purely for the
|
|
||||||
// manager's ergonomics. The URL is the path inside the manager
|
|
||||||
// container (`/applied/<n>/.git`), where the RO bind in
|
|
||||||
// `set_nspawn_flags` makes it real. hive-c0re itself never
|
|
||||||
// dereferences this remote.
|
|
||||||
ensure_applied_remote(proposed_dir, name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Author the initial config in place: `agent.nix` (the agent's own module)
|
|
||||||
/// and `flake.nix` (the boilerplate that lets the meta flake import this repo
|
|
||||||
/// as an input — meta locks at a specific sha and reads
|
|
||||||
/// `nixosModules.default`, so `flake.nix` must be in the commit). `flake.nix`
|
|
||||||
/// isn't meant to be edited, but it's tracked so it can be read.
|
|
||||||
///
|
|
||||||
/// The fallback half of [`setup_proposed`]: reached only when the forge has
|
|
||||||
/// no config for this agent to take.
|
|
||||||
async fn seed_template(proposed_dir: &Path, name: &str) -> Result<()> {
|
|
||||||
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()))?;
|
|
||||||
}
|
|
||||||
let flake_path = proposed_dir.join("flake.nix");
|
|
||||||
if !flake_path.exists() {
|
|
||||||
std::fs::write(&flake_path, initial_flake_nix())
|
|
||||||
.with_context(|| format!("write {}", flake_path.display()))?;
|
|
||||||
}
|
|
||||||
git(proposed_dir, &["init", "--initial-branch=main"]).await?;
|
|
||||||
git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?;
|
|
||||||
git_commit(proposed_dir, "hive-c0re init").await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> {
|
|
||||||
let want = format!("/applied/{name}/.git");
|
|
||||||
let existing = git_command()
|
|
||||||
.current_dir(proposed_dir)
|
|
||||||
.args(["remote", "get-url", "applied"])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("git remote get-url applied in {}", proposed_dir.display()))?;
|
|
||||||
if existing.status.success() {
|
|
||||||
let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned();
|
|
||||||
if current == want {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// URL drifted (path scheme changed, etc.) — re-point it.
|
|
||||||
return git(proposed_dir, &["remote", "set-url", "applied", &want]).await;
|
|
||||||
}
|
|
||||||
git(proposed_dir, &["remote", "add", "applied", &want]).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up the applied repo. First-spawn only: init the repo, pull
|
|
||||||
/// proposed's initial commit in via `git fetch`, tag it `deployed/0`.
|
|
||||||
/// This is the *only* time hive-c0re reads from `proposed` for an
|
|
||||||
/// agent — subsequent config changes are fetched from the forge's `main`
|
|
||||||
/// (see `actions::advance_applied_to_rev`).
|
|
||||||
///
|
|
||||||
/// `proposed_dir` is `None` on rebuild paths where the repo already
|
|
||||||
/// exists — we just verify it's the right shape and bail otherwise.
|
|
||||||
/// Unlike the pre-overhaul code path, `flake.nix` is no longer
|
|
||||||
/// regenerated at the host level: it's tracked in proposed (seeded by
|
|
||||||
/// `setup_proposed`) and rides along on every fetch.
|
|
||||||
pub async fn setup_applied(
|
|
||||||
applied_dir: &Path,
|
|
||||||
proposed_dir: Option<&Path>,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
std::fs::create_dir_all(applied_dir)
|
std::fs::create_dir_all(applied_dir)
|
||||||
.with_context(|| format!("create {}", applied_dir.display()))?;
|
.with_context(|| format!("create {}", applied_dir.display()))?;
|
||||||
|
|
||||||
if !applied_dir.join(".git").exists() {
|
if !applied_dir.join(".git").exists() {
|
||||||
let Some(proposed) = proposed_dir else {
|
if crate::forge::fetch_config_main_into_applied(name).await {
|
||||||
bail!(
|
return Ok(());
|
||||||
"applied repo at {} is missing its .git directory; \
|
}
|
||||||
cannot rebuild without a proposed source to seed from. \
|
seed_template(applied_dir, name).await?;
|
||||||
destroy --purge and re-spawn this agent.",
|
|
||||||
applied_dir.display()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
git(applied_dir, &["init", "--initial-branch=main"]).await?;
|
|
||||||
let proposed_str = proposed.display().to_string();
|
|
||||||
// Seed the applied repo at the root (template) commit of proposed,
|
|
||||||
// not at `main`, so `deployed/0` is the template baseline rather
|
|
||||||
// than whatever the manager may have already committed on top.
|
|
||||||
let root_sha = git_root_commit(proposed).await?;
|
|
||||||
git(
|
|
||||||
applied_dir,
|
|
||||||
// --update-head-ok lets us fetch into refs/heads/main while
|
|
||||||
// HEAD still points there. git's default safeguard refuses
|
|
||||||
// to avoid index/working-tree desync, but the working tree
|
|
||||||
// is empty (we just `init`'d) and we read-tree-reset right
|
|
||||||
// after, so the safeguard is moot here.
|
|
||||||
&[
|
|
||||||
"fetch",
|
|
||||||
"--no-tags",
|
|
||||||
"--update-head-ok",
|
|
||||||
&proposed_str,
|
|
||||||
&format!("{root_sha}:refs/heads/main"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
git_read_tree_reset(applied_dir, "refs/heads/main").await?;
|
|
||||||
git_tag(applied_dir, "deployed/0", "refs/heads/main").await?;
|
git_tag(applied_dir, "deployed/0", "refs/heads/main").await?;
|
||||||
} else if git_rev_parse(applied_dir, "refs/tags/deployed/0")
|
} else if git_rev_parse(applied_dir, "refs/tags/deployed/0")
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
// Pre-overhaul applied repo — no deployed/* tag scheme,
|
// Pre-overhaul applied repo - no deployed/* tag scheme,
|
||||||
// flake.nix may be untracked, agent.nix possibly authored by
|
// flake.nix may be untracked, agent.nix possibly authored by
|
||||||
// hive-c0re directly. The startup auto-migration fixes this
|
// hive-c0re directly. The startup auto-migration fixes this
|
||||||
// in place; if it didn't run (or got skipped), surface a
|
// in place; if it didn't run (or got skipped), surface a
|
||||||
|
|
@ -168,6 +62,30 @@ pub async fn setup_applied(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Author the initial config in place: `agent.nix` (the agent's own module)
|
||||||
|
/// and `flake.nix` (the boilerplate that lets the meta flake import this repo
|
||||||
|
/// as an input: meta locks at a specific sha and reads
|
||||||
|
/// `nixosModules.default`, so `flake.nix` must be in the commit). `flake.nix`
|
||||||
|
/// isn't meant to be edited, but it's tracked so it can be read.
|
||||||
|
///
|
||||||
|
/// The fallback half of [`setup_applied`]: reached only when the forge has
|
||||||
|
/// no config for this agent to take.
|
||||||
|
async fn seed_template(dir: &Path, name: &str) -> Result<()> {
|
||||||
|
let agent_path = dir.join("agent.nix");
|
||||||
|
if !agent_path.exists() {
|
||||||
|
std::fs::write(&agent_path, initial_agent_nix(name))
|
||||||
|
.with_context(|| format!("write {}", agent_path.display()))?;
|
||||||
|
}
|
||||||
|
let flake_path = dir.join("flake.nix");
|
||||||
|
if !flake_path.exists() {
|
||||||
|
std::fs::write(&flake_path, initial_flake_nix())
|
||||||
|
.with_context(|| format!("write {}", flake_path.display()))?;
|
||||||
|
}
|
||||||
|
git(dir, &["init", "--initial-branch=main"]).await?;
|
||||||
|
git(dir, &["add", "agent.nix", "flake.nix"]).await?;
|
||||||
|
git_commit(dir, "hive-c0re init").await
|
||||||
|
}
|
||||||
|
|
||||||
/// Create the per-agent Claude credentials dir if missing. Mode 0755 — hive-core
|
/// Create the per-agent Claude credentials dir if missing. Mode 0755 — hive-core
|
||||||
/// needs read+execute to list the directory so `claude_has_session` can detect a
|
/// needs read+execute to list the directory so `claude_has_session` can detect a
|
||||||
/// valid session; credential files inside (`.credentials.json` etc.) are 0600 so
|
/// valid session; credential files inside (`.credentials.json` etc.) are 0600 so
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,31 @@
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Regression test: `setup_proposed` must seed both agent.nix and flake.nix
|
/// Regression test: `setup_applied` must seed both agent.nix and flake.nix
|
||||||
/// in the initial commit. Before commit 5b5a93e flake.nix was missing from
|
/// in the initial commit, and tag it `deployed/0`. Before commit 5b5a93e
|
||||||
/// the scaffold, requiring manual creation (seen with the damocles agent).
|
/// flake.nix was missing from the scaffold, requiring manual creation (seen
|
||||||
|
/// with the damocles agent).
|
||||||
///
|
///
|
||||||
/// Exercises the template arm: there is no forge to clone from in a test
|
/// Exercises the template arm: there is no forge to take a config from in a
|
||||||
/// (`forge::is_present` needs the priv socket), so `setup_proposed` falls
|
/// test (no reader token), so `setup_applied` falls through to
|
||||||
/// through to `seed_template` — which is the arm this asserts about.
|
/// `seed_template` — which is the arm this asserts about.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn setup_proposed_seeds_flake_nix() {
|
async fn setup_applied_seeds_flake_nix() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let proposed = dir.path().join("proposed");
|
let applied = dir.path().join("applied");
|
||||||
setup_proposed(&proposed, "test-agent")
|
setup_applied(&applied, "test-agent")
|
||||||
.await
|
.await
|
||||||
.expect("setup_proposed");
|
.expect("setup_applied");
|
||||||
|
git_rev_parse(&applied, "refs/tags/deployed/0")
|
||||||
|
.await
|
||||||
|
.expect("the seed commit is tagged deployed/0");
|
||||||
|
|
||||||
// Both files must exist on disk.
|
// Both files must exist on disk.
|
||||||
assert!(proposed.join("agent.nix").exists(), "agent.nix missing");
|
assert!(applied.join("agent.nix").exists(), "agent.nix missing");
|
||||||
assert!(proposed.join("flake.nix").exists(), "flake.nix missing");
|
assert!(applied.join("flake.nix").exists(), "flake.nix missing");
|
||||||
|
|
||||||
// flake.nix must export nixosModules.default (the meta-flake contract).
|
// flake.nix must export nixosModules.default (the meta-flake contract).
|
||||||
let flake = std::fs::read_to_string(proposed.join("flake.nix")).unwrap();
|
let flake = std::fs::read_to_string(applied.join("flake.nix")).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
flake.contains("nixosModules.default"),
|
flake.contains("nixosModules.default"),
|
||||||
"flake.nix does not export nixosModules.default"
|
"flake.nix does not export nixosModules.default"
|
||||||
|
|
@ -31,7 +35,7 @@ async fn setup_proposed_seeds_flake_nix() {
|
||||||
|
|
||||||
// Both files must be tracked in the initial git commit.
|
// Both files must be tracked in the initial git commit.
|
||||||
let out = git_command()
|
let out = git_command()
|
||||||
.current_dir(&proposed)
|
.current_dir(&applied)
|
||||||
.args(["show", "--name-only", "--format=", "HEAD"])
|
.args(["show", "--name-only", "--format=", "HEAD"])
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
|
|
@ -128,22 +132,22 @@ fn network_isolation_errors_name_the_offending_variable() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `setup_proposed` is idempotent: calling it on an existing repo is a
|
/// `setup_applied` is idempotent: calling it on an already seeded repo is a
|
||||||
/// no-op (the fresh guard skips all writes).
|
/// no-op (the shape check passes and nothing is written).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn setup_proposed_idempotent() {
|
async fn setup_applied_idempotent() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let proposed = dir.path().join("proposed");
|
let applied = dir.path().join("applied");
|
||||||
setup_proposed(&proposed, "test-agent")
|
setup_applied(&applied, "test-agent")
|
||||||
.await
|
.await
|
||||||
.expect("first call");
|
.expect("first call");
|
||||||
// Second call must not error even though .git already exists.
|
// Second call must not error even though .git already exists.
|
||||||
setup_proposed(&proposed, "test-agent")
|
setup_applied(&applied, "test-agent")
|
||||||
.await
|
.await
|
||||||
.expect("second call");
|
.expect("second call");
|
||||||
// Still one commit.
|
// Still one commit.
|
||||||
let out = git_command()
|
let out = git_command()
|
||||||
.current_dir(&proposed)
|
.current_dir(&applied)
|
||||||
.args(["rev-list", "--count", "HEAD"])
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
|
|
@ -158,10 +162,10 @@ async fn setup_proposed_idempotent() {
|
||||||
/// Build a two-commit repo and return `(dir, repo_path, first, second)`.
|
/// Build a two-commit repo and return `(dir, repo_path, first, second)`.
|
||||||
async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
|
async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let repo = dir.path().join("proposed");
|
let repo = dir.path().join("applied");
|
||||||
setup_proposed(&repo, "test-agent")
|
setup_applied(&repo, "test-agent")
|
||||||
.await
|
.await
|
||||||
.expect("setup_proposed");
|
.expect("setup_applied");
|
||||||
let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first");
|
let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first");
|
||||||
|
|
||||||
std::fs::write(repo.join("second.txt"), "second").expect("write second.txt");
|
std::fs::write(repo.join("second.txt"), "second").expect("write second.txt");
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue