forge_admin interpolated its whole argument vector into the error
context, and two callers pass a live operator password in that vector
(user create --password, user change-password). Any failure of those
commands wrote the password to hive-c0re's log in cleartext -- and the
likeliest trigger is forgejo rejecting a weak password, so the secret
got logged precisely because forgejo refused it.
Redacting the value after --password would repeat the bug the issue is
about: redact_password_line matched one keyword and a differently named
secret walked past it. A denylist fails open, silently, and the next
secret-bearing flag would leak until someone extended the list.
describe_forge_admin keeps the leading verb path and stops at the first
flag, so "user create --username iris --password ..." is reported as
"forgejo admin user create". The verbs are a closed set this crate
chooses itself; argument values never are, so a new flag is excluded by
construction. Nothing useful is lost -- the context says which operation
failed, and the underlying error already carries forgejo's own message
about why.
The same pattern in hive-priv is deliberately untouched: that crate runs
as root and the redactor's shape is still an open question on the issue.
This change holds under either answer.
This file auto-loads into every agent's context on every turn, so each
error in it is paid continuously by everyone rather than once by whoever
reads it. Four of them:
Five workspace members were missing entirely: hive-jobq, hive-types,
hive-agent-sock, hive-core-agent-sock, hive-screen-mcp. hive-jobq is the
worst of those to omit -- it is the crate the c0re-side job_queue layer
is being folded into, so the map that loads every turn did not mention
the target crate exists.
hive-claude was listed as a workspace member. It has no directory here
and is consumed as a dependency, so anyone following the map went
looking for source that is not in this repo. Moved to its own section
that says so.
The issue tracker was linked at a loopback address. Inside an agent
container loopback is the agent, not the forge, so the link cannot
resolve for its primary audience; point at the env var instead.
The ops label was written with a colon. The real label uses a slash,
and a colon does not fail loudly -- the filter matches nothing and
returns unfiltered results, which reads like a backlog instead of like
an error.
mint_token interpolated forgejo's raw stdout into its anyhow context on
the parse-failure path, and on that call stdout carries the access token
that was just created. The happy path below it is careful to log only
the user and token names; the error path handed the secret over whole.
It fires exactly when forgejo's output format drifts, which is the same
drift that breaks extract_token in the first place -- so the "help me
debug this" context printed the secret it had failed to find.
Report the shape of the output (bytes, lines) instead of its contents.
That is what diagnoses a version drift anyway: you want to know forgejo
printed something with no token-shaped word in it, not the bytes.
Redaction at a logging call site does not cover the error path.
with_context and bail! are output channels too.
docs/web-ui/agent.md still described a "loose-ends" and "tasks" flyout
that were superseded by the todos flyout when loose-ends-v2 landed —
GET /api/loose-ends and GET /api/bash-tasks are both gone server-side.
Replaced with an accurate description of the todos flyout (including
the mark-done bulk action from #2919), and noted that the ask->operator
inline-answer binding this doc also describes is currently
non-functional (its data source was the same removed endpoint) —
tracked separately as #2922, not fixed here.
buildLooseEndsList in app.js rendered the old loose-ends flyout and had
zero call sites left; removed it. buildAnswerForm stays — reconcileAskBinds
still calls it, even though that path is currently dead per #2922.
Fixes#2920
Phase 4 (repoint every container onto `meta#<n>`) and phase 5 (rename
the `root` container to `h-root`) were marker-guarded one-shots for
layouts no live hive still has: containers are rendered onto `meta#<n>`
at creation, and the `h-` prefix has been the naming for far longer than
any deployment predates. A one-shot nobody can still trigger is dead
weight, so both are gone along with `repoint_container`,
`rename_manager_container`, `CONTAINER_TIMEOUT` and the two marker paths.
Phase 6 was not obsolete, only misplaced. Ruth's tool groups are now
seeded by `ensure_root_agent` on the one path that creates her, rather
than re-asserted on every hive-c0re boot. The skip-if-already-set guard
survives the move: a destroy+recreate under the same name must not reset
an operator's chosen group set back to MANAGER_DEFAULT.
That also settles a latent bug. Phase 4's marker check was a `return`,
not a skip, so on any hive carrying the marker phases 5 and 6 never ran
at all — the tool-group backfill, whose whole job was preventing a silent
privilege downgrade, has not executed here in a long time. Moving it to
create-time removes the question rather than answering it.
What stays is convergence: three unguarded, idempotent phases that re-run
each boot and no-op once their state is right. The module doc now names
the three categories so the next person can tell which kind they're
adding.
The per-agent web UI todos flyout (loose-ends v2) had no mark-done
affordance at all — dismissing a todo was only possible via the
cancel_loose_end MCP tool, one id at a time. Add a checkbox per row,
a select-all/select-none/mark-done bulk row, and a new
POST /api/todos/mark-done handler that loops the existing single-id
MarkTodoDone request over the in-agent socket (no new wire request
type needed — the todos list is small, so N same-host round-trips is
cheap).
Fixes#2917
The doc still described a `label`/`deliberate_stop` parameter pair
inherited from `transient_guard`, which this function replaced and whose
signature it does not share — it only takes `name`.
Rewritten to say what it does and, more usefully, what must not come
through it: the queue answers the same question from the node itself via
`NodeKind::takes_container_down`, so this path is only for the two
operations that have no node behind them yet.
mara on !2910: "also remove imperative path for the things that are not
nodes yet, file follow up issue to fix that".
`TransientGuard`, the stored map and both manual set/clear are gone.
`transient_snapshot()` is derived and nothing else — destroy and
migration show no pill, because there is no node to derive one from. The
pill returns for free when they become nodes.
What those three guards were actually doing, though, was suppressing the
crash watcher, not drawing a pill. `migrate.rs` said so in its own
comment: without it, `crash_watch` fires `ContainerCrash` for every
migrated agent and the manager tries to recover containers that were
stopped on purpose. Destroy is the same — the container disappears
deliberately and nothing in the graph says so.
Deleting them outright would therefore have traded a dashboard pill for
false crash alerts on every destroy and every migration. So the
suppression survives as its own thing, `suppress_crash_watch`, with a
name that says what it is. It is still RAII, and still held for the
operation rather than stamped once, because the crash watcher's grace
window is finite and a destroy is not — a single tombstone would expire
mid-operation. The drop stamps the tombstone, covering the poll that
lands just after.
That leaves RAII in the codebase for exactly one purpose instead of two.
Untangling the pill from the suppression is what made the transient layer
deletable at all.
Follow-up issue for making destroy + migration real queue nodes to
follow; at that point this guard goes too.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
mara on !2910: "why is set_transient still a thing if it completely
derives from nodes?"
It was still a thing because the scheduler mirrored the derived set into
a stored map that every consumer read — derived state computed once and
then cached, with the reconciliation loop existing only to keep the cache
honest. `transient_snapshot()` now derives: `running_transients()` off the
live graph, with the handful of entries that have no node behind them
(destroy, migration) overlaid on top. There is no cached copy left to go
stale or disagree with what is running.
`set_transient` / `clear_transient` split by what they actually do:
`set_manual_transient` / `clear_manual_transient` own the stored map for
the no-node callers, and `emit_transient_set` / `emit_transient_cleared`
publish the edges both paths need.
Two things had to survive, and both are edges rather than state:
- The dashboard's `TransientSet` / `TransientCleared` events. The
scheduler carries the previous derived value and emits the diff.
- The crash watcher's grace window. `recent_transient_within` answers
"was a transient cleared just now?", which is what stops a deliberate
stop from reading as a crash on the next 10s poll — a derived read of
current state cannot answer it, so the clear still stamps. The
scheduler keeps `deliberate_stop` alongside the label precisely so it
is available at clear time: the node it came from is, by definition, no
longer running to be asked.
`TransientState::since` becomes wall-clock and, for derived entries, is
the node's own `started_at` — the true start of the operation rather than
the moment a watcher first noticed it, which is what the old
guard-creation timestamp actually measured.
`running_transients` returns a named `RunningTransient` rather than a
4-tuple; two of its fields are strings and one is a bool whose meaning is
not guessable at a call site.
Note for anyone reaching for a timestamp here: chrono is vendored with
`default-features = false`, so there is no `Utc::now()`. The workspace
convention is `wire_time::now_unix()` / `from_secs()`.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
mara on !2910: "transient guard as well - should be removable now?" — for
the queue path, yes.
`set_transient`'s own doc explained why the RAII guard existed: a
cancelled future must not leak an imperatively-set transient and pin the
dashboard on "rebuilding…" forever. That cannot happen to a derived set.
`running_transients()` is recomputed from the graph every loop, so a node
that stops running stops appearing — there is nothing to own and nothing
to leak.
So the scheduler no longer holds a guard per pill. It keeps the previous
derived value and publishes the transitions, which is the one thing a
derived read cannot express: the dashboard wants `TransientSet` /
`TransientCleared` edges, and the crash watcher wants the *moment* a pill
cleared, since its grace window is what stops an operator stop from
reading as a crash.
That also retires a hazard rather than restating it. The old code carried
a warning that stale guards had to be dropped before new ones were
created, because `TransientGuard::drop` clears by agent with no notion of
which label it was clearing — so a same-agent label change could clear
the pill it had just set. With no guards there is no ordering to get
wrong; clears are emitted before sets so a relabel reads as
clear-then-set rather than two overlapping pills.
`set_transient` / `clear_transient` become `pub(crate)`. The guard stays
for destroy and migration, which have no node behind them and where the
cancellation concern is real.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
mara on !2910: "rename now, we will see if we can remove it later when
some of the users have been removed or work differently."
Nothing is held. The old name described a transient the DAG declared and
kept for its whole lifetime — precisely the thing this PR replaces — so
it outlived its own meaning the moment the derivation landed. The value
is recomputed from the running set on every call.
Kept as a function rather than inlined at its single call site, per the
above: removing it is a later step that depends on its users changing,
not something this PR should force.
Rename plus its two references (the call in `reconcile_transients` and
the module doc link). No behaviour change; the doc comment records what
the old name meant so the rename doesn't erase the reason for it.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
argus flagged on !2910 that the dashboard doc still described the pill's
old shape. It did, in two ways that now teach the wrong thing:
- it listed a fixed vocabulary, where the label is now the running
node's own wire tag — the `NodeKind::as_str` strings `NodeView.kind`
already carries. A client that switches on specific values is now
wrong, and `restarting` in particular no longer exists at all.
- it described the transient as operator-initiated ("set the moment the
operator clicks"), which was the distinction between it and the
rebuild-queue fallback. That is no longer true: the transient is
derived from the running node, so worker-driven work the operator
never clicked lights the same pill.
The queue-`kind` half of that section is untouched — that path did not
change and its vocabulary is still fixed.
Also notes on the wire-event list that `transient_kind` is a display
string to render, not an enum to branch on, since that is the property a
client would otherwise have to infer from a now-open set.
Docs only, no code change.
The dashboard pill was declared once per DAG at submit time, so a rebuild
reported `rebuilding` for its entire life — through the prebuild, the
stop, the swap, the tail and the reconcile. It named the intent of the
request, not what was happening.
It is now read off the nodes actually running. A node lights a pill when
it is `Running` and declares the agent's own resource. Declaring is the
test, not targeting: `Prebuild` and `MetaSync` name an agent but are
lease-exempt on purpose (the container keeps serving), so they must not
light one. It is also not the lease *owner* — `resource_state()` answers
"who holds the slot", which is a different question from "what is
running", and a descendant that borrows an ancestor's grant never
appears in that map.
`TransientKind` is gone entirely rather than being re-derived. The label
is the node's own wire tag (`NodeKind::as_str`) — the same vocabulary
`NodeView.kind` already ships, so a pill and a DAG node name an operation
identically and there is no second taxonomy to keep in step. Work with no
node behind it (destroy, migration) supplies its own literal.
`DagSpec::transient`, `Claim::transient`, `DagMeta::transient` and
`NodeKind::Dag`'s `transient` field all go with it.
## the safety half, which is deliberately not the display half
`crash_watch::is_deliberate_stop` used to match a `TransientKind` to
decide whether a vanished container was intentional or a crash. That made
a pill's display vocabulary decide an alerting question, so renaming or
adding a label would silently move the alerting boundary.
`TransientState` now carries two independent fields: `label` (rendered,
nothing branches on it) and `deliberate_stop` (read only by the crash
watcher). The producer sets the second, because the producer is the only
thing that knows — it is not recoverable from the first.
For queue work that value is `NodeKind::takes_container_down()`, and it
is emphatically not "holds a lease": `Create` and `Start` hold the
agent's lease exactly like `Stop` does, and a container dying *while
starting* is a real crash that must keep reporting as one. The default is
`false` on purpose — a wrong `false` costs a spurious crash event, a
wrong `true` swallows a real crash silently.
## known cost, accepted on the issue
A restart no longer reads `restarting`. No `NodeKind` is unique to a
restart — `restart_chain` reuses `Signal` / `StopForUpdate` / `Drain` /
`Reconcile` — because "restart" is a property of the DAG's shape, not of
any node. A restart now reads `signal` / `stop_for_update`, then the
agent returns.
`Start` / `Stop` / `PostSwap` run inside a lease-holding ancestor and
re-declare nothing, so they light no pill and the agent reads idle for
those windows. Closing that is the resources-where-constructed work
(#2818), not this change.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (321 + 40 passed) and `nix fmt`.
mara on !2909: "shouldnt node_by_id be part of jobq?" — yes. Resolving a
raw value to a `NodeId` is the exact inverse of `NodeId::get`, which
already lives in hive-jobq, and it is only a search because the graph
owns the counter that makes ids unfabricable. Both halves of that
round-trip belong on the same side of the crate boundary.
Placing it in `QueueInner` also put it in a layer slated for removal, so
the c0re-side helper would have had to move later anyway — and it was
private there, leaving any other caller needing the same resolution to
write the same `nodes().find_map(…)` by hand.
`Graph::resolve_id` replaces it, with a unit test covering the
round-trip and the rejection of a value that was never an id.
While re-reading the diff for that question: the doc comment added in
the previous commit landed *between* `container`'s doc comment and its
signature, silently reattaching "The container node of `dag_id`" to the
new helper and leaving `container` undocumented. Restored.
No behaviour change and no wire change — same search, same call site.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-jobq -p hive-c0re` (41 + 322 passed) and `nix fmt`.
`JobQueue::cancel(dag_id)` resolved the id to a `NodeKind::Dag` container
and cancelled that. But the container lookup was the only DAG-specific
part — everything that makes cancel work already lives in the scheduler:
`cancel_node` marks the node `Cancelled` and cascades to its pending
descendants, sparing any node whose edge accepts `Cancelled` (which is
what keeps a dropped approval DAG from dangling its row).
So `cancel` now takes any node id. A group root cancels the whole group,
which is what the dashboard's button does today and why nothing about
its behaviour changes: a DAG id *is* its root node's id. An interior
node cancels just that branch — a capability the DAG-scoped version
could not express, covered by the new test (a hive-wide restart drops
one agent's subgraph while the other keeps running).
`QueueInner::node_by_id` replaces `container()` here: same search, same
cost, without asserting the node is a DAG container. `container()` stays
for `first_error` and the append-subgraph guard, which are genuinely
DAG-scoped.
No wire change. The route is `POST /api/rebuild-queue/{id}/cancel` with
a `u64` path param — same type, same route, and the client keeps sending
the same number. Only the param's documented meaning moves from "DAG id"
to "node id".
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re` (322 passed) and `nix fmt`. No option surface touched, so no
nix-eval gate.
handle_list_descendants derived a container's running state from
membership in nixos-container list output, but that command returns
every registered container - stopped and ghost-registered (machined
still holds the name after the process died) ones included - not just
running ones. use lifecycle::is_running (systemctl is-active on the
container's systemd unit) per container instead, same source
container_view::build_all already uses for the dashboard.
fixeshyperhive/hyperhive#2846
The container-resource exporter identified the samples it sent by
container and by hive, but not by machine or by build — so a sample
could not be attributed to the host it came from or the deploy that
produced it.
Both go on the OTEL resource rather than on each data point, for the
same reason `hive` / `swarm` already do: they are constant across one
hive-c0re. Only per-container facts stay per-data-point.
- `host.arch` — mapped to the semconv spelling, not forwarded from
rust's. The two disagree on exactly the architectures this runs on
(`x86_64` / `aarch64` vs `amd64` / `arm64`), and the failure mode is
silent: a dashboard filtering the standard value matches nothing.
A test pins this, since nothing else would catch it.
- `service.version` — the running flake rev, via
`auto_update::current_flake_rev`, the same source the dashboard
snapshot and `get_agent_meta` already use. NOT the crate version:
that's a workspace constant that never moves between deploys, so it
could not answer "which build produced this sample?". Omitted rather
than guessed when the flake ref carries no rev.
`spawn_exporter` takes the flake ref to reach the rev — the string it
needs, not the whole `Coordinator`, so the module's coupling doesn't
widen for one attribute.
The issue's third item, `container`, needs no change: `attrs()` has
emitted `container.name` per data point since this exporter landed.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re` (321 passed) and `nix fmt`. No option surface is touched, so
no nix-eval gate.
Second of the `Dag` field removals, and the same shape as the first:
`DagSpec`/`NodeKind::Dag` carried an `inputs: Vec<String>` that exactly
one node ever read. Both reads live inside `run_meta_lock` — the
`meta::lock_update` call and the `meta_update_cascade_agents` fan-out —
so the list now rides `NodeKind::MetaLock` itself.
The executor stops touching `Claim` for this node entirely: its dispatch
arm already destructured `MetaLock { sweep, fanout }`, so `inputs` joins
them and the `claim` parameter, which had no other use, is gone.
Falls out of that:
- `Claim::inputs` and `DagMeta::inputs` delete.
- `dag_view`'s DAG-level projection onto the `MetaLock` node reads the
payload instead. The wire `NodeView::inputs` is unchanged: still
populated on the `meta_lock` node alone.
- the boot sweep names no inputs (it bumps `hyperhive` alone via
`lock_update_hyperhive`), which the construction site now says out loud
rather than leaving implicit in an empty DAG-level field.
Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re` (320 passed) and `nix fmt`. No option surface is touched, so
no nix-eval gate.
`DagSpec`/`NodeKind::Dag` carried an `Option<i64>` approval id that four
deploy phases read back out through `Claim`, via a fallible helper whose
error ("approval deploy dag N has no approval_id") described a state the
type system should have forbidden. Two other templates (`spawn`,
`meta_update`) set the field for nothing: their approval is resolved by
the `ResolveApproval` tails, which already carry the id themselves.
So the id moves onto the nodes that actually need it —
`DeployWindow` / `MergeVerify` / `DeployApply` / `FinalizeDeploy` /
`DeployTail` each take an `i64`, the same way `ResolveApproval` always
has. `templates::approval_deploy` builds all of them in one place with
the value in hand, and `deploy_rebuild_nodes` takes it as a parameter so
the `FinalizeDeploy` it appends at runtime is constructed the same way.
Falls out of that:
- `deploy_approval_id` and its runtime error path delete; each executor
takes the id from its own node payload at dispatch.
- `run_deploy_window` had nothing left to do but validate that id, so the
node joins `Dag` on the shared no-op arm.
- `Claim::approval_id` and `DagMeta::approval_id` delete.
- `dag_view`'s DAG-level projection onto `DeployWindow` reads the payload
instead. The wire `NodeView::approval_id` is unchanged: still set on
the deploy root alone, so the dashboard still renders one approval link
per DAG rather than one per phase.
No option surface is touched, so there is no nix-eval gate here; checked
with clippy (`--all-targets -D warnings`), `cargo test -p hive-c0re`
(320 passed) and `nix fmt`.
The OTEL resource attributes are baked into every agent's
managed-settings.json at evaluation time, but claude-settings.nix read
the names from the container's environment.variables - where they are
never set. meta.rs forwards them as runtime env only, so the reader hit
its "unknown" fallback and every agent shipped
service.name=hyperhive-agent,agent=<a>,hive=unknown,swarm=unknown
on every metric, while the same process's env held pr1ma/constellation.
Measured in this container's /etc/claude-code/managed-settings.json.
The map from forwarded env var to agent option already existed for the
service URLs, with a doc comment naming this exact hazard: "setting only
one leaves the other on its default". The names were simply never added
to it. They are now, and the constant is renamed FORWARDED_VAR_OPTIONS
since it no longer holds only URLs.
hyperhive.hiveName / hyperhive.swarmName follow the forge.url shape:
nullOr str defaulting to null, where null means the hive did not name
itself and "unknown" is an honest label rather than a guess baked at
eval time.
Also fixes, unasked: CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX reads the
same value, so remote-control sessions were named "unknown-<agent>".
Gated with nix, not just cargo - the blast radius here is module eval,
which fmt/clippy/test cannot see:
agent-base assertions -> [] (no failures)
extendModules with both options -> hive=pr1ma,swarm=constellation
Note the value is baked, so every agent needs a rebuild before the new
label appears on its metrics.
Per mara's review on #2896: has_log: bool was fully redundant once
build_log_id: Option<i64> existed alongside it (has_log was always
just build_log_id.is_some()). Dropped has_log, threading the single
Option<i64> field through job_queue::mod.rs, the hivectl NodeView
test-helper literal, and the one remaining frontend consumer
(findLiveBuild's live-log-panel gate, which now checks
build_log_id != null instead of the separate bool).
Also fixed a now-stale doc comment on GET /api/build-log/{node_id}
that claimed the dashboard used on-demand node-id fetches "instead
of an inline build_log_id on the wire" -- no longer true after this
PR put one there for the BUILD L0GS deep-link.
cargo build/clippy/test clean across the three touched crates; nix
fmt clean; frontend build verified (0 has_log references, 3
build_log_id references in the built builds.js bundle).
Fixes hyperhive#2895. The rebuild-queue tree's per-node log icon (the
printer-glyph "open" affordance next to each node in the R3BU1LD
QU3U3 tab) linked directly to the raw-text download endpoint
(/api/build-log/<node_id>/raw, which sets Content-Disposition:
attachment server-side) -- surprising, since nothing about that icon
signals "this leaves the app", unlike the other two explicit
"download raw"/"raw" links elsewhere on the page.
Point it at the existing ?id=N#buildlogs deep-link into the BUILD
L0GS tab instead (builds.js's fetchBuild already auto-expands +
scrolls to the matching row there). That deep-link's id is the
build-log history row id -- a different id space than the queue
tree's NodeId, and wasn't exposed to the frontend before (only a
derived has_log bool was). Added NodeView.build_log_id: Option<i64>
to the wire type alongside the existing has_log (kept, since
findLiveBuild's separate live-log-panel gate still needs a plain
bool), threaded through job_queue::mod.rs, updated hivectl's NodeView
test-helper literal.
The raw download is still one click away once on that row's BUILD
L0GS detail (the two already-explicit raw-download links are
untouched). cargo build/clippy/test clean across the three touched
crates (hive-c0re, hive-host-sock, hivectl); nix fmt clean; frontend
build verified (grep for build_log_id in the built builds.js bundle).
Fixes hyperhive#2893: 'Element.attachShadow: Unable to re-attach to
existing ShadowDOM', crashing swarm.js's live-update render path.
A custom element's connectedCallback fires again on a same-document
*move* (insertBefore/append repositioning an already-connected node
runs the removal + insertion steps for its whole subtree), not just
on a fresh mount. swarm.js's row-fingerprint cache reuses + reorders
existing <li> subtrees on live updates -- reordering an unchanged,
cached row moves its already-initialised <hive-agent-menu>/<hive-menu>
without ever really detaching it from the document, so connectedCallback
re-runs full setup on an instance that's already set up. attachShadow()
throws unconditionally if the host already has a shadow root, and
HiveAgentMenu's unconditional child-menu creation would have appended a
second <hive-menu> on top of the first, doubling the dropdown, once the
shadow-attach crash itself was out of the way.
Both connectedCallbacks now bail early if already initialised
(shadowRoot present / _menu already built). Reproduced the crash and
duplicate-menu bug with an unguarded control copy of both files driven
via headless Chromium (simulating the exact row-reorder move), then
confirmed the guarded version throws nothing, keeps the same shadowRoot
object identity across the move, and doesn't duplicate the dropdown.
Same pure structural move as the previous commit, applied to the one
other remaining genuine component in shared/src (a self-contained
widget with its own behaviour + CSS, same class as hive-btn/hive-
dialog/hive-toast/hive-menu/side-panel/tabs) -- not the CSS-foundation
files (colors/theme/base/chrome.css) or the utility modules (forms.js,
dom.js, modal.js, shadow-css.js), which aren't components and don't
fit the one-dir-per-component convention.
External callers resolve terminal.js/terminal.css only through
@hive/shared's exports map, so again the two exports targets are the
only external-facing change. index.js's own internal re-export uses a
relative path within the package, so that needed updating too. Zero
call-site changes outside @hive/shared. Verified the built dashboard
(flow.js/common.css) and agent (app.js/agent.css) bundles still
resolve both files.
Pure structural move, no API or behaviour change: tabs.js/tabs.css
move into shared/src/tabs/, matching the one-dir-per-component
layout the other shared components already use (hive-btn,
hive-dialog, hive-toast, hive-menu, side-panel).
Both files are consumed exclusively through @hive/shared's
package.json exports map (./tabs.js, ./tabs.css), never by a raw
relative path, so updating the two export targets is the only
change needed -- none of the 9 call sites (dashboard tabbar, logs,
core, builds, credentials, stats x2, agent stats) touch anything.
Verified the built dashboard/agent bundles still resolve both
files correctly.
The connectivity-overrides section still documented
`hyperhive.forge.url = "http://localhost:3000"` as the default and said
the empty string was a valid value. Both stopped being true when the
option became `nullOr str` with `default = null`.
Also says what `null` means and why, since "the default is null" alone
reads like an oversight rather than the point: a loopback default is
only correct when the forge shares the agent's netns, and the units that
would consume the URL are simply not generated without one.
Refs #2860
Per mara's review: '2 and maybe 1, but 3 also sounds reasonable on first
glance' (against 3 options I posted). Doing 2 and 1, leaving open()/
openNamed() as-is (option 3, tentative only).
Both dashboard/common.js and agent/app.js now export/use the
<hive-side-panel> element instance directly (sidePanel) instead of a
thin Panel = { open, openNamed, refresh, close } object that existed
purely to keep the old call-site shape unchanged. All 6 real call sites
updated to call the element's own methods directly.
The .side-panel-body class each wrapper stamped onto its own instance,
purely so common.css/agent.css's pre-existing content-styling selectors
kept matching, is gone too -- those selectors now use the element's own
tag name as the root (hive-side-panel .md, hive-side-panel .agent-inbox),
which already uniquely identifies the light-DOM instance without a
compatibility class. Verified via headless Chromium/CDP that the
tag-name selectors resolve correctly with no class needed.
Drive-by: removed an unrelated dead Panel import in call.js.
Per mara's review on the side-panel PR: create the shared <hive-side-panel>
instance once at module-evaluation time instead of lazily on first call
via an ensurePanel() guard every wrapper method had to remember to call.
ES modules execute after the document is parsed (same timing as a defer
script), so document.body is already available when this code runs --
lazy init bought nothing here and left a footgun for any future method
added to either wrapper.
The dashboard's Panel singleton and the per-agent UI's own inline Panel
IIFE each had their own near-identical implementation of the right-side
slide-in drawer used for file previews, diffs, logs, and inbox/todo
lists. Both are now thin wrappers around a new <hive-side-panel>
shadow-DOM custom element in @hive/shared, following the same house
pattern as <hive-menu>: the element owns and builds all its structural
chrome itself (backdrop, drawer, resize handle, header, title, close
button) in connectedCallback, and only the caller's opaque content node
is projected in via a default <slot> so each package's own
content-type-specific CSS keeps reaching it.
Public API is the union of both originals: open(title, content),
openNamed(name, title, content), refresh(name, title, content),
close(), and currentOwner(). Drag-to-resize + localStorage width
persistence (ported verbatim from the dashboard's original
implementation, the only one of the two that had it) is now available
to both consumers by default — a deliberate behavior widening for the
agent UI, which didn't have resize before. Along the way, fixed a
latent bug in the ported CSS: the resize handle was setting a
--side-panel-w custom property that no width rule ever consumed, so
dragging never actually resized the drawer even though it looked wired
up; the new shared stylesheet's width rule reads it properly.
Each package's own global stylesheet keeps its content-specific rules
(common.css's .side-panel-body .md, agent.css's .side-panel-body
.agent-inbox) exactly where they were — those can never be reached from
the shared element's shadow tree, same architectural floor as
<hive-menu>'s item-row styling. Each wrapper applies a plain
'side-panel-body' compatibility class to its own <hive-side-panel>
instance so those existing selectors keep matching by ordinary
light-DOM descendant matching, with the shared element itself having no
knowledge of what that class name means.
Panel.bind() is gone from both packages' public API — the shared
element wires its own listeners in connectedCallback, so there's no
bind step left to call. tabs.js's one call site (the only bind() caller
in either package) was updated to drop it.
The two original chrome CSS blocks disagreed on several purely visual
details beyond the resize-handle rules (z-index, backdrop color, drawer
border/box-shadow, title typography) — the dashboard's values (the more
feature-complete of the two) were kept as canonical, which is a small
visible style change for the agent UI's panel chrome (thinner border,
no box-shadow, no bold purple title). Flagged for visibility since nothing
in the original two implementations called this out explicitly.
Verified with a real headless-Chromium/CDP harness (bundled the actual
component + built page CSS, served statically, drove via raw CDP) for
both usage shapes: open/close, backdrop-click dismiss, Escape dismiss,
refresh() owner-matching (no-op on wrong owner, applies on matching
owner), and drag-to-resize (drawer width updates live during drag and
persists to localStorage on release).
With the agent option nullable, a missing `HIVE_FORGE_URL` would no
longer fail anything — it would deploy a whole fleet of agents that
silently never log into the forge. The forge is not optional on a
running hive, so the hive asserts that itself rather than leaning on a
module that legitimately allows "no forge" when evaluated standalone.
`sync_agents` checks it before writing anything. That is the moment the
hive commits to a flake, and it keeps `render_flake` a pure string
operation: the renderer is exercised directly by a dozen tests, so
making *it* env-dependent would force each of them to either set a
process-wide var — the parallel-test race this module already avoids —
or fail for reasons unrelated to what they assert.
`require_service_urls` is pure over the already-collected pairs, so its
two tests need no process env at all.
Refs #2860
The option had a `http://localhost:3000` default, which is only ever
correct when the forge shares the caller's network namespace — inside an
agent's netns `localhost` is the agent, and the forge may well be on
another host. Making it *required* instead was worse: the flake's own
container configs are what hive-c0re extends per agent, so the value
they needed in order to evaluate became a second definition on every
agent and collided with the real one.
`null` resolves both. It is not a URL, so nothing can quietly talk to
the wrong machine, and it needs no placeholder anywhere: the bases
evaluate as they are, so nothing deployment-shaped sits on the config
agents inherit from. The units that would consume the URL — tea-login
and forge-avatar-sync — are simply not generated without one, making an
absent forge an absent integration rather than a misdirected one.
hive-forge-notify is unaffected: it reads HIVE_FORGE_URL from the
forwarded global environment, not from this option.
Verified:
agent-base/ruth evaluate with forge.url = null, zero failing assertions
bare base: tea_login_present = false, avatar_present = false,
notify_present = true
extended with a rendered URL: FORGE_URL=http://forge.real.test
Refs #2860