Commit graph

3,754 commits

Author SHA1 Message Date
iris
328ba19595 swarm-ui: fix nav underline gap argus caught in round 2
The previous fix put both the touch-target min-height and the
active-state border-bottom on the same <a>: centering the text
within a 2.75em box pushed the border ~0.7em away from it, a real
visible regression argus caught by actually rendering the CSS rather
than reasoning about the box model abstractly.

Split the two concerns onto two elements: the <a> (.shell-nav-link)
owns the full-height tappable box, an inner <span>
(.shell-nav-link-text) wrapping just the label owns the underline,
so the indicator stays directly under the text regardless of the
tappable box's height.

Verified: tsc clean, build succeeds, screenshotted close-up (600x100)
and at 320px — underline sits flush under the text in both, no gap.
2026-08-18 22:44:24 +02:00
iris
ea7cc03fd7 swarm-ui: fix .shell-nav-link touch target argus caught
The proposed scope on this issue named three things to give the
2.75em touch-target floor: nav links, buttons, and the links-menu
trigger. Only the links-menu trigger actually got it in the first
pass — the primary nav (hives/new agent/jobs/components), the
highest-traffic touch target in the whole shell, was still sitting at
padding: 0.25em 0 (~27px tall).

display: flex + min-height rather than more padding: padding alone
would have pushed the active-state border-bottom away from the text
to reach 2.75em; flex centres the text within the full-height box so
the indicator still sits directly under it.

Verified: build succeeds, screenshotted at desktop width and 320px —
active underline still sits correctly under the text at the taller
height, wrap behavior from the earlier commit unaffected.
2026-08-18 22:44:24 +02:00
iris
7c15cd46d2 swarm-ui: don't break at a narrow viewport
Closes #3447.

- Shell.css: .shell-header and .shell-nav wrap onto a second line
  below a breakpoint instead of forcing horizontal overflow — no
  hamburger/collapse menu, per mara's own 'don't break, don't
  over-invest' scoping.
- LinksMenu.css: bumped .links-menu-button and .links-menu-item to the
  same 2.75em (~44px, WCAG 2.5.5) touch-target floor the shared ui/
  kit's form controls and buttons already carry — this trigger is
  bespoke, not built from that kit, but the floor applies regardless.
- Table.tsx/.css: wrapped the table in a .ui-table-scroll container
  (overflow-x: auto) so a wide table scrolls within its own box on a
  narrow viewport instead of forcing the whole page to overflow
  horizontally — not named in the issue's two concrete spots, but the
  same 'should not break' failure mode, and fixing it at the shared
  Table primitive covers every future caller.

.create-agent-input's fixed width -> max-width was already fixed as
part of #3448's form kit.

Verified: tsc clean, build succeeds, screenshotted at 320px (hives
nav wraps, table scrolls internally, page itself doesn't overflow)
and 480px (create-agent form fields fit).
2026-08-18 22:44:24 +02:00
atlas
5e6f26f209 fix(#3471): keep Metrics Drilldown, which declarativePlugins had silently removed
Setting declarativePlugins to any list -- the empty one included -- also
sets upstream's plugins.preinstall_disabled, which nixpkgs defaults to
declarativePlugins != null. Preinstall is what fetches the Drilldown apps
on first start, so claiming the plugin directory removed them without
touching anything a person had installed.

The comment above that line reasoned about the plugin path and the update
check and never about preinstall, and its closing claim -- nothing is
taken over on a hive with no plugins -- was true only of plugins a PERSON
installs. Upstream installs some itself, and those are the ones that went
missing.

Listing it as a package is better than the preinstall it replaces: store
pinned, no runtime fetch from grafana.com, survives a state reset. Its
Logs/Traces/Profiles siblings stay off -- they front Loki, Tempo and
Pyroscope, none of which this swarm runs.
2026-08-18 22:40:59 +02:00
atlas
72b2a0357b docs(otel): stop describing the removed credential path in present tense
environment.nix explains why HYPERHIVE_OTEL_HEADERS_CREDENTIAL is not
emitted by naming the machinery that used to consume it -- machinery this
PR deletes. Left as written it would describe a removed nspawn credential
and a removed oneshot as if both still ran, in the same paragraph that
justifies withholding the variable.

That is this PR's own defect inverted: it exists so an auditor asking
'can an agent obtain the OTEL token?' finds nothing misleading, and a
comment claiming live delivery machinery is exactly the kind of thing
that costs an auditor a reconstruction.

Keeps the rule rather than the history -- one holder, on the host -- since
that is the part still worth reading.
2026-08-18 22:23:35 +02:00
atlas
fbeff69fd7 fix(otel): stop delivering the hive's upstream token to agents
The host-side collector is the only path telemetry leaves a hive, so
HYPERHIVE_OTEL_HEADERS_CREDENTIAL is never emitted and everything
downstream of it is unreachable. What made it worth removing rather than
leaving inert is what it looked like to a reader: a complete,
well-commented mechanism for writing the hive's upstream credential into
a file the agent can read, described in the present tense. Anyone auditing
"can an agent obtain the OTEL token?" had to reconstruct the whole env-var
chain to find out the answer is no.

Gone: the per-agent `hyperhive.otel.headersCredential` option, the
`hive-otel-header` oneshot that merged OTEL_EXPORTER_OTLP_HEADERS into the
agent's own settings.json, and meta.rs's field, env read and render.

⚠️ Scoped by NAMESPACE, not by name. `hyperhive.otel.headersCredential`
(per-agent) and `services.hyperhive.otel.headersCredential` (host) are
different options sharing a leaf name — the host one is read by
`stats/otel_metrics.rs` for c0re's own container-resource exporter and
stays. Sweeping the string would have taken out working code.

The comment above `otelSettingsEnv` now states the property rather than
the absence: there is no auth header and no mechanism to add one, because
an agent exports to the hive's own collector and nothing an agent can read
is a secret to the swarm. The old behaviour is named in the past tense so
it reads as removed rather than overlooked.

meta.rs's assertions that pinned the injection are deleted rather than
adjusted; the surrounding test keeps covering extraResourceAttributes and
the endpoint/protocol injection, which are live.
2026-08-18 22:23:35 +02:00
iris
a5ef31ed8b swarm-ui: self-updating RelativeTime component
Closes #3445.

New ui/relative-time/RelativeTime — takes a UTC epoch-ms instant, not
a precomputed age, and re-renders itself on a 1s interval so a tab
left open doesn't silently show a frozen 'fresh (5s ago)' hours
later. Pauses while the document is hidden, resyncs immediately on
becoming visible again.

StatusChip's label widened from string to ComponentChildren so a
chip can embed the live ticker (a plain string couldn't carry it).
HivesPage's freshness column now derives the age from last_seen_unix
client-side via RelativeTime instead of rendering the once-computed
age_seconds from the API response.

Verified: tsc clean, build succeeds, screenshotted the components
page (ticking observed advancing within one virtual-time-budget
window) and the hives page (fresh/stale/never-reported all render
correctly).
2026-08-18 22:12:52 +02:00
damocles
8ffc22eaea fix hint text per argus review: group into subdirs, not split into more top-level files
argus caught that the previous wording ("splitting a long-lived file
into dated pieces") suggested a remedy that increases top-level entry
count unless the pieces land in a subdirectory - wrong advice for the
exact metric this watch counts.

Also added mara's ask: explicitly note the agent can leave the todo
open and act on it later, no pressure to resolve immediately.
2026-08-18 22:07:05 +02:00
damocles
301a576feb hive-agent: no-pressure hint when state dir top level hits 30+ entries
New state_entry_watch.rs, mirroring disk_watch.rs's shape exactly:
periodic in-process probe, Todos::upsert with a stable count-bucketed
summary (anti-nag - drifting inside one bucket stays silent, crossing
a bucket speaks up again), clears once back under threshold.

Top-level entry count only, deliberately - a large subdirectory (git
clone, build tree) counts as one entry regardless of what's inside it,
which is disk_watch's problem to catch on its own axis (bytes), not
this one's.

Wired into main.rs's spawn_todo_socket alongside disk_watch::run.

closes #3464
2026-08-18 22:07:05 +02:00
atlas
41f0d7e036 fix(#3462): apply the name check in the unit that runs on the deploy
hive-tls-ca re-signs at service activation -- the rebuild itself --
while hive-tls-resign only fires from a weekly timer. The previous commit
put the coverage check in the timer unit, so a corrected serviceDomains
would not have taken effect until up to a week after the deploy that
changed it. Same bug one level along: found a trigger, not the trigger.

Two guards now share one definition rather than each carrying their own,
because a rule enforced in one and not the other is worse than one
enforced in neither -- it looks fixed and only fires on whichever path
you did not take.

Also widens hive-tls-ca's condition to the services leaf. Both leaves are
signed inside that block but only the hive leaf gated it, so a fresh
gateway.pem suppressed the re-signing of a swarm-services.pem that was
missing or stale.
2026-08-18 21:54:38 +02:00
atlas
2d2f16406f fix(#3462): re-sign a leaf when it stops covering the configured names
Expiry was the only re-sign trigger, so a leaf signed when the name set
was smaller stayed valid -- and wrong -- for its whole lifetime. Adding a
service to swarm.serviceDomains reissues the sub-CA (its own .names
reconciliation) but nothing regenerated the leaf nginx actually serves,
which left the previous commit's config change unable to fix anything on
a hive whose gateway leaf was not near expiry.

covers() reads the DNS names back out of the certificate rather than a
sidecar file: the pem is what nginx serves, and a bookkeeping file drifts
from it the moment a leaf is replaced by hand. Applied to both leaves --
the hive leaf has the same defect if the hive domain ever changes.
2026-08-18 21:54:38 +02:00
atlas
2dea5798e4 fix(#3462): the swarm-services leaf never covered grafana, metrics or otel
swarm.serviceDomains is what gateway.lib.tlsFor consults to pick the
services leaf over the hive leaf. grafana, victoriametrics and otel each
claim a gateway name under the swarm apex but were absent from that list,
so their vhosts were served the HIVE certificate -- which cannot cover a
name under a different apex.

Invisible until a machine client hit it: a name mismatch is a
click-through warning in a browser and an outright refusal in an OTLP
exporter. The metrics UI and store looked healthy while the collector
failed every POST and dropped the samples.

  tls: failed to verify certificate: x509: certificate is valid for
  probe.example, *.probe.example, not otel.swarm.example
2026-08-18 21:54:38 +02:00
iris
537d6f9dac swarm-ui: rename OverviewPage/'overview' to HivesPage/'hives'
Closes #3460.

Renamed for accuracy — the page/nav item is the hive roster, not a
general dashboard overview. Nav label, page title, component name,
and file all renamed together so the internal name doesn't drift from
what's displayed (a component still called OverviewPage under a
'hives' nav label would be exactly the kind of stale prior-art that
makes the next contributor search harder, not less).
2026-08-18 21:26:52 +02:00
iris
02aa866e5b fix(#3458): add missing swarm_name field to test AppState initializer
3643eccf added swarm_name: Option<Arc<str>> to AppState and updated
the production initializer, but not state_with_roster()'s test-only
one — main's --all-targets/test build has been broken since. None,
matching atlas's proposed shape: the roster/links tests this helper
backs don't read the display name, and the loading path already has
its own coverage (load_swarm_name_covers_missing_and_set).

Closes #3458.
2026-08-18 21:24:48 +02:00
iris
f0c362b688 docs: clarify dynamic colour derivation still traces to base16
Per mara's follow-up: the doc shouldn't read as 'no dynamic colour at
all' — mixing/lighter/darker derivation is fine and matches Material's
own approach, the constraint is that every derived colour still
traces back to the base16/stylix contract, not an independent source.
2026-08-18 21:21:11 +02:00
iris
61b7bb4b4d docs: rework design guide per mara's review
Addresses all 7 line comments from her REQUEST_CHANGES review:
- drop the issue-#3444 history pointer and any issue-number tracking
  refs throughout (a design guide states expectations, it isn't a
  change log or a status report)
- stop naming specific rejected technical solutions (SSE/WS) for a
  design constraint — state the chosen shape only
- flip the component-first heuristic: build the primitive first/
  alongside its first real caller, not after — the point is giving the
  next thing built ready-made blocks, not lagging behind usage
- drop the 'Open questions' section entirely — that's what the issue
  thread is for, not a doc
- stop leading Visual language with 'basis: Material Design' and then
  immediately carving out big exceptions — lead with what we actually
  want, mention Material as a minor closing influence instead
- drop 'future work'/timeline framing everywhere (motion override,
  PWA-as-future-slice, 'not shipped yet' theming caveats) — state the
  target design as the expectation, not its current build status
- reworded the stylix-wins theming bullet to drop 'build time', which
  reads wrong from a frontend dev's perspective (stylix supplies the
  palette separately, it isn't decided by the frontend's own build)
2026-08-18 21:21:11 +02:00
iris
a5864c0fde docs: fix design-guide accuracy nits argus flagged
- matrix-rain reference is in packages/dashboard/src/home.js, not
  swarm-ui — cite it correctly (dashboard package) rather than implying
  it lives inside this doc's own scope.
- drop FormField from the visible component-first inventory (it has no
  /components demo, deliberately — a real exception to the 'every new
  ui/ component gets a demo' rule stated two paragraphs later) and note
  the exception explicitly instead of leaving the contradiction.
2026-08-18 21:21:11 +02:00
iris
7dc3e710b4 docs: swarm-ui design guide
Distills the design-language discussion on #3444 into a durable
reference: visual language, motion, theming policy, data-freshness/
refresh, error UX, empty states, layout/viewport, component-first
design, and attention — plus an open-questions table pointing at the
still-unbuilt issues (#3452/#3453/#3454/#3456) instead of asserting
them as settled.

Mirrors css-vars.md's shape and links to it + to /components rather
than duplicating either. First draft for comment, not a final spec.
2026-08-18 21:21:11 +02:00
atlas
d2fb4bff79 feat(#3125): reshape the hive-to-swarm OTEL hop by domain
Drops swarm.otel.url (a loopback default an operator had to override on a
split host) in favor of swarm.otel.domain -- the same
gateway.localNames + nginx-vhost-through-the-gateway shape every other
swarm service (authelia, grafana, victoriametrics, ui) already uses. The
hive tier's exporter now reaches it as https://<domain> unconditionally,
resolved locally by dnsmasq on a co-located host and over the real network
otherwise, instead of a config knob nobody sets until they hit the silent
drop.

Costs CA trust on the hive tier: otel.nix wires
lib/hive-ca-trust.nix's trustBundle with hostUnit = true on the
opentelemetry-collector host unit, the same flag #3441/#3442 added for
swarm-controller and hive-c0re.

mara, #3125 comment 58363: "go c".
2026-08-18 21:02:18 +02:00
atlas
28623e5eff fix(#3125): the swarm collector writes its own resolver, like its siblings
All four sibling swarm containers import swarm-container-resolver.nix;
this one did not. It matters more here than most: otel.endpoint is an
operator-configured external hostname, and reaching it is the entire
reason this container holds a credential.

Also aligns two details with those siblings - the enable default is
asserted from swarm-required-services.nix with the metrics pair it
feeds, so that file remains the one place a service host is declared,
and machine is readOnly since its description already calls it a fact
rather than a knob.
2026-08-18 21:02:18 +02:00
atlas
98ab0ad59f style: drop tracker tags from the collector modules' comments
The pre-push lint refuses them, and rightly: a comment that names an
issue number ages into a pointer at a closed thread. The constraint each
one carried is stated directly instead.
2026-08-18 21:02:18 +02:00
atlas
636dba8d61 fix(#3125): the swarm tier's self-metrics must not fight the hive tier's
A collector serves its own metrics on localhost:8888 unless told
otherwise, and co-located tiers share a network namespace, so the second
one to start dies with 'bind: address already in use'.

The port appears in neither config - it is a default inside the binary -
so comparing the ports the configs name reports them distinct. A
behavioural probe found it by being unable to start the chain.

metrics.address is the spelling that looks right and is rejected by this
version ('migration.MetricsConfigV030' has invalid keys: address);
readers is the schema it accepts.
2026-08-18 21:02:18 +02:00
atlas
80c9118f87 docs(#3125): the collector pair, and what an operator sets on which host
observability.md described a single collector holding the upstream
credential. It also said endpoint and protocol are what agents are
handed; agents get the derived first hop, which has been true since the
collector was introduced.

The swarm tier is documented beside its sibling swarm services rather
than here, and the one line an operator must not miss - swarm.otel.url
on a hive that does not run them - is called out in both places, since
leaving it unset loses telemetry silently.
2026-08-18 21:02:18 +02:00
atlas
094a54e785 feat(#3125): the hive tier forwards to the swarm tier and holds nothing
The hive collector's only exporter becomes the swarm's collector, and
the upstream credential, the metrics-store exporter and the choice of
destination all move one tier up.

Its assertion goes with them: 'endpoint or a local store' was the right
rule while this tier picked the destination, and is the wrong one now.
A hive that runs no swarm services has neither, forwards to a swarm
collector elsewhere, and is correctly configured — the rule that
replaces it lives in swarm-otel.nix, where the destinations are.

The option descriptions here described a topology with one collector in
it: endpoint and protocol are not what agents are handed (they get the
derived first hop, see hive-c0re/environment.nix), and the credential is
not read by this tier.
2026-08-18 21:02:18 +02:00
atlas
f61c927310 feat(#3125): a swarm-tier OTEL collector, in its own container
The swarm tier is the only holder of the upstream credential, the only
writer to the swarm's metrics store, and (once #3283 lands) the place
that stamps hive= from the authenticated connection rather than from
anything a sender can choose. Today one collector does both tiers' jobs,
which works only because they land on one box.

A container rather than a second host unit, for two reasons that agree:
every sibling swarm service is one, and `services.opentelemetry-collector`
is a singleton NixOS option already spoken for on the host by the hive
tier. A container gets its own evaluation and therefore its own
collector.

Port defaults to 4319, deliberately not the OTLP default 4318 the hive
tier uses: swarm containers share the host netns, and two listeners
claiming one port is not a build failure but a runtime coin toss with
nothing in any log saying so -- the same collision grafana and the forge
hit on 3000.

`url` is an option with a co-located default rather than a loopback
literal in the exporter, so a split-host deployment is a config change
instead of a code change.

Wires nothing yet: the hive tier still exports directly, and switching it
over is the next commit.
2026-08-18 21:02:18 +02:00
iris
5fdbf0b452 swarm-ui: add required hive dropdown to CreateAgentPage
Closes #3434.

Agent creation had no way to record which hive an agent runs on.
POST /api/agents now requires a hive, so the roster fetched off
GET /api/hives backs a required SelectField here rather than a
free-text field. Single-hive swarms auto-select their only hive;
multi-hive swarms show a disabled placeholder and force an explicit
choice.

Rebuilt on top of the #3448 form kit (merged after this branch was
originally opened): reuses TextField/SelectField/Button instead of
page-scoped input chrome, and SelectField gains an optional disabled
placeholder option (needed for the loading/empty/multi-hive states
here, generalizes cleanly for future callers). Form goes back to a
column layout per mara's earlier visual feedback on this same page
(two fields of different natural width no longer line up in a row).
2026-08-18 21:02:17 +02:00
atlas
6dffa74d90 refactor(#3434): drop the agent_repo identity function
mara, PR #3438 review: 'remove the identity function. agent names
are unique and all repos go into agent-configs namespace anyway'. Right --
repo==agent isn't a convention worth a name once every call site can just
say so; call create_repo/add_repo_member/seed_agent_config with &agent
directly.
2026-08-18 20:58:24 +02:00
atlas
4d526b8492 refactor(#3434): the job nodes carry the agent, not the repo too
Every repo in this graph is `agents/<agent>` -- the node payloads were
carrying the same string under two names, and `create_agent` opened with
a `let repo = agent.clone()` that said so out loud.

All four node kinds now carry `agent` alone, and `forge::agent_repo` is
the single home for the naming convention. The identity it returns is the
point: a caller holding an agent name never writes a repo name itself, so
changing the convention later is one edit rather than a search.

`forge::Client`'s methods keep taking a repo, because they are a general
forge client and `add_repo_member(repo, user)` is a real signature -- the
derivation belongs at the call site that knows the two are the same here,
not baked into an API that has no reason to assume it.

`data()`'s four arms are now identical and merged into one or-pattern.
Left as an explicit list rather than a catch-all so a fifth variant fails
to compile here instead of silently rendering as an agent name.
2026-08-18 20:58:24 +02:00
atlas
f2790ab360 fix(#3434): InitAgentConfigRepo does not need the hive name
Seeding a config repo is the same work whichever hive the agent is bound
for, and an agent's config states nothing about where it runs -- so the
node has no use for the address. It stays on the request, where it is
parsed and checked against the roster, and reaches its consumer when the
node that sends a deploy message exists.

That leaves this endpoint validating a field it carries no further, which
is the intended end state: the request shape is the breaking half, and
settling it once is cheaper for every caller than doing it twice.

`data()`'s two repo-and-agent arms now share a body. Merged rather than
kept apart -- `label()` is what distinguishes the nodes to a viewer.
2026-08-18 20:58:24 +02:00
atlas
8b55a8b9fd feat(#3434): agent creation takes the hive it is aimed at
`POST /api/agents` now requires `hive` alongside `name`. It is parsed as
an `Ident` like `name` already was, and then checked against the roster
loaded from `SWARM_CONTROLLER_HIVES` -- a hive that is not in this swarm
is a 400 naming the ones that are, rather than a typo accepted and
forgotten. The roster check is what makes the field worth having; without
it nothing notices until a deploy message is addressed to a hive that
does not exist.

`hive` is an address, not an attribute of the agent: it is where a deploy
message goes over the queue, so nothing writes it into the agent's config
repo. A config naming its own hive would be a second statement of where
the agent lives, free to drift from the queue that actually delivers to
it.

It rides on the `InitAgentConfigRepo` node payload because the graph is
the only thing carrying the operator's choice forward from the API
boundary; seeding does not consume it. The node that routes on it is the
deploy node in #3124.

The refusal is asserted by effect -- the test checks that *nothing was
queued*, not just the status code, since a version that queued the graph
and then complained would satisfy a status-only assertion while still
creating the agent.

This is a breaking change for every existing caller: the swarm-UI create
page posts `{name}` only and needs its hive dropdown to land alongside.
2026-08-18 20:58:24 +02:00
atlas
3efeffd95f fix(#3442): give hive-c0re hive-CA trust
hive-c0re is a host unit that wired no CA trust at all. Harmless today
-- every endpoint it is handed defaults to plain http or loopback, so
nothing verifies a certificate -- and latent for the split-host shape the
options invite: `swarm.matrix.apiUrl`'s own example is an https URL, and
pointing it or `statusPublish.natsUrl` at another hive's gateway means
verifying a leaf signed by a CA generated at runtime.

Unlike the other consumers of this helper, hive-c0re already makes a
public-TLS call -- the OTEL exporter reaches whatever `otel.endpoint`
names. `SSL_CERT_FILE` replaces the trust store rather than adding to it,
so the bundle being system CAs + hive CA is what keeps that path working;
narrowing it to the hive CA alone would fix a case nobody hits yet and
break one that runs today.
2026-08-18 20:34:44 +02:00
iris
ba873926fa swarm-ui: shared form-field kit (TextField, SelectField, Button)
Closes #3448.

New ui/ primitives: FormField (shared label+control wrapper),
TextField, SelectField, Button — each with a min-height touch target
(2.75em ~= 44px, WCAG 2.5.5) per mara's #3447 ask, and a max-width
instead of a fixed width so the control caps on desktop without
overflowing a narrow/touch viewport.

CreateAgentPage's name field + submit button now come from the kit
instead of page-scoped CSS; ComponentsPage gets a section for each new
primitive with an editable sample.
2026-08-18 20:29:11 +02:00
iris
3643eccf22 swarm-ui: show the swarm's name as the page title and top-left brand
New GET /api/swarm on swarm-controller, backed by
services.hyperhive.swarm.name (SWARM_CONTROLLER_NAME env var, same
optionalAttrs-gated-on-option-resolving shape queueEnv/forgeEnv/etc.
already use). swarm-ui's <Shell> fetches it once and sets both
document.title and the header's brand text; falls back to the
existing static "hyperhive swarm" label when the operator never set
a name or the fetch fails.

Extracted the swarm-queue connect block out of main() into its own
connect_status_reader() fn to keep main() under clippy's line-count
lint after adding the new field wiring — no behavior change, same
comments moved as-is.
2026-08-18 18:34:56 +02:00
atlas
d14963ad2b fix(#3391): give the swarm controller hive-CA trust
The controller's forge client speaks TLS to `https://<forge domain>`,
which the gateway serves with a leaf signed by the hive CA. That CA is
generated at runtime, so nothing build-time can name it and it is not in
the system store -- and `reqwest`/`rustls` resolves roots through
`rustls-native-certs`, whose `SSL_CERT_FILE` *replaces* the store rather
than adding to it. With no bundle wired, every forge call failed
`invalid peer certificate: UnknownIssuer` and agent creation died at its
first step.

`lib/hive-ca-trust.nix` already solved this, but only for containers: it
sources the CA through `/run/hive-ca/trust-bundle.pem`, a bind mount that
does not exist on the host. `hostUnit` makes it read the host copy and
wait on `hive-tls-ca.service` itself -- one flag driving both, because a
host source without that ordering is a race.

`enable` is the other half, and it is the sharp edge: a container caller
imports this into the container's module set, so it disappears with the
container. A host caller imports it at the host's top level, where
`imports` is unconditional -- without the flag, a hive with the
controller turned off would get a bundle oneshot and a `swarm-controller`
service conjured by `genAttrs`, holding an `SSL_CERT_FILE` and no
`ExecStart`.
2026-08-18 17:49:57 +02:00
damocles
8b83eca0c1 fix(#3435): hive-forge issue/pr show fail on forgejo's null-for-empty reactions
Forgejo returns a bare `null` body (not `[]`) for a reactions list when
nothing has reacted yet. issue_reactions/comment_reactions deserialized
straight into forgejo-api's typed Vec<Reaction>, which has no null
tolerance, so issue show / pr show / view failed on every item with zero
reactions - i.e. nearly everything.

Generalize pr_status's existing null_as_empty (same quirk, hit earlier
on combined-status statuses) into a shared helper in verbs/mod.rs, and
fetch reactions via the raw JSON path (Client::get_api_json) with a
NullableVec<T> wrapper instead of the typed client's bare Vec<Reaction>.

Also names the failing request in errors going forward, since
get_api_json's error context includes the URL - closes the gap the
issue itself flagged (the old error said what didn't parse but not
what was fetched).
2026-08-18 15:26:07 +02:00
damocles
fb9cc5635a hive-forge reaction: add --list-allowed to surface the instance's configured shortcodes 2026-08-18 13:53:27 +02:00
damocles
96a27cef3c docs: cover the reaction verb and comments --show-reactions 2026-08-18 13:53:27 +02:00
damocles
2c45a9960f hive-forge: show + post/remove emoji reactions on issues, PRs, and comments 2026-08-18 13:53:27 +02:00
atlas
eb8387bd73 docs(#3255): state the present, drop the changelog framing
mara: "pls remove historical wording, only present pls".

The correction was written as a diff against what the docs used to claim
-- "this used to say X", "where this is going", "the intended state for
now". That is a changelog, and a reader arriving cold has to reconstruct
the current truth from it. The reasoning about why the old shape was
wrong belongs in the PR that changed it, not in the file.

Now says what is true: the controller interprets a delivery and emits a
semantic message; receipt is all that is wired today because the
swarm->hive channel does not exist yet.
2026-08-18 12:35:27 +02:00
atlas
b172e67fbd docs(#3255): correct the rest of the relay-shaped wording
mara asked for the docs and comments to be fixed in one PR before the
implementation, since a half-corrected description is what confuses a
reader coming back after a context compact.

Three more places said or implied "forward the payload":

- post_webhook_forge's doc ("relays the delivery to every hive ... the
  payload is never parsed here")
- its OpenAPI request_body description ("the delivery can be relayed
  unmodified") -- this one is published in the spec, so it was the most
  externally visible of them
- DeliveryKind::as_str, which called the hook kind the event's routing
  key; the message is semantic, and which hook it arrived on is an input
  to deriving it rather than the thing sent

docs/swarm/README.md gains the direction next to the interim state, so
"two hooks, one of which only logs" reads as a step rather than a design.
2026-08-18 12:33:15 +02:00
atlas
0ce6e8410a docs(#3255): the controller is to interpret deliveries, not relay them
mara's ruling: the controller should not forward received events, it
should create specific messages from them ("knowledge repo changed",
"deploy agent foo to rev abc123") and send those to whoever needs them.
The module doc said the opposite as settled design.

The argument it made was wrong in a specific way worth keeping: it
counted this daemon as a *second* place deciding what a delivery means,
while assuming the hives stay interpreters. They do not -- the parsing
moves rather than duplicating, so the end state has one interpreter, not
two.

Doc-only. The behaviour is unchanged and still receipt-only; what changes
is that the next reader is not told the wrong direction.
2026-08-18 12:30:53 +02:00
atlas
834ea88420 docs(#3162): point the serverName pin section at the new rebuild warning
argus's optional note. Nothing in the doc was inaccurate, but an operator
who sees the activation-time warning has no thread back to the page that
explains why pinning matters -- and this section is the explanation.

States the one property they need from it: it never fails the rebuild, so
acting on it is theirs to do before the ids are minted.
2026-08-18 12:29:31 +02:00
atlas
b5a631c3a3 feat(#3162): warn when a hive with an existing homeserver has not pinned serverName
`serverName` is baked irrevocably into every user and room id, so a hive
that rebuilds onto a new default is a *different homeserver*, not a
renamed one: existing accounts and rooms are stranded, and reverting the
config does not undo it. Its neighbours (`gatewayHost`, the forge domain)
are routing, rediscovered through `.well-known` and fixed by editing them
back. Same diff shape, three orders of magnitude apart in blast radius --
which is an asymmetry a module should carry rather than an operator.

An activation script and not `warnings`, which is where this obviously
belongs and does not work: the condition needs the host filesystem, and
`nixos-rebuild switch --flake` evaluates purely, where
`builtins.pathExists "/var/lib/..."` answers false rather than throwing.
A `warnings` entry gated on it would evaluate, deploy, and print nothing
on every real deployment.

Rendered only when `serverName` is null, so a pinned hive has no script
rather than a script that stays quiet -- a guard that cries wolf at a
correctly-configured deployment makes the next real one read as noise.
Never fails the activation: it warns about a choice that cannot be
undone, and refusing the rebuild of a hive that already chose
deliberately is the opposite of helping.

The probed path is read out of the container's own evaluated config
rather than hardcoded. A guessed path resolves cleanly and silently never
matches, which is the same failure this guard exists to catch one level
up.
2026-08-18 12:29:31 +02:00
atlas
19c9f0c815 docs(#3255): record why the public URL is gated on the swarm UI
mara asked whether SWARM_CONTROLLER_PUBLIC_URL should also be set when the
controller does not run on the same host, and then said the two read as one
service to her. Both are worth answering in the file rather than only in the
thread: that split is not representable today, and the reason is two hops
away from this line.

The UI's /api/ location proxies http://unix:<socketPath> -- a path that
resolves nowhere else -- and this daemon binds no TCP address at all. So the
vhost and the daemon are co-located by construction, and ui.enable is the
flag that declares the vhost rather than a guess about some host serving it.

Also names the tripwire: the day the daemon grows a TCP listener, this line
silently stops being right (env unset, registration quietly skipped, no
error). That is when an explicit publicUrl option becomes correct -- not
before, while there is exactly one derivable answer.
2026-08-18 12:28:09 +02:00
atlas
4573865745 feat(#3255): register the swarm-wide forge hooks against the controller
The endpoint landed inert: nothing pointed at it, so the only way to see
it work was to mint an HMAC by hand. Register the two swarm-wide hooks
at startup so a real forge event produces a journal line.

Registered ALONGSIDE the per-hive hooks, not instead of them. Every hive
keeps receiving and acting on its own deliveries; the controller gets a
copy and logs it. Moving the registration is a later step and has to be:
fan-out swarm->hive does not exist yet, so a hook moved now would point
at a receiver that forwards nowhere, silently on both sides.

Deliberately no stale-hook deletion arm, unlike the two per-hive
registrars this otherwise mirrors: theirs delete hooks matching their own
path with a foreign base, and the hives' hooks are not stale.

The route prefix is what keeps this safe. Both hive-side registrars
delete any hook ending in /webhook/knowledge or /webhook/config-pr with a
different base, so a swarm hook under those paths would be deleted by
every hive on every boot. Serving them under /webhook/forge/ avoids it,
and a test pins it -- there is nothing else that can.

SWARM_CONTROLLER_PUBLIC_URL is set only where the swarm vhost is served,
because a hook whose target_url nothing answers is worse than no hook.
2026-08-18 12:28:09 +02:00
atlas
a8bc084262 feat(#3255): expose the controller's webhook endpoint through the swarm vhost
A forge webhook is a machine POST carrying an HMAC and no session cookie,
so it cannot pass the authelia auth-request subrequest every other location
on this vhost uses. This location deliberately omits it; the HMAC check in
the controller is what guards the path.

Scoped to /webhook/forge/ rather than /webhook/ so a future endpoint under
the same prefix does not inherit the bypass.
2026-08-18 12:28:09 +02:00
atlas
645fd0d56c docs(#3255): record the controller's first persisted file
The crate's module doc claimed no persistence and no writes, which this
change makes false, and docs/persistence.md had no swarm-controller entry
at all — the webhook secret is the daemon's first persisted state.
2026-08-18 12:28:09 +02:00
atlas
b2596097d8 feat(#3255): receive swarm-wide forge webhooks in the controller
A Forgejo webhook has one target_url, so every hive registering the same
swarm-wide hooks is last-writer-wins rather than idempotent. The controller
is the only swarm-wide thing in the deployment, so it becomes the receiver.

It verifies the HMAC and treats the payload as opaque bytes keyed by the hook
kind in the URL path; it deliberately does not parse the payload, because the
hives' existing handlers already decide what a delivery means.

Nothing is registered against the endpoint yet. The replacement path is built
and observable before anything takes the old one away, so the swarm's single
target_url never points at a receiver that forwards nowhere.
2026-08-18 12:28:09 +02:00
damocles
a796c24037 hive-forge list: show dep-progress count for items with open dependencies 2026-08-18 12:18:57 +02:00
atlas
ddccdf2bab chore(#3422): drop swarmctl's now-unused serde_json dependency
Its last call sites were the JSON store's to_string_pretty/from_str,
which this branch replaced with serde_norway. Nothing in swarmctl/src
names it any more.

Caught by argus, and it is the second time: the same reviewer found a
dead reqwest in swarm-controller after a move earlier this week. No lint
in this workspace sees an unused dependency, so both were green locally
and in CI -- a move has two sides and my gate only looked at one. My
local gate now carries an advisory check for it.
2026-08-18 10:40:36 +02:00