mara: "add mjs to the lint and fix it in this pr". Both lints scope their
git ls-files scan to a fixed extension list that never included .mjs, so
all three frontend build.mjs files (swarm-ui, dashboard, agent) were
completely invisible to both -- not merely under the limits, outside the
scan entirely.
check-comment-blocks.sh: added '*.mjs' to the ls-files glob and to the
mode_of regex (same slash-comment treatment .js/.ts/.tsx already get --
.mjs is the same comment syntax, argus's exact proposed fix). check-issue-refs.sh:
added '*.mjs' to its glob list, no regex change needed.
Fixing the scope immediately surfaced real, pre-existing violations in
the other two build.mjs files (swarm-ui's own is already handled by this
PR's earlier commits):
- dashboard/build.mjs: 49-line comment block (trimmed to 15 -- redundant
per-file output-layout table cut, the code below already names every
output path) plus three bare tracker tags (#448 x2, #453) in the
stream-worker comment, reworded to prose
- agent/build.mjs: two bare tracker tags (hyperhive#3685 x2), reworded
No logic changes anywhere -- comment-only edits plus the two lint
scripts. Closes#4300 and (independently filed, consolidated into that
one) #4299.
mara flagged (PR #4297) that the comment-block lint doesn't catch this
file at all -- check-comment-blocks.sh (and check-issue-refs.sh) scope
to a fixed git ls-files extension list that omits .mjs. That's a real
gap (dashboard/build.mjs already has a 49-line block, pre-existing),
tracked separately -- but the fix here is to just respect the 30-line
convention regardless of whether the lint enforces it on this
extension. Cut the redundant per-file output-layout table (the code
below already names every output path) and kept only the genuinely
non-obvious decisions -- no content lost, verified against a plain awk
run of the lint's own line-counting logic.
Every redeploy currently changes the nix store path serving swarm-ui's
JS/CSS but never the URL the browser requested (main.js, main.css,
theme.css, swarm-ui.css are all fixed filenames) -- so a browser can get
stuck serving yesterday's bundle after a deploy until someone clears the
cache by hand.
build.mjs now hashes main.tsx's JS bundle + its companion CSS output,
plus theme.css and swarm-ui.css, via esbuild's own metafile (not a
guessed hash algorithm), and rewrites the real URLs into index.html and
sw.js after the build.
colors.css deliberately stays unhashed: nix/host-modules/swarm-ui.nix's
stylix theming swaps that exact fixed path via an `= /static/colors.css`
nginx location override with no npm/esbuild rebuild involved. Hashing it
would silently break that swap on a themed host.
sw.js's CACHE_VERSION is now derived from the real hashed URLs instead of
a hand-bumped literal, so any shell-asset change gets a fresh cache name
and the SW's own activate-time sweep evicts the previous one in full --
fulfilling a promise its own prior comment already made.
Scope: swarm-ui only, per mara's call on hyperhive#4283 ("this is about
swarm ui - everything else will eventually migrate over"). dashboard and
agent are untouched.
`BAO_CLIENT_CERT` decides which certificate the TLS handshake presents. It is
not an identity: cert auth is a login, and a `bao kv` call without a token asks
its token helper for one instead — a `sh` neither unit carries on `path`.
Measured on this host, from `swarm-bao-matrix-token.service`:
swarm-bao did not return secret/swarm/matrix/registration-token
failed to get token helper: error expanding config path "":
exec: "sh": executable file not found in $PATH
So the first credential meant to travel through the store never has, and the
publisher added last week would not have either.
`-token-only` rather than a plain login: storing is the default, and it stores
through that same helper, so the obvious form reproduces the failure one line
further down. It is `-field=token -no-store`, which keeps the token on stdout
and out of the filesystem.
The two units degrade differently on purpose, and that is preserved. The matrix
fetch is `Wants=`-only and must not hold up the homeserver, so a refused login
reports why and keeps the token already in place. The publisher is
`Restart=on-failure`, where a store that cannot authenticate this host is worth
retrying and "published 0" would read as an ordinary quiet day.
`swarm-bao.nix` is untouched: it authenticates with the bootstrap token from a
file, which is a real identity and not a cert exchange. Its shape is where the
export idiom here comes from.
`--agent-client-suffix` takes a value that opens with `-`, and clap reads
one space-separated as a short-flag cluster. `swarm-nats.nix` passes it that
way, so the responder aborted at startup on `unexpected argument '-a'`.
A responder that never subscribes is not a degraded queue. `auth_callout` is
fail-closed by design, so the server denies every client, and each denial
arrives as `authorization violation` on the client — naming nothing, on a
process that is itself healthy. Two unrelated daemons reported it at once and
neither was at fault.
The option's own `default_value` is `-agent`, so the binary could not accept
the value it ships. Fixing the module's call site instead would leave that
true for anyone running it by hand.
The three tests parse argument vectors rather than assert on a struct: one is
the literal vector the unit builds, one is the control that an ordinary value
goes through the same flag, and one pins the default against the module.
`policy::render()` became `render(hive)` when a hive gained read on its own
entry, so two places now describe a document that no longer exists: this
module's header said it "is the same for every hive and depends on nothing",
and the security doc said the grant reaches the agent-credential prefix and
nothing else.
The module header is the load-bearing one. It sits above `write_policy_for`
and says, to anyone about to touch that function, that the render is
hive-independent — which is an invitation to hoist it to a shared constant
and hand every hive the stanza naming one of them.
The response.ok fix from the earlier review round was never reformatted
after editing. nix flake check has no --keep-going, so this one real
treefmt-check failure was aborting the whole check run -- and whatever
other derivation happened to be mid-build in the same parallel batch got
killed and logged with an empty error message, misread as an
infrastructure flake across many CI attempts. No logic change.
- manifest.webmanifest: mara — "name should be hyperhive", was
"hyperhive swarm".
- sw.js: argus — cache-write path didn't check response.ok before
caching; an error response would get served back as if it were the
real asset on the next offline/failed fetch.
Closes#4282. mara: "scope looks good" — approving the plan posted
there (manifest + icons + minimal shell-only service worker + iOS meta
tags) and both explicit questions (network-first-with-offline-fallback,
never cache /api/*).
docs/web-ui/design-guide.md's "Layout & viewport" section already
asserted swarm-ui is installable as a PWA — this is what actually backs
it.
- manifest.webmanifest: name/icons/start_url/standalone display, theme
#cba6f7 / background #1e1e2e matching the mocha --purple/--bg values.
- sw.js: plain JS, not TypeScript — the DOM lib swarm-ui's own tsconfig
uses and the WebWorker lib a service worker's globals need are
mutually exclusive in one tsc program, not worth a second tsconfig for
a self-contained ~100-line file. Scoped to the app shell only, never
touches /api/* at all, network-first with offline-fallback-to-cache
(not cache-first) since main.js/main.css are unhashed filenames and a
cache-first SW would risk wedging an operator on stale JS after a
deploy.
- index.html: manifest link, theme-color meta, iOS
apple-mobile-web-app-* tags (Safari ignores the manifest spec).
- main.tsx: feature-detected SW registration.
- branding/hyperhive-maskable.svg: hyperhive.svg's own artwork already
fills nearly its whole canvas, so a maskable icon needs a padded
variant or an OS mask crops the outer ring/corner brackets — embeds
the original via a scaled <image> ref rather than duplicating markup.
- nix/packages/swarm-ui.nix: rasterizes icon-192/512/512-maskable.png
from the branding SVGs at build time via librsvg, rather than
checking in static PNGs.
Verified for real: typecheck+build green, and a real headless-chromium
tab driven over CDP confirms the service worker registers and becomes
the active controller, and a simulated-offline reload still serves the
full cached shell rather than a browser error page. nix build .#swarm-ui
also verified green, including the rasterized icon output.
Pinning a decision rather than changing behaviour, because I was one
edit away from reversing it and left no reason on the field.
The sibling `matrix::Credential::homeserver` is an `Option`, which reads
like the house style to copy. It is not: that field is optional because
it was added to objects already in the store, and KV2 keeps those
versions forever. This path has never been written, so there is nothing
to stay compatible with.
Making it optional would also defeat the field. The id rides with the
secret so a reader never has to spell `hive-<name>-agent` itself, and
the only thing a reader holding `None` can do is exactly that. An object
without an id is not a usable credential, so failing to decode is the
behaviour we want.
Refs #3853
Five cases: the leaf and the pairing that points at it, that the push
hands bao a path rather than the secret, a path per hive in the roster,
that a publisher holding an identity renders on a host with no store,
and the control that renders none without an identity.
They fail on this branch, and that is the finding. The suite was green
at 100 cases with the publisher already committed, because no fixture
enabled both authelia and a store identity — so the module's `config`
never activated and its script was never evaluated. An imported module
whose config never fires is as unmeasured as an unimported one.
What they surface: `swarm.authelia.agentClientSuffix` does not exist on
main. The agent principal these secrets belong to is minted by the PR
for the agent queue principal, whose branch adds both the option and the
`agentClients` list authelia mints from. Delivery of a secret nothing
mints cannot evaluate, let alone run — so that PR lands first and this
one rebases onto it.
The argv case strips comments before matching, which it earned: a
`script` renders its own comments into the text, and this unit's
comments name the hazard verbatim so the next editor does not
reintroduce it. Matching the raw text read that warning and failed —
a check the artifact defeats by describing the thing it is checked for.
Refs #3853
A hive that does not host authelia has no path to its own agent queue
client secret. The mint writes the plaintext to a host directory whose
other reader lives in a different container, so the host that mints is
the only place both trees are addressable — which is where this unit
runs.
Four pieces, in the order they depend on each other: the leaf
(glue-bao-tls.nix signs it, because the thing that owns a private key
owns issuing from it), the module declaring its own cert/key options,
the one-pairing glue file pointing them at that leaf, and the imports.
The unit is gated on holding a client identity, never on
deploy.bao.enable — that option is the co-location assumption itself,
and the publisher is the case that assumption excludes.
The secret is passed to bao as `value=@<path>`, never as an argv
element: bao is an external binary, so an argument is world-readable in
/proc for the life of the call.
Refs #3853
A sibling unit rather than more script in swarm-bao-controller-policy, because
that unit's name is an operator-facing string: docs/getting-started/setup.md
tells a reader to run `systemctl status swarm-bao-controller-policy`. Widening
it to two principals makes the name wrong; renaming it makes the instruction
wrong.
`after` and not `requires`. The controller's unit creates the KV and cert-auth
mounts this one writes into, so the ordering is real — but a failed oneshot
still counts as finished, so `requires` would neither wait for its success nor
re-run this unit when the sibling's own retry eventually lands. Ordering plus
this unit's `Restart=on-failure` is what converges.
Four module-eval cases, because the unit arrived with every claim about it in
prose and the suite still reporting the same count: the grant is write-only and
reaches the hive prefix alone (pinned as the whole capability list, since an
added capability is what a presence check misses, with negative arms for the
agent prefix, the bare swarm prefix and the policy path); it is ordered after
the unit that creates the mounts; it renders on the host; and the control, that
it does not render inside the store's container.
Refs #3853
Write-only `create`/`update` on `secret/data/swarm/hives/*`, and nothing else.
It copies secrets in and never reads one back; a read capability would let a
file-copier recover every hive's credentials rather than merely replace them.
`hives/` and not `swarm/*` because this principal has no business with an
agent's or a service's credentials, and the hive prefix is the only one whose
paths it produces. `secret/data/` is KV v2's ACL prefix, inserted by the engine
rather than written by the caller — the same trap the controller's grant
documents one binding up.
Named outside `hive-*`: the controller may create policies under that prefix,
and a policy it can rewrite is not a constraint on it.
The unit that writes this lands next. Refs #3853
The unit that will copy authelia's minted OIDC client secrets into the store
needs an identity of its own. Not the controller's: that grant includes
rewriting every hive's policy and login role, which a unit whose whole job is
copying one file has no business holding.
The subject joins `certAuthCns`, so it is unrepresentable as a hive name for
the same reason the controller's is — cert auth trusts the CA, and a hive's
own leaf carries its name as the CN.
The module-eval case collides with the SECOND list element and leaves the
controller's subject at its default. A list with one consulted element and one
dead one is indistinguishable from the first element's case, so without this
the addition could be inert and nothing would say so.
Refs #3853
The agreement half of delivering the agent queue principal's client secret
through the store. No producer yet, so nothing writes this path — the unit
that does lands in the same PR, with the write grant it needs.
queue.rs is the sibling matrix.rs prescribes for a second kind of secret
rather than another field on a shared struct. Keyed per HIVE, not per agent:
the queue identity is minted once per hive at deploy time and says which hive
an agent belongs to, never which agent.
The client id rides with the secret for matrix.rs's stated reason — a
credential has to be reconstructable from the store alone, and deriving
`hive-<name>-agent` on the reading side is the split spelling the authelia
module warns denies every agent as a timeout.
policy.rs's render() takes the hive name now and emits a second, narrow
stanza for that hive's own path. The agent stanza is untouched: an agent's
path does not name its hive, so narrowing it still needs the enumeration
docs/trust-boundary/security.md rejects. A hive path does name its principal,
so scoping it costs nothing and drifts nowhere.
every_hive_gets_a_byte_identical_document is replaced rather than deleted.
Its surviving half is that the text is a function of the deploy-time name
alone, so a re-emission cannot drift; the new arms are that one hive's
document cannot reach another's path, and that a name which could close the
stanza is refused — live again now that a name reaches the document text.
Refs #3853
mara, PR review: "i want to see it over time - sounds like you built
bar chart?" — correct, it was a bargauge (instant/lastNotNull). Switched
to timeseries, matching the CPU/memory-by-agent panels' own treatment of
a plain live gauge (raw value plotted across the range, no rate()/
increase() since it's not a cumulative counter).
Closes#4284. hyperhive.agent.claude_md.lines is already exported (see
hive-agent's claude_md_watch module) but had no dashboard panel — this
is the display half.
Same bargauge shape as the two sibling per-agent gauges already in this
file (container storage, active time): sum by (agent), instant query,
lastNotNull reduction, since it's a continuously-live gauge rather than
a cumulative counter. Metric name uses the dotted OTLP form per this
file's own documented convention for this store.
A collector's `refused` / `failed` / queue-depth counters are the only
signal that says telemetry is being dropped, and nothing read them at
any tier — so a collector losing records looked exactly like a quiet
system.
The hive tier could not be scraped without first naming its port. 8888
is the collector's built-in default and appeared in no config, which is
also why nothing comparing configured ports could see it clash with a
co-located collector — swarm-otel.nix sidesteps 8888 by hand for that
reason, and says so. Declaring the port and binding it explicitly makes
the value comparable; wiring the scrape is then one entry per tier.
Extending the port-collision assertion to cover it is deliberately left
out: that belongs with the other port work, and coupling a collision
fix to a scraping fix makes both harder to review.
Gate: 101 module properties hold, was 95. The six cases pin the rendered
scrape job rather than the option; the metrics pipeline naming the
prometheus receiver, a path never emitted on any hive before this since
the hive tier's scrapeTargets was empty everywhere; the `readers`
spelling, with a control so a missing telemetry block cannot pass the
port check vacuously; the swarm tier's own entry; the two tiers not
claiming the same port; and the absence arm, a hive with no collector
declaring no target.
scrapeTargets' description said "Empty by default, and that is the
shipped case". This makes that false, so the paragraph moves with it.
`agent-<hive>` reads as "the agent named <hive>" — which is the one thing that
identity does not carry, since it is minted per hive. It becomes
`hive-<hive>-agent`: the hive's own id, extended.
The rename is not a string swap. `hive-foo-agent` satisfies the hive parse too
(it strips to a hive named `foo-agent`), so the responder's agent rule now runs
BEFORE its hive rule — most specific wins. Hive-first would have handed every
agent its hive's grant, including writing that hive's status key, with nothing
to report it: the client authenticates and is merely able to do more than it
should.
`Policy::new`'s overlap check goes with the prefix it was written for. The
invariant the suffix form needs instead is that the suffix is non-empty: an
empty one makes `strip_suffix` succeed on every hive id, so the two principals
become one string and whichever arm runs first answers for both.
The suffix form also introduces a collision the prefix form did not have: a hive
genuinely named `foo-agent` mints `hive-foo-agent`, which is hive `foo`'s agent
id. The responder cannot see it — it has no roster, deliberately — so
`swarm-authelia.nix` asserts at eval that no hive name ends with the suffix. The
existing duplicate-id assertion does not cover this: it fires only when both
`foo` and `foo-agent` are on the roster, and with `foo-agent` alone there is no
duplicate, just a hive quietly receiving its agents' grant.
A test written by analogy with `the_prefix_alone_names_no_hive` failed, correctly
— `hive--agent` is a hive named `-agent` under the hive parse, which this module
cannot rule out. It now asserts only the part this module owns: no empty hive
name is ever expanded into a subject.
Agents have authelia *users*; they had no machine identity at all, so an
agent could not authenticate to the swarm queue as anything. This mints
one `agent-<hive>` OIDC client per hive beside the existing
`hive-<hive>` one, teaches the auth-callout responder an agent arm, and
opens the queue's client port on the bridge so a container can reach it.
One client per HIVE, not per agent: agents are created at runtime, and a
per-agent client would make creating one a config change plus an
authelia reload. The cost is that agents on a hive are indistinguishable
to the broker, which is deliberate and tracked separately.
The agent grant is deny-by-default twice over. An agent id matches no
hive rule, so it gets a hive's status-key grant from neither; and with
no agent subject configured the responder returns no grant at all rather
than an empty publish list, which would be a denial wearing a grant's
shape. What an agent may publish is a deployment's decision, taken
through `--agent-publish-subject` the same way `--hive-publish-subject`
already works.
`Policy::new` now refuses two prefixes where one contains the other. The
arms are tried in order, so that overlap does not error at match time -
it silently hands one principal the other's grant.
Not shipped here, and neither is reachable without it: no subject is
configured for agents anywhere in nix, and nothing yet delivers
`agent-<hive>.secret` into an agent container. Both belong to the stream
that will be the first consumer.
The reader had one failure branch, and it could not fail. It named three
states — the store holds nothing, the store is sealed, the store is
unreachable — treated all three as success, and sent bao's stderr to
/dev/null, which is the only thing that tells them apart.
The degraded mode is right and is unchanged: a missing registration token
means new agent accounts cannot be provisioned and nothing else breaks,
so this still exits 0 and leaves the local token alone. What changes is
that the journal now carries the store's own message instead of a
sentence of ours asserting all three at once.
Deliberately not branching on that message. Distinguishing "no value
found" from "permission denied" programmatically would mean matching
prose from an external tool that I cannot reach a live instance of to
confirm, which is inventing a discriminator rather than reporting one.
Nothing asserted this script before — eleven module-eval cases cover the
unit's existence and its ordering, none its contents, which is how the
branch stayed. The case added here covers the property that was missing.
Gated: `96 module properties hold`, exit 0 (95 on the base commit, +1 =
the case added). `nix fmt` reported 0 changed over 723 files emitted in a
fresh worktree. Tracker-tag, comment-block and doc-pointer lints all exit
0 after staging.
mara: "make clickable badges a proper pill not a roundrect" (#4276).
.ui-badge's base border-radius: 1em is a genuine pill at its own
compact display-only height, but .ui-badge-interactive's min-height:
2.75em (the WCAG 2.5.5 touch-target floor) makes an interactive badge
tall enough that the fixed 1em radius no longer reaches half the box's
height -- the corners round without the sides ever meeting the same
curve, i.e. a rounded rectangle. border-radius: 999px is the standard
stadium-shape trick (the browser clamps it to exactly half the box's
height whenever it exceeds it), same value .hive-pill/.hive-pill-sm
already use. Shared component -- fixes every interactive Badge caller
(swarm-ui's WantedMenu/AgentCard/model-effort pickers, the per-agent
page's StatusChips/MetaNav/HeaderPill) in one place.
Verified with a real headless-chromium screenshot of the /components
Badge section before/after: the interactive "model sonnet"/"pause"
badges were visibly rectangular with rounded corners before this,
matching the non-interactive display badges' pill shape after.
Both places that warned about a hive named after the controller's cert-auth
subject still told the reader it was unmitigated, and the option's description
recommended a migration — "change this to something outside the hive-name
grammar, at the cost of a role rename in any store that has already run the
granting unit" — that is no longer the answer and is not cheap.
swarm.nix now feeds the subject into the guard on `swarm.hives`, so a
colliding roster fails evaluation. Both paragraphs get SHORTER saying so: a
guarded hazard needs the consequence ("reserved as a hive name") rather than
the threat model, and the write-site keeps only the sentence the next person
needs — a role added beside this one must join that list.
Found by sweeping for the claim rather than for the symbol: the change that
made these stale touched swarm.nix, swarm-otel.nix and module-eval.nix, so no
diff-context or doc-comment rule covers a paragraph two files away. Grepping
the tree for "would satisfy" and for prose about a hive named after a subject
turned up exactly these two and nothing in docs/.
mara, PR review: "make agentspage a subdir now that its split into
sub components". AgentsPage.tsx/.css, AgentCard.tsx/.css,
AgentTypes.ts, and WantedMenu.tsx move as a family into their own
pages/agents/ directory; CreateAgentForm and LinkMatrixAccountForm
stay in pages/ since they aren't part of this split (CreateAgentForm
is still rendered inside AgentsPage's own dialog but is a standalone,
independently-named form, not one of the pieces carved out of the
page itself).
Pure move: relative imports within the new pages/agents/ family are
unchanged (they were always siblings), only the ones reaching back
out to ui/ and the two forms above gained one more '../', plus
App.tsx's route import.
Three more asks from the same review thread:
- "agentspage is now giant and deserves a split" - AgentsPage.tsx was
1047 lines. Split into AgentTypes.ts (AgentRow and friends),
WantedMenu.tsx, AgentCard.tsx (+ its own CSS), leaving AgentsPage.tsx
as state/actions/columns/the render tree - 649 lines, and every piece
it composes is now independently readable.
- "what about the component that represents filtered data ... that the
card view and table can both use?" - extracted FilterableView
(ui/filterable-view/): takes columns/rows/rowKey/storageKey/view/
renderCard, builds its filter bar from *every* filterable column (not
a hand-picked subset - the old AgentFilterBar only showed 4 of the
agent columns' 6 filterable fields, an accidental gap the table's own
popovers didn't have), and renders either the card list or Table.
AgentsPage now just tells it which view to show; the view toggle
itself stays page-side since it's Panel-header chrome, not filtering.
Disclosed side effect: card view's filter bar now also covers
message/config-PR (text filters), matching table view exactly instead
of a narrower subset.
- CSS audit: AgentsPage.css now holds only what's genuinely page-specific
(the view toggle, the detail-panel field grid) - everything else moved
to its owning component's own colocated CSS.
FilterableView gets a /components demo (view toggle + filter bar + both
render modes, same day per the design guide). Verified: AgentsPage
still renders the same (real screenshot), and the demo's own table
toggle produces a real Table with the same rows.
argus, PR review: detailTarget stored the whole AgentRow object at
selection time, so it never picked up a later refresh() or a
declareState patch - the card list updated live, the panel next to it
kept showing whatever was true the moment it was opened. Concrete
repro: open an agent's detail panel, wait for the next refresh or
destroy it from inside the panel itself, watch the panel not update.
Fix: store only the selected agent's name (detailTargetName) and
re-derive the actual row from rows on every render
(rows?.find(r => r.name === detailTargetName)). The panel can't drift
from what the list is showing since it's reading the same array.
Verified with a real refresh cycle against a mock server that returns
different data on the second call: before, both card and panel show
"idle"; after a live refresh, both show the new value in sync.
Mara: "result looks like the shape i am looking for, but the code does
not. you did not follow component first principle" - the card's
clickable/selectable mechanics, the card-view filter trigger+popover,
and the list+detail split layout were all one-off page-local JSX in
AgentsPage.tsx instead of docs/web-ui/design-guide.md's "Component-first
design" primitives. Three new ui/ components, each with a same-day
/components demo section per that doc's own rule:
- ui/card/Card.tsx - the role=button/keyboard-activation/selected
mechanics AgentCard now wraps agent-specific content around, instead
of owning them itself.
- ui/multiselect-filter/MultiselectFilter.tsx - the checkbox-list
trigger+popover control. This was also a straight duplicate of
Table's own inline popover content once AgentsPage's filter toolbar
needed the identical thing; Table now renders the same
MultiselectFilterOptions piece too (keeping its own th-anchored
trigger and fixed+portal positioning, which are genuinely
table-specific), not a second copy.
- ui/split-view/SplitView.tsx - the list+detail flex-wrap layout, no
opinion on what's inside either pane.
AgentsPage.tsx's own CSS shrinks to just the agent-specific content
inside these primitives (card line/message layout, detail-panel field
grid, the name-search input) - the container/positioning rules moved
to each component's own colocated CSS.
No behavior change for any other Table caller (HivesPage,
IssueReportPage, the components demo's own Table samples) - the
popover's visual output is identical, just sourced from the shared
component instead of inline JSX.
Verified: typecheck/build clean, real screenshots of both AgentsPage
(pixel-identical to before) and the three new /components sections,
plus a live click confirming MultiselectFilter's popover opens
correctly on the demo page too.
Two more asks from mara's live review:
- "i want the same filters for the cards tho, thats why i suggested
separating data and filter from view" - extracted Table's filter
*state* (not its popover UI, which stays table-shaped) into a new
exported useTableFilters hook. Table calls it internally, unchanged
behavior for every existing caller. AgentsPage now calls the same hook
with the same storageKey, so card view and table view read/write one
shared filter state instead of each having their own (or cards having
none at all). Card view gets its own toolbar (AgentFilterBar) - a
name search input plus one FilterMultiselect per multiselect column,
same checkbox-list markup Table's own popover uses, driving the same
state. Switching the view toggle no longer loses or hides whatever's
filtered.
- "why no separate panel? i mean a second panel on agent page" - replaced
the modal Dialog with a real second Panel, always mounted (empty state
when nothing's selected, so selecting an agent never shifts the page's
own layout). Panel gained an optional `class` prop so the two panels
can flex-size themselves in a row. List/detail panels sit side by side
in a flex-wrap row that stacks on a narrow viewport - content-driven,
same approach the shell's own nav uses, not a second hardcoded
breakpoint. Selected card gets a highlight so it's clear which one the
detail panel is showing.
Verified with real CDP clicks: split layout with nothing selected,
selecting a card highlights it and populates the detail panel, opening
a card-view filter and checking a value narrows both the card list AND
(after switching the toggle) the table to the identical row set.
Three things from mara's PR review + argus's:
- "the info from main list should be included in the agent view" - detail
panel now repeats status/message/wanted alongside the panel-only fields
(hive, config PR, matrix link), not just the leftovers.
- "destroy is already available via wanted state" - dropped the standalone
"destroy agent" button; the detail panel's wanted field is a real
WantedMenu (default showDestroy) instead, same control as the card/table,
just with the fourth option back. Also resolves argus's stale-doc-comment
finding (the comment described a second WantedMenu call site that didn't
exist yet - now it does).
- Rebased onto main to pick up the just-merged dropdown-portal-clip fix -
this branch was cut before that merged, so it had silently regressed
back to the pre-fix Dropdown the whole time.
That rebase surfaced a real bug of its own, likely "the third screenshot
shows a layout bug": WantedMenu always passed `portal` to Dropdown, and a
portaled (position: fixed, body-appended) dropdown renders *behind* an
open native <dialog> - the dialog is promoted to the browser's top layer,
which composites above ordinary body content regardless of z-index. Only
the sliver of the dropdown extending past the dialog's own edge was
visible. WantedMenu's `portal` is now its own prop, opt-in, true only at
the table's call site (the one with an actual clipping ancestor to escape)
- card and detail-panel call sites render it as a plain child instead,
which is both correct inside the dialog and one fewer moving part where
it isn't needed.
Verified with real CDP clicks: detail panel shows all fields, and its
wanted dropdown now renders in the right place with all four options
visible instead of mostly hidden behind the dialog.
Mara's follow-up on #4257, right after the card-only version went up:
"still want the filters tho, maybe split the data component from the
view" / "if we split data component and view, we can make it switchable
between table and specialized card view."
AgentsPage already had its data/actions (rows, declareState, the
dialog-target state) separated from rendering by this point - the only
missing piece was a second renderer. Restores the original Table +
columns array as the "table" viewMode alongside the new card view,
adds a small two-button toggle in the Panel's actions row, and persists
the choice to localStorage (same pattern as Table's own per-column
filter persistence) so a reload keeps the last choice. Cards stays the
default.
Verified with a real CDP click switching to table view: columns,
sort arrows, and filter icons are all back.
Replaces AgentsPage's Table-rendered roster with one AgentCard per
agent: name/status/wanted on the first line, the free-form status
message on the second (mara, scoping #4257: "main view: name, status,
message, wanted" / "message as second line" / "more like card per
agent").
Everything the old table's other columns carried (hive, matrix
link-account, config-PR link, destroy) moves into a detail panel that
opens on card click, reusing the existing Dialog modal rather than a
new docked/slideover primitive - the shared hive-side-panel drawer is a
shadow-DOM custom element swarm-ui's esbuild config can't consume yet
(same gap Dialog.tsx's own comment already flags for hive-dialog).
WantedMenu gains a showDestroy flag: the card's own menu keeps the
three everyday states, destroy gets its own button in the detail panel
instead of a fourth dropdown entry next to states someone reaches for
often.
Known regression, flagged for follow-up rather than silently dropped:
the old table's per-column sort/filter has no replacement in this view
yet.
Backend list/detail endpoint split (also requested in #4257) is
deliberately left for a follow-up PR - it's an orthogonal optimization,
not required for this interaction to work correctly against the
existing single /api/agents/status response.
The two hive-name guards lived in swarm-otel.nix, inside its
`config = lib.mkIf (… && deployCfg.swarm-otel.enable)`. A swarm running the
secret store and the controller but no collector therefore had no hive-name
check at all, while the names were still composed into OIDC client ids, bao
policies and cert-auth roles exactly the same way. They move to swarm.nix,
which declares `swarm.hives` and is unconditional. swarm-otel keeps the
assertion that its own entry is still in the shared list — that one is about
this module's stake in a file it no longer controls.
The equality guard also takes the store's cert-auth subjects now. Cert auth
trusts the CA, so `allowed_common_names` is the whole of what narrows a role
to one identity, and the same CA signs every hive's leaf with the hive's name
as its CN. A hive named after a role's subject presents a certificate that
role accepts, which for the controller is write access to every hive's
credentials and policies.
A list rather than the one string, because the next role added beside it
widens what a hive name must not collide with, and because the subject is an
option an operator sets — a literal deny entry covers the default and nothing
else.
Four module-eval cases, two of them controls. The fixture overrides the
subject to `ctl` on purpose: the default contains `swarm`, which the substring
guard catches whatever the new arm does, so a fixture using it could not tell
the two apart. The controls are that a legal roster trips neither guard, and
that all three fixtures really do have the collector disabled — without the
second, every case would pass while testing the arrangement they exist to
rule out.
A `WantedMenu` badge dropdown on the agents table clips against
`.ui-table-scroll`s overflow the moment its row is the last (or
near-last) one — the popover extends past the table content the
scroll container bounds itself to. `Table.tsx` already solved the
identical clip for its own column-filter popover with a
position:fixed + portal computed from the anchor rect; `Dropdown` now
takes an optional `portal` prop that opts a caller into that same
recipe instead of a second hand-rolled copy of it. Off by default —
every other current caller (StatusChips x2, the components-page demo)
keeps its existing non-portal behavior unchanged.
The crate had a single path convention and it was per-agent:
`swarm/agents/<agent>/matrix/<account>`. The secrets still to move into the
store do not fit it — one belongs to a hive, one to a swarm service, one to
the controller itself — so each would have picked its own shape, and each
would have been a separate grant to get wrong.
mara ruled the scheme on the epic: `swarm/<kind>/<name>/<secret>`, over
`agents`, `hives`, `services` and `controller`. This lands it.
`Kind` is an enum rather than free strings for one reason: the store's grant
is written in nix and cannot be reached from Rust, so a misspelled kind is a
403 at provision time and not a compile error. `Kind::ALL` lets a test
enumerate the set instead of restating it, which is what makes adding a kind
a deliberate edit rather than an accidental grant.
Note `Kind` sits beside `checked_segment`'s existing `kind` argument, which
means something else entirely — the label of the name being validated. They
are not the same concept and should not be merged.
Nothing about the rendered policy changes. `policy::render` still grants read
on the agent kind alone; the other kinds are absent on purpose, because what a
hive may read of its own kind is a boundary question and not a consequence of
the namespace growing. The controller's write grant likewise stays scoped to
`agents/` — it widens when a path outside it gains a writer, not when the
kinds are declared.
Verified: `cargo test -p swarm-secret-client` 23 passed, 0 failed. The two
tests pinning the rendered strings (`the_document_grants_read_over_the_whole_agent_prefix`
and matrix's path assertion) still assert the same literals they did before,
which is what shows this is a faithful port rather than a reshape. `nix fmt`
710 emitted, 10 formatted, 0 changed; the three scripts/check-*.sh lints pass
with the change staged. No reference to the removed `path::AGENT_PREFIX`
survives in the crate or in nix — checked with a scoped pattern, because the
unqualified name also belongs to hive-host-sock's container prefix and greps
for it are answering a different question.
The mode was declared twice in this file — the service unit's
RuntimeDirectoryMode and the socket unit's DirectoryMode — with only a
prose "must match" note tying them. Whichever unit activates first creates
the directory, so they cannot be allowed to disagree.
Both literals are in one file, so they become a `let`. Deleting a copy
beats checking it, and unlike rendering the mode into hive-priv it costs no
config knob for a value nobody should ever set.
hive-priv's tmpfiles.d entry for the same path is a third declaration that
cannot read this binding, and is left in step by hand. An earlier revision
of this branch added a CI check for exactly that pair; mara pointed out it
was keyed to one path rather than to the class, and looking for the general
case found two more paths declared by more than one mechanism — including
/run/hive-agent, where hive-gateway's tmpfiles rule and hive-priv's
generated one disagreed on the owner and the winner depended on systemd's
read order. That check is being reworked as a general one, tracked
separately, so nothing about it rides in here.
Verified: nix fmt (713 traversed, 5 formatted, 0 changed); the three
scripts/check-*.sh lints all exit 0 with the tree staged; .forgejo/ is now
byte-identical to main and the diff is this one file. checks.module-eval
reported 91 module properties hold on the previous revision of this branch
— the only nix change since is comment text inside the same let block,
which cannot affect evaluation.