docs: sort the top-level directories into five sections by what the reader wants
Every docs directory moves under 1-getting-started, 2-handbook, 4-implementation or 5-project; the options reference (section 3) comes from the website and has no directory here. trust-boundary stays whole under the handbook for now. web-ui, swarm and tools split between handbook and implementation, and the command-line references move to 2-handbook/reference. Links, path references in code and nix, and the docs index follow the moves; no page content changes.
This commit is contained in:
parent
53ccbcb4b3
commit
6739bb903d
1 changed files with 736 additions and 701 deletions
|
|
@ -1,586 +0,0 @@
|
|||
# Conventions
|
||||
|
||||
Code-style and process expectations across the workspace. Most of these
|
||||
exist because something already went wrong without them.
|
||||
|
||||
## Naming
|
||||
|
||||
- Containers are length-bounded by `nixos-container` (≤ 11 chars).
|
||||
- Sub-agents are `h-<name>` with `<name>` ≤ 9 chars.
|
||||
- One agent is the bootstrap/root container, with a fixed name (`ruth` today).
|
||||
- `MAX_AGENT_NAME` in `hive-c0re/src/lifecycle/mod.rs` enforces the cap.
|
||||
- Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE`
|
||||
(8100..8999) for every agent; dashboard
|
||||
`services.hyperhive.deploy.hive-controller.dashboardPort` (default 7000).
|
||||
|
||||
## Hive identity (label + domain + display names)
|
||||
|
||||
Four env vars cover the identity surface, read by
|
||||
`hive-agent/src/identity.rs`:
|
||||
|
||||
- `HIVE_LABEL` — short, hive-local agent label (`iris`,
|
||||
`damocles`). `label()` returns it; falls back to empty string if
|
||||
the env var is missing so downstream callers can decide how to
|
||||
surface "unknown agent" rather than getting a panic from this
|
||||
module.
|
||||
- `HYPERHIVE_HIVE_DOMAIN` — the hive's canonical DNS domain (e.g.
|
||||
`darkest.space`), set by `nix/host-modules/hive-c0re/environment.nix`
|
||||
from `services.hyperhive.domain`. When configured, `qualify(label)`
|
||||
returns `${label}@${domain}` (for example `iris@darkest.space`); when
|
||||
unset (single-hive deployments, dev/test) it degrades to just
|
||||
the short label so existing callers see no change. The
|
||||
qualified form surfaces in the per-agent web UI title, the
|
||||
system-prompt template, and `/api/state.qualified_label`.
|
||||
- `HYPERHIVE_HIVE_NAME` — human display name of this hive
|
||||
(`pr1ma`). Read by `hive_name()`; `None` when unset.
|
||||
- `HYPERHIVE_SWARM_NAME` — human display name of the wider swarm
|
||||
this hive belongs to (`constellat1on`). Read by `swarm_name()`;
|
||||
federated hives at different DNS domains can share a swarm
|
||||
name.
|
||||
|
||||
Inside an agent container, the hive's name and domain don't come from the
|
||||
agent's build, and neither do the hive's other per-host values: the dashboard
|
||||
port (`HIVE_DASHBOARD_PORT`), the pinned claude-code
|
||||
(`HYPERHIVE_CLAUDE_CODE_PATH`), extra OTEL labels
|
||||
(`HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`) and the queue's address
|
||||
(`HIVE_AGENT_NATS_URL`, from `deploy.hive-controller.queue.agentNatsUrl`) and the egress proxy's address and direct hosts (`HIVE_SQUID_PROXY`, `HIVE_EGRESS_NO_PROXY`, see [the egress proxy](../networking/network.md#egress-proxy-squid)). `hive-priv` writes them to
|
||||
`/etc/hyperhive-hive-facts` before every container start, leaving out any the
|
||||
hive doesn't set; the agent then uses its own default. The agent's environment
|
||||
generator (`nix/agent-modules/hive-facts.nix`) passes all but the two egress facts, which only the proxy fetch reads, to every unit,
|
||||
including `hivectl choom` sessions, and `/etc/set-environment` exports them in login
|
||||
shells that no unit starts (`nixos-container root-login`). None is part of
|
||||
an agent's closure, and an agent started on another hive reports that hive.
|
||||
|
||||
`hive_name` + `swarm_name` are **distinct** from
|
||||
`HYPERHIVE_HIVE_DOMAIN`: the domain may carry the hive name as
|
||||
its leftmost label by convention, but the convention isn't
|
||||
machine-readable, and federated hives at different DNS domains
|
||||
can share a swarm name. Humans want both: the address
|
||||
(`@darkest.space`) AND the prose name (`pr1ma`). Matrix MXIDs
|
||||
use the domain-based convention.
|
||||
|
||||
## Identity = socket
|
||||
|
||||
No auth tokens exist on the per-agent unix sockets. The socket
|
||||
*path* identifies the principal; perms come from "who has the
|
||||
bind-mount." A sub-agent only sees its own `/run/hive/mcp.sock`;
|
||||
hive-c0re owns the host admin socket.
|
||||
|
||||
### Wake injection
|
||||
|
||||
`hive_core_agent_sock::Request::Wake { from, body }` is the
|
||||
wake-event-injection surface on the host-served per-agent socket.
|
||||
Recipient is implicit — the agent the socket belongs to — and `from`
|
||||
is caller-chosen so the wake prompt can label the source verbatim
|
||||
(`"matrix: new message in #general"`, `"forge: PR #42 opened"`, etc.). <!-- lint:allow: example strings, not real tags -->
|
||||
`hive-c0re` handles it; matrix, bash and forge don't call it — their
|
||||
notifications upsert a todo on the harness's in-agent socket
|
||||
(`HIVE_AGENT_SOCKET`), which signals the same turn loop without a
|
||||
hive-c0re round-trip. See
|
||||
[`docs/turn-loop/mcp.md` § Waking the agent from inside the
|
||||
container](../turn-loop/mcp.md#waking-the-agent-from-inside-the-container).
|
||||
|
||||
Identity = socket means anything that can connect to
|
||||
`/run/hive/mcp.sock` is implicitly trusted to inject wakes. That's
|
||||
fine: the bind-mount only exposes the socket inside the agent's own
|
||||
container, so the trust boundary is the container's process
|
||||
namespace, not the wire surface.
|
||||
|
||||
## Recipient sentinels
|
||||
|
||||
The broker reserves a few recipient names, which have a fixed
|
||||
meaning that ordinary agent labels can never collide with — agent
|
||||
name validation rejects any character outside `[a-z0-9_-]`, so the
|
||||
angle-bracket and asterisk shapes below are structurally safe.
|
||||
|
||||
- `*` — broadcast: deliver to every running agent except the sender
|
||||
(`socket_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
||||
- `operator` — the human at the dashboard. Reserved so no agent can take
|
||||
the name, and refused as a recipient: the broker answers a `send` to it
|
||||
with `OPERATOR_SEND_REFUSAL` (`hive-sh4re/src/manager.rs`). It appears
|
||||
only as the sender of operator-authored messages.
|
||||
|
||||
The broker stores the recipient exactly as the agent passed it.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
JSON line-delimited over unix sockets in both directions (host admin
|
||||
/ manager / agent). SSE streams (`/dashboard/stream` on hive-c0re,
|
||||
`/events/stream` on the per-agent web UIs) are `text/event-stream`;
|
||||
each frame carries a `seq` field for the snapshot-dedupe dance
|
||||
(see `docs/web-ui/shape.md`). Request/response types live in `hive-sh4re`
|
||||
— change them in one place. The dashboard event vocabulary lives
|
||||
in `hive-c0re::dashboard_events::DashboardEvent`.
|
||||
|
||||
### Broker delivery + ack cycle
|
||||
|
||||
`AgentRequest::Recv` is the only path that delivers messages to an
|
||||
agent. Always returns a list (`Messages { messages }`) — empty when
|
||||
nothing's pending, single-pop when `max = None` (default 1, the
|
||||
single-message behaviour), batched up to `max` when caller asks for
|
||||
more (server-side cap is 5; values above clamp silently). The wire
|
||||
request still carries an optional `wait_seconds` (long-poll the first
|
||||
message, once one arrives — or one is already pending — the call
|
||||
drains up to `max` in total): the harness's own turn-driving loop
|
||||
uses it internally (`hive-agent`'s `recv_next`, 180s). The
|
||||
agent-facing MCP `recv` tool doesn't expose this parameter — it
|
||||
always passes `wait_seconds: None`, an immediate peek.
|
||||
|
||||
Per-row bookkeeping inside the broker:
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
- `delivered_at = NOW` set on every popped row.
|
||||
- Each recipient has an in-memory `unacked_ids` list of every row
|
||||
delivered since the last `AckTurn`.
|
||||
- `redelivered = true` on a row if `RequeueInflight` resurfaced it
|
||||
(the harness prepends a "may already be handled" hint when this
|
||||
flag is set so the per-message warning is visible).
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
`AgentRequest::AckTurn` closes out the in-memory list — the harness
|
||||
fires it after `TurnOutcome::Ok`, marking every message popped since
|
||||
the last ack as fully handled. Claude doesn't see this surface; it's
|
||||
strictly a harness↔broker pairing. On `TurnOutcome::Failed` the
|
||||
harness intentionally skips the ack so the unacked rows stay
|
||||
in-flight in the DB and get picked up by the next requeue sweep.
|
||||
|
||||
`AgentRequest::RequeueInflight` is the recovery pair: fired by the
|
||||
harness exactly once at boot, before the serve loop starts. Catches
|
||||
the crashed-mid-turn / OOM-killed / container-restarted cases where
|
||||
a previous harness session popped messages but never drove them to
|
||||
a clean turn-end. Resets `delivered_at` back to NULL on every
|
||||
unacked row (so the next `Recv` pops them again), and remembers
|
||||
each id in a per-recipient in-memory set so the next `Recv` can tag
|
||||
the row with `redelivered: true`. Idempotent + cheap when there's
|
||||
nothing in flight, so the at-boot fire is unconditional.
|
||||
|
||||
`AgentRequest::AckUntil { up_to }` is the agent-facing bulk-triage
|
||||
escape hatch (`mcp__hyperhive__ack_until`). Unlike `AckTurn` **it's**
|
||||
visible to claude: each recv row and wake prompt carries a
|
||||
`[msg #<id>]` marker (the broker row id; transient pings show no
|
||||
marker — their sentinel id 0 has nothing to ack), and
|
||||
`ack_until(up_to: n)` marks every one of the agent's rows with
|
||||
`id <= n` handled in a single UPDATE — pending and delivered alike.
|
||||
This bounds the redelivered-flood cost after a restart: instead of
|
||||
popping dozens of already-handled messages one turn at a time, the
|
||||
agent notes the highest id it has seen and acks up to it.
|
||||
Recipient-scoped (an agent can only ack its own rows); also drains
|
||||
the in-memory `unacked_ids` / `requeued_ids` bookkeeping below the
|
||||
cutoff so a later `AckTurn` doesn't double-update and a stale
|
||||
redelivery tag can't outlive its row. The operator-side sibling is
|
||||
the dashboard's "mark all read" (unbounded, per-agent).
|
||||
|
||||
### Loose-ends wire shape
|
||||
|
||||
`LooseEnd` is the per-row response shape for `GetLooseEnds` — one
|
||||
request shape, no per-socket flavours. Tagged enum so new thread kinds
|
||||
(forge PRs, etc.) can land later without breaking existing handlers.
|
||||
Each row carries enough context that the caller renders
|
||||
it directly as a bulleted list, no follow-up fetch needed.
|
||||
|
||||
`GetLooseEnds` takes no target — it always returns the caller's own
|
||||
rows. `Reminder` rows always carry `owner == self`.
|
||||
|
||||
Per-variant fields:
|
||||
|
||||
- `Reminder { id, owner, message, due_at, age_seconds }` —
|
||||
`due_at` is the absolute time the scheduler is targeting (RFC
|
||||
3339 on the wire, see *Timestamps on the wire* below); clients
|
||||
compute time-until-fire against it.
|
||||
- `PendingMessages { count }` — undelivered inbox messages the
|
||||
agent still owes itself a `recv` for. Informational + not
|
||||
cancellable (drain with `recv`); only emitted when `count > 0`,
|
||||
and surfaced first in the list as the most
|
||||
actionable signal. Counted host-side from the broker
|
||||
(`count_pending`), so it reflects what's genuinely still queued —
|
||||
the wake-message that drove the current turn is already delivered
|
||||
and not counted.
|
||||
- `UnreadMatrix { rooms, summary }` — unread matrix notifications.
|
||||
Informational + not cancellable (clear with `mark_read`). Unlike
|
||||
the others, the in-container harness injects this, not hive-c0re,
|
||||
because the matrix daemon lives inside the agent.
|
||||
|
||||
`age_seconds` saturates at zero on any clock anomaly (back-step,
|
||||
unsynchronised wall clock, etc.) so the bulleted list never
|
||||
shows nonsense ages.
|
||||
|
||||
`cancel_loose_end` is the matching write surface, handled in the
|
||||
harness without a hive-c0re round-trip: `reminder` deletes one of the
|
||||
caller's own pending reminders, `todo` clears one of its todos.
|
||||
|
||||
### Agent metadata
|
||||
|
||||
`AgentRequest::GetAgentMeta { name }` returns identity + status for
|
||||
an agent. Self-introspection when `name = None`; target query when
|
||||
`name = Some`.
|
||||
|
||||
Response is `AgentMeta { name, running, hyperhive_rev,
|
||||
status_text, status_set_at, hive_name, swarm_name, matrix_accounts }`:
|
||||
|
||||
- `hyperhive_rev`: `None` only when the configured flake URL has
|
||||
no canonical path. Otherwise carries the rev the target is
|
||||
currently pinned at.
|
||||
- `running`: whether the target's container is currently up. When
|
||||
`false`, the host clears `status_text` / `status_set_at` —
|
||||
on-disk values from before the stop are stale snapshots, not
|
||||
live status. Defaults to `true` on the
|
||||
wire: the deserializer treats a payload without the field as
|
||||
running.
|
||||
- `status_text` / `status_set_at`: last value written via
|
||||
`SetStatus`, plus its unix timestamp. Both `None` when the
|
||||
target has never set a status, when the agent name is unknown,
|
||||
or when `running = false` (see above).
|
||||
- `hive_name` / `swarm_name`: display names read from
|
||||
`HYPERHIVE_HIVE_NAME` / `HYPERHIVE_SWARM_NAME` env (sourced from
|
||||
`services.hyperhive.deploy.hive-controller.hiveName` / `services.hyperhive.swarm.name`).
|
||||
Both `None` when the options aren't configured.
|
||||
- `matrix_accounts`: one `MatrixIdentity` per configured + live matrix
|
||||
account the agent can act as. Empty for agents with no matrix
|
||||
provisioning.
|
||||
|
||||
### Timestamps on the wire
|
||||
|
||||
Timestamp fields that cross a JSON boundary (dashboard API + SSE,
|
||||
the wire structs in hive-sh4re) serialize as **RFC 3339 UTC strings**
|
||||
(`2026-07-02T18:30:00Z`) via `hive_sh4re::wire_time` — Rust keeps the
|
||||
fields as `i64` unix seconds internally, only the JSON representation
|
||||
changes, and deserialization accepts both the string form and a bare
|
||||
integer (rolling-deploy skew, persisted blobs).
|
||||
**Input-direction** fields agents compute as epoch (`remind`'s
|
||||
`at_unix_timestamp`) stay integers. The
|
||||
`*_unix` field *names* stay for now — renaming is the wire-types
|
||||
refactor's concern. The dashboard frontend parses via
|
||||
`util.js::epochSec` wherever it needs arithmetic and feeds the string
|
||||
straight to `new Date(s)` for display.
|
||||
|
||||
### HTTP error bodies
|
||||
|
||||
Every HTTP API in this repo answers failures with **RFC 9457
|
||||
`application/problem+json`** (`{ type, title, status, detail }`), with the
|
||||
human-readable cause in `detail`. An endpoint returning a bare string
|
||||
or a bespoke error shape is a **bug to file against the daemon that
|
||||
returned it**, not something for the caller to work around.
|
||||
|
||||
Use the `problem_details` crate (`features = ["axum"]`), which the daemons
|
||||
already depend on: type a handler `Result<_, ProblemDetails>` and hand
|
||||
`ProblemDetails::from_status_code(...).with_detail(...)` to `Err`.
|
||||
|
||||
The reason is the consumer, not tidiness. The UIs show errors through one
|
||||
shared component with a copy button, so a caller has to know **which part of
|
||||
the body is the message**. A bare string forces it to treat the whole payload
|
||||
as prose, which is the difference between offering "copy the cause" and
|
||||
dumping a response — and the cause is frequently the entire diagnosis (a
|
||||
JetStream permission refusal, a TLS chain failure) rather than a summary.
|
||||
|
||||
Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a
|
||||
JSON-line protocol with their own result types; RFC 9457 is an HTTP format.
|
||||
|
||||
## Permissions
|
||||
|
||||
One set of named `Permission` values (`hive_types::Permission`)
|
||||
decides what an agent may do: which MCP and built-in tools it gets, and which
|
||||
host access it has.
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `messaging` | `send`, `recv`, `ack_until` |
|
||||
| `meta` | `get_agent_meta` (`set_status` is always-on, see below) |
|
||||
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` |
|
||||
| `execution` | the `bash` MCP server (`mcp__bash__run`/`status`/`kill`), rendered into the agent's MCP config only with it; a subagent gets Claude's own `Bash` instead. See `docs/tools/bash.md`. |
|
||||
| `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` |
|
||||
| `manage_root_agent` | every agent's state (rw) bind-mounted into the holder's container, plus `/meta` (ro) |
|
||||
| `logs` | the log store: `swarm-logs` queries. Checked by `swarm-logs-auth` at the gateway, not by a tool list. Off by default. |
|
||||
| `metrics` | the metrics store: `swarm-metrics` queries. Checked by `swarm-metrics-agent-auth`, the same binary, for the metrics gateway. Off by default. |
|
||||
|
||||
**Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`,
|
||||
`compact`, and `mark_todos_done` to every agent regardless of its
|
||||
permissions. The operator dashboard depends on every agent being able to
|
||||
report its status chip, and the server-side `SetStatus` handler has no
|
||||
permission check (only length validation), so gating it would only desync
|
||||
the `--allowedTools` list from what the host actually accepts. Revoking
|
||||
`meta` therefore drops `get_agent_meta` but never `set_status`.
|
||||
`mark_todos_done` is here because producers push todos to an agent whether
|
||||
or not it holds `inbox`, and an agent without it still needs to clear them.
|
||||
|
||||
**Where they live** — in the swarm, not on the hive: the `permissions` field of
|
||||
the agent's key in the `agent-permissions` KV bucket
|
||||
(`swarm-wire/src/agent_permissions.rs`), which swarm-controller alone
|
||||
writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
|
||||
(`/api/agents/{name}/permissions`), or on the swarm UI's create-agent screen
|
||||
(`permissions` in `POST /api/agents`). A missing key or an unset field means
|
||||
the default (`Permission::DEFAULT`): `messaging`, `meta`, `inbox`,
|
||||
`execution`, for every agent whatever its name. `"permissions": []` grants
|
||||
none. swarm-controller logs every refused `PUT` or `DELETE` on that route at `warn` as
|
||||
`agent permissions: request refused`, with the fields `op`, `agent`, `status` and `detail`.
|
||||
|
||||
**`logs` and `metrics`** — off for every agent until granted, and separate
|
||||
because logs are the more sensitive. Grant one with
|
||||
`swarmctl agent permissions set <agent>` (listing it with the others) or the
|
||||
create-agent screen; the setup assistant creates ruth with both. An agent
|
||||
without it gets a 403 from the store's gateway when `swarm-logs` or
|
||||
`swarm-metrics` queries; a bad secret is still a 401.
|
||||
|
||||
An agent decodes its own record, in its harness
|
||||
(`hive-agent/src/swarm_permissions.rs`, through `swarm_queue_client::agent_permissions::read`),
|
||||
and a build that doesn't know a name would drop to no permissions. The controller
|
||||
therefore refuses a change that adds `logs` or `metrics` to an existing
|
||||
agent, with a 409, unless both the agent's hive (`tolerant-permissions` in its
|
||||
status snapshot) and its running harness (the same entry in its turn-state
|
||||
header, `swarm_wire::hive_status`) advertise it. A harness not yet updated, a
|
||||
stopped agent and one that hasn't sent its first header (up to a minute after
|
||||
a restart) all refuse; keeping a grant it already holds needs no report. A
|
||||
create has no harness yet and checks the hive only.
|
||||
|
||||
The controller mirrors the two grants to the store leaf
|
||||
`swarm/agents/<agent>/observability` (`swarm_secret_client::observability`),
|
||||
which the gateways read, and the gateway denies when it can't read that leaf.
|
||||
A change that drops a grant writes the leaf first and the record second, so a
|
||||
failed write changes nothing; a change that adds one writes the record first,
|
||||
so a failed write leaves the gateway denying. Every five minutes the controller
|
||||
also makes each agent's leaf equal its record and logs a warning for each leaf
|
||||
it corrects. A revoked grant stops working within `authVerdictTtl` (60
|
||||
seconds by default) of the agent's last allowed query.
|
||||
|
||||
**Internet flag** — the record also carries `unrestricted_internet`, a
|
||||
boolean that isn't a `Permission`. An absent value means on, so every agent
|
||||
that existed before the field keeps the open access it had. `POST /api/agents`
|
||||
writes `false` for an agent that has no record, unless the request names a
|
||||
value. Over an existing record (a re-create) it keeps the stored list and flag,
|
||||
an absent flag included, and a value in the request wins. A `PUT` that leaves
|
||||
the field out and a `DELETE` (`swarmctl agent permissions clear`) also keep the
|
||||
stored value; a `DELETE` on an agent that has one rewrites the record to just
|
||||
that value. Read it with
|
||||
`swarm_queue_client::agent_permissions::unrestricted`. The hive reads it for
|
||||
the agent's squid policy: [the egress proxy](../networking/network.md#egress-proxy-squid)
|
||||
lets an unrestricted agent's credential reach any destination, and holds the
|
||||
others to the base list and the allow-list. An agent sends its credential once it has fetched it, from its next start.
|
||||
|
||||
**Log mode** — `internet_log` is a second boolean, meaningful only beside an
|
||||
explicit `unrestricted_internet: false`; read the pair with
|
||||
`swarm_queue_client::agent_permissions::internet_mode`. A `PUT` that sets
|
||||
`unrestricted_internet` without `internet_log` turns it off, the route refuses
|
||||
`internet_log: true` unless the stored or requested flag is `false`, and a
|
||||
`DELETE` keeps it. A hive that doesn't read the field sees `false` and enforces
|
||||
the list. The proxy's handling is under _Log mode_ in [the egress proxy](../networking/network.md#egress-proxy-squid).
|
||||
|
||||
**How a change applies** —
|
||||
- The agent's harness reads its own key every 10 seconds and builds every
|
||||
turn's `--tools`, `--allowedTools` and MCP config from the last value, so a
|
||||
change reaches the next turn. An ACP agent's permission check reads it on
|
||||
each request.
|
||||
- The subagent daemon reads the key on each `start`/`continue`; a running
|
||||
subagent keeps its tools.
|
||||
- hive-c0re reads each of its agents' keys every 30 seconds
|
||||
(`workers/swarm_permissions.rs`) into
|
||||
`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo.
|
||||
nspawn fixes the bind mounts at container start, so when an agent's
|
||||
`manage_root_agent` changes hive-c0re restarts it after its current turn.
|
||||
- Until its first read succeeds, an agent holds no permissions: no
|
||||
`execution` (so no `bash`), only the always-on tools. A subagent it starts
|
||||
holds the same. After a read, an unset key means the default. A failed read
|
||||
keeps the last value read, so an unreachable queue at start keeps the agent
|
||||
closed until a read succeeds.
|
||||
|
||||
Every reader keeps its last good value on a failed read.
|
||||
|
||||
**Moving off the meta repo** — on its first boot with this code a hive copies
|
||||
`meta/capabilities.json` and `meta/tool-groups.json` into the stored value,
|
||||
sends them to swarm-controller once as one list per agent (without
|
||||
`lifecycle` and `forge`, which gave no tools; the controller writes a key
|
||||
only where there is none, and only for agents that hive's wanted state
|
||||
declares), and deletes
|
||||
both files from the meta repo when the controller answers
|
||||
(`workers/permissions_import.rs`). Until then hive-c0re reads no keys. An
|
||||
agent reads a key the controller hasn't yet written as unset, the default,
|
||||
so an agent whose meta grant differs from the default holds the default
|
||||
until the import lands.
|
||||
|
||||
**Not configurable from `agent.nix`** — that file changes through config PRs
|
||||
an agent can open, so letting it declare its own permissions would let an
|
||||
agent grant itself any tool by submitting a config commit, bypassing the
|
||||
operator gate.
|
||||
|
||||
**Adding to the set** — add a variant to `Permission` in
|
||||
`hive-types/src/permission.rs` and to `Permission::ALL`, and enforce it where
|
||||
it applies. A new `#[tool]` fn on `AgentServer` (`hive-agent-mcp/src/mcp/mod.rs`)
|
||||
goes into the matching `Permission::tools()` slice: that's the single source
|
||||
of truth `mcp_config::allowed_mcp_tools` (`hive-agent/src/mcp_config.rs`)
|
||||
reads each turn.
|
||||
|
||||
## Async forms
|
||||
|
||||
Dashboard + per-agent mutating forms carry `data-async`; the shared
|
||||
`bindAsyncForms` `submit` listener (`frontend/packages/shared/src/forms.js`,
|
||||
imported as `@hive/shared/forms.js` and wired up from `tabs.js` on the
|
||||
dashboard and `app.js` on the per-agent UI) intercepts, shows a spinner,
|
||||
POSTs `application/x-www-form-urlencoded` (axum's `Form` extractor
|
||||
rejects multipart), calls `refreshState()` on success. New mutating
|
||||
forms should add `data-async` and optionally `data-confirm` (for a
|
||||
JS-side `confirm()` prompt).
|
||||
|
||||
`refreshState` defers automatically when `document.activeElement`
|
||||
sits inside a managed section so the operator's typing isn't lost;
|
||||
collapsible `<details data-restore-key=…>` survive the re-render
|
||||
via `snapshotOpenDetails` / `restoreOpenDetails`.
|
||||
|
||||
## `rebuild` is the reconcile verb
|
||||
|
||||
`job_queue::templates::rebuild` builds the DAG that reconciles a
|
||||
container to its wanted state: it folds `write_dropins` (the nspawn-conf
|
||||
rewrite — `PRIVATE_NETWORK=1`, `HOST_ADDRESS` = the bridge gateway IP,
|
||||
sets `EXTRA_NSPAWN_FLAGS` — plus the systemd resource-limits drop-in)
|
||||
into the `Swap` node, then runs `nixos-container update` + stop +
|
||||
start across the `StopForUpdate → Swap → RebuildBookkeeping`
|
||||
brace and the tail `Reconcile` node. `flake.nix` itself lives in the agent's
|
||||
`agent-configs/<name>` repo and rides along with its pinned commit (see
|
||||
`docs/agent-lifecycle/config-changes.md::Deploy markers per agent`).
|
||||
|
||||
Anything that changes per-container state on the host should be
|
||||
re-applied here so a manual `↻ R3BU1LD` from the dashboard is
|
||||
sufficient to recover.
|
||||
|
||||
## Actions live in one place
|
||||
|
||||
`destroy` (and the lifecycle helper) live in
|
||||
`actions.rs` / `hive-c0re/src/dashboard/`. The admin socket and the dashboard
|
||||
POST handlers both call into them so the two surfaces never drift.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Short, lowercase, no `Co-Authored-By` trailer. Imperative mood, no
|
||||
period. Body explains *why* when the reasoning isn't self-evident;
|
||||
otherwise the subject alone is fine. Wrap at ~72 cols.
|
||||
|
||||
## Commit before test
|
||||
|
||||
Stage and commit when work *looks* ready, then run validation
|
||||
(`cargo check`, `nix flake check`, real deploy). Failures get a
|
||||
follow-up commit rather than an amend. The commit history is the
|
||||
work log; rewriting it loses signal.
|
||||
|
||||
## Building & local checks
|
||||
|
||||
Build through the **flake devshell**, not a bare toolchain — agent
|
||||
containers ship no global rust. `nix develop -c <cmd>` runs one
|
||||
command inside the project-pinned env (cargo/clippy/rustfmt plus the
|
||||
C compiler + `libsqlite3`/`ring` link deps); outside it a bare
|
||||
`cargo build` fails with `failed to find tool "cc"` / `cannot find
|
||||
-lsqlite3`. One command per invocation — agents run each task as a
|
||||
fresh non-interactive process, so there's no persistent shell to
|
||||
reuse.
|
||||
|
||||
```sh
|
||||
nix develop -c cargo clippy --all-targets -- -D warnings
|
||||
nix develop -c cargo test
|
||||
nix fmt # treefmt — authoritative, NOT bare cargo fmt
|
||||
```
|
||||
|
||||
`nix fmt` (treefmt) is the formatter CI gates on; bare `cargo fmt`
|
||||
misses the non-rust files treefmt also covers, so always run `nix
|
||||
fmt` before pushing.
|
||||
|
||||
**Clippy discipline — never add `#[allow(clippy::…)]`.** All lints are
|
||||
CI-fatal at `-D warnings` (pedantic included); every warning that fires
|
||||
must be *fixed*, not silenced. Common patterns:
|
||||
|
||||
- `too_many_lines` — extract a helper function or a sub-struct
|
||||
(the `TurnAccum` extraction in `stats.rs` is a worked example).
|
||||
- `doc_markdown` (brand name without backticks in a doc comment) — add
|
||||
backticks: `` `DOMPurify` `` instead of `DOMPurify`.
|
||||
- `must_use` / `unused_results` — actually handle or explicitly discard
|
||||
the return value (`let _ = …` is fine when intentional).
|
||||
|
||||
If a lint seems wrong for a specific call site, file an issue and ask
|
||||
mara — don't add `#[allow]` speculatively. The gate is intentional.
|
||||
|
||||
**The devshell checks aren't the full `nix flake check`.** Clippy /
|
||||
fmt / `cargo test` cover most gates, but `nix flake check` runs extra
|
||||
check derivations they don't:
|
||||
|
||||
- **`hivectl-docs`** regenerates `docs/tools/hivectl-cli.md` from
|
||||
hivectl's clap tree and **fails if the committed copy is stale**.
|
||||
**After any change to a hivectl verb or flag, regenerate it**:
|
||||
```sh
|
||||
nix develop -c cargo run --bin hivectl -- markdown-docs > docs/tools/hivectl-cli.md
|
||||
```
|
||||
clippy / fmt / `cargo test` all pass *without* this — only the
|
||||
flake check catches the drift, and `ci-log` often can't show you
|
||||
why (it 500s on a fast failure), so you're left guessing "builder
|
||||
flake" when it's a stale doc.
|
||||
- **`swarmctl-docs`** is the same check for `swarmctl` /
|
||||
`docs/tools/swarmctl-cli.md`:
|
||||
```sh
|
||||
nix develop -c cargo run --bin swarmctl -- markdown-docs > docs/tools/swarmctl-cli.md
|
||||
```
|
||||
- there's also a flake `cargo-test` check and NixOS module
|
||||
evaluation in the set.
|
||||
|
||||
When local clippy/fmt/test pass but CI's `nix flake check` fails,
|
||||
**don't assume a transient builder problem** — reproduce the real
|
||||
gate locally: `nix flake check` (shares the build farm, use
|
||||
sparingly) or build just the suspect check, for example `nix build
|
||||
.#checks.x86_64-linux.hivectl-docs`.
|
||||
|
||||
## Testing seams and test doubles
|
||||
|
||||
- **A new test seam is a trait, used generically** (`impl Seam` or a type
|
||||
parameter). No `dyn Fn` closure bags: a trait names each question the code
|
||||
asks and gives the test one place to answer it.
|
||||
- **A double that only answers and records uses `mockall`** (a
|
||||
dev-dependency): put `#[cfg_attr(test, mockall::automock)]` on the trait,
|
||||
set canned replies with `returning`, and assert calls with `times` and
|
||||
`withf`. The attribute keeps `mockall` out of release builds. Examples:
|
||||
`Probes` in `hive-priv/src/storage.rs`, `Publisher` and `Tokens` in
|
||||
`swarm-builder/src/main.rs`.
|
||||
- **`automock` can't mock a trait with a supertrait** such as `Clone`: a mock
|
||||
doesn't implement it. Declare the mock with `mockall::mock!` as a plain
|
||||
struct and implement the trait for `Arc<Mock…>`, as `swarm-builder` does.
|
||||
Better, leave the supertrait off the trait and bound the callers.
|
||||
- **Stateful fakes stay hand-written**: in-memory stores whose reads see earlier
|
||||
writes. Real-process stubs such as `ScriptSpawn` also stay, since they run a
|
||||
real `sh` on purpose.
|
||||
- **Nothing migrates in one sweep.** Move an existing expectation-style fake
|
||||
to `mockall` when you touch its file for another reason.
|
||||
|
||||
## Best-effort oneshot services
|
||||
|
||||
The harness ships a family of one-shot systemd services that
|
||||
configure agent-side surfaces from values delivered to the agent at
|
||||
provisioning time:
|
||||
|
||||
- `forge-avatar-sync` — uploads `services.hyperhive.agent.icon` SVG to the
|
||||
agent's Forgejo profile, so the icon shows up on commits / PRs /
|
||||
issue comments.
|
||||
|
||||
(The matrix profile avatar is **not** a oneshot — `hive-matrix-daemon`
|
||||
sets it over its live authenticated Client; see
|
||||
`docs/agent-lifecycle/persistence.md::matrix avatar`.)
|
||||
|
||||
Shape contract — every one of these:
|
||||
|
||||
1. **Always `exit 0`**, even on internal failure. A non-zero exit
|
||||
would mark the unit `failed`, which in turn aborts
|
||||
`nixos-container update` and blocks rebuilds. The agent's
|
||||
capability surface isn't allowed to gate the container
|
||||
build.
|
||||
2. **No `set -e`** in the script body. Subshell failures must not
|
||||
propagate. Use `... || true` on every external call that can
|
||||
fail (forge unreachable, missing icon, parse error, etc.)
|
||||
3. **Skip silently when prerequisites are missing**: no token
|
||||
file, no icon, no reachable upstream → `echo` a short skip
|
||||
line + `exit 0`. The next boot tries again.
|
||||
4. **Wired to `multi-user.target`** so they run on every boot
|
||||
(lets a rotated token / new icon take effect without
|
||||
`systemctl restart` gymnastics).
|
||||
5. **Re-runnable**: a second invocation produces the same final
|
||||
state (idempotent uploads, idempotent config rewrites). Used
|
||||
by the `.path` watchers that re-fire on token appearance (see
|
||||
`docs/agent-lifecycle/persistence.md::Matrix per-agent daemon`).
|
||||
|
||||
The service stays root-owned so the bootstrap ordering doesn't need a
|
||||
user-existence check before each fire.
|
||||
|
||||
This pattern keeps the rebuild path resilient: any failure inside
|
||||
these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The
|
||||
operator notices through `journalctl -u <unit>` rather than a
|
||||
broken switch-to-configuration.
|
||||
Loading…
Reference in a new issue